wuffle 0.70.1 → 0.72.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -63,13 +63,15 @@ export function filterUser(user) {
63
63
  const {
64
64
  login,
65
65
  avatar_url,
66
- html_url
66
+ html_url,
67
+ type
67
68
  } = user;
68
69
 
69
70
  return {
70
71
  login,
71
72
  avatar_url,
72
- html_url
73
+ html_url,
74
+ type
73
75
  };
74
76
  }
75
77
 
@@ -138,13 +140,15 @@ export function filterReview(review) {
138
140
  const {
139
141
  state,
140
142
  user,
141
- html_url
143
+ html_url,
144
+ author_association
142
145
  } = review;
143
146
 
144
147
  return {
145
148
  state,
146
149
  html_url,
147
- user: filterUser(user)
150
+ user: filterUser(user),
151
+ author_association
148
152
  };
149
153
  }
150
154
 
@@ -173,7 +177,8 @@ export function filterPull(pullRequest) {
173
177
  order,
174
178
  column,
175
179
  check_runs = [],
176
- statuses = []
180
+ statuses = [],
181
+ author_association
177
182
  } = pullRequest;
178
183
 
179
184
  return {
@@ -198,7 +203,8 @@ export function filterPull(pullRequest) {
198
203
  order,
199
204
  column,
200
205
  statuses: statuses.map(filterStatus),
201
- check_runs: check_runs.map(filterCheckRun)
206
+ check_runs: check_runs.map(filterCheckRun),
207
+ author_association
202
208
  };
203
209
  }
204
210
 
@@ -222,7 +228,8 @@ export function filterIssue(issue) {
222
228
  comments = [],
223
229
  links = [],
224
230
  order,
225
- column
231
+ column,
232
+ author_association
226
233
  } = issue;
227
234
 
228
235
  return {
@@ -242,7 +249,8 @@ export function filterIssue(issue) {
242
249
  html_url,
243
250
  links: links.map(filterLink),
244
251
  order,
245
- column
252
+ column,
253
+ author_association
246
254
  };
247
255
 
248
256
  }
@@ -117,7 +117,8 @@ function filterReview(review) {
117
117
  submitted_at,
118
118
  state,
119
119
  user,
120
- html_url
120
+ html_url,
121
+ author_association
121
122
  } = review;
122
123
 
123
124
  return {
@@ -128,6 +129,7 @@ function filterReview(review) {
128
129
  submitted_at,
129
130
  state: state.toLowerCase(),
130
131
  user: filterUser(user),
131
- html_url
132
+ html_url,
133
+ author_association
132
134
  };
133
135
  }
@@ -8,7 +8,12 @@ const CHILD_LINK_TYPES = {
8
8
  };
9
9
 
10
10
  /**
11
- * @typedef { { defaultFilter?: string } } SearchConfig
11
+ * @typedef { { defaultFilter?: string, treatBotsAsReviewers?: boolean } } SearchConfig
12
+ * @typedef { import('../../util/search.js').SearchTerm } SearchTerm
13
+ *
14
+ * @typedef { import('../../types.js').GitHubUser } GitHubUser
15
+ *
16
+ * @typedef { (issue: any) => boolean } FilterFn
12
17
  */
13
18
 
