pr-report-core 0.0.6 → 0.2.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 (42) hide show
  1. package/dist/checks.d.mts.map +1 -1
  2. package/dist/checks.mjs +8 -8
  3. package/dist/checks.mjs.map +1 -1
  4. package/dist/code-owners.d.mts +24 -24
  5. package/dist/code-owners.d.mts.map +1 -1
  6. package/dist/code-owners.mjs +42 -42
  7. package/dist/code-owners.mjs.map +1 -1
  8. package/dist/labels.d.mts +18 -0
  9. package/dist/labels.d.mts.map +1 -1
  10. package/dist/labels.mjs +18 -0
  11. package/dist/labels.mjs.map +1 -1
  12. package/dist/merge-after.d.mts.map +1 -1
  13. package/dist/merge-after.mjs +13 -13
  14. package/dist/merge-after.mjs.map +1 -1
  15. package/dist/queue-order.d.mts +14 -1
  16. package/dist/queue-order.d.mts.map +1 -1
  17. package/dist/queue-order.mjs +34 -17
  18. package/dist/queue-order.mjs.map +1 -1
  19. package/dist/queue-owner.d.mts +17 -17
  20. package/dist/queue-owner.d.mts.map +1 -1
  21. package/dist/queue-owner.mjs +51 -51
  22. package/dist/queue-owner.mjs.map +1 -1
  23. package/dist/report.d.mts +5 -5
  24. package/dist/report.d.mts.map +1 -1
  25. package/dist/report.mjs +24 -24
  26. package/dist/report.mjs.map +1 -1
  27. package/dist/start-after.d.mts +32 -7
  28. package/dist/start-after.d.mts.map +1 -1
  29. package/dist/start-after.mjs +20 -1
  30. package/dist/start-after.mjs.map +1 -1
  31. package/package.json +5 -5
  32. package/src/checks.mts +10 -10
  33. package/src/code-owners.mts +81 -81
  34. package/src/labels.mts +20 -0
  35. package/src/merge-after.mts +23 -23
  36. package/src/queue-order.mts +49 -27
  37. package/src/queue-order.test.mts +37 -0
  38. package/src/queue-owner.mts +84 -84
  39. package/src/report.mts +44 -43
  40. package/src/report.test.mts +40 -0
  41. package/src/start-after.mts +47 -8
  42. package/src/start-after.test.mts +54 -7
package/src/labels.mts CHANGED
@@ -77,3 +77,23 @@ export const PRIORITY_MID_LABEL = 'priority:mid';
77
77
  * `unblock-prs`: what is held is simply not labelled `merge-queued`.
78
78
  */
79
79
  export const PENDING_LABEL = 'pending';
80
+
81
+ /**
82
+ * The label that says someone has started an issue, before there is a pull
83
+ * request to say so. `start-issue` puts it on, and refuses an issue that
84
+ * already carries it, so that two sessions do not pick up the same one; the
85
+ * Pull Requests Manager page and `pr-report` show a startable issue carrying
86
+ * it as in progress, as they show one an open pull request closes
87
+ * (`start-after.mts`'s `progressOf`). Nothing takes it off when the work
88
+ * merges: the issue closes, and the label with it stops mattering.
89
+ */
90
+ export const IN_PROGRESS_LABEL = 'in-progress';
91
+
92
+ /**
93
+ * The label that says someone means to start an issue next. It holds nothing
94
+ * back — `start-issue` takes it off as it puts {@link IN_PROGRESS_LABEL} on —
95
+ * and only tells whoever is choosing what to start that this one is spoken
96
+ * for; the page and `pr-report` show a startable issue carrying it as
97
+ * planned.
98
+ */
99
+ export const PLANNED_LABEL = 'planned';
@@ -43,29 +43,6 @@ export const parseMergeAfter = (body: string): readonly number[] =>
43
43
  export const parseStartAfter = (body: string): readonly number[] =>
44
44
  parseDeclaration(body, 'start-after');
45
45
 
