wuffle 0.74.0 → 0.76.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.
@@ -0,0 +1,132 @@
1
+ import { repoAndOwner } from '../../util/index.js';
2
+
3
+ import gql from 'fake-tag';
4
+
5
+ /**
6
+ * @typedef { {
7
+ * id: number,
8
+ * node_id: string,
9
+ * body: string,
10
+ * created_at: string,
11
+ * authorAssociation: string,
12
+ * html_url: string,
13
+ * user: {
14
+ * id: number,
15
+ * node_id: string,
16
+ * login: string,
17
+ * avatar_url: string,
18
+ * html_url: string,
19
+ * type: string
20
+ * }
21
+ * } } GithubComment
22
+ *
23
+ * @typedef { import('../../util/meta.js').Issue } Issue
24
+ */
25
+
26
+
27
+ /**
28
+ * This component fetches GitHub comments data required for background synchronization.
29
+ *
30
+ * @constructor
31
+ *
32
+ * @param {import('../github-client/GithubClient.js').default} githubClient
33
+ */
34
+ export default function GithubCommentsBackend(githubClient) {
35
+
36
+ /**
37
+ * Return comments for given issue.
38
+ *
39
+ * @param {Issue} issue
40
+ *
41
+ * @return {Promise<GithubComment[]>}
42
+ */
43
+ this.getIssueComments = async function(issue) {
44
+
45
+ const {
46
+ number
47
+ } = issue;
48
+
49
+ const {
50
+ repo,
51
+ owner
52
+ } = repoAndOwner(issue);
53
+
54
+ const github = await githubClient.getOrgScoped(owner);
55
+
56
+ const result = await github.graphql(gql`
57
+
58
+ fragment CommentInfo on IssueComment {
59
+ id: databaseId
60
+ node_id: id
61
+ body: bodyText
62
+ created_at: publishedAt
63
+ authorAssociation,
64
+ html_url: url,
65
+ user: author {
66
+ login
67
+ avatar_url: avatarUrl,
68
+ html_url: url,
69
+ type: __typename
70
+ ... on User {
71
+ id: databaseId
72
+ node_id: id
73
+ }
74
+ ... on Bot {
75
+ id: databaseId
76
+ node_id: id
77
+ }
78
+ }
79
+ }
80
+
81
+ query FetchComments(
82
+ $repo: String!,
83
+ $owner: String!,
84
+ $issue_number: Int!,
85
+ $after: String
86
+ ) {
87
+ repository(name: $repo, owner: $owner) {
88
+ issueOrPullRequest(number: $issue_number) {
89
+ ...on Issue {
90
+ comments(first: 100, after: $after) {
91
+ edges {
92
+ node {
93
+ ...CommentInfo
94
+ }
95
+ }
96
+ pageInfo {
97
+ endCursor
98
+ hasNextPage
99
+ }
100
+ totalCount
101
+ }
102
+ }
103
+ ...on PullRequest {
104
+ comments(first: 100, after: $after) {
105
+ edges {
106
+ node {
107
+ ...CommentInfo
108
+ }
109
+ }
110
+ pageInfo {
111
+ endCursor
112
+ hasNextPage
113
+ }
114
+ totalCount
115
+ }
116
+ }
117
+ }
118
+ }
119
+ }`,
120
+ {
121
+ owner,
122
+ repo,
123
+ issue_number: number
124
+ });
125
+
126
+ const comments = /** @type { GithubComment[] } */ (
127
+ result.repository.issueOrPullRequest.comments.edges.map(e => e.node)
128
+ );
129
+
130
+ return comments;
131
+ };
132
+ }
@@ -1,6 +1,8 @@
1
1
  import GithubComments from './GithubComments.js';
2
+ import GithubCommentsBackend from './GithubCommentsBackend.js';
2
3
 
3
4
  export default {
4
5
  __init__: [ 'githubComments' ],
5
- githubComments: [ 'type', GithubComments ]
6
+ githubComments: [ 'type', GithubComments ],
7
+ githubCommentsBackend: [ 'type', GithubCommentsBackend ]
6
8
  };