14
19
  /**
@@ -26,6 +31,10 @@ export default function Search(config, logger, store) {
26
31
  name: 'wuffle:search'
27
32
  });
28
33
 
34
+ const {
35
+ treatBotsAsReviewers = false
36
+ } = config;
37
+
29
38
  function filterNoop(issue) {
30
39
  return true;
31
40
  }
@@ -62,12 +71,46 @@ export default function Search(config, logger, store) {
62
71
  return issue.pull_request;
63
72
  }
64
73
 
74
+ function isValidReview(review) {
75
+
76
+ const {
77
+ user,
78
+ author_association
79
+ } = review;
80
+
81
+ if (!user) {
82
+ return false;
83
+ }
84
+
85
+ if (user.type === 'Bot') {
86
+ return treatBotsAsReviewers;
87
+ }
88
+
89
+ // backwards compatibility - we did not have that
90
+ // data stored previously
91
+ return author_association ? [ 'COLLABORATOR', 'MEMBER', 'OWNER' ].includes(author_association) : true;
92
+ }
93
+
94
+ function getEffectiveReviews(reviews) {
95
+ const byReviewer = new Map();
96
+
97
+ for (const review of reviews) {
98
+ if (review.state === 'commented' || !isValidReview(review)) continue;
99
+
100
+ byReviewer.set(review.user?.login, review);
101
+ }
102
+
103
+ return [ ...byReviewer.values() ];
104
+ }
105
+
65
106
  function isApproved(issue) {
66
- return (issue.reviews || []).some(r => r.state === 'approved');
107
+ return getEffectiveReviews(issue.reviews || []).some(
108
+ r => r.state === 'approved'
109
+ );
67
110
  }
68
111
 
69
112
  function isReviewed(issue) {
70
- return (issue.reviews || []).length > 0;
113
+ return getEffectiveReviews(issue.reviews || []).length > 0;
71
114
  }
72
115
 
73
116
  const filters = {
@@ -153,6 +196,10 @@ export default function Search(config, logger, store) {
153
196
 
154
197
  return !links || !links.some(link => CHILD_LINK_TYPES[link.type]);
155
198
  };
199
+ case 'referenced':
200
+ return function filterReferenced(issue) {
201
+ return (issue.links || []).length > 0;
202
+ };
156
203
  default:
157
204
  return filterNoop;
158
205
  }
@@ -281,7 +328,29 @@ export default function Search(config, logger, store) {
281
328
  return function filterCreated(issue) {
282
329
  return matchTemporal(issue.updated_at);
283
330
  };
284
- })
331
+ }),
332
+
333
+ 'or': function orFilter(value, _exact, user) {
334
+
335
+ const childTerms = /** @type {SearchTerm[]} */ (value);
336
+
337
+ const filterFns = childTerms.map(childTerm => buildTermFn(childTerm, user));
338
+
339
+ return (issue) => filterFns.some(
340
+ filterFn => filterFn(issue)
341
+ );
342
+ },
343
+
344
+ 'and': function andFilter(value, _exact, user) {
345
+
346
+ const childTerms = /** @type {SearchTerm[]} */ (value);
347
+
348
+ const filterFns = childTerms.map(childTerm => buildTermFn(childTerm, user));
349
+
350
+ return (issue) => filterFns.every(
351
+ filterFn => filterFn(issue)
352
+ );
353
+ }
285
354
  };
286
355
 
287
356
  function temporalFilter(fn) {
@@ -318,71 +387,82 @@ export default function Search(config, logger, store) {
318
387
  };
319
388
  }
320
389
 
