pr-report-core 0.2.0 → 0.3.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.
Files changed (107) hide show
  1. package/README.md +5 -2
  2. package/dist/branch-rules.d.mts +30 -0
  3. package/dist/branch-rules.d.mts.map +1 -0
  4. package/dist/branch-rules.mjs +71 -0
  5. package/dist/branch-rules.mjs.map +1 -0
  6. package/dist/checks.d.mts +61 -62
  7. package/dist/checks.d.mts.map +1 -1
  8. package/dist/checks.mjs +0 -1
  9. package/dist/checks.mjs.map +1 -1
  10. package/dist/claim.d.mts +21 -15
  11. package/dist/claim.d.mts.map +1 -1
  12. package/dist/claim.mjs +1 -1
  13. package/dist/claim.mjs.map +1 -1
  14. package/dist/closing-issues.d.mts +18 -19
  15. package/dist/closing-issues.d.mts.map +1 -1
  16. package/dist/closing-issues.mjs +0 -1
  17. package/dist/closing-issues.mjs.map +1 -1
  18. package/dist/code-owners.d.mts +33 -34
  19. package/dist/code-owners.d.mts.map +1 -1
  20. package/dist/code-owners.mjs +0 -2
  21. package/dist/code-owners.mjs.map +1 -1
  22. package/dist/index.d.mts +3 -0
  23. package/dist/index.d.mts.map +1 -1
  24. package/dist/index.mjs +3 -0
  25. package/dist/index.mjs.map +1 -1
  26. package/dist/issue-claim.d.mts +56 -0
  27. package/dist/issue-claim.d.mts.map +1 -0
  28. package/dist/issue-claim.mjs +87 -0
  29. package/dist/issue-claim.mjs.map +1 -0
  30. package/dist/issue-priority.d.mts +44 -0
  31. package/dist/issue-priority.d.mts.map +1 -0
  32. package/dist/issue-priority.mjs +91 -0
  33. package/dist/issue-priority.mjs.map +1 -0
  34. package/dist/labels.d.mts +43 -42
  35. package/dist/labels.d.mts.map +1 -1
  36. package/dist/labels.mjs +2 -1
  37. package/dist/labels.mjs.map +1 -1
  38. package/dist/merge-after.d.mts +22 -16
  39. package/dist/merge-after.d.mts.map +1 -1
  40. package/dist/merge-after.mjs +87 -24
  41. package/dist/merge-after.mjs.map +1 -1
  42. package/dist/queue-comments.d.mts +2 -2
  43. package/dist/queue-order.d.mts +32 -32
  44. package/dist/queue-order.mjs +0 -2
  45. package/dist/queue-order.mjs.map +1 -1
  46. package/dist/queue-owner.d.mts +14 -14
  47. package/dist/queue-owner.d.mts.map +1 -1
  48. package/dist/queue-owner.mjs +18 -0
  49. package/dist/queue-owner.mjs.map +1 -1
  50. package/dist/report.d.mts +4 -4
  51. package/dist/review-hold.d.mts +14 -14
  52. package/dist/ruleset.d.mts +12 -13
  53. package/dist/ruleset.d.mts.map +1 -1
  54. package/dist/ruleset.mjs +0 -1
  55. package/dist/ruleset.mjs.map +1 -1
  56. package/dist/set-aside.d.mts +29 -28
  57. package/dist/set-aside.d.mts.map +1 -1
  58. package/dist/set-aside.mjs +2 -0
  59. package/dist/set-aside.mjs.map +1 -1
  60. package/dist/stack.d.mts +14 -15
  61. package/dist/stack.d.mts.map +1 -1
  62. package/dist/stack.mjs +0 -1
  63. package/dist/stack.mjs.map +1 -1
  64. package/dist/start-after.d.mts +20 -21
  65. package/dist/start-after.d.mts.map +1 -1
  66. package/dist/start-after.mjs +0 -1
  67. package/dist/start-after.mjs.map +1 -1
  68. package/dist/summarize.d.mts +2 -2
  69. package/dist/timestamp.d.mts +10 -10
  70. package/dist/tree.d.mts +5 -5
  71. package/dist/types.d.mts +32 -33
  72. package/dist/types.d.mts.map +1 -1
  73. package/package.json +10 -10
  74. package/src/branch-rules.mts +105 -0
  75. package/src/branch-rules.test.mts +221 -0
  76. package/src/checks.mts +64 -65
  77. package/src/checks.test.mts +30 -31
  78. package/src/claim.mts +16 -16
  79. package/src/closing-issues.mts +18 -19
  80. package/src/closing-issues.test.mts +2 -2
  81. package/src/code-owners.mts +39 -41
  82. package/src/index.mts +3 -0
  83. package/src/issue-claim.mts +138 -0
  84. package/src/issue-claim.test.mts +149 -0
  85. package/src/issue-priority.mts +116 -0
  86. package/src/issue-priority.test.mts +71 -0
  87. package/src/labels.mts +43 -42
  88. package/src/merge-after.mts +142 -49
  89. package/src/merge-after.test.mts +80 -3
  90. package/src/queue-comments.mts +2 -2
  91. package/src/queue-order.mts +60 -62
  92. package/src/queue-order.test.mts +6 -6
  93. package/src/queue-owner.mts +46 -25
  94. package/src/queue-owner.test.mts +4 -2
  95. package/src/report.mts +9 -9
  96. package/src/review-hold.mts +14 -14
  97. package/src/review-hold.test.mts +3 -3
  98. package/src/ruleset.mts +12 -13
  99. package/src/set-aside.mts +30 -28
  100. package/src/set-aside.test.mts +22 -0
  101. package/src/stack.mts +14 -15
  102. package/src/stack.test.mts +4 -4
  103. package/src/start-after.mts +20 -21
  104. package/src/summarize.mts +2 -2
  105. package/src/timestamp.mts +14 -14
  106. package/src/tree.mts +5 -5
  107. package/src/types.mts +32 -33