46
- /**
47
- * `<Key>: #1901, #1903` in a body, anywhere in it, on as many lines as it
48
- * likes. Only this exact trailer is read — a sentence mentioning another pull
49
- * request is prose, and treating it as a declaration would make every
50
- * cross-reference a constraint.
51
- */
52
- const parseDeclaration = (
53
- body: string,
54
- key: 'merge-after' | 'start-after',
55
- ): readonly number[] =>
56
- Arr.uniq(
57
- outsideCodeFences(body)
58
- .matchAll(DECLARATION_LINE[key])
59
- .flatMap((line) =>
60
- (line.groups?.['refs'] ?? '').matchAll(NUMBER_REF).flatMap((ref) => {
61
- const parsed = Num.safeParseInt(ref.groups?.['number'] ?? '');
62
-
63
- return Result.isOk(parsed) ? [parsed.value] : [];
64
- }),
65
- )
66
- .toArray(),
67
- );
68
-
69
46
  /**
70
47
  * The body with the contents of its fenced code blocks blanked out, line
71
48
  * count preserved.
@@ -149,6 +126,29 @@ export const findMergeAfterCycles = (
149
126
  return Array.from(mut_cycles.values());
150
127
  };
151
128
 
129
+ /**
130
+ * `<Key>: #1901, #1903` in a body, anywhere in it, on as many lines as it
131
+ * likes. Only this exact trailer is read — a sentence mentioning another pull
132
+ * request is prose, and treating it as a declaration would make every
133
+ * cross-reference a constraint.
134
+ */
135
+ const parseDeclaration = (
136
+ body: string,
137
+ key: 'merge-after' | 'start-after',
138
+ ): readonly number[] =>
139
+ Arr.uniq(
140
+ outsideCodeFences(body)
141
+ .matchAll(DECLARATION_LINE[key])
142
+ .flatMap((line) =>
143
+ (line.groups?.['refs'] ?? '').matchAll(NUMBER_REF).flatMap((ref) => {
144
+ const parsed = Num.safeParseInt(ref.groups?.['number'] ?? '');
145
+
146
+ return Result.isOk(parsed) ? [parsed.value] : [];
147
+ }),
148
+ )
149
+ .toArray(),
150
+ );
151
+
152
152
  const rotateToLowest = (cycle: readonly number[]): readonly number[] => {
153
153
  if (!Arr.isNonEmpty(cycle)) {
154
154
  return cycle;
@@ -422,6 +422,26 @@ export const priorityRankOfLabels = (labels: readonly string[]): 0 | 1 | 2 => {
422
422
  }
423
423
  };
424
424
 
425
+ /**
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,
431
+ * it still has its checks run. `priority:high` is a person saying they want
432
+ * that 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
+
425
445
  /**
426
446
  * The order the worker picks candidates in. The declared order has already
427
447
  * been applied, as a gate rather than a sort, so this is lowest number first
@@ -432,7 +452,9 @@ export const priorityRankOfLabels = (labels: readonly string[]): 0 | 1 | 2 => {
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.
@@ -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,28 @@ 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`
502
+ * in place, which narrows the label constant itself to `never` where it says
503
+ * no.
504
+ */
505
+ const hasLabel = (
506
+ pr: Readonly<{ labels: readonly string[] }>,
507
+ name: string,
508
+ ): boolean => pr.labels.includes(name);
509
+
478
510
  /** Why a pull request is none of the queue's business, if it is not. */
479
511
  const outOfScopeReason = (
480
512
  pr: QueueFacts,
@@ -521,6 +553,17 @@ const waitingOn = (
521
553
  : undefined;
522
554
  };
523
555
 
556
+ /**
557
+ * Pending — including a required context that has not reported at all — or
558
+ * green and about to merge is in flight; a failed required check is failing.
559
+ */
560
+ const byChecks = (reads: QueueReads): QueueStanding =>
561
+ reads.checksFailed === undefined
562
+ ? ({ kind: 'needs', read: 'checksFailed' } as const)
563
+ : reads.checksFailed
564
+ ? ({ kind: 'failing' } as const)
565
+ : ({ kind: 'in-flight' } as const);
566
+
524
567
  /**
525
568
  * The version pull request: held while anything labelled `blocks-release`
526
569
  * is open, and while the branch was built before the tip of the base, then
@@ -547,24 +590,3 @@ const versionStanding = (
547
590
  ? { kind: 'candidate' }
548
591
  : byChecks(reads);
549
592
  };
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);
@@ -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,
388
+ // and 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 = (
@@ -64,87 +64,6 @@ export const SET_ASIDE_REASONS = [
64
64
 
65
65
  export type SetAsideReason = (typeof SET_ASIDE_REASONS)[number];
66
66
 
67
- /**
68
- * Whose a pull request set aside for `reason` is, while the comment stands
69
- * at its head. A skill's reason is one a session can fix by pushing: a
70
- * conflict to resolve, a failure to reproduce. A person's is one only a
71
- * setting, a permission, a review or a merge by hand clears, and the comment
72
- * says which. A `switch` with no default, so a reason added to
73
- * {@link SET_ASIDE_REASONS} without an owner fails to compile.
74
- */
75
- const setAsideOwner = (
76
- reason: SetAsideReason,
77
- ): Readonly<{ owner: 'human' | 'skill'; why: string }> => {
78
- switch (reason) {
79
- case 'checks-failed':
80
- case 'rebase-failed':
81
- return { owner: 'skill', why: 'the skill fixes it' };
82
-
83
- case 'already-in-base':
84
- return {
85
- owner: 'human',
86
- why: 'every commit is already in the base; close it',
87
- };
88
-
89
- case 'arm-failed':
90
- return {
91
- owner: 'human',
92
- why: 'auto-merge could not be armed; a setting or a permission holds it',
93
- };
94
-
95
- case 'awaiting-bypass':
96
- return {
97
- owner: 'human',
98
- why: 'green, and only a ruleset bypass merges it',
99
- };
100
-
101
- case 'not-merging':
102
- return {
103
- owner: 'human',
104
- why: 'green but not merging; something the worker cannot read holds it',
105
- };
106
-
107
- case 'push-failed':
108
- return {
109
- owner: 'human',
110
- why: 'the push was refused; a setting or a permission holds it',
111
- };
112
-
113
- case 'unlabel-failed':
114
- return {
115
- owner: 'human',
116
- why: '`skip-ci` could not be taken off; a permission holds it',
117
- };
118
-
119
- case 'watch-timeout':
120
- return {
121
- owner: 'human',
122
- why: 'its checks ran past the watch; look at the run',
123
- };
124
- }
125
- };
126
-
127
- /** What {@link queueOwner} reads about one pull request. */
128
- export type QueueOwnerInput = Readonly<{
129
- number: number;
130
- labels: readonly string[];
131
- isDraft: boolean;
132
- fromFork: boolean;
133
- headRef: string;
134
- headSha: string;
135
- baseRef: string;
136
- defaultBranch: string;
137
- /** The open pull request whose branch this one is on, if any. */
138
- stackedOn: number | undefined;
139
- /** Whether it sits on a `Merge-After` cycle. */
140
- inCycle: boolean;
141
- /** This account's set-aside comment on it (`set-aside.mts`). */
142
- setAside: SetAsideComment | undefined;
143
- /** This account's claim comment on it (`claim.mts`). */
144
- claim: ClaimComment | undefined;
145
- nowEpochMs: number;
146
- }>;
147
-
148
67
  /**
149
68
  * Who moves `pr` next, or `undefined` for a pull request the queue does not
150
69
  * cover: neither `merge-queued` nor `auto-rebase` under `skip-ci`.
@@ -250,6 +169,90 @@ export const queueOwnerOfEntry = (
250
169
  nowEpochMs: context.nowEpochMs,
251
170
  });
252
171
 
172
+ export const isSetAsideReason = (reason: string): reason is SetAsideReason =>
173
+ KNOWN_REASONS.has(reason);
174
+
175
+ /**
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.
182
+ */
183
+ const setAsideOwner = (
184
+ reason: SetAsideReason,
185
+ ): Readonly<{ owner: 'human' | 'skill'; why: string }> => {
186
+ switch (reason) {
187
+ case 'checks-failed':
188
+ case 'rebase-failed':
189
+ return { owner: 'skill', why: 'the skill fixes it' };
190
+
191
+ case 'already-in-base':
192
+ return {
193
+ owner: 'human',
194
+ why: 'every commit is already in the base; close it',
195
+ };
196
+
197
+ case 'arm-failed':
198
+ return {
199
+ owner: 'human',
200
+ why: 'auto-merge could not be armed; a setting or a permission holds it',
201
+ };
202
+
203
+ case 'awaiting-bypass':
204
+ return {
205
+ owner: 'human',
206
+ why: 'green, and only a ruleset bypass merges it',
207
+ };
208
+
209
+ case 'not-merging':
210
+ return {
211
+ owner: 'human',
212
+ why: 'green but not merging; something the worker cannot read holds it',
213
+ };
214
+
215
+ case 'push-failed':
216
+ return {
217
+ owner: 'human',
218
+ why: 'the push was refused; a setting or a permission holds it',
219
+ };
220
+
221
+ case 'unlabel-failed':
222
+ return {
223
+ owner: 'human',
224
+ why: '`skip-ci` could not be taken off; a permission holds it',
225
+ };
226
+
227
+ case 'watch-timeout':
228
+ return {
229
+ owner: 'human',
230
+ why: 'its checks ran past the watch; look at the run',
231
+ };
232
+ }
233
+ };
234
+
235
+ /** What {@link queueOwner} reads about one pull request. */
236
+ export type QueueOwnerInput = Readonly<{
237
+ number: number;
238
+ labels: readonly string[];
239
+ isDraft: boolean;
240
+ fromFork: boolean;
241
+ headRef: string;
242
+ headSha: string;
243
+ baseRef: string;
244
+ defaultBranch: string;
245
+ /** The open pull request whose branch this one is on, if any. */
246
+ stackedOn: number | undefined;
247
+ /** Whether it sits on a `Merge-After` cycle. */
248
+ inCycle: boolean;
249
+ /** This account's set-aside comment on it (`set-aside.mts`). */
250
+ setAside: SetAsideComment | undefined;
251
+ /** This account's claim comment on it (`claim.mts`). */
252
+ claim: ClaimComment | undefined;
253
+ nowEpochMs: number;
254
+ }>;
255
+
253
256
  /**
254
257
  * The set-aside `comment` says, if it stands at `headSha` with no retry
255
258
  * asked for. The base is not read: see the module comment.
@@ -264,9 +267,6 @@ const standingAt = (
264
267
  ? comment.setAside
265
268
  : undefined;
266
269
 
267
- export const isSetAsideReason = (reason: string): reason is SetAsideReason =>
268
- KNOWN_REASONS.has(reason);
269
-
270
270
  const KNOWN_REASONS: ReadonlySet<string> = new Set(SET_ASIDE_REASONS);
271
271
 
272
272
  /**
package/src/report.mts CHANGED
@@ -9,6 +9,7 @@ import {
9
9
  decideStartStates,
10
10
  inheritStartAfter,
11
11
  issuesInProgress,
12
+ progressOf,
12
13
  withProgress,
13
14
  type ReferenceStatus,
14
15
  } from './start-after.mjs';
@@ -81,49 +82,6 @@ export const buildReport = ({
81
82
  issuesLimit,
82
83
  }) as const;
83
84
 
84
- /**
85
- * Each open issue with how it stands for being started
86
- * (`docs/issue-start-after.md`): its start state, or `in-progress` when it
87
- * is startable and an open pull request closes it, and where each thing it
88
- * names stands.
89
- */
90
- const reportIssues = (
91
- issues: readonly OpenIssue[],
92
- pulls: readonly PullRequestFacts[],
93
- references: ReadonlyMap<number, ReferenceStatus>,
94
- ): readonly ReportIssue[] => {
95
- const listed = new Set(issues.map(({ number }) => number));
96
-
97
- const open = new Set(pulls.map(({ number }) => number));
98
-
99
- const statusOf = (number: number): ReferenceStatus =>
100
- listed.has(number) || open.has(number)
101
- ? 'waiting'
102
- : (references.get(number) ?? 'unknown');
103
-
104
- const states = decideStartStates(issues, statusOf);
105
-
106
- const worked = issuesInProgress(pulls);
107
-
108
- return issues.map((issue) => ({
109
- ...issue,
110
- start: withProgress(
111
- states.get(issue.number) ?? 'free',
112
- worked.has(issue.number),
113
- ),
114
- named: issue.startAfter.map((number) => ({
115
- number,
116
- status: statusOf(number),
117
- })),
118
- }));
119
- };
120
-
121
- export type DecidedEntries = Readonly<{
122
- entries: readonly ReportEntry[];
123
- roots: readonly TreeNode[];
124
- cycles: readonly (readonly number[])[];
125
- }>;
126
-
127
85
  /**
128
86
  * The part of a report that is decided rather than read: each open pull
129
87
  * request's declared order, the stack it is in, its check verdict, and the
@@ -235,6 +193,49 @@ export const buildEntries = ({
235
193
  return { entries, roots, cycles };
236
194
  };
237
195
 
196
+ /**
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.
201
+ */
202
+ const reportIssues = (
203
+ issues: readonly OpenIssue[],
204
+ pulls: readonly PullRequestFacts[],
205
+ references: ReadonlyMap<number, ReferenceStatus>,
206
+ ): readonly ReportIssue[] => {
207
+ const listed = new Set(issues.map(({ number }) => number));
208
+
209
+ const open = new Set(pulls.map(({ number }) => number));
210
+
211
+ const statusOf = (number: number): ReferenceStatus =>
212
+ listed.has(number) || open.has(number)
213
+ ? 'waiting'
214
+ : (references.get(number) ?? 'unknown');
215
+
216
+ const states = decideStartStates(issues, statusOf);
217
+
218
+ const worked = issuesInProgress(pulls);
219
+
220
+ return issues.map((issue) => ({
221
+ ...issue,
222
+ start: withProgress(
223
+ states.get(issue.number) ?? 'free',
224
+ progressOf(issue.labels, worked.has(issue.number)),
225
+ ),
226
+ named: issue.startAfter.map((number) => ({
227
+ number,
228
+ status: statusOf(number),
229
+ })),
230
+ }));
231
+ };
232
+
233
+ export type DecidedEntries = Readonly<{
234
+ entries: readonly ReportEntry[];
235
+ roots: readonly TreeNode[];
236
+ cycles: readonly (readonly number[])[];
237
+ }>;
238
+
238
239
  const NO_DECLARATIONS: IssueDeclarations = {
239
240
  startAfter: new Map<number, readonly number[]>(),
240
241
  open: new Set<number>(),
@@ -379,6 +379,36 @@ describe(buildReport, () => {
379
379
 
380
380
  assert.strictEqual(report.issues[0]?.start, 'attention');
381
381
  });
382
+
383
+ test('reads the in-progress and planned labels of a startable issue', () => {
384
+ const report = buildReport({
385
+ repo: { owner: 'o', name: 'r' },
386
+ defaultBranch: 'main',
387
+ generatedAt: '2026-10-24T00:00:00Z',
388
+ required: [],
389
+ authenticated: true,
390
+ pulls: [],
391
+ merged: [],
392
+ mergedWithinDays: 7,
393
+ issues: [
394
+ labelled(1, [], 'in-progress'),
395
+ labelled(2, [], 'planned'),
396
+ // Blocked says so, whatever is planned for it.
397
+ labelled(3, [1], 'planned'),
398
+ ],
399
+ issuesLimit: 30,
400
+ references: new Map<number, ReferenceStatus>(),
401
+ });
402
+
403
+ assert.deepStrictEqual(
404
+ report.issues.map(({ number, start }) => ({ number, start })),
405
+ [
406
+ { number: 1, start: 'in-progress' },
407
+ { number: 2, start: 'planned' },
408
+ { number: 3, start: 'blocked' },
409
+ ],
410
+ );
411
+ });
382
412
  });
383
413
 
384
414
  const issue = (
@@ -398,3 +428,13 @@ const issue = (
398
428
  startAfter,
399
429
  misplacedMergeAfter,
400
430
  }) as const;
431
+
432
+ const labelled = (
433
+ number: number,
434
+ startAfter: readonly number[],
435
+ name: string,
436
+ ): OpenIssue =>
437
+ ({
438
+ ...issue(number, startAfter),
439
+ labels: [{ name, color: 'cccccc', description: '' }],
440
+ }) as const;
@@ -5,6 +5,7 @@
5
5
  */
6
6
 
7
7
  import { Arr } from 'ts-data-forge';
8
+ import { IN_PROGRESS_LABEL, PLANNED_LABEL } from './labels.mjs';
8
9
  import { findMergeAfterCycles, parseStartAfter } from './merge-after.mjs';
9
10
 
10
11
  /**
@@ -48,19 +49,57 @@ export const waitsFor = (
48
49
  ): readonly number[] => Arr.uniq(parseStartAfter(body).concat(blockedBy));
49
50
 
50
51
  /**
51
- * How an issue is shown: its {@link StartState}, or `in-progress` when it is
52
- * startable and an open pull request closes it. Kept apart from the start
53
- * state in the rules (`docs/issue-start-after.md`): one that is blocked or
54
- * on a cycle says so whether or not someone has started it.
52
+ * How an issue is shown: its {@link StartState}, or how far someone has got
53
+ * with it when it is startable ({@link IssueProgress}). Kept apart from the
54
+ * start state in the rules (`docs/issue-start-after.md`): one that is blocked
55
+ * or on a cycle says so whether or not someone has started it.
55
56
  */
56
- export type IssueStartState = StartState | 'in-progress';
57
+ export type IssueStartState = StartState | 'in-progress' | 'planned';
57
58
 
58
- /** The {@link IssueStartState} of an issue, given whether it is worked on. */
59
+ /**
60
+ * How far someone has got with an issue, whatever it waits for:
61
+ *
62
+ * - `in-progress` — an open pull request closes it, or it is labelled
63
+ * `in-progress` (`labels.mts`), which is how it says so before there is one.
64
+ * - `planned` — it is labelled `planned`: someone means to start it next.
65
+ * - `none` — neither.
66
+ */
67
+ export type IssueProgress = 'in-progress' | 'none' | 'planned';
68
+
69
+ /**
70
+ * The {@link IssueProgress} of an issue carrying `labels`, given whether an
71
+ * open pull request closes it. Being worked on wins over being planned: one
72
+ * carrying both was started without the plan being taken off.
73
+ */
74
+ export const progressOf = (
75
+ labels: readonly Readonly<{ name: string }>[],
76
+ closedByOpenPull: boolean,
77
+ ): IssueProgress =>
78
+ closedByOpenPull || hasLabel(labels, IN_PROGRESS_LABEL)
79
+ ? 'in-progress'
80
+ : hasLabel(labels, PLANNED_LABEL)
81
+ ? 'planned'
82
+ : 'none';
83
+
84
+ /**
85
+ * Whether `labels` holds `name`, whatever its case. GitHub's label names
86
+ * are: adding `in-progress` to a repository that has `In-Progress` puts that
87
+ * one on, so an exact match would not see the label it just added.
88
+ */
89
+ export const hasLabel = (
90
+ labels: readonly Readonly<{ name: string }>[],
91
+ name: string,
92
+ ): boolean =>
93
+ labels.some((label) => label.name.toLowerCase() === name.toLowerCase());
94
+
95
+ /** The {@link IssueStartState} of an issue, given how far it has got. */
59
96
  export const withProgress = (
60
97
  state: StartState,
61
- worked: boolean,
98
+ progress: IssueProgress,
62
99
  ): IssueStartState =>
63
- worked && (state === 'free' || state === 'ready') ? 'in-progress' : state;
100
+ progress !== 'none' && (state === 'free' || state === 'ready')
101
+ ? progress
102
+ : state;
64
103
 
65
104
  /** The issues an open pull request closes: the ones being worked on. */
66
105
  export const issuesInProgress = (