@@ -4,8 +4,17 @@ const {
4
4
  CLOSES
5
5
  } = linkTypes;
6
6
 
7
+ /**
8
+ * @typedef { import('probot').Context } ProbotContext
9
+ *
10
+ * @typedef { import('../../columns.js').ColumnDefinition } ColumnDefinition
11
+ */
7
12
 
8
13
  /**
14
+ * Moves GitHub issues between board columns by applying the
15
+ * corresponding state (open/closed), label, and assignee changes
16
+ * via the GitHub API.
17
+ *
9
18
  * @constructor
10
19
  *
11
20
  * @param {any} config
@@ -18,6 +27,16 @@ export default function GithubIssues(config, logger, columns) {
18
27
  name: 'wuffle:github-issues'
19
28
  });
20
29
 
30
+ /**
31
+ * Return the assignee update required to add `newAssignee` to the
32
+ * issue, or an empty object if the assignee is already set or not
33
+ * provided.
34
+ *
35
+ * @param {Object} issue
36
+ * @param {string|null} newAssignee
37
+ *
38
+ * @return {{ assignees?: string[] }}
39
+ */
21
40
  function getAssigneeUpdate(issue, newAssignee) {
22
41
 
23
42
  if (!newAssignee) {
@@ -39,6 +58,15 @@ export default function GithubIssues(config, logger, columns) {
39
58
 
40
59
  }
41
60
 
61
+ /**
62
+ * Return the state update (`open` / `closed`) required to reflect
63
+ * the new column, or an empty object if no change is needed.
64
+ *
65
+ * @param {Object} issue
66
+ * @param {ColumnDefinition} newColumn
67
+ *
68
+ * @return {{ state?: 'open'|'closed' }}
69
+ */
42
70
  function getStateUpdate(issue, newColumn) {
43
71
 
44
72
  let update = {};
@@ -55,16 +83,29 @@ export default function GithubIssues(config, logger, columns) {
55
83
  return update;
56
84
  }
57
85
 
86
+ /**
87
+ * Return the label changes required to reflect the new column:
88
+ * the column label to add and any other column labels to remove.
89
+ *
90
+ * @param {Object} issue
91
+ * @param {ColumnDefinition} newColumn
92
+ *
93
+ * @return {{ addLabels: string[], removeLabels: string[] }}
94
+ */
58
95
  function getLabelUpdate(issue, newColumn) {
59
96
 
60
- const issueLabels = issue.labels.map(l => l.name);
97
+ const issueLabels = /** @type { string[] } */ (
98
+ issue.labels.map(l => l.name)
99
+ );
61
100
 
62
101
  const newLabel = newColumn.label;
63
102
 
64
103
  const addLabels = (!newLabel || issueLabels.includes(newLabel)) ? [] : [ newLabel ];
65
104
 
66
- const removeLabels = columns.getAll().map(c => c.label).filter(
67
- label => label && label !== newLabel && issueLabels.includes(label)
105
+ const removeLabels = /** @type { string[] } */ (
106
+ columns.getAll().map(c => c.label).filter(
107
+ label => label && label !== newLabel && issueLabels.includes(label)
108
+ )
68
109
  );
69
110
 
70
111
  return {
@@ -73,6 +114,10 @@ export default function GithubIssues(config, logger, columns) {
73
114
  };
74
115
  }
75
116
 
117
+ /**
118
+ * @param {ProbotContext} context
119
+ * @param {number} issue_number
120
+ */
76
121
  function findIssue(context, issue_number) {
77
122
 
78
123
  const params = context.repo({ issue_number });
@@ -88,12 +133,43 @@ export default function GithubIssues(config, logger, columns) {
88
133
  });
89
134
  }
90
135
 
91
- function findAndMoveIssue(context, number, newColumn, newAssignee, test = (issue) => true) {
92
- return findIssue(context, number)
93
- .then((issue) => issue && test(issue) && moveIssue(context, issue, newColumn, newAssignee));
136
+ /**
137
+ * Fetch an issue by number and move it to a new column if the
138
+ * optional predicate passes. Resolves to `false` if the issue is
139
+ * not found or the predicate rejects it.
140
+ *
141
+ * @param {ProbotContext} context
142
+ * @param {number} number
143
+ * @param {ColumnDefinition} newColumn
144
+ * @param {string|null} newAssignee
145
+ * @param {(issue: Object) => boolean} [test]
146
+ *
147
+ * @return {Promise<void|false>}
148
+ */
149
+ async function findAndMoveIssue(context, number, newColumn, newAssignee, test = (issue) => true) {
150
+ const issue = await findIssue(context, number);
151
+
152
+ if (!issue || !test(issue)) {
153
+ return false;
154
+ }
155
+
156
+ return moveIssue(context, issue, newColumn, newAssignee);
94
157
  }
95
158
 
96
- async function moveReferencedIssues(context, issue, newColumn, newAssignee) {
159
+ /**
160
+ * Move all issues referenced via `closes` links in the given issue
161
+ * or pull request body to a new column.
162
+ *
163
+ * Only issues within the same repository are moved.
164
+ *
165
+ * @param {ProbotContext} context
166
+ * @param {Object} issue
167
+ * @param {ColumnDefinition} newColumn
168
+ * @param {string|null} [newAssignee=null]
169
+ *
170
+ * @return {Promise<(void|false)[]>}
171
+ */
172
+ async function moveReferencedIssues(context, issue, newColumn, newAssignee = null) {
97
173
 
98
174
  // TODO(nikku): do that lazily, i.e. react to PR label changes?
99
175
  // would slower the movement but support manual moving-issue with PR
@@ -128,7 +204,18 @@ export default function GithubIssues(config, logger, columns) {
128
204
  }));
129
205
  }
130
206
 
131
- function moveIssue(context, issue, newColumn, newAssignee) {
207
+ /**
208
+ * Move an issue to a new column, applying state, label, and
209
+ * assignee updates via the GitHub API.
210
+ *
211
+ * @param {ProbotContext} context
212
+ * @param {Object} issue
213
+ * @param {ColumnDefinition} newColumn
214
+ * @param {string|null} [newAssignee=null]
215
+ *
216
+ * @return {Promise<void>}
217
+ */
218
+ function moveIssue(context, issue, newColumn, newAssignee = null) {
132
219
 
133
220
  const {
134
221
  number: issue_number
@@ -199,22 +286,87 @@ export default function GithubIssues(config, logger, columns) {
199
286
  );
200
287
  }
201
288
 
202
- return Promise.all(invocations);
289
+ return Promise.all(invocations).then(() => {});
203
290
  }
204
291
 
205
292
 
206
293
  // api /////////////////////////////
207
294
 
295
+ /**
296
+ * Move an issue to a new column, applying state, label, and
297
+ * assignee updates via the GitHub API.
298
+ *
299
+ * @param {ProbotContext} context
300
+ * @param {Object} issue
301
+ * @param {ColumnDefinition} newColumn
302
+ * @param {string} [newAssignee]
303
+ *
304
+ * @return {Promise<void>}
305
+ */
208
306
  this.moveIssue = moveIssue;
209
307
 
308
+ /**
309
+ * Move all issues referenced via `closes` links in the given issue
310
+ * or pull request body to a new column.
311
+ *
312
+ * Only issues within the same repository are moved.
313
+ *
314
+ * @param {ProbotContext} context
315
+ * @param {Object} issue
316
+ * @param {ColumnDefinition} newColumn
317
+ * @param {string|null} [newAssignee=null]
318
+ *
319
+ * @return {Promise<(void|false)[]>}
320
+ */
210
321
  this.moveReferencedIssues = moveReferencedIssues;
211
322
 
323
+ /**
324
+ * Return the state update (`open` / `closed`) required to reflect
325
+ * the new column, or an empty object if no change is needed.
326
+ *
327
+ * @param {Object} issue
328
+ * @param {ColumnDefinition} newColumn
329
+ *
330
+ * @return {{ state?: 'open'|'closed' }}
331
+ */
212
332
  this.getStateUpdate = getStateUpdate;
213
333
 
334
+ /**
335
+ * Return the assignee update required to add `newAssignee` to the
336
+ * issue, or an empty object if the assignee is already set or not
337
+ * provided.
338
+ *
339
+ * @param {Object} issue
340
+ * @param {string} [newAssignee]
341
+ *
342
+ * @return {{ assignees?: string[] }}
343
+ */
214
344
  this.getAssigneeUpdate = getAssigneeUpdate;
215
345
 
346
+ /**
347
+ * Return the label changes required to reflect the new column:
348
+ * the column label to add and any other column labels to remove.
349
+ *
350
+ * @param {Object} issue
351
+ * @param {ColumnDefinition} newColumn
352
+ *
353
+ * @return {{ addLabels: string[], removeLabels: string[] }}
354
+ */
216
355
  this.getLabelUpdate = getLabelUpdate;
217
356
 
357
+ /**
358
+ * Fetch an issue by number and move it to a new column if the
359
+ * optional predicate passes. Resolves to `false` if the issue is
360
+ * not found or the predicate rejects it.
361
+ *
362
+ * @param {ProbotContext} context
363
+ * @param {number} number
364
+ * @param {ColumnDefinition} newColumn
365
+ * @param {string|null} newAssignee
366
+ * @param {(issue: Object) => boolean} [test]
367
+ *
368
+ * @return {Promise<void|false>}
369
+ */
218
370
  this.findAndMoveIssue = findAndMoveIssue;
219
371
 
220
372
  }
@@ -5,6 +5,8 @@ import { filterUser, filterPull } from '../../filters.js';
5
5
  /**
6
6
  * This component updates the stored issues based on GitHub events.
7
7
  *
8
+ * It also hooks into `BackgroundSync` to fetch reviews for a PR.
9
+ *
8
10
  * @constructor
9
11
  *
10
12
  * @param {import('../webhook-events/WebhookEvents.js').default} webhookEvents
@@ -5,6 +5,8 @@ import { filterPull } from '../../filters.js';
5
5
  /**
6
6
  * This component updates synchronizes GitHub statuses with pull requests.
7
7
  *
8
+ * It also hooks into `BackgroundSync` to fetch statuses for a PR.
9
+ *
8
10
  * @constructor
9
11
  *
10
12
  * @param {import('../webhook-events/WebhookEvents.js').default} webhookEvents
@@ -1,5 +1,7 @@
1
1
  /**
2
2
  * @typedef { { ignoreFilter?: string } } StoreFilterConfig
3
+ *
4
+ * @typedef { Extract<import('@octokit/webhooks').EmitterWebhookEventName, 'issues' | `issues.${string}` | 'issue_comment' | `issue_comment.${string}` | 'pull_request' | `pull_request.${string}` | 'pull_request_review' | `pull_request_review.${string}` | 'pull_request_review_comment' | `pull_request_review_comment.${string}` | 'pull_request_review_thread' | `pull_request_review_thread.${string}`> } IssueOrPullWebhookEventName
3
5
  */
4
6
 
5
7
  import { filterIssueOrPull } from '../../filters.js';
@@ -41,7 +43,7 @@ export default function IssueFilter(config, store, search, logger) {
41
43
  /**
42
44
  * @param {import('../../types.js').Logger } log
43
45
  *
44
- * @return { (filterFn) => (any) => any }
46
+ * @return {<E extends IssueOrPullWebhookEventName>(fn: (context: import('probot').Context<E>) => any) => (context: import('probot').Context<E>) => any}
45
47
  */
46
48
  function createWebhookFilter(log) {
47
49
 
@@ -49,7 +51,7 @@ export default function IssueFilter(config, store, search, logger) {
49
51
 
50
52
  return (context) => {
51
53
 
52
- const payload = context.payload;
54
+ const payload = /** @type {any} */ (context.payload);
53
55
 
54
56
  const issueOrPull = filterIssueOrPull(
55
57
  payload.issue || payload.pull_request,
@@ -79,7 +81,7 @@ export default function IssueFilter(config, store, search, logger) {
79
81
  /**
80
82
  * @param {import('../../types.js').Logger } log
81
83
  *
82
- * @return { (filterFn) => (any) => any }
84
+ * @return {<E extends IssueOrPullWebhookEventName>(fn: (context: import('probot').Context<E>) => any) => (context: import('probot').Context<E>) => any}
83
85
  */
84
86
  this.createWebhookFilter = createWebhookFilter;
85
87
  }
@@ -37,7 +37,7 @@ export default function Search(config, logger, store) {
37
37
 
38
38
  const {
39
39
  treatBotsAsReviewers = false,
40
- defaultFilter
40
+ defaultFilter = ''
41
41
  } = config;
42
42
 
43
43
  function filterNoop(issue) {
@@ -63,13 +63,17 @@ export default function Search(config, logger, store) {
63
63
  *
64
64
  * @return { boolean }
65
65
  */
66
- function includes(actual, pattern, exact) {
66
+ function includes(actual, pattern, exact = false) {
67
+
68
+ if (!pattern) {
69
+ return false;
70
+ }
67
71
 
68
72
  if (exact) {
69
- return pattern && actual === pattern;
73
+ return actual === pattern;
70
74
  }
71
75
 
72
- return pattern && actual.toLowerCase().includes(pattern.toLowerCase());
76
+ return actual.toLowerCase().includes(pattern.toLowerCase());
73
77
  }
74
78
 
75
79
  function isPull(issue) {
@@ -445,7 +449,7 @@ export default function Search(config, logger, store) {
445
449
  * Retrieve a filter function from the given search string.
446
450
  *
447
451
  * @param {string} search
448
- * @param {GitHubUser} [user]
452
+ * @param {GitHubUser|null} [user]
449
453
  *
450
454
  * @return {FilterFn}
451
455
  */
@@ -1,4 +1,11 @@
1
1
  /**
2
+ * @typedef { import('probot').Probot } Probot
3
+ * @typedef { import('@octokit/webhooks').EmitterWebhookEventName } WebhookEventName
4
+ */
5
+
6
+ /**
7
+ * A lightweight wrapper around {@link Probot#on} and {@link Probot#onAny}.
8
+ *
2
9
  * @constructor
3
10
  *
4
11
  * @param {import('../../types.js').ProbotApp} app
@@ -7,8 +14,7 @@
7
14
  export default function WebhookEvents(app, githubApp) {
8
15
 
9
16
  /**
10
- * @template {Function} T
11
- * @param {T} fn
17
+ * @param {(context: import('probot').Context) => any} fn
12
18
  */
13
19
  function ifEnabled(fn) {
14
20
 
@@ -37,8 +43,9 @@ export default function WebhookEvents(app, githubApp) {
37
43
  * Register a event lister for a single
38
44
  * or a number of webhook events.
39
45
  *
40
- * @param {any|any[]} events
41
- * @param {Function} fn listener
46
+ * @template {WebhookEventName} E
47
+ * @param {E | E[]} events
48
+ * @param {(context: import('probot').Context<E>) => any} fn listener
42
49
  */
43
50
  function on(events, fn) {
44
51
  app.on(events, ifEnabled(fn));
@@ -48,7 +55,7 @@ export default function WebhookEvents(app, githubApp) {
48
55
  * Register an event listener for all
49
56
  * webhook events.
50
57
  *
51
- * @param {Function} fn
58
+ * @param {(context: import('probot').Context) => any} fn
52
59
  */
53
60
  function onAny(fn) {
54
61
  app.onAny(ifEnabled(fn));
package/lib/events.js CHANGED
@@ -139,8 +139,8 @@ Events.prototype.createEvent = function(data) {
139
139
  * @param {Object} [data] the event object
140
140
  * @param {...Object} additionalArgs to be passed to the callback functions
141
141
  *
142
- * @return {Promise<boolean>} the events return value, if specified or false if the
143
- * default action was prevented by listeners
142
+ * @return {Promise<boolean|undefined|any>} the events return value, if
143
+ * specified or false if the default action was prevented by listeners
144
144
  */
145
145
  Events.prototype.emit = async function(type, data, ...additionalArgs) {
146
146
 
package/lib/filters.js CHANGED
@@ -214,7 +214,8 @@ export function filterIssue(githubIssue, githubRepository) {
214
214
  milestone,
215
215
  pull_request,
216
216
  html_url,
217
- author_association
217
+ author_association,
218
+ parent_issue_url
218
219
  } = githubIssue;
219
220
 
220
221
  // stable ID that is independent from GitHubs internal issue/pr distinction
@@ -245,7 +246,8 @@ export function filterIssue(githubIssue, githubRepository) {
245
246
  repository: filterRepository(githubRepository),
246
247
  pull_request: !!pull_request,
247
248
  html_url,
248
- author_association
249
+ author_association,
250
+ parent_issue_url: parent_issue_url || null
249
251
  };
250
252
 
251
253
  }
@@ -103,7 +103,8 @@ function validateSetup() {
103
103
 
104
104
  const setup = new ManifestCreation();
105
105
 
106
- const manifest = JSON.parse(setup.getManifest(setup.pkg, process.env.BASE_URL));
106
+ // TODO(nikku): process.env.BASE_URL may not be defined here
107
+ const manifest = JSON.parse(setup.getManifest(setup.pkg, /** @type { string } */ (process.env.BASE_URL)));
107
108
 
108
109
  return [
109
110
  !manifest.url && new Error('No <url> configured in app.yml'),
package/lib/util/links.js CHANGED
@@ -145,6 +145,22 @@ export function findLinks(issue, types) {
145
145
  links.push(link);
146
146
  }
147
147
 
148
+ // add CHILD_OF link from parent_issue_url (GitHub sub-issues)
149
+ const { parent_issue_url } = issue;
150
+
151
+ if (parent_issue_url) {
152
+ const parentMatch = parent_issue_url.match(/\/repos\/([^/]+)\/([^/]+)\/issues\/(\d+)/);
153
+
154
+ if (parentMatch) {
155
+ links.push({
156
+ type: CHILD_OF,
157
+ owner: parentMatch[1],
158
+ repo: parentMatch[2],
159
+ number: parseInt(parentMatch[3], 10)
160
+ });
161
+ }
162
+ }
163
+
148
164
  if (typeof types !== 'undefined') {
149
165
  return filterLinks(links, types);
150
166
  }
@@ -116,7 +116,7 @@ export function parseSearch(str) {
116
116
 
117
117
  do {
118
118
  lastTerm = stack.pop();
119
- } while (!lastTerm.group);
119
+ } while (lastTerm && !lastTerm.group);
120
120
 
121
121
  continue;
122
122
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wuffle",
3
- "version": "0.74.0",
3
+ "version": "0.76.0",
4
4
  "description": "A multi-repository task board for GitHub issues",
5
5
  "author": {
6
6
  "name": "Nico Rehwaldt",
@@ -44,7 +44,7 @@
44
44
  "dependencies": {
45
45
  "@aws-sdk/client-s3": "^3.1037.0",
46
46
  "async-didi": "^1.0.0",
47
- "body-parser": "^2.2.2",
47
+ "body-parser": "^2.3.0",
48
48
  "compression": "^1.8.1",
49
49
  "express-session": "^1.19.0",
50
50
  "fake-tag": "^5.0.0",
@@ -63,11 +63,11 @@
63
63
  "@types/mocha": "^10.0.10",
64
64
  "@types/sinon": "^21.0.1",
65
65
  "chai": "^6.2.2",
66
- "graphql": "^16.13.2",
67
- "mocha": "^11.7.5",
68
- "nock": "^14.0.13",
66
+ "graphql": "^17.0.0",
67
+ "mocha": "^11.7.6",
68
+ "nock": "^14.0.15",
69
69
  "nodemon": "^3.1.14",
70
- "npm-run-all2": "^8.0.4",
70
+ "npm-run-all2": "^9.0.2",
71
71
  "sinon": "^22.0.0",
72
72
  "sinon-chai": "^4.0.0",
73
73
  "typescript": "^5.9.3"
@@ -83,5 +83,5 @@
83
83
  "index.js",
84
84
  "wuffle.config.example.js"
85
85
  ],
86
- "gitHead": "ea9ac74526b02f9834b5e926df0e8e82536eba07"
86
+ "gitHead": "3a21cf84280395b89d369422bbaf16f2b63c20dd"
87
87
  }