@@ -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(
@@ -384,8 +384,8 @@ describe(orderCandidates, () => {
384
384
  );
385
385
  });
386
386
 
387
- // Picked early, it is released, goes green and is set aside for a person,
388
- // and the cycle it took went to nothing that could merge on its own.
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
389
  test('puts one only a bypass merges with priority:low, unless labelled priority:high', () => {
390
390
  assert.deepStrictEqual(
391
391
  order(
@@ -4,26 +4,26 @@
4
4
  * Who is to move each pull request in the queue next: the worker
5
5
  * (`pnpm run unblock-prs`), a skill, a person, or — which is a bug — nobody.
6
6
  *
7
- * The worker and the `/unblock-prs` skill move the same queue side by side
8
- * and cannot see each other. Each used to decide for itself whether a
7
+ * The worker and the `/unblock-prs` skill move the same queue side by side and
8
+ * cannot see each other. Each used to decide for itself whether a
9
9
  * set-aside still stood, from its own reading of the tip of the default
10
- * branch, and when the two readings differed each left the pull request to
11
- * the other (#2267). So the answer is decided here, once, and every reader
12
- * asks this function: the worker before it acts, `pr-report` for the skill,
10
+ * branch, and when the two readings differed each left the pull request to the
11
+ * other (#2267). So the answer is decided here, once, and every reader asks
12
+ * this function: the worker before it acts, `pr-report` for the skill,
13
13
  * `claim-pr` before it lets a claim be written, and `report-unowned-prs` to
14
14
  * turn `none` into an issue.
15
15
  *
16
16
  * **It reads the comments and the pull request, never the tip of the base.**
17
- * The set-aside comment is the hand-off: it stands until the worker rewrites
18
- * it as resolved — when the head moves, when a retry is asked for, or, for a
17
+ * The set-aside comment is the hand-off: it stands until the worker rewrites it
18
+ * as resolved — when the head moves, when a retry is asked for, or, for a
19
19
  * reason tied to the base, when the base moves and the worker takes the pull
20
- * request back to try again. Until then it is the skill's or a person's, and
21
- * a worker that has stopped running leaves it so rather than nobody's.
20
+ * request back to try again. Until then it is the skill's or a person's, and a
21
+ * worker that has stopped running leaves it so rather than nobody's.
22
22
  *
23
23
  * **Every reason the worker sets aside for has an owner**
24
24
  * ({@link setAsideOwner}, exhaustive over {@link SET_ASIDE_REASONS}), so `none`
25
- * is only ever a combination nothing here expected: a reason this module
26
- * does not know, written by a worker newer than the reader.
25
+ * is only ever a combination nothing here expected: a reason this module does
26
+ * not know, written by a worker newer than the reader.
27
27
  */
28
28
 
29
29
  import { claimApplies, type ClaimComment } from './claim.mjs';
@@ -40,8 +40,8 @@ export const QUEUE_OWNERS = ['worker', 'skill', 'human', 'none'] as const;
40
40
 
41
41
  /**
42
42
  * `skill` is the `/unblock-prs` skill, which fixes every queued pull request
43
- * the worker sets aside — a branch the repository generates included, which
44
- * it builds again rather than rebases (`generatedBranches` in
43
+ * the worker sets aside — a branch the repository generates included, which it
44
+ * builds again rather than rebases (`generatedBranches` in
45
45
  * `.github/unblock-prs.json`).
46
46
  */
47
47
  export type QueueOwner = Readonly<{
@@ -55,8 +55,11 @@ export const SET_ASIDE_REASONS = [
55
55
  'arm-failed',
56
56
  'awaiting-bypass',
57
57
  'checks-failed',
58
+ 'checks-not-started',
59
+ 'head-out-of-sync',
58
60
  'not-merging',
59
61
  'push-failed',
62
+ 'read-failed',
60
63
  'rebase-failed',
61
64
  'unlabel-failed',
62
65
  'watch-timeout',
@@ -96,8 +99,8 @@ export const queueOwner = (pr: QueueOwnerInput): QueueOwner | undefined => {
96
99
  return { owner: 'human', reason: unmovable };
97
100
  }
98
101
 
99
- // A layer waits for the one below whatever it says, and the worker carries
100
- // it along when that one moves.
102
+ // A layer waits for the one below whatever it says, and the worker carries it
103
+ // along when that one moves.
101
104
  if (pr.stackedOn !== undefined) {
102
105
  return {
103
106
  owner: 'worker',
@@ -173,12 +176,12 @@ export const isSetAsideReason = (reason: string): reason is SetAsideReason =>
173
176
  KNOWN_REASONS.has(reason);
174
177
 
175
178
  /**
176
- * Whose a pull request set aside for `reason` is, while the comment stands
177
- * at its head. A skill's reason is one a session can fix by pushing: a
178
- * conflict to resolve, a failure to reproduce. A person's is one only a
179
- * setting, a permission, a review or a merge by hand clears, and the comment
180
- * says which. A `switch` with no default, so a reason added to
181
- * {@link SET_ASIDE_REASONS} without an owner fails to compile.
179
+ * Whose a pull request set aside for `reason` is, while the comment stands at
180
+ * its head. A skill's reason is one a session can fix by pushing: a conflict to
181
+ * resolve, a failure to reproduce. A person's is one only a setting, a
182
+ * permission, a review or a merge by hand clears, and the comment says which. A
183
+ * `switch` with no default, so a reason added to {@link SET_ASIDE_REASONS}
184
+ * without an owner fails to compile.
182
185
  */
183
186
  const setAsideOwner = (
184
187
  reason: SetAsideReason,
@@ -206,12 +209,30 @@ const setAsideOwner = (
206
209
  why: 'green, and only a ruleset bypass merges it',
207
210
  };
208
211
 
212
+ case 'checks-not-started':
213
+ return {
214
+ owner: 'human',
215
+ why: 'GitHub started no run for a required check on its head; the base moving puts it back in the queue to be rebased in its turn, and a new commit pushed to the branch starts the checks sooner',
216
+ };
217
+
218
+ case 'head-out-of-sync':
219
+ return {
220
+ owner: 'human',
221
+ why: 'GitHub never took in the last push to its branch, so it still shows an older head; push to the branch again, or close and reopen it',
222
+ };
223
+
209
224
  case 'not-merging':
210
225
  return {
211
226
  owner: 'human',
212
227
  why: 'green but not merging; something the worker cannot read holds it',
213
228
  };
214
229
 
230
+ case 'read-failed':
231
+ return {
232
+ owner: 'human',
233
+ why: 'its checks or its base could not be read survey after survey; the comment says what failed',
234
+ };
235
+
215
236
  case 'push-failed':
216
237
  return {
217
238
  owner: 'human',
@@ -254,8 +275,8 @@ export type QueueOwnerInput = Readonly<{
254
275
  }>;
255
276
 
256
277
  /**
257
- * The set-aside `comment` says, if it stands at `headSha` with no retry
258
- * asked for. The base is not read: see the module comment.
278
+ * The set-aside `comment` says, if it stands at `headSha` with no retry asked
279
+ * for. The base is not read: see the module comment.
259
280
  */
260
281
  const standingAt = (
261
282
  comment: SetAsideComment | undefined,
@@ -270,8 +291,8 @@ const standingAt = (
270
291
  const KNOWN_REASONS: ReadonlySet<string> = new Set(SET_ASIDE_REASONS);
271
292
 
272
293
  /**
273
- * Why nothing in the queue can move `pr` until a person does something, if
274
- * so: the worker passes these over with a note, and no skill takes them up.
294
+ * Why nothing in the queue can move `pr` until a person does something, if so:
295
+ * the worker passes these over with a note, and no skill takes them up.
275
296
  */
276
297
  const unmovableReason = (pr: QueueOwnerInput): string | undefined =>
277
298
  pr.isDraft
@@ -135,6 +135,8 @@ describe(queueOwner, () => {
135
135
  'unlabel-failed',
136
136
  'push-failed',
137
137
  'watch-timeout',
138
+ 'head-out-of-sync',
139
+ 'checks-not-started',
138
140
  ] as const)('is a person’s when set aside %s', (reason) => {
139
141
  assert.strictEqual(
140
142
  queueOwner({ ...queued, setAside: standing(reason) })?.owner,
@@ -188,8 +190,8 @@ describe(queueOwner, () => {
188
190
  },
189
191
  );
190
192
 
191
- // The same as for any other branch: which branches are generated, and
192
- // how, is the repository's configuration, which the skill reads.
193
+ // The same as for any other branch: which branches are generated, and how,
194
+ // is the repository's configuration, which the skill reads.
193
195
  assert.deepStrictEqual(
194
196
  queueOwner({ ...update, setAside: standing('rebase-failed') }),
195
197
  {
package/src/report.mts CHANGED
@@ -29,8 +29,8 @@ import type {
29
29
  /**
30
30
  * Reads the declared merge order and the check verdicts out of the facts.
31
31
  *
32
- * Nothing here talks to GitHub, which is what lets the whole shape of a
33
- * report be tested against a handful of literals.
32
+ * Nothing here talks to GitHub, which is what lets the whole shape of a report
33
+ * be tested against a handful of literals.
34
34
  */
35
35
  export const buildReport = ({
36
36
  repo,
@@ -96,8 +96,8 @@ export const buildEntries = ({
96
96
  }: Readonly<{
97
97
  /**
98
98
  * The contexts the ruleset requires, or `undefined` for a repository that
99
- * declares none: each pull request is then judged over every context
100
- * reported on its head.
99
+ * declares none: each pull request is then judged over every context reported
100
+ * on its head.
101
101
  */
102
102
  required: readonly string[] | undefined;
103
103
  defaultBranch: string;
@@ -195,9 +195,9 @@ export const buildEntries = ({
195
195
 
196
196
  /**
197
197
  * Each open issue with how it stands for being started
198
- * (`docs/issue-start-after.md`): its start state, or how far someone has
199
- * got with it when it is startable (`progressOf`), and where each thing it
200
- * names stands.
198
+ * (`docs/issue-start-after.md`): its start state, or how far someone has got
199
+ * with it when it is startable (`progressOf`), and where each thing it names
200
+ * stands.
201
201
  */
202
202
  const reportIssues = (
203
203
  issues: readonly OpenIssue[],
@@ -243,8 +243,8 @@ const NO_DECLARATIONS: IssueDeclarations = {
243
243
 
244
244
  /**
245
245
  * The issues a pull request closes that may still declare an order: any not
246
- * known to be closed. One read from the body without a token has an
247
- * `unknown` state, and is given the benefit of the doubt.
246
+ * known to be closed. One read from the body without a token has an `unknown`
247
+ * state, and is given the benefit of the doubt.
248
248
  */
249
249
  const notKnownClosed = (
250
250
  linkedIssues: PullRequestFacts['linkedIssues'],
@@ -5,18 +5,18 @@
5
5
  * `main`'s ruleset asks for two things no check reports: an approval from a
6
6
  * code owner of every owned path the pull request touches, and every review
7
7
  * conversation resolved. A queued pull request missing either goes green and
8
- * sits there, and a rebase cannot change that. Released anyway, it costs a
9
- * full matrix and holds the queue for the length of a watch; and because
10
- * `main` moving is what puts it back in the running, it would be rebased and
11
- * run again every time anything else merged. So triage reads both before
12
- * picking, and leaves a pull request they hold in the queue with a note,
13
- * until an approval or a resolved conversation changes the answer.
8
+ * sits there, and a rebase cannot change that. Released anyway, it costs a full
9
+ * matrix and holds the queue for the length of a watch; and because `main`
10
+ * moving is what puts it back in the running, it would be rebased and run again
11
+ * every time anything else merged. So triage reads both before picking, and
12
+ * leaves a pull request they hold in the queue with a note, until an approval
13
+ * or a resolved conversation changes the answer.
14
14
  *
15
15
  * Except when nothing can change it: GitHub does not let an author approve
16
16
  * their own pull request, so one whose waiting paths only its author owns
17
- * merges by a ruleset bypass or not at all. Holding that one would hold it
18
- * for ever, with `skip-ci` on and no verdict for whoever makes the bypass
19
- * merge to go by. So it is released and watched like any other, with
17
+ * merges by a ruleset bypass or not at all. Holding that one would hold it for
18
+ * ever, with `skip-ci` on and no verdict for whoever makes the bypass merge to
19
+ * go by. So it is released and watched like any other, with
20
20
  * auto-merge left off — it could never fire — until its checks report, and
21
21
  * then left for that person (`awaiting-bypass`).
22
22
  *
@@ -50,11 +50,11 @@ export type ReviewRules = Readonly<{
50
50
  }>;
51
51
 
52
52
  /**
53
- * What the review does to the pull request: `held` waits for an approval or
54
- * a resolved conversation, `bypass` waits for nobody — only its author owns
55
- * what is waiting — and `undefined` holds nothing. What cannot be told for
56
- * sure — changed files past what could be read — holds nothing: the watch
57
- * still catches a pull request that goes green and does not merge, and
53
+ * What the review does to the pull request: `held` waits for an approval or a
54
+ * resolved conversation, `bypass` waits for nobody — only its author owns what
55
+ * is waiting — and `undefined` holds nothing. What cannot be told for sure —
56
+ * changed files past what could be read — holds nothing: the watch still
57
+ * catches a pull request that goes green and does not merge, and
58
58
  * `demotions.mts` is what follows.
59
59
  */
60
60
  export type ReviewHold = Readonly<{ kind: 'bypass' | 'held'; reason: string }>;
@@ -123,9 +123,9 @@ describe(reviewHold, () => {
123
123
  );
124
124
  });
125
125
 
126
- // A changed file past what could be read might be owned, but "might" is not
127
- // a reason to hold the queue: the watch still catches a pull request that
128
- // goes green and does not merge.
126
+ // A changed file past what could be read might be owned, but "might" is not a
127
+ // reason to hold the queue: the watch still catches a pull request that goes
128
+ // green and does not merge.
129
129
  test('holds nothing it cannot be sure of', () => {
130
130
  assert.isUndefined(
131
131
  reviewHold(
package/src/ruleset.mts CHANGED
@@ -10,8 +10,8 @@ export type RulesetRequirements = Readonly<{
10
10
  /** The contexts a pull request has to satisfy. */
11
11
  requiredContexts: readonly string[];
12
12
  /**
13
- * Whether a change to a path `.github/CODEOWNERS` lists waits for one of
14
- * its owners to approve it.
13
+ * Whether a change to a path `.github/CODEOWNERS` lists waits for one of its
14
+ * owners to approve it.
15
15
  */
16
16
  requireCodeOwnerReview: boolean;
17
17
  /** Whether every review conversation has to be resolved first. */
@@ -21,13 +21,12 @@ export type RulesetRequirements = Readonly<{
21
21
  /**
22
22
  * The requirements, read from the declaration rather than from GitHub, for
23
23
  * `pnpm run pr-report`, which reads the checkout. The file is the desired
24
- * state; what GitHub enforces is {@link requirementsOfRules} over
25
- * `rules/branches/{branch}`, which `unblock-prs` and the Pull Requests
26
- * Manager read and which wants no more than read access. The gap is that a
27
- * file under `repo-settings/` changes nothing until
28
- * `pnpm run repo-settings:apply` runs, so a context added here and not
29
- * applied is reported as missing on every pull request — which is a fair
30
- * thing for a report to say out loud.
24
+ * state; what GitHub enforces is `readBranchRequirements`, which reads it for
25
+ * `unblock-prs`, the Pull Requests Manager and `pr-report` pointed elsewhere,
26
+ * and which wants no more than read access. The gap is that a file under
27
+ * `repo-settings/` changes nothing until `pnpm run repo-settings:apply` runs,
28
+ * so a context added here and not applied is reported as missing on every pull
29
+ * request — which is a fair thing for a report to say out loud.
31
30
  *
32
31
  * `source` names the file in the messages.
33
32
  */
@@ -98,8 +97,8 @@ const RulesetSchema = t.record({ rules: t.array(t.unknown()) });
98
97
 
99
98
  /**
100
99
  * One rule of the several kinds a ruleset holds. The others carry entirely
101
- * different parameters, so each rule is tried against this and the ones that
102
- * do not match are simply not the rule being looked for.
100
+ * different parameters, so each rule is tried against this and the ones that do
101
+ * not match are simply not the rule being looked for.
103
102
  *
104
103
  * Written here rather than taken from `octokit-safe-types`, whose
105
104
  * `RepositoryRule` is the whole union: validating against it would refuse a
@@ -126,8 +125,8 @@ const ConversationResolutionRuleSchema = t.record({
126
125
 
127
126
  /**
128
127
  * The rules as read: `ruleset.test.mts` checks each against GitHub's API
129
- * description, so that a field read here that GitHub renames or retypes
130
- * fails the type check.
128
+ * description, so that a field read here that GitHub renames or retypes fails
129
+ * the type check.
131
130
  */
132
131
  export type RequiredStatusChecksRule = t.TypeOf<
133
132
  typeof RequiredStatusChecksRuleSchema
package/src/set-aside.mts CHANGED
@@ -1,30 +1,30 @@
1
1
  /**
2
2
  * Why `unblock-prs` set a pull request aside, written where GitHub keeps it.
3
3
  *
4
- * The script decides on someone's machine and says so on standard output,
5
- * which is where nobody can see it afterwards. So each time it sets a pull
6
- * request aside it also writes a comment on that pull request — one per pull
7
- * request, edited in place from then on — whose first line is a hidden
8
- * record this module writes and reads back, and whose last line is a task
9
- * box a person ticks to have the script try again. What lies between is
10
- * prose for whoever reads it, and nothing reads it back. The Pull Requests
4
+ * The script decides on someone's machine and says so on standard output, which
5
+ * is where nobody can see it afterwards. So each time it sets a pull request
6
+ * aside it also writes a comment on that pull request — one per pull request,
7
+ * edited in place from then on — whose first line is a hidden record this
8
+ * module writes and reads back, and whose last line is a task box a person
9
+ * ticks to have the script try again. What lies between is prose for whoever
10
+ * reads it, and nothing reads it back. The Pull Requests
11
11
  * Manager page and the script itself read the record; anyone else reads the
12
12
  * prose on the pull request.
13
13
  *
14
14
  * **A comment rather than a commit status**, which is what it used to be. A
15
- * status has no state that means "for information": `failure` left a red ✗ on
16
- * a context nothing requires, until the next push, with 140 characters to say
15
+ * status has no state that means "for information": `failure` left a red ✗ on a
16
+ * context nothing requires, until the next push, with 140 characters to say
17
17
  * why, and nobody could take it off or ask for another try. **Rather than the
18
18
  * body**, because the author, a session and `open-pr` write the body too, the
19
19
  * REST API has no conditional update to keep an edit made in between, and the
20
20
  * version pull request's body is rewritten on every push to the base.
21
21
  *
22
- * **Only the comments the reader's own account wrote count.** The repository
23
- * is public, so anyone can post a comment that starts with the marker; the
24
- * script and the page both ask GitHub for `viewerDidAuthor` and read nothing
25
- * else — the account that runs `unblock-prs` is the one that looks at the
26
- * page. A task box in someone else's comment can be ticked only by those who
27
- * can write to the repository, so the retry is not open to outsiders either.
22
+ * **Only the comments the reader's own account wrote count.** The repository is
23
+ * public, so anyone can post a comment that starts with the marker; the script
24
+ * and the page both ask GitHub for `viewerDidAuthor` and read nothing else —
25
+ * the account that runs `unblock-prs` is the one that looks at the page. A task
26
+ * box in someone else's comment can be ticked only by those who can write to
27
+ * the repository, so the retry is not open to outsiders either.
28
28
  *
29
29
  * **Its own record rather than GitHub's `mergeable`.** That answers whether
30
30
  * *merging* the branch conflicts, from a cached background computation; the
@@ -32,8 +32,8 @@
32
32
  * parent was squash-merged and on a cache that has not caught up. Only the
33
33
  * script knows what the rebase said.
34
34
  *
35
- * **It lasts exactly as long as the state it was reached in.** The record
36
- * names the head and the base it was reached at; a push moves the head, and
35
+ * **It lasts exactly as long as the state it was reached in.** The record names
36
+ * the head and the base it was reached at; a push moves the head, and
37
37
  * {@link setAsideApplies} is where a moved head and a moved base are judged,
38
38
  * for the script and the page alike. Once it no longer applies, or a person
39
39
  * asked for a retry, the script rewrites the comment to one resolved line
@@ -60,9 +60,9 @@ export type SetAside = Readonly<{
60
60
  }>;
61
61
 
62
62
  /**
63
- * What one of the script's comments says: a pull request still set aside,
64
- * and whether a person has ticked the retry box on it, or one that was and
65
- * is not any more.
63
+ * What one of the script's comments says: a pull request still set aside, and
64
+ * whether a person has ticked the retry box on it, or one that was and is not
65
+ * any more.
66
66
  */
67
67
  export type SetAsideComment = Readonly<
68
68
  | { kind: 'resolved' }
@@ -70,9 +70,9 @@ export type SetAsideComment = Readonly<
70
70
  >;
71
71
 
72
72
  /**
73
- * The record line, `prose`, and an empty retry box on the last line. The
74
- * whole SHAs rather than short ones, because what reads them back compares
75
- * them with the head and the base.
73
+ * The record line, `prose`, and an empty retry box on the last line. The whole
74
+ * SHAs rather than short ones, because what reads them back compares them with
75
+ * the head and the base.
76
76
  */
77
77
  export const writeSetAsideComment = (
78
78
  setAside: SetAside,
@@ -148,11 +148,12 @@ export const setAsideApplies = (
148
148
  * judged the head.
149
149
  *
150
150
  * Every reason clears when the base moves, because a moved base is the one
151
- * thing the script knows how to act on — a rebase that conflicted may not
152
- * any more — except two whose head a rebase would only put through the same
153
- * matrix again: `checks-failed`, which the push that carries the fix clears,
154
- * and `awaiting-bypass`, green and waiting for a ruleset bypass merge, which
155
- * does not wait for the base.
151
+ * thing the script knows how to act on — a rebase that conflicted may not any
152
+ * more — except two whose head a rebase would only put through the same matrix
153
+ * again: `checks-failed`, which the push that carries the fix clears, and
154
+ * `awaiting-bypass`, green and waiting for a ruleset bypass merge, which does
155
+ * not wait for the base. Nor does `head-out-of-sync`: GitHub not having taken
156
+ * in the branch's last push is not something the base moving changes.
156
157
  */
157
158
  export const setAsideStillApplies = (
158
159
  setAside: Readonly<{ reason: string; baseSha: string }>,
@@ -160,6 +161,7 @@ export const setAsideStillApplies = (
160
161
  ): boolean =>
161
162
  setAside.reason === 'checks-failed' ||
162
163
  setAside.reason === 'awaiting-bypass' ||
164
+ setAside.reason === 'head-out-of-sync' ||
163
165
  setAside.baseSha === baseTip;
164
166
 
165
167
  /** The first line starts with this, so the comment is found by it. */
@@ -160,4 +160,26 @@ describe(setAsideStillApplies, () => {
160
160
  ),
161
161
  );
162
162
  });
163
+
164
+ // GitHub never took in the branch's last push, and the base moving does not
165
+ // make it take it in; a comment rewritten on every merge would be noise.
166
+ test('outlives a moved base when the branch is out of sync with GitHub', () => {
167
+ assert.isTrue(
168
+ setAsideStillApplies(
169
+ { reason: 'head-out-of-sync', baseSha: BASE },
170
+ 'b'.repeat(40),
171
+ ),
172
+ );
173
+ });
174
+
175
+ // The rebase that a moved base brings is a new head, and the push that
176
+ // carries it is what starts the workflows GitHub did not start before.
177
+ test('ends with a moved base when no check run started on the head', () => {
178
+ assert.isFalse(
179
+ setAsideStillApplies(
180
+ { reason: 'checks-not-started', baseSha: BASE },
181
+ 'b'.repeat(40),
182
+ ),
183
+ );
184
+ });
163
185
  });
package/src/stack.mts CHANGED
@@ -3,13 +3,13 @@
3
3
  * branch, so that its diff is its own layer and nothing below it.
4
4
  *
5
5
  * The stack is read from the bases alone, because GitHub already holds it: a
6
- * pull request onto `a` is on top of whichever pull request is `a`'s. When
7
- * that one merges, GitHub moves the layer above it onto its base and rebases
8
- * it, so the base chain is the merge order, and everything that orders pull
9
- * requests here reads a parent as a `Merge-After:` on it. `open-pr --base`
10
- * writes that trailer too, so that the body states the order as it does for
11
- * every other; a stack made any other way, without one, counts all the same,
12
- * and one naming the parent is not counted twice.
6
+ * pull request onto `a` is on top of whichever pull request is `a`'s. When that
7
+ * one merges, GitHub moves the layer above it onto its base and rebases it, so
8
+ * the base chain is the merge order, and everything that orders pull requests
9
+ * here reads a parent as a `Merge-After:` on it. `open-pr --base` writes that
10
+ * trailer too, so that the body states the order as it does for every other; a
11
+ * stack made any other way, without one, counts all the same, and one naming
12
+ * the parent is not counted twice.
13
13
  */
14
14
 
15
15
  /** The fields a stack is read from. */
@@ -20,8 +20,8 @@ export type StackMember = Readonly<{
20
20
  headRef: string;
21
21
  baseRef: string;
22
22
  /**
23
- * Whether the head branch lives in a fork. A fork's branch is not a branch
24
- * of this repository, whatever its name, so it is never a parent.
23
+ * Whether the head branch lives in a fork. A fork's branch is not a branch of
24
+ * this repository, whatever its name, so it is never a parent.
25
25
  */
26
26
  fromFork: boolean;
27
27
  }>;
@@ -31,9 +31,9 @@ export type StackMember = Readonly<{
31
31
  * A pull request that is not stacked has no entry.
32
32
  *
33
33
  * A base is a parent's head only when exactly one open pull request from this
34
- * repository heads it: a branch opened against two bases is two pull
35
- * requests, and which of them a layer above means cannot be told. Nothing
36
- * onto the default branch is stacked, even if some odd pull request heads it.
34
+ * repository heads it: a branch opened against two bases is two pull requests,
35
+ * and which of them a layer above means cannot be told. Nothing onto the
36
+ * default branch is stacked, even if some odd pull request heads it.
37
37
  */
38
38
  export const findStackParents = (
39
39
  pulls: readonly StackMember[],
@@ -60,9 +60,8 @@ export const findStackParents = (
60
60
  };
61
61
 
62
62
  /**
63
- * Every layer above `number`, each after the one it is on — the order in
64
- * which they are moved when `number` moves. Siblings keep the lowest number
65
- * first.
63
+ * Every layer above `number`, each after the one it is on — the order in which
64
+ * they are moved when `number` moves. Siblings keep the lowest number first.
66
65
  */
67
66
  export const stackDescendants = (
68
67
  parents: ReadonlyMap<number, number>,
@@ -65,8 +65,8 @@ describe(findStackParents, () => {
65
65
  });
66
66
 
67
67
  test('nothing onto the default branch is stacked, whatever heads it', () => {
68
- // A pull request *from* the default branch is odd but possible, and
69
- // reading it as a parent would put every other pull request under it.
68
+ // A pull request *from* the default branch is odd but possible, and reading
69
+ // it as a parent would put every other pull request under it.
70
70
  const parents = findStackParents(
71
71
  pulls([
72
72
  [1, 'main', 'release'],
@@ -79,8 +79,8 @@ describe(findStackParents, () => {
79
79
  });
80
80
 
81
81
  test('a branch two open pull requests head is not a parent', () => {
82
- // One branch opened against two bases: which of them the stack means
83
- // cannot be told, so neither is taken.
82
+ // One branch opened against two bases: which of them the stack means cannot
83
+ // be told, so neither is taken.
84
84
  const parents = findStackParents(
85
85
  pulls([
86
86
  [1, 'a', 'main'],