pr-report-core 0.1.0 → 0.2.1

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.
Files changed (83) hide show
  1. package/dist/checks.d.mts +61 -62
  2. package/dist/checks.d.mts.map +1 -1
  3. package/dist/checks.mjs +8 -9
  4. package/dist/checks.mjs.map +1 -1
  5. package/dist/claim.d.mts +13 -14
  6. package/dist/claim.d.mts.map +1 -1
  7. package/dist/claim.mjs +0 -1
  8. package/dist/claim.mjs.map +1 -1
  9. package/dist/closing-issues.d.mts +18 -19
  10. package/dist/closing-issues.d.mts.map +1 -1
  11. package/dist/closing-issues.mjs +0 -1
  12. package/dist/closing-issues.mjs.map +1 -1
  13. package/dist/code-owners.d.mts +48 -49
  14. package/dist/code-owners.d.mts.map +1 -1
  15. package/dist/code-owners.mjs +41 -43
  16. package/dist/code-owners.mjs.map +1 -1
  17. package/dist/labels.d.mts +38 -40
  18. package/dist/labels.d.mts.map +1 -1
  19. package/dist/labels.mjs +0 -2
  20. package/dist/labels.mjs.map +1 -1
  21. package/dist/merge-after.d.mts +22 -16
  22. package/dist/merge-after.d.mts.map +1 -1
  23. package/dist/merge-after.mjs +97 -34
  24. package/dist/merge-after.mjs.map +1 -1
  25. package/dist/queue-comments.d.mts +2 -2
  26. package/dist/queue-order.d.mts +43 -30
  27. package/dist/queue-order.d.mts.map +1 -1
  28. package/dist/queue-order.mjs +33 -18
  29. package/dist/queue-order.mjs.map +1 -1
  30. package/dist/queue-owner.d.mts +30 -30
  31. package/dist/queue-owner.d.mts.map +1 -1
  32. package/dist/queue-owner.mjs +51 -51
  33. package/dist/queue-owner.mjs.map +1 -1
  34. package/dist/report.d.mts +9 -9
  35. package/dist/report.d.mts.map +1 -1
  36. package/dist/report.mjs +23 -23
  37. package/dist/report.mjs.map +1 -1
  38. package/dist/review-hold.d.mts +14 -14
  39. package/dist/ruleset.d.mts +10 -11
  40. package/dist/ruleset.d.mts.map +1 -1
  41. package/dist/ruleset.mjs +0 -1
  42. package/dist/ruleset.mjs.map +1 -1
  43. package/dist/set-aside.d.mts +28 -28
  44. package/dist/stack.d.mts +14 -15
  45. package/dist/stack.d.mts.map +1 -1
  46. package/dist/stack.mjs +0 -1
  47. package/dist/stack.mjs.map +1 -1
  48. package/dist/start-after.d.mts +20 -21
  49. package/dist/start-after.d.mts.map +1 -1
  50. package/dist/start-after.mjs +0 -1
  51. package/dist/start-after.mjs.map +1 -1
  52. package/dist/summarize.d.mts +2 -2
  53. package/dist/timestamp.d.mts +10 -10
  54. package/dist/tree.d.mts +5 -5
  55. package/dist/types.d.mts +32 -33
  56. package/dist/types.d.mts.map +1 -1
  57. package/package.json +8 -8
  58. package/src/checks.mts +74 -75
  59. package/src/checks.test.mts +30 -31
  60. package/src/claim.mts +13 -14
  61. package/src/closing-issues.mts +18 -19
  62. package/src/closing-issues.test.mts +2 -2
  63. package/src/code-owners.mts +107 -109
  64. package/src/labels.mts +38 -40
  65. package/src/merge-after.mts +164 -71
  66. package/src/merge-after.test.mts +80 -3
  67. package/src/queue-comments.mts +2 -2
  68. package/src/queue-order.mts +103 -83
  69. package/src/queue-order.test.mts +41 -4
  70. package/src/queue-owner.mts +103 -103
  71. package/src/queue-owner.test.mts +2 -2
  72. package/src/report.mts +49 -49
  73. package/src/review-hold.mts +14 -14
  74. package/src/review-hold.test.mts +3 -3
  75. package/src/ruleset.mts +10 -11
  76. package/src/set-aside.mts +28 -28
  77. package/src/stack.mts +14 -15
  78. package/src/stack.test.mts +4 -4
  79. package/src/start-after.mts +20 -21
  80. package/src/summarize.mts +2 -2
  81. package/src/timestamp.mts +14 -14
  82. package/src/tree.mts +5 -5
  83. package/src/types.mts +32 -33