321
- function buildFilterFns(search, user) {
322
-
323
- const terms = search ? parseSearch(search) : [];
324
-
325
- return terms.map(term => {
326
- let {
327
- qualifier,
328
- value,
329
- negated,
330
- exact
331
- } = term;
390
+ /**
391
+ * @param {SearchTerm} term
392
+ * @param {any} user
393
+ *
394
+ * @return {FilterFn}
395
+ */
396
+ function buildTermFn(term, user) {
397
+ let {
398
+ qualifier,
399
+ value,
400
+ negated,
401
+ exact
402
+ } = term;
403
+
404
+ const wrap = (fn) => negated ? ((issue) => !fn(issue)) : fn;
405
+
406
+ if (!value) {
407
+ return noopFilter();
408
+ }
332
409
 
333
- if (!value) {
334
- return noopFilter();
410
+ if (value === '@me') {
411
+ if (!user) {
412
+ return noneFilter();
335
413
  }
336
414
 
337
- if (value === '@me') {
338
- if (!user) {
339
- return noneFilter();
340
- }
415
+ value = user.login;
416
+ exact = true;
417
+ }
341
418
 
342
- value = user.login;
343
- exact = true;
344
- }
419
+ const factoryFn = filters[qualifier];
345
420
 
346
- const factoryFn = filters[qualifier];
421
+ if (!factoryFn) {
422
+ return noopFilter();
423
+ }
347
424
 
348
- if (!factoryFn) {
349
- return noopFilter();
350
- }
425
+ return wrap(factoryFn(value, exact, user));
426
+ }
351
427
 
352
- const fn = factoryFn(value, exact);
428
+ function buildFilterFn(search, user) {
353
429
 
354
- if (negated) {
355
- return function(arg) {
356
- return !fn(arg);
357
- };
358
- }
430
+ const term = search && parseSearch(search);
431
+
432
+ if (!term) {
433
+ return null;
434
+ }
359
435
 
360
- return fn;
361
- });
436
+ return buildTermFn(term);
362
437
  }
363
438
 
364
439
  /**
365
440
  * Retrieve a filter function from the given search string.
366
441
  *
367
442
  * @param {string} search
368
- * @param {import('../../types.js').GitHubUser} [user]
443
+ * @param {GitHubUser} [user]
369
444
  *
370
- * @return {Function}
445
+ * @return {FilterFn}
371
446
  */
372
447
  function getSearchFilter(search, user) {
373
448
 
374
- const filterFns = buildFilterFns(search, user);
449
+ const filterFn = buildFilterFn(search, user);
375
450
 
376
- const ignoreFilterFns = buildFilterFns(config.defaultFilter, user);
451
+ const ignoreFilterFn = buildFilterFn(config.defaultFilter, user);
377
452
 
378
453
  return function(issue) {
379
454
  try {
380
- if (filterFns.length) {
381
- return filterFns.every(fn => fn(issue));
382
- } else {
455
+ if (filterFn) {
456
+ return filterFn(issue);
457
+ }
383
458
 
384
- return ignoreFilterFns.every(fn => fn(issue));
459
+ if (ignoreFilterFn) {
460
+ return ignoreFilterFn(issue);
385
461
  }
462
+
463
+ // no user search, no ignore filter,
464
+ // show all issues
465
+ return true;
386
466
  } catch (err) {
387
467
  log.warn({ issue: issueIdent(issue), err }, 'filter failed');
388
468
 
package/lib/filters.js CHANGED
@@ -47,7 +47,8 @@ export function filterUser(githubUser) {
47
47
  node_id,
48
48
  login,
49
49
  avatar_url,
50
- html_url
50
+ html_url,
51
+ type
51
52
  } = githubUser;
52
53
 
53
54
  return {
@@ -55,7 +56,8 @@ export function filterUser(githubUser) {
55
56
  node_id,
56
57
  login,
57
58
  avatar_url,
58
- html_url
59
+ html_url,
60
+ type
59
61
  };
60
62
  }
61
63
 
@@ -147,7 +149,8 @@ export function filterPull(githubPull, githubRepository) {
147
149
  additions,
148
150
  deletions,
149
151
  changed_files,
150
- html_url
152
+ html_url,
153
+ author_association
151
154
  } = githubPull;
152
155
 
153
156
  // stable ID that is independent from GitHubs internal issue/pr distinction
@@ -187,7 +190,8 @@ export function filterPull(githubPull, githubRepository) {
187
190
  changed_files,
188
191
  pull_request: true,
189
192
  repository: filterRepository(githubRepository),
190
- html_url
193
+ html_url,
194
+ author_association
191
195
  };
192
196
  }
193
197
 
@@ -209,7 +213,8 @@ export function filterIssue(githubIssue, githubRepository) {
209
213
  labels,
210
214
  milestone,
211
215
  pull_request,
212
- html_url
216
+ html_url,
217
+ author_association
213
218
  } = githubIssue;
214
219
 
215
220
  // stable ID that is independent from GitHubs internal issue/pr distinction
@@ -239,7 +244,8 @@ export function filterIssue(githubIssue, githubRepository) {
239
244
  milestone: milestone ? filterMilestone(milestone) : null,
240
245
  repository: filterRepository(githubRepository),
241
246
  pull_request: !!pull_request,
242
- html_url
247
+ html_url,
248
+ author_association
243
249
  };
244
250
 
245
251
  }
@@ -54,58 +54,149 @@ function startOfDay(time) {
54
54
  return date.getTime();
55
55
  }
56
56
 
57
+ /**
58
+ * @typedef { {
59
+ * qualifier: string,
60
+ * exact?: boolean,
61
+ * negated?: boolean,
62
+ * value?: string|SearchTerm[],
63
+ * } } SearchTerm
64
+ */
65
+
57
66
  /**
58
67
  * @param {string} str
59
68
  *
60
- * @return { {
61
- * qualifier: string,
62
- * value: string|undefined,
63
- * exact: boolean,
64
- * negated?: boolean
65
- * }[] }
69
+ * @return {SearchTerm}
66
70
  */
67
71
  export function parseSearch(str) {
68
72
 
69
- const regexp = /(?:([\w#/&]+)|"([\w#/&\s-.]+)"|([-!]?)([\w]+):(?:([\w-#/&@<>=.]+)|"([\w-#/&@:.,; ]+)")?)(?:\s|$)/g;
73
+ const regexp = /(\()|(\))|(OR)\s*|(AND)\s|([-!]|NOT\s+)?(?:"([^"]+)"|([\w#/&]+)(?:(:)(?:([\w-#/&@<>=.]+)|"([^"]+)")?)?)/g;
70
74
 
71
- const terms = [];
75
+ const stack = [ {
76
+ qualifier: 'and',
77
+ value: /** @type {SearchTerm[]} */ ([])
78
+ } ];
72
79
 
73
80
  let match;
74
81
 
75
82
  while ((match = regexp.exec(str))) {
76
83
 
84
+ const term = stack[stack.length - 1];
85
+
77
86
  const [
78
87
  _,
79
- text,
80
- textEscaped,
88
+ openGroup,
89
+ closeGroup,
90
+ or,
91
+ and,
81
92
  negated,
93
+ textEscaped,
94
+ text,
82
95
  qualifier,
83
96
  qualifierText,
84
97
  qualifierTextEscaped
85
98
  ] = match;
86
99
 
100
+ if (openGroup) {
101
+ const groupTerm = {
102
+ qualifier: 'and',
103
+ value: [],
104
+ group: true
105
+ };
87
106
 
88
- const textValue = text || textEscaped;
107
+ stack.push(groupTerm);
108
+ term.value.push(groupTerm);
89
109
 
90
- if (textValue) {
91
- terms.push({
92
- qualifier: 'text',
93
- value: textValue,
94
- exact: !!textEscaped
95
- });
110
+ continue;
111
+ }
112
+
113
+ if (closeGroup) {
114
+
115
+ let lastTerm;
116
+
117
+ do {
118
+ lastTerm = stack.pop();
119
+ } while (!lastTerm.group);
120
+
121
+ continue;
122
+ }
123
+
124
+ if (and) {
125
+
126
+ // default semantics
127
+ continue;
128
+ }
129
+
130
+ if (or) {
131
+ const nextTerm = {
132
+ qualifier: 'and',
133
+ value: []
134
+ };
135
+
136
+ const groupTerm = {
137
+ qualifier: 'or',
138
+ value: [
139
+ {
140
+ qualifier: 'and',
141
+ value: term.value.slice()
142
+ },
143
+ nextTerm
144
+ ]
145
+ };
146
+
147
+ // replace existing terms with <OR> term
148
+ term.value.length = 0;
149
+ term.value.push(groupTerm);
150
+
151
+ stack.push(nextTerm);
152
+
153
+ continue;
96
154
  }
97
155
 
156
+ const textValue = text || textEscaped;
98
157
  const qualifierValue = qualifierText || qualifierTextEscaped;
99
158
 
100
159
  if (qualifier) {
101
- terms.push({
102
- qualifier,
160
+ term.value.push({
161
+ qualifier: textValue,
103
162
  value: qualifierValue,
104
163
  negated: !!negated,
105
164
  exact: !!qualifierTextEscaped
106
165
  });
166
+ } else {
167
+ term.value.push({
168
+ qualifier: 'text',
169
+ value: textValue,
170
+ exact: !!textEscaped,
171
+ negated: !!negated
172
+ });
107
173
  }
108
174
  }
109
175
 
110
- return terms;
176
+ return collapseSearch(stack[0]);
177
+ }
178
+
179
+ /**
180
+ * @param {SearchTerm} term
181
+ *
182
+ * @return {SearchTerm}
183
+ */
184
+ export function collapseSearch(term) {
185
+
186
+ if (![ 'and', 'or' ].includes(term.qualifier)) {
187
+ return term;
188
+ }
189
+
190
+ const nestedTerms = /** @type {SearchTerm[]} */ (term.value);
191
+
192
+ const nestedTermsCollapsed = nestedTerms.map(collapseSearch);
193
+
194
+ if (term.qualifier === 'and' && nestedTermsCollapsed.length === 1) {
195
+ return nestedTermsCollapsed[0];
196
+ }
197
+
198
+ return {
199
+ ...term,
200
+ value: nestedTermsCollapsed
201
+ };
111
202
  }
package/package.json CHANGED
@@ -1,13 +1,13 @@
1
1
  {
2
2
  "name": "wuffle",
3
- "version": "0.70.1",
3
+ "version": "0.72.0",
4
4
  "description": "A multi-repository task board for GitHub issues",
5
5
  "author": {
6
6
  "name": "Nico Rehwaldt",
7
7
  "url": "https://github.com/nikku"
8
8
  },
9
9
  "bin": {
10
- "wuffle": "./bin/wuffle"
10
+ "wuffle": "bin/wuffle"
11
11
  },
12
12
  "exports": {
13
13
  ".": "./index.js",
@@ -19,7 +19,7 @@
19
19
  "homepage": "https://github.com/nikku/wuffle",
20
20
  "repository": {
21
21
  "type": "git",
22
- "url": "https://github.com/nikku/wuffle.git",
22
+ "url": "git+https://github.com/nikku/wuffle.git",
23
23
  "directory": "packages/app"
24
24
  },
25
25
  "keywords": [
@@ -42,13 +42,13 @@
42
42
  "auto-test": "npm test -- --watch"
43
43
  },
44
44
  "dependencies": {
45
- "@aws-sdk/client-s3": "^3.1014.0",
45
+ "@aws-sdk/client-s3": "^3.1037.0",
46
46
  "async-didi": "^1.0.0",
47
47
  "body-parser": "^2.2.2",
48
48
  "compression": "^1.8.1",
49
49
  "express-session": "^1.19.0",
50
50
  "fake-tag": "^5.0.0",
51
- "memorystore": "^1.6.7",
51
+ "memorystore": "^1.6.8",
52
52
  "min-dash": "^5.0.0",
53
53
  "p-defer": "^4.0.1",
54
54
  "prexit": "^2.3.0",
@@ -59,15 +59,15 @@
59
59
  "@graphql-eslint/eslint-plugin": "^4.4.0",
60
60
  "@octokit/graphql-schema": "^15.25.0",
61
61
  "@types/compression": "^1.8.1",
62
- "@types/express-session": "^1.18.2",
62
+ "@types/express-session": "^1.19.0",
63
63
  "@types/mocha": "^10.0.10",
64
64
  "chai": "^6.2.2",
65
- "graphql": "^16.13.1",
65
+ "graphql": "^16.13.2",
66
66
  "mocha": "^11.7.5",
67
- "nock": "^14.0.11",
67
+ "nock": "^14.0.13",
68
68
  "nodemon": "^3.1.14",
69
69
  "npm-run-all2": "^8.0.4",
70
- "sinon": "^21.0.3",
70
+ "sinon": "^21.1.2",
71
71
  "sinon-chai": "^4.0.0",
72
72
  "typescript": "^5.9.3"
73
73
  },
@@ -82,5 +82,5 @@
82
82
  "index.js",
83
83
  "wuffle.config.example.js"
84
84
  ],
85
- "gitHead": "e3234b1499bb1c75e3bf40c15b0c105bedeba70c"
85
+ "gitHead": "f42a56e87fb1f6d3f06ef5e9215c5e0b91544c0b"
86
86
  }