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.
package/app.yml CHANGED
@@ -46,6 +46,7 @@ default_events:
46
46
  # - team
47
47
  # - team_add
48
48
  # - watch
49
+ - sub_issues
49
50
 
50
51
  # The set of permissions needed by the GitHub App. The format of the object uses
51
52
  # the permission name for the key (for example, issues) and the access type for
@@ -60,7 +60,7 @@ export default function AuthRoutes(logger, router, securityContext) {
60
60
  };
61
61
 
62
62
  const params = new URLSearchParams();
63
- params.append('client_id', process.env.GITHUB_CLIENT_ID);
63
+ params.append('client_id', /** @type {string} */ (process.env.GITHUB_CLIENT_ID));
64
64
  params.append('state', state);
65
65
  params.append('redirect_uri', appUrl('/wuffle/login/callback'));
66
66
 
@@ -81,10 +81,9 @@ export default function AuthRoutes(logger, router, securityContext) {
81
81
 
82
82
  log.debug({ session_id }, 'logging out');
83
83
 
84
- return req.session.destroy(function(err) {
85
- return res.redirect(redirectTo);
86
- }) && null;
87
-
84
+ req.session.destroy(function(err) {
85
+ res.redirect(redirectTo);
86
+ });
88
87
  });
89
88
 
90
89
 
@@ -128,8 +127,8 @@ export default function AuthRoutes(logger, router, securityContext) {
128
127
  const params = new URLSearchParams();
129
128
  params.append('code', /** @type {string} */ (code));
130
129
  params.append('state', /** @type {string} */ (state));
131
- params.append('client_id', process.env.GITHUB_CLIENT_ID);
132
- params.append('client_secret', process.env.GITHUB_CLIENT_SECRET);
130
+ params.append('client_id', /** @type {string} */ (process.env.GITHUB_CLIENT_ID));
131
+ params.append('client_secret', /** @type {string} */ (process.env.GITHUB_CLIENT_SECRET));
133
132
  params.append('redirect_uri', appUrl('/wuffle/login/callback'));
134
133
 
135
134
  const {
@@ -185,7 +184,9 @@ export default function AuthRoutes(logger, router, securityContext) {
185
184
  } = session;
186
185
 
187
186
  if (!githubUser) {
188
- return res.type('json').json(null) && null;
187
+ res.type('json').json(null);
188
+
189
+ return;
189
190
  }
190
191
 
191
192
  const {
@@ -216,17 +217,18 @@ export default function AuthRoutes(logger, router, securityContext) {
216
217
  }, 'failed to check GitHub authentication');
217
218
 
218
219
  // access is not granted anymore, clear current session
219
- return req.session.destroy(function(err) {
220
- return res.type('json').json(null);
221
- }) && null;
220
+ req.session.destroy(function(err) {
221
+ res.type('json').json(null);
222
+ });
223
+
224
+ return;
222
225
  }
223
226
  }
224
227
 
225
- return res.type('json').json({
228
+ res.type('json').json({
226
229
  login,
227
230
  avatar_url
228
- }) && null;
229
-
231
+ });
230
232
  });
231
233
 
232
234
 
@@ -31,14 +31,13 @@ export default function(webhookEvents, githubIssues, columns, issueFilter, logge
31
31
  'pull_request.closed'
32
32
  ], ifEnabled(async (context) => {
33
33
 
34
- const {
35
- pull_request,
36
- issue
37
- } = context.payload;
38
-
39
34
  const column = columns.getByState(DONE);
40
35
 
41
- await githubIssues.moveIssue(context, issue || pull_request, column);
36
+ const issueOrPull = 'issue' in context.payload
37
+ ? context.payload.issue
38
+ : context.payload.pull_request;
39
+
40
+ await githubIssues.moveIssue(context, issueOrPull, column);
42
41
  }));
43
42
 