@@ -2,13 +2,13 @@
2
2
  * Where each pull request stands in `unblock-prs`'s queue, and the order the
3
3
  * worker picks the ones it may pick.
4
4
  *
5
- * The worker decides on someone's machine, from what `gh` and `git` return,
6
- * and the Pull Requests Manager page draws the same queue from what GraphQL
5
+ * The worker decides on someone's machine, from what `gh` and `git` return, and
6
+ * the Pull Requests Manager page draws the same queue from what GraphQL
7
7
  * returns. So the deciding is here, once, over facts either side can supply,
8
8
  * and each side does only its own reading: the worker reads some facts only
9
9
  * when the answer needs them — the checks of a `BLOCKED` pull request, the
10
- * native stack of one without auto-merge — which is why
11
- * {@link queueStanding} asks for them rather than taking them all up front.
10
+ * native stack of one without auto-merge — which is why {@link queueStanding}
11
+ * asks for them rather than taking them all up front.
12
12
  *
13
13
  * What the worker alone knows is left out: which pull requests one run has
14
14
  * demoted (`demotions.mts` in `unblock-prs`) lives in that run's memory, so
@@ -39,8 +39,8 @@ export type QueueFacts = Readonly<{
39
39
  headRef: string;
40
40
  baseRef: string;
41
41
  /**
42
- * GitHub's `mergeStateStatus` — `BEHIND`, `DIRTY`, `CLEAN`, `BLOCKED` and
43
- * so on — or as near to it as the reader can tell.
42
+ * GitHub's `mergeStateStatus` — `BEHIND`, `DIRTY`, `CLEAN`, `BLOCKED` and so
43
+ * on — or as near to it as the reader can tell.
44
44
  */
45
45
  mergeState: string;
46
46
  autoMerge: boolean;
@@ -57,8 +57,8 @@ export type QueueGraphMember = Readonly<{
57
57
  baseRef: string;
58
58
  fromFork: boolean;
59
59
  /**
60
- * The open issues it closes, whose `Start-After:` it inherits. Absent for
61
- * a reader that did not read them: nothing is inherited.
60
+ * The open issues it closes, whose `Start-After:` it inherits. Absent for a
61
+ * reader that did not read them: nothing is inherited.
62
62
  */
63
63
  closes?: readonly number[];
64
64
  }>;
@@ -68,8 +68,8 @@ export type QueueGraph = Readonly<{
68
68
  /** Which open pull request each stacked one is on: child → parent. */
69
69
  stackParents: ReadonlyMap<number, number>;
70
70
  /**
71
- * What each pull request must merge after: what it declared, the one it
72
- * is stacked on, and what it inherits.
71
+ * What each pull request must merge after: what it declared, the one it is
72
+ * stacked on, and what it inherits.
73
73
  */
74
74
  dependencies: ReadonlyMap<number, readonly number[]>;
75
75
  /**
@@ -165,9 +165,9 @@ export const versionBranchName = (defaultBranch: string): string =>
165
165
  `changeset-release/${defaultBranch}` as const;
166
166
 
167
167
  /**
168
- * Whether a pull request from `headRef` is the version pull request. The
169
- * branch is the only stable handle on it: the title and body are rewritten
170
- * on every run, and the number changes with every release.
168
+ * Whether a pull request from `headRef` is the version pull request. The branch
169
+ * is the only stable handle on it: the title and body are rewritten on every
170
+ * run, and the number changes with every release.
171
171
  */
172
172
  export const isVersionBranch = (
173
173
  headRef: string,
@@ -191,8 +191,8 @@ export const isMergeableState = (mergeState: string): boolean =>
191
191
 
192
192
  /**
193
193
  * The stacks, the declared order and its cycles, and what holds the release,
194
- * read off the open pull requests. `startAfterOf` is what the issues they
195
- * close declare, each issue's `Start-After:` by its number.
194
+ * read off the open pull requests. `startAfterOf` is what the issues they close
195
+ * declare, each issue's `Start-After:` by its number.
196
196
  */
197
197
  export const queueGraph = (
198
198
  pulls: readonly QueueGraphMember[],
@@ -219,12 +219,12 @@ export const queueGraph = (
219
219
 
220
220
  return [
221
221
  pr.number,
222
- // The version pull request declares nothing: its body is rewritten
223
- // on every push to the base, so a trailer written there would hold
224
- // for a cycle or two and then silently stop. `blocks-release`, on
225
- // the pull requests it is waiting for, is how an order is declared
226
- // on it. Other pull requests may still name *it*. A stacked pull
227
- // request waits for the layer below it whether or not it says so.
222
+ // The version pull request declares nothing: its body is rewritten on
223
+ // every push to the base, so a trailer written there would hold for a
224
+ // cycle or two and then silently stop. `blocks-release`, on the pull
225
+ // requests it is waiting for, is how an order is declared on it. Other
226
+ // pull requests may still name *it*. A stacked pull request waits for
227
+ // the layer below it whether or not it says so.
228
228
  isVersionBranch(pr.headRef, defaultBranch)
229
229
  ? []
230
230
  : Arr.uniq([
@@ -241,8 +241,8 @@ export const queueGraph = (
241
241
 
242
242
  return [
243
243
  number,
244
- // The version pull request closes nothing it means to, and is held
245
- // by `blocks-release` alone.
244
+ // The version pull request closes nothing it means to, and is held by
245
+ // `blocks-release` alone.
246
246
  isVersionBranch(headRef, defaultBranch)
247
247
  ? []
248
248
  : (inheritedOrders.get(number)?.mergeAfter ?? []).filter(
@@ -288,15 +288,15 @@ export const queueStanding = (
288
288
  if (outOfScope !== undefined) {
289
289
  // `merge-queued` is an explicit request, so anything that answers it with
290
290
  // "no" is worth saying out loud — a queued pull request left as a draft
291
- // would otherwise sit in the queue forever with nothing saying why. A
292
- // pull request that never asked is passed over in silence.
291
+ // would otherwise sit in the queue forever with nothing saying why. A pull
292
+ // request that never asked is passed over in silence.
293
293
  return hasLabel(pr, MERGE_QUEUED_LABEL)
294
294
  ? { kind: 'unactionable', reason: outOfScope }
295
295
  : { kind: 'ignore' };
296
296
  }
297
297
 
298
- // A pull request set aside, or claimed, is a skill's or a person's until
299
- // its comment is resolved (`queue-owner.mts`).
298
+ // A pull request set aside, or claimed, is a skill's or a person's until its
299
+ // comment is resolved (`queue-owner.mts`).
300
300
  const owner = context.owners.get(pr.number);
301
301
 
302
302
  if (owner !== undefined && owner.owner !== 'worker') {
@@ -327,9 +327,9 @@ export const queueStanding = (
327
327
  }
328
328
  }
329
329
 
330
- // The version pull request is rebuilt from the tip of the base by the
331
- // release workflow, never rebased here; taking `skip-ci` off is the whole
332
- // of what it can be given.
330
+ // The version pull request is rebuilt from the tip of the base by the release
331
+ // workflow, never rebased here; taking `skip-ci` off is the whole of what it
332
+ // can be given.
333
333
  if (isVersionBranch(pr.headRef, context.defaultBranch)) {
334
334
  return versionStanding(pr, context, reads);
335
335
  }
@@ -342,9 +342,9 @@ export const queueStanding = (
342
342
  }
343
343
 
344
344
  switch (pr.mergeState) {
345
- // `DIRTY` is a candidate too: it answers whether *merging* conflicts,
346
- // from a cached computation, where the worker *rebases*, and the rebase
347
- // is what says whether the conflict is real.
345
+ // `DIRTY` is a candidate too: it answers whether *merging* conflicts, from
346
+ // a cached computation, where the worker *rebases*, and the rebase is what
347
+ // says whether the conflict is real.
348
348
  case 'BEHIND':
349
349
  case 'DIRTY':
350
350
  return waitingOn(pr, context) ?? { kind: 'candidate' };
@@ -363,10 +363,10 @@ export const queueStanding = (
363
363
  };
364
364
 
365
365
  /**
366
- * Whether what {@link queueStanding} decided is worth asking the review
367
- * about: one about to be picked, or one about to be watched while GitHub
368
- * still calls it `BLOCKED`. A merge state GitHub already calls mergeable is
369
- * its own answer that nothing but the checks is left.
366
+ * Whether what {@link queueStanding} decided is worth asking the review about:
367
+ * one about to be picked, or one about to be watched while GitHub still calls
368
+ * it `BLOCKED`. A merge state GitHub already calls mergeable is its own answer
369
+ * that nothing but the checks is left.
370
370
  */
371
371
  export const asksReview = (
372
372
  standing: QueueStanding,
@@ -377,8 +377,8 @@ export const asksReview = (
377
377
 
378
378
  /**
379
379
  * The standing once the review is known: a hold that waits for someone holds
380
- * it; one that only a bypass merges, and none at all, change nothing here —
381
- * the worker leaves the first unarmed, which is not a question of order.
380
+ * it; one that only a bypass merges, and none at all, change nothing here — the
381
+ * worker leaves the first unarmed, which is not a question of order.
382
382
  */
383
383
  export const withReviewHold = (
384
384
  standing: QueueStanding,
@@ -392,9 +392,9 @@ export const withReviewHold = (
392
392
  export type QueuePriority = 'high' | 'low' | 'none' | 'both';
393
393
 
394
394
  /**
395
- * Which priority label a pull request carries: `both` is the two at once,
396
- * which contradict each other and so count as neither. `priority:mid` is the
397
- * same as neither here.
395
+ * Which priority label a pull request carries: `both` is the two at once, which
396
+ * contradict each other and so count as neither. `priority:mid` is the same as
397
+ * neither here.
398
398
  */
399
399
  export const priorityOfLabels = (labels: readonly string[]): QueuePriority => {
400
400
  const high = labels.includes(PRIORITY_HIGH_LABEL);
@@ -423,16 +423,38 @@ export const priorityRankOfLabels = (labels: readonly string[]): 0 | 1 | 2 => {
423
423
  };
424
424
 
425
425
  /**
426
- * The order the worker picks candidates in. The declared order has already
427
- * been applied, as a gate rather than a sort, so this is lowest number first
428
- * within these ranks, outermost first:
426
+ * {@link priorityRankOfLabels}, for a pull request that `bypass` says only a
427
+ * ruleset bypass merges ({@link ReviewHold}): that one ranks with
428
+ * `priority:low` unless labelled `priority:high` — alone, since the two
429
+ * together count as neither. Picked, it is released, goes green and is set
430
+ * aside for a person, so the cycle spent on it lands nothing; picked last, it
431
+ * still has its checks run. `priority:high` is a person saying they want that
432
+ * verdict now, and is the one way to ask for it, so it still counts.
433
+ */
434
+ export const pickPriorityRank = (
435
+ labels: readonly string[],
436
+ bypass: boolean,
437
+ ): 0 | 1 | 2 => {
438
+ const rank = priorityRankOfLabels(labels);
439
+
440
+ return bypass && rank !== 0
441
+ ? priorityRankOfLabels([PRIORITY_LOW_LABEL])
442
+ : rank;
443
+ };
444
+
445
+ /**
446
+ * The order the worker picks candidates in. The declared order has already been
447
+ * applied, as a gate rather than a sort, so this is lowest number first within
448
+ * these ranks, outermost first:
429
449
  *
430
450
  * 1. What a watch has seen green and still open (`demoted`) goes after
431
451
  * everything else, the release and `priority:high` included: everything
432
452
  * else is not known to sit without merging, and this is.
433
453
  * 2. `priority:high` before the rest, `priority:low` after them. It is a
434
454
  * person's declaration, so it outranks the two guesses below — and the
435
- * version pull request reads it too.
455
+ * version pull request reads it too. One only a ruleset bypass merges
456
+ * counts as `priority:low` unless labelled `priority:high`
457
+ * ({@link pickPriorityRank}).
436
458
  * 3. What GitHub only *thinks* conflicts goes after the rest: a rebase the
437
459
  * worker is sure of is worth spending the cycle on before one it is
438
460
  * guessing at.
@@ -441,8 +463,8 @@ export const priorityRankOfLabels = (labels: readonly string[]): 0 | 1 | 2 => {
441
463
  * release: it only says that when a queued change and the release are
442
464
  * both ready, the change goes first.
443
465
  *
444
- * `toSorted` is stable, so each sort keeps the order of the one before it
445
- * among equals, and the last one is the outermost.
466
+ * `toSorted` is stable, so each sort keeps the order of the one before it among
467
+ * equals, and the last one is the outermost.
446
468
  */
447
469
  export const orderCandidates = <P,>(
448
470
  candidates: readonly P[],
@@ -450,6 +472,7 @@ export const orderCandidates = <P,>(
450
472
  context: Readonly<{
451
473
  defaultBranch: string;
452
474
  demoted: (candidate: P) => boolean;
475
+ bypass: (candidate: P) => boolean;
453
476
  }>,
454
477
  ): readonly P[] => {
455
478
  const rank = (candidate: P): number => {
@@ -462,19 +485,27 @@ export const orderCandidates = <P,>(
462
485
  : 0;
463
486
  };
464
487
 
488
+ const priorityRank = (candidate: P): number =>
489
+ pickPriorityRank(factsOf(candidate).labels, context.bypass(candidate));
490
+
465
491
  return candidates
466
492
  .toSorted((a, b) => factsOf(a).number - factsOf(b).number)
467
493
  .toSorted((a, b) => rank(a) - rank(b))
468
- .toSorted(
469
- (a, b) =>
470
- priorityRankOfLabels(factsOf(a).labels) -
471
- priorityRankOfLabels(factsOf(b).labels),
472
- )
494
+ .toSorted((a, b) => priorityRank(a) - priorityRank(b))
473
495
  .toSorted(
474
496
  (a, b) => Number(context.demoted(a)) - Number(context.demoted(b)),
475
497
  );
476
498
  };
477
499
 
500
+ /**
501
+ * Whether `pr` carries the label `name`. A function rather than `includes` in
502
+ * place, which narrows the label constant itself to `never` where it says no.
503
+ */
504
+ const hasLabel = (
505
+ pr: Readonly<{ labels: readonly string[] }>,
506
+ name: string,
507
+ ): boolean => pr.labels.includes(name);
508
+
478
509
  /** Why a pull request is none of the queue's business, if it is not. */
479
510
  const outOfScopeReason = (
480
511
  pr: QueueFacts,
@@ -490,10 +521,9 @@ const outOfScopeReason = (
490
521
  return `not labelled ${MERGE_QUEUED_LABEL}`;
491
522
  }
492
523
 
493
- // A base that is neither the default branch nor another open pull
494
- // request's branch is one nothing here knows how to land on. A stacked one
495
- // whose parent has just merged reads this way for the moment before GitHub
496
- // moves it.
524
+ // A base that is neither the default branch nor another open pull request's
525
+ // branch is one nothing here knows how to land on. A stacked one whose parent
526
+ // has just merged reads this way for the moment before GitHub moves it.
497
527
  return pr.isDraft
498
528
  ? 'draft'
499
529
  : pr.baseRef !== context.defaultBranch &&
@@ -522,17 +552,28 @@ const waitingOn = (
522
552
  };
523
553
 
524
554
  /**
525
- * The version pull request: held while anything labelled `blocks-release`
526
- * is open, and while the branch was built before the tip of the base, then
527
- * a candidate if paused and otherwise judged by its checks.
555
+ * Pending — including a required context that has not reported at all — or
556
+ * green and about to merge is in flight; a failed required check is failing.
557
+ */
558
+ const byChecks = (reads: QueueReads): QueueStanding =>
559
+ reads.checksFailed === undefined
560
+ ? ({ kind: 'needs', read: 'checksFailed' } as const)
561
+ : reads.checksFailed
562
+ ? ({ kind: 'failing' } as const)
563
+ : ({ kind: 'in-flight' } as const);
564
+
565
+ /**
566
+ * The version pull request: held while anything labelled `blocks-release` is
567
+ * open, and while the branch was built before the tip of the base, then a
568
+ * candidate if paused and otherwise judged by its checks.
528
569
  */
529
570
  const versionStanding = (
530
571
  pr: QueueFacts,
531
572
  context: QueueContext,
532
573
  reads: QueueReads,
533
574
  ): QueueStanding => {
534
- // Never one of its own blockers: the label on it would otherwise hold it
535
- // for as long as it existed.
575
+ // Never one of its own blockers: the label on it would otherwise hold it for
576
+ // as long as it existed.
536
577
  const by = context.releaseBlockers.filter(
537
578
  (blocker) => blocker.number !== pr.number,
538
579
  );
@@ -547,24 +588,3 @@ const versionStanding = (
547
588
  ? { kind: 'candidate' }
548
589
  : byChecks(reads);
549
590
  };
550
-
551
- /**
552
- * Pending — including a required context that has not reported at all — or
553
- * green and about to merge is in flight; a failed required check is failing.
554
- */
555
- const byChecks = (reads: QueueReads): QueueStanding =>
556
- reads.checksFailed === undefined
557
- ? ({ kind: 'needs', read: 'checksFailed' } as const)
558
- : reads.checksFailed
559
- ? ({ kind: 'failing' } as const)
560
- : ({ kind: 'in-flight' } as const);
561
-
562
- /**
563
- * Whether `pr` carries the label `name`. A function rather than `includes`
564
- * in place, which narrows the label constant itself to `never` where it says
565
- * no.
566
- */
567
- const hasLabel = (
568
- pr: Readonly<{ labels: readonly string[] }>,
569
- name: string,
570
- ): boolean => pr.labels.includes(name);
@@ -253,8 +253,8 @@ describe(queueStanding, () => {
253
253
  );
254
254
  });
255
255
 
256
- // Otherwise it would hold itself for as long as it existed, with nothing
257
- // able to take the label off — a `Merge-After` cycle by another name.
256
+ // Otherwise it would hold itself for as long as it existed, with nothing able
257
+ // to take the label off — a `Merge-After` cycle by another name.
258
258
  test('never holds the release by its own label', () => {
259
259
  const release = facts({
260
260
  number: 1,
@@ -338,8 +338,8 @@ describe(orderCandidates, () => {
338
338
  });
339
339
 
340
340
  // Priority comes before every rank but the demotion, the release included:
341
- // a release can be declared urgent, and a change declared able to wait
342
- // goes out in the release after.
341
+ // a release can be declared urgent, and a change declared able to wait goes
342
+ // out in the release after.
343
343
  test('takes priority:high first and priority:low last, before what sat green', () => {
344
344
  assert.deepStrictEqual(
345
345
  order(
@@ -383,15 +383,52 @@ describe(orderCandidates, () => {
383
383
  [2, 3, 1],
384
384
  );
385
385
  });
386
+
387
+ // Picked early, it is released, goes green and is set aside for a person, and
388
+ // the cycle it took went to nothing that could merge on its own.
389
+ test('puts one only a bypass merges with priority:low, unless labelled priority:high', () => {
390
+ assert.deepStrictEqual(
391
+ order(
392
+ [
393
+ facts({ number: 1 }),
394
+ facts({
395
+ number: 2,
396
+ labels: [
397
+ MERGE_QUEUED_LABEL,
398
+ PRIORITY_HIGH_LABEL,
399
+ PRIORITY_LOW_LABEL,
400
+ ],
401
+ }),
402
+ facts({
403
+ number: 3,
404
+ labels: [MERGE_QUEUED_LABEL, PRIORITY_LOW_LABEL],
405
+ }),
406
+ facts({ number: 4, headRef: 'changeset-release/main' }),
407
+ facts({ number: 5, mergeState: 'DIRTY' }),
408
+ facts({ number: 6 }),
409
+ facts({ number: 7 }),
410
+ facts({
411
+ number: 8,
412
+ labels: [MERGE_QUEUED_LABEL, PRIORITY_HIGH_LABEL],
413
+ }),
414
+ ],
415
+ [7],
416
+ [1, 2, 7, 8],
417
+ ),
418
+ [8, 6, 5, 4, 1, 2, 3, 7],
419
+ );
420
+ });
386
421
  });
387
422
 
388
423
  const order = (
389
424
  candidates: readonly QueueFacts[],
390
425
  demoted: readonly number[] = [],
426
+ bypass: readonly number[] = [],
391
427
  ): readonly number[] =>
392
428
  orderCandidates(candidates, (candidate) => candidate, {
393
429
  defaultBranch: 'main',
394
430
  demoted: (candidate) => demoted.includes(candidate.number),
431
+ bypass: (candidate) => bypass.includes(candidate.number),
395
432
  }).map(({ number }) => number);
396
433
 
397
434
  const facts = (