44
43
  webhookEvents.on('pull_request.converted_to_draft', ifEnabled(async (context) => {
@@ -107,7 +106,7 @@ export default function(webhookEvents, githubIssues, columns, issueFilter, logge
107
106
  const newAssignee = (
108
107
  process.env.AUTO_ASSIGN_PULLS && !external &&
109
108
  author && author.type === 'User' && author.login
110
- );
109
+ ) || null;
111
110
 
112
111
  await Promise.all([
113
112
  githubIssues.moveIssue(context, pull_request, column, newAssignee),
@@ -172,7 +171,7 @@ export default function(webhookEvents, githubIssues, columns, issueFilter, logge
172
171
  return;
173
172
  }
174
173
 
175
- const issue_number = match[1];
174
+ const issue_number = parseInt(match[1], 10);
176
175
 
177
176
  const column = columns.getByState(IN_PROGRESS);
178
177
 
@@ -9,6 +9,17 @@ function isInternalError(error) {
9
9
  /**
10
10
  * This component performs a periodic background sync of a project.
11
11
  *
12
+ * Unless disabled via `process.env.DISABLE_BACKGROUND_SYNC` it will
13
+ * register a recurring check.
14
+ *
15
+ * Background check performs various optimizations to ensure only relevant
16
+ * data is stored on the board:
17
+ *
18
+ * * Closed issues/PRs on the board will be thrashed
19
+ * * Closed issues/PRs wiil not be synchronized from GitHub
20
+ * * Open but stale issues/PRs will not be synchronized to the board
21
+ * * Open issue/PR details will only be synchronized for recent issues
22
+ *
12
23
  * @constructor
13
24
  *
14
25
  * @param {Object} config
@@ -21,25 +32,25 @@ export default function BackgroundSync(config, logger, store, events, background
21
32
 
22
33
  // 30 days
23
34
  const syncClosedLookback = (
24
- parseInt(process.env.BACKGROUND_SYNC_SYNC_CLOSED_LOOKBACK, 10) ||
35
+ parseInt(process.env.BACKGROUND_SYNC_SYNC_CLOSED_LOOKBACK || '', 10) ||
25
36
  1000 * 60 * 60 * 24 * 30
26
37
  );
27
38
 
28
39
  // 4 hours
29
40
  const syncClosedDetailsLookback = (
30
- parseInt(process.env.BACKGROUND_SYNC_SYNC_CLOSED_DETAILS_LOOKBACK, 10) ||
41
+ parseInt(process.env.BACKGROUND_SYNC_SYNC_CLOSED_DETAILS_LOOKBACK || '', 10) ||
31
42
  1000 * 60 * 60 * 4
32
43
  );
33
44
 
34
45
  // 1 day
35
46
  const syncOpenDetailsLookback = (
36
- parseInt(process.env.BACKGROUND_SYNC_SYNC_OPEN_DETAILS_LOOKBACK, 10) ||
47
+ parseInt(process.env.BACKGROUND_SYNC_SYNC_OPEN_DETAILS_LOOKBACK || '', 10) ||
37
48
  1000 * 60 * 60 * 24
38
49
  );
39
50
 
40
51
  // 60 days
41
52
  const removeClosedLookback = (
42
- parseInt(process.env.BACKGROUND_SYNC_REMOVE_CLOSED_LOOKBACK, 10) ||
53
+ parseInt(process.env.BACKGROUND_SYNC_REMOVE_CLOSED_LOOKBACK || '', 10) ||
43
54
  1000 * 60 * 60 * 24 * 60
44
55
  );
45
56
 
@@ -393,6 +404,13 @@ We automatically synchronize all repositories you granted us access to via the G
393
404
  return Promise.all(jobs);
394
405
  }
395
406
 
407
+ /**
408
+ * Trigger background synchronization for all connected repositories.
409
+ *
410
+ * This ensures that data out-of-sync with the board is fetched from remote.
411
+ *
412
+ * @return {Promise<void>}
413
+ */
396
414
  async function backgroundSync() {
397
415
 
398
416
  log.info('start');
@@ -410,7 +428,7 @@ We automatically synchronize all repositories you granted us access to via the G
410
428
  }
411
429
 
412
430
  const syncInterval = (
413
- parseInt(process.env.BACKGROUND_SYNC_SYNC_INTERVAL, 10) || (
431
+ parseInt(process.env.BACKGROUND_SYNC_SYNC_INTERVAL || '', 10) || (
414
432
  process.env.NODE_ENV !== 'production'
415
433
 
416
434
  // five minutes
@@ -442,6 +460,13 @@ We automatically synchronize all repositories you granted us access to via the G
442
460
 
443
461
  // api ///////////////////
444
462
 
463
+ /**
464
+ * Trigger background synchronization for all connected repositories.
465
+ *
466
+ * This ensures that data out-of-sync with the board is fetched from remote.
467
+ *
468
+ * @return {Promise<void>}
469
+ */
445
470
  this.backgroundSync = backgroundSync;
446
471
 
447
472
 
@@ -174,17 +174,17 @@ export default async function BoardApiRoutes(
174
174
 
175
175
  return filterBoardItems(req, items).then(filteredItems => {
176
176
 
177
- return res.type('json').json({
177
+ res.type('json').json({
178
178
  items: filteredItems,
179
179
  cursor
180
- }) && null;
180
+ });
181
181
  }).catch(err => {
182
182
  log.error({
183
183
  err,
184
184
  cursor
185
185
  }, 'failed to retrieve cards');
186
186
 
187
- return res.status(500).json({ error : true }) && null;
187
+ res.status(500).json({ error : true });
188
188
  });
189
189
  });
190
190
 
@@ -196,7 +196,7 @@ export default async function BoardApiRoutes(
196
196
  name
197
197
  } = config;
198
198
 
199
- return res.type('json').json({
199
+ res.type('json').json({
200
200
  columns: columns.map(c => {
201
201
  const { name, collapsed } = c;
202
202
 
@@ -206,7 +206,7 @@ export default async function BoardApiRoutes(
206
206
  };
207
207
  }),
208
208
  name: name || 'Wuffle Board'
209
- }) && null;
209
+ });
210
210
 
211
211
  });
212
212
 
@@ -216,18 +216,16 @@ export default async function BoardApiRoutes(
216
216
 
217
217
  const updates = cursor ? store.getUpdates(cursor) : [];
218
218
 
219
- return (
220
- filterUpdates(req, updates).then(filteredUpdates => {
221
- res.type('json').json(filteredUpdates);
222
- }).catch(err => {
223
- log.error({
224
- err,
225
- cursor
226
- }, 'failed to retrieve card updates');
227
-
228
- res.status(500).json({ error : true });
229
- })
230
- );
219
+ return filterUpdates(req, updates).then(filteredUpdates => {
220
+ res.type('json').json(filteredUpdates);
221
+ }).catch(err => {
222
+ log.error({
223
+ err,
224
+ cursor
225
+ }, 'failed to retrieve card updates');
226
+
227
+ res.status(500).json({ error : true });
228
+ });
231
229
  });
232
230
 
233
231
 
@@ -236,7 +234,9 @@ export default async function BoardApiRoutes(
236
234
  const user = authRoutes.getGitHubUser(req);
237
235
 
238
236
  if (!user) {
239
- return res.status(401).json({}) && null;
237
+ res.status(401).json({});
238
+
239
+ return;
240
240
  }
241
241
 
242
242
  const body = JSON.parse(req.body);
@@ -251,13 +251,17 @@ export default async function BoardApiRoutes(
251
251
  const issue = await store.getIssueById(id);
252
252
 
253
253
  if (!issue) {
254
- return res.status(404).json({}) && null;
254
+ res.status(404).json({});
255
+
256
+ return;
255
257
  }
256
258
 
257
259
  const column = columns.getByName(columnName);
258
260
 
259
261
  if (!column) {
260
- return res.status(404).json({}) && null;
262
+ res.status(404).json({});
263
+
264
+ return;
261
265
  }
262
266
 
263
267
  const repo = repoAndOwner(issue);
@@ -265,7 +269,9 @@ export default async function BoardApiRoutes(
265
269
  const canWrite = await userAccess.canWrite(user, repo);
266
270
 
267
271
  if (!canWrite) {
268
- return res.status(403).json({}) && null;
272
+ res.status(403).json({});
273
+
274
+ return;
269
275
  }
270
276
 
271
277
  const octokit = await githubClient.getUserScoped(user);
@@ -280,15 +286,13 @@ export default async function BoardApiRoutes(
280
286
  }
281
287
  };
282
288
 
283
- return (
284
- moveIssue(context, issue, column, { before, after }).then(() => {
285
- res.type('json').json({});
286
- }).catch(err => {
287
- log.error(err, 'failed to move issue');
289
+ return moveIssue(context, issue, column, { before, after }).then(() => {
290
+ res.type('json').json({});
291
+ }).catch(err => {
292
+ log.error(err, 'failed to move issue');
288
293
 
289
- res.status(500).json({ error : true });
290
- })
291
- ) && null;
294
+ res.status(500).json({ error : true });
295
+ });
292
296
 
293
297
  });
294
298
 
@@ -16,6 +16,18 @@ export default function S3() {
16
16
  S3_ENDPOINT: endpoint
17
17
  } = process.env;
18
18
 
19
+ if (!accessKeyId) {
20
+ throw new Error('process.env.AWS_ACCESS_KEY_ID required');
21
+ }
22
+
23
+ if (!secretAccessKey) {
24
+ throw new Error('process.env.AWS_SECRET_ACCESS_KEY required');
25
+ }
26
+
27
+ if (!bucket) {
28
+ throw new Error('process.env.S3_BUCKET required');
29
+ }
30
+
19
31
  const s3client = new S3Client({
20
32
  region,
21
33
  endpoint,
@@ -227,12 +227,29 @@ export default function EventsSync(webhookEvents, store, logger) {
227
227
  });
228
228
 
229
229
 
230
- // issues ///////////////////////////////
230
+ // sub-issues /////////////////////
231
231
 
232
- // https://docs.github.com/en/free-pro-team@latest/developers/webhooks-and-events/webhook-events-and-payloads#issues
233
232
 
234
- // issue transfer is mapped to the following GitHub events
233
+ // https://docs.github.com/en/webhooks/webhook-events-and-payloads#sub_issues
234
+ //
235
+ // update sub-issues on change - updating parent <> child relationship
236
+ webhookEvents.on(
237
+ /** @type {any} */ ([ 'sub_issues.sub_issue_added', 'sub_issues.sub_issue_removed' ]),
238
+ async ({ payload }) => {
239
+
240
+ const {
241
+ sub_issue,
242
+ repository
243
+ } = /** @type {any} */ (payload);
244
+
245
+ return store.updateIssue(filterIssue(sub_issue, repository));
246
+ }
247
+ );
248
+
249
+
250
+ // https://docs.github.com/en/free-pro-team@latest/developers/webhooks-and-events/webhook-events-and-payloads#issues
235
251
  //
252
+ // issue transfer is mapped to the following GitHub events:
236
253
  // -> issues.opened (new issue is being opened by GitHub)
237
254
  // -> issues.transferred (old issue was deleted by GitHub)
238
255
  //
@@ -19,11 +19,13 @@ const RequiredEvents = [
19
19
  'issues',
20
20
  'issue_comment',
21
21
  'label',
22
+ 'member',
22
23
  'milestone',
23
24
  'pull_request',
24
25
  'pull_request_review',
25
26
  'repository',
26
- 'status'
27
+ 'status',
28
+ 'sub_issues'
27
29
  ];
28
30
 
29
31
  /**
@@ -41,8 +43,9 @@ const RequiredEvents = [
41
43
  * @param {import('../../types.js').ProbotApp} app
42
44
  * @param {import('../../types.js').Logger} logger
43
45
  * @param {import('../../types.js').Injector} injector
46
+ * @param {import('../../events.js').default} events
44
47
  */
45
- export default function GithubApp(config, app, logger, injector) {
48
+ export default function GithubApp(config, app, logger, injector, events) {
46
49
 
47
50
  const log = logger.child({
48
51
  name: 'wuffle:github-app'
@@ -266,6 +269,31 @@ export default function GithubApp(config, app, logger, injector) {
266
269
  log.debug('validated installations');
267
270
  }
268
271
 
272
+ async function validateApp() {
273
+ const octokit = await getAppScopedClient();
274
+ const { data: app } = await octokit.rest.apps.getAuthenticated();
275
+
276
+ // app may not be configured yet
277
+ if (!app) {
278
+ return;
279
+ }
280
+
281
+ const missingEvents = RequiredEvents.filter(
282
+ event => !app.events.includes(event)
283
+ );
284
+
285
+ if (missingEvents.length) {
286
+ log.error({
287
+ missingEvents,
288
+ events: app.events
289
+ }, 'app is missing required event subscriptions; update app settings on GitHub');
290
+ }
291
+ }
292
+
293
+ events.once('wuffle.start', async function() {
294
+ await validateApp().catch(err => log.warn({ err }, 'failed to validate app configuration'));
295
+ });
296
+
269
297
  /**
270
298
  * Fetch active installations.
271
299
  *
@@ -312,7 +340,7 @@ export default function GithubApp(config, app, logger, injector) {
312
340
  /**
313
341
  * Get an installation for the given id.
314
342
  *
315
- * @param {string} id
343
+ * @param {string|number} id
316
344
  *
317
345
  * @return {Promise<Installation | undefined>}
318
346
  */
@@ -3,6 +3,8 @@ import { repoAndOwner, Cache } from '../../util/index.js';
3
3
  /**
4
4
  * This component updates the stored issues based on GitHub events.
5
5
  *
6
+ * It also hooks into `BackgroundSync` to fetch checks for a PR.
7
+ *
6
8
  * @constructor
7
9
  *
8
10
  * @param {import('../webhook-events/WebhookEvents.js').default} webhookEvents
@@ -1,21 +1,21 @@
1
- import { repoAndOwner } from '../../util/index.js';
2
1
  import { filterUser, filterIssue } from '../../filters.js';
3
- import gql from 'fake-tag';
4
2
 
5
3
 
6
4
  /**
7
5
  * This component updates the stored issues based on GitHub events.
8
6
  *
7
+ * It also hooks into `BackgroundSync` to fetch comments for issues/PRs.
8
+ *
9
9
  * @constructor
10
10
  *
11
11
  * @param {import('../webhook-events/WebhookEvents.js').default} webhookEvents
12
12
  * @param {import('../../events.js').default} events
13
- * @param {import('../github-client/GithubClient.js').default} githubClient
14
13
  * @param {import('../../store.js').default} store
15
14
  * @param {import('../issue-filter/IssueFilter.js').default} issueFilter
16
15
  * @param {import('../../types.js').Logger } logger
16
+ * @param {import('./GithubCommentsBackend.js').default} githubCommentsBackend
17
17
  */
18
- export default function GithubComments(webhookEvents, events, githubClient, store, issueFilter, logger) {
18
+ export default function GithubComments(webhookEvents, events, store, issueFilter, logger, githubCommentsBackend) {
19
19
 
20
20
  const log = logger.child({
21
21
  name: 'wuffle:github-comments'
@@ -32,81 +32,10 @@ export default function GithubComments(webhookEvents, events, githubClient, stor
32
32
  } = event;
33
33
 
34
34
  const {
35
- id,
36
- number
35
+ id
37
36
  } = issue;
38
37
 
39
- const {
40
- repo,
41
- owner
42
- } = repoAndOwner(issue);
43
-
44
- const github = await githubClient.getOrgScoped(owner);
45
-
46
- const result = await github.graphql(gql`
47
-
48
- fragment CommentInfo on IssueComment {
49
- id: databaseId
50
- node_id: id
51
- body: bodyText
52
- created_at: publishedAt
53
- authorAssociation,
54
- html_url: url,
55
- user: author {
56
- login
57
- avatar_url: avatarUrl,
58
- html_url: url
59
- }
60
- }
61
-
62
- query FetchComments(
63
- $repo: String!,
64
- $owner: String!,
65
- $issue_number: Int!,
66
- $after: String
67
- ) {
68
- repository(name: $repo, owner: $owner) {
69
- issueOrPullRequest(number: $issue_number) {
70
- ...on Issue {
71
- comments(first: 100, after: $after) {
72
- edges {
73
- node {
74
- ...CommentInfo
75
- }
76
- }
77
- pageInfo {
78
- endCursor
79
- hasNextPage
80
- }
81
- totalCount
82
- }
83
- }
84
- ...on PullRequest {
85
- comments(first: 100, after: $after) {
86
- edges {
87
- node {
88
- ...CommentInfo
89
- }
90
- }
91
- pageInfo {
92
- endCursor
93
- hasNextPage
94
- }
95
- totalCount
96
- }
97
- }
98
- }
99
- }
100
- }`,
101
- {
102
- owner,
103
- repo,
104
- issue_number: number
105
- });
106
-
107
- const comments = (
108
- result.repository.issueOrPullRequest.comments.edges.map(e => e.node)
109
- );
38
+ const comments = await githubCommentsBackend.getIssueComments(issue);
110
39
 
111
40
  await store.queueUpdate({
112
41
  id,
@@ -115,7 +44,9 @@ export default function GithubComments(webhookEvents, events, githubClient, stor
115
44
  });
116
45
 
117
46
  webhookEvents.on([
118
- 'issue_comment'
47
+ 'issue_comment.created',
48
+ 'issue_comment.edited',
49
+ 'issue_comment.deleted'
119
50
  ], ifEnabled(async ({ payload }) => {
120
51
  const {
121
52
  action,