pr-report-core 0.0.5 → 0.1.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.
@@ -8,6 +8,7 @@ import {
8
8
  statesFromCheckRuns,
9
9
  summarizeChecks,
10
10
  } from './checks.mjs';
11
+ import type { ContextState } from './types.mjs';
11
12
 
12
13
  describe(classifyCheckRun, () => {
13
14
  test('an unfinished run is pending whatever it last concluded', () => {
@@ -60,7 +61,7 @@ describe(summarizeChecks, () => {
60
61
  test('every required context green is a pass', () => {
61
62
  const summary = summarizeChecks({
62
63
  required,
63
- reported: new Map([
64
+ reported: new Map<string, ContextState>([
64
65
  ['code-check-result / result', 'passed'],
65
66
  ['no-skip-ci-label', 'passed'],
66
67
  ]),
@@ -79,7 +80,9 @@ describe(summarizeChecks, () => {
79
80
  // absent from the check list rather than pending in it.
80
81
  const summary = summarizeChecks({
81
82
  required,
82
- reported: new Map([['code-check-result / result', 'passed']]),
83
+ reported: new Map<string, ContextState>([
84
+ ['code-check-result / result', 'passed'],
85
+ ]),
83
86
  paused: false,
84
87
  running: false,
85
88
  baseCovered: true,
@@ -95,7 +98,7 @@ describe(summarizeChecks, () => {
95
98
  test('a failure outranks anything still running', () => {
96
99
  const summary = summarizeChecks({
97
100
  required,
98
- reported: new Map([
101
+ reported: new Map<string, ContextState>([
99
102
  ['code-check-result / result', 'failed'],
100
103
  ['no-skip-ci-label', 'pending'],
101
104
  ]),
@@ -117,7 +120,7 @@ describe(summarizeChecks, () => {
117
120
  // asks about.
118
121
  const summary = summarizeChecks({
119
122
  required,
120
- reported: new Map([
123
+ reported: new Map<string, ContextState>([
121
124
  ['code-check-result / result', 'failed'],
122
125
  ['no-skip-ci-label', 'pending'],
123
126
  ]),
@@ -136,7 +139,7 @@ describe(summarizeChecks, () => {
136
139
  test('a skipped context counts as met and is listed as skipped', () => {
137
140
  const summary = summarizeChecks({
138
141
  required,
139
- reported: new Map([
142
+ reported: new Map<string, ContextState>([
140
143
  ['code-check-result / result', 'skipped'],
141
144
  ['no-skip-ci-label', 'passed'],
142
145
  ]),
@@ -155,7 +158,7 @@ describe(summarizeChecks, () => {
155
158
  test('anything still running on the commit keeps the verdict pending', () => {
156
159
  const summary = summarizeChecks({
157
160
  required,
158
- reported: new Map([
161
+ reported: new Map<string, ContextState>([
159
162
  ['code-check-result / result', 'passed'],
160
163
  ['no-skip-ci-label', 'passed'],
161
164
  ]),
@@ -173,7 +176,7 @@ describe(summarizeChecks, () => {
173
176
  test('a failure is not hidden by something still running', () => {
174
177
  const summary = summarizeChecks({
175
178
  required,
176
- reported: new Map([
179
+ reported: new Map<string, ContextState>([
177
180
  ['code-check-result / result', 'failed'],
178
181
  ['no-skip-ci-label', 'passed'],
179
182
  ]),
@@ -188,7 +191,7 @@ describe(summarizeChecks, () => {
188
191
  test('a context nothing requires is not counted', () => {
189
192
  const summary = summarizeChecks({
190
193
  required,
191
- reported: new Map([
194
+ reported: new Map<string, ContextState>([
192
195
  ['code-check-result / result', 'passed'],
193
196
  ['no-skip-ci-label', 'passed'],
194
197
  ['some-optional-job', 'failed'],
@@ -213,7 +216,7 @@ describe(summarizeChecks, () => {
213
216
  test('a context that never reported is not waited for', () => {
214
217
  const summary = summarizeChecks({
215
218
  required: withLint,
216
- reported: new Map([
219
+ reported: new Map<string, ContextState>([
217
220
  ['code-check-result / result', 'passed'],
218
221
  ['no-skip-ci-label', 'passed'],
219
222
  ]),
@@ -236,7 +239,7 @@ describe(summarizeChecks, () => {
236
239
  // on it at all; that is not a pass over zero contexts.
237
240
  const summary = summarizeChecks({
238
241
  required: withLint,
239
- reported: new Map(),
242
+ reported: new Map<string, ContextState>(),
240
243
  paused: false,
241
244
  running: false,
242
245
  baseCovered: false,
@@ -252,7 +255,7 @@ describe(summarizeChecks, () => {
252
255
  test('a context that reported is judged as anywhere else', () => {
253
256
  const summary = summarizeChecks({
254
257
  required: withLint,
255
- reported: new Map([
258
+ reported: new Map<string, ContextState>([
256
259
  ['code-check-result / result', 'failed'],
257
260
  ['no-skip-ci-label', 'pending'],
258
261
  ]),
@@ -270,7 +273,9 @@ describe(summarizeChecks, () => {
270
273
  test('on a base the ruleset covers, nothing is excused from reporting', () => {
271
274
  const summary = summarizeChecks({
272
275
  required,
273
- reported: new Map([['code-check-result / result', 'passed']]),
276
+ reported: new Map<string, ContextState>([
277
+ ['code-check-result / result', 'passed'],
278
+ ]),
274
279
  paused: false,
275
280
  running: false,
276
281
  baseCovered: true,
@@ -422,7 +427,7 @@ describe(reportedContexts, () => {
422
427
  [completedRun('code-check-result / result', 'success')],
423
428
  [{ context: 'no-skip-ci-label', state: 'pending' }],
424
429
  ),
425
- new Map([
430
+ new Map<string, ContextState>([
426
431
  ['code-check-result / result', 'passed'],
427
432
  ['no-skip-ci-label', 'pending'],
428
433
  ]),
@@ -435,7 +440,7 @@ describe(reportedContexts, () => {
435
440
  [completedRun('lint', 'failure')],
436
441
  [{ context: 'lint', state: 'success' }],
437
442
  ),
438
- new Map([['lint', 'failed']]),
443
+ new Map<string, ContextState>([['lint', 'failed']]),
439
444
  );
440
445
 
441
446
  assert.deepStrictEqual(
@@ -443,7 +448,7 @@ describe(reportedContexts, () => {
443
448
  [completedRun('lint', 'success')],
444
449
  [{ context: 'lint', state: 'pending' }],
445
450
  ),
446
- new Map([['lint', 'pending']]),
451
+ new Map<string, ContextState>([['lint', 'pending']]),
447
452
  );
448
453
  });
449
454
  });
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';
@@ -141,7 +141,7 @@ describe(findMergeAfterCycles, () => {
141
141
  test('finds nothing in a chain', () => {
142
142
  assert.deepStrictEqual(
143
143
  findMergeAfterCycles(
144
- new Map([
144
+ new Map<number, readonly number[]>([
145
145
  [3, [2]],
146
146
  [2, [1]],
147
147
  [1, []],
@@ -154,7 +154,7 @@ describe(findMergeAfterCycles, () => {
154
154
  test('finds nothing in a diamond', () => {
155
155
  assert.deepStrictEqual(
156
156
  findMergeAfterCycles(
157
- new Map([
157
+ new Map<number, readonly number[]>([
158
158
  [4, [2, 3]],
159
159
  [3, [1]],
160
160
  [2, [1]],
@@ -168,7 +168,7 @@ describe(findMergeAfterCycles, () => {
168
168
  test('finds a two-node cycle, starting at its lowest number', () => {
169
169
  assert.deepStrictEqual(
170
170
  findMergeAfterCycles(
171
- new Map([
171
+ new Map<number, readonly number[]>([
172
172
  [2, [1]],
173
173
  [1, [2]],
174
174
  ]),
@@ -178,7 +178,10 @@ describe(findMergeAfterCycles, () => {
178
178
  });
179
179
 
180
180
  test('finds a pull request that declares itself', () => {
181
- assert.deepStrictEqual(findMergeAfterCycles(new Map([[7, [7]]])), [[7]]);
181
+ assert.deepStrictEqual(
182
+ findMergeAfterCycles(new Map<number, readonly number[]>([[7, [7]]])),
183
+ [[7]],
184
+ );
182
185
  });
183
186
 
184
187
  test('reports a cycle once however it is entered', () => {
@@ -186,7 +189,7 @@ describe(findMergeAfterCycles, () => {
186
189
  // different node each time.
187
190
  assert.deepStrictEqual(
188
191
  findMergeAfterCycles(
189
- new Map([
192
+ new Map<number, readonly number[]>([
190
193
  [9, [1]],
191
194
  [10, [2]],
192
195
  [1, [2]],
@@ -201,7 +204,7 @@ describe(findMergeAfterCycles, () => {
201
204
  test('finds two independent cycles', () => {
202
205
  assert.deepStrictEqual(
203
206
  findMergeAfterCycles(
204
- new Map([
207
+ new Map<number, readonly number[]>([
205
208
  [1, [2]],
206
209
  [2, [1]],
207
210
  [5, [6]],
@@ -216,6 +219,9 @@ describe(findMergeAfterCycles, () => {
216
219
  });
217
220
 
218
221
  test('follows an edge to a pull request that declared nothing', () => {
219
- assert.deepStrictEqual(findMergeAfterCycles(new Map([[1, [2]]])), []);
222
+ assert.deepStrictEqual(
223
+ findMergeAfterCycles(new Map<number, readonly number[]>([[1, [2]]])),
224
+ [],
225
+ );
220
226
  });
221
227
  });
@@ -197,7 +197,10 @@ export const isMergeableState = (mergeState: string): boolean =>
197
197
  export const queueGraph = (
198
198
  pulls: readonly QueueGraphMember[],
199
199
  defaultBranch: string,
200
- startAfterOf: ReadonlyMap<number, readonly number[]> = new Map(),
200
+ startAfterOf: ReadonlyMap<number, readonly number[]> = new Map<
201
+ number,
202
+ readonly number[]
203
+ >(),
201
204
  ): QueueGraph => {
202
205
  const sorted = pulls.toSorted((a, b) => a.number - b.number);
203
206
 
@@ -207,7 +210,7 @@ export const queueGraph = (
207
210
  const inheritedOrders = inheritStartAfter({
208
211
  pulls: sorted.map(({ number, closes = [] }) => ({ number, closes })),
209
212
  startAfterOf,
210
- openIssues: new Set(),
213
+ openIssues: new Set<number>(),
211
214
  });
212
215
 
213
216
  const declared: ReadonlyMap<number, readonly number[]> = new Map(
@@ -14,6 +14,7 @@ import {
14
14
  type QueueFacts,
15
15
  type QueueGraphMember,
16
16
  } from './queue-order.mjs';
17
+ import type { QueueOwner } from './queue-owner.mjs';
17
18
 
18
19
  describe(queueGraph, () => {
19
20
  test('reads the declared order and the stack together, and the cycles in them', () => {
@@ -26,11 +27,14 @@ describe(queueGraph, () => {
26
27
  'main',
27
28
  );
28
29
 
29
- assert.deepStrictEqual(graph.stackParents, new Map([[2, 1]]));
30
+ assert.deepStrictEqual(
31
+ graph.stackParents,
32
+ new Map<number, number>([[2, 1]]),
33
+ );
30
34
 
31
35
  assert.deepStrictEqual(
32
36
  graph.dependencies,
33
- new Map([
37
+ new Map<number, readonly number[]>([
34
38
  [1, [3]],
35
39
  [2, [1]],
36
40
  [3, [1]],
@@ -65,7 +69,7 @@ describe(queueGraph, () => {
65
69
  member({ number: 3, body: 'Merge-After: #1' }),
66
70
  ],
67
71
  'main',
68
- new Map([
72
+ new Map<number, readonly number[]>([
69
73
  [11, [12]],
70
74
  [12, [3]],
71
75
  ]),
@@ -73,7 +77,7 @@ describe(queueGraph, () => {
73
77
 
74
78
  assert.deepStrictEqual(
75
79
  graph.dependencies,
76
- new Map([
80
+ new Map<number, readonly number[]>([
77
81
  [1, [2]],
78
82
  [2, [3]],
79
83
  [3, [1]],
@@ -82,7 +86,7 @@ describe(queueGraph, () => {
82
86
 
83
87
  assert.deepStrictEqual(
84
88
  graph.inherited,
85
- new Map([
89
+ new Map<number, readonly number[]>([
86
90
  [1, [2]],
87
91
  [2, [3]],
88
92
  [3, []],
@@ -132,7 +136,7 @@ describe(queueStanding, () => {
132
136
  assert.deepStrictEqual(
133
137
  queueStanding(
134
138
  facts({ number: 1 }),
135
- context({ owners: new Map([[1, owner]]) }),
139
+ context({ owners: new Map<number, QueueOwner>([[1, owner]]) }),
136
140
  ),
137
141
  { kind: 'owned', owner },
138
142
  );
@@ -142,7 +146,7 @@ describe(queueStanding, () => {
142
146
  assert.deepStrictEqual(
143
147
  queueStanding(
144
148
  facts({ number: 2, baseRef: 'feature/1' }),
145
- context({ stackParents: new Map([[2, 1]]) }),
149
+ context({ stackParents: new Map<number, number>([[2, 1]]) }),
146
150
  ),
147
151
  { kind: 'stacked', on: 1, cyclic: false },
148
152
  );
@@ -184,8 +188,8 @@ describe(queueStanding, () => {
184
188
  queueStanding(
185
189
  pr,
186
190
  context({
187
- dependencies: new Map([[2, [1]]]),
188
- openNumbers: new Set([1, 2]),
191
+ dependencies: new Map<number, readonly number[]>([[2, [1]]]),
192
+ openNumbers: new Set<number>([1, 2]),
189
193
  }),
190
194
  ),
191
195
  { kind: 'waiting', on: [1], cyclic: false },
@@ -420,13 +424,13 @@ const member = (
420
424
  const context = (fields: Partial<QueueContext> = {}): QueueContext =>
421
425
  ({
422
426
  defaultBranch: 'main',
423
- stackParents: new Map(),
424
- dependencies: new Map(),
425
- inherited: new Map(),
427
+ stackParents: new Map<number, number>(),
428
+ dependencies: new Map<number, readonly number[]>(),
429
+ inherited: new Map<number, readonly number[]>(),
426
430
  cycles: [],
427
- cyclic: new Set(),
428
- openNumbers: new Set(),
431
+ cyclic: new Set<number>(),
432
+ openNumbers: new Set<number>(),
429
433
  releaseBlockers: [],
430
- owners: new Map(),
434
+ owners: new Map<number, QueueOwner>(),
431
435
  ...fields,
432
436
  }) as const;
@@ -40,8 +40,9 @@ 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 — the dependency update's (`chore/pnpm-update`)
44
- * included.
43
+ * the worker sets aside — a branch the repository generates included, which
44
+ * it builds again rather than rebases (`generatedBranches` in
45
+ * `.github/unblock-prs.json`).
45
46
  */
46
47
  export type QueueOwner = Readonly<{
47
48
  owner: 'human' | 'none' | 'skill' | 'worker';
@@ -214,13 +215,7 @@ export const queueOwner = (pr: QueueOwnerInput): QueueOwner | undefined => {
214
215
  const handedOff = skillHandOff(pr, setAside.reason, queued);
215
216
 
216
217
  return handedOff === undefined
217
- ? {
218
- owner: 'skill',
219
- reason:
220
- setAside.reason === 'rebase-failed' && isPnpmUpdate(pr)
221
- ? `${said}; the skill has \`pnpm-update.yml\` rebuild it from main`
222
- : `${said}; ${why}`,
223
- }
218
+ ? { owner: 'skill', reason: `${said}; ${why}` }
224
219
  : { owner: 'human', reason: `${said}: ${handedOff}` };
225
220
  };
226
221
 
@@ -274,15 +269,6 @@ export const isSetAsideReason = (reason: string): reason is SetAsideReason =>
274
269
 
275
270
  const KNOWN_REASONS: ReadonlySet<string> = new Set(SET_ASIDE_REASONS);
276
271
 
277
- /**
278
- * The dependency update, which `pnpm-update.yml` builds from the default
279
- * branch: a conflict on it is resolved by building it again, not by a rebase.
280
- */
281
- export const PNPM_UPDATE_BRANCH = 'chore/pnpm-update';
282
-
283
- const isPnpmUpdate = (pr: QueueOwnerInput): boolean =>
284
- pr.headRef === PNPM_UPDATE_BRANCH;
285
-
286
272
  /**
287
273
  * Why nothing in the queue can move `pr` until a person does something, if
288
274
  * so: the worker passes these over with a note, and no skill takes them up.
@@ -177,7 +177,7 @@ describe(queueOwner, () => {
177
177
  );
178
178
  });
179
179
 
180
- test('gives the dependency update’s failure and its conflict to the skill', () => {
180
+ test('gives a generated branch’s failure and its conflict to the skill', () => {
181
181
  const update = { ...queued, headRef: 'chore/pnpm-update' } as const;
182
182
 
183
183
  assert.deepStrictEqual(
@@ -188,13 +188,13 @@ describe(queueOwner, () => {
188
188
  },
189
189
  );
190
190
 
191
- // Not rebased by hand: the skill has `pnpm-update.yml` rebuild it.
191
+ // The same as for any other branch: which branches are generated, and
192
+ // how, is the repository's configuration, which the skill reads.
192
193
  assert.deepStrictEqual(
193
194
  queueOwner({ ...update, setAside: standing('rebase-failed') }),
194
195
  {
195
196
  owner: 'skill',
196
- reason:
197
- 'set aside `rebase-failed` at its head; the skill has `pnpm-update.yml` rebuild it from main',
197
+ reason: 'set aside `rebase-failed` at its head; the skill fixes it',
198
198
  },
199
199
  );
200
200
  });
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';
@@ -43,7 +44,7 @@ export const buildReport = ({
43
44
  issues,
44
45
  issuesLimit,
45
46
  issueDeclarations,
46
- references = new Map(),
47
+ references = new Map<number, ReferenceStatus>(),
47
48
  }: Readonly<{
48
49
  repo: RepoRef;
49
50
  defaultBranch: string;
@@ -83,8 +84,8 @@ export const buildReport = ({
83
84
 
84
85
  /**
85
86
  * 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
87
+ * (`docs/issue-start-after.md`): its start state, or how far someone has
88
+ * got with it when it is startable (`progressOf`), and where each thing it
88
89
  * names stands.
89
90
  */
90
91
  const reportIssues = (
@@ -109,7 +110,7 @@ const reportIssues = (
109
110
  ...issue,
110
111
  start: withProgress(
111
112
  states.get(issue.number) ?? 'free',
112
- worked.has(issue.number),
113
+ progressOf(issue.labels, worked.has(issue.number)),
113
114
  ),
114
115
  named: issue.startAfter.map((number) => ({
115
116
  number,
@@ -236,8 +237,8 @@ export const buildEntries = ({
236
237
  };
237
238
 
238
239
  const NO_DECLARATIONS: IssueDeclarations = {
239
- startAfter: new Map(),
240
- open: new Set(),
240
+ startAfter: new Map<number, readonly number[]>(),
241
+ open: new Set<number>(),
241
242
  } as const;
242
243
 
243
244
  /**
@@ -1,4 +1,5 @@
1
1
  import { buildEntries, buildReport } from './report.mjs';
2
+ import type { ReferenceStatus } from './start-after.mjs';
2
3
  import type {
3
4
  ContextState,
4
5
  LinkedIssue,
@@ -26,7 +27,7 @@ const facts = (
26
27
  updatedAt: '2026-09-26T00:00:00Z',
27
28
  headCommittedAt: undefined,
28
29
  comparison: undefined,
29
- reported: new Map(),
30
+ reported: new Map<string, ContextState>(),
30
31
  checksRunning: false,
31
32
  linkedIssues: [],
32
33
  ...overrides,
@@ -103,8 +104,8 @@ describe(buildEntries, () => {
103
104
  issues: {
104
105
  // #11 waits for #12, which #2 is closing, and for the open #3; #13
105
106
  // is open and nobody has started it.
106
- startAfter: new Map([[11, [12, 3, 13]]]),
107
- open: new Set([11, 12, 13]),
107
+ startAfter: new Map<number, readonly number[]>([[11, [12, 3, 13]]]),
108
+ open: new Set<number>([11, 12, 13]),
108
109
  },
109
110
  });
110
111
 
@@ -153,7 +154,10 @@ describe(buildEntries, () => {
153
154
  }),
154
155
  facts(2),
155
156
  ],
156
- issues: { startAfter: new Map([[11, [2]]]), open: new Set() },
157
+ issues: {
158
+ startAfter: new Map<number, readonly number[]>([[11, [2]]]),
159
+ open: new Set<number>(),
160
+ },
157
161
  });
158
162
 
159
163
  assert.deepStrictEqual(entries[0]?.inheritedMergeAfter, []);
@@ -169,7 +173,10 @@ describe(buildEntries, () => {
169
173
  facts(1, { linkedIssues: [linked(11, 'open')] }),
170
174
  facts(2, { body: 'Merge-After: #1' }),
171
175
  ],
172
- issues: { startAfter: new Map([[11, [2]]]), open: new Set() },
176
+ issues: {
177
+ startAfter: new Map<number, readonly number[]>([[11, [2]]]),
178
+ open: new Set<number>(),
179
+ },
173
180
  });
174
181
 
175
182
  assert.deepStrictEqual(cycles, [[1, 2]]);
@@ -180,7 +187,10 @@ describe(buildEntries, () => {
180
187
  required: [],
181
188
  defaultBranch: 'main',
182
189
  pulls: [facts(1, { linkedIssues: [linked(11, 'closed')] }), facts(2)],
183
- issues: { startAfter: new Map([[11, [2]]]), open: new Set() },
190
+ issues: {
191
+ startAfter: new Map<number, readonly number[]>([[11, [2]]]),
192
+ open: new Set<number>(),
193
+ },
184
194
  });
185
195
 
186
196
  assert.deepStrictEqual(entries[0]?.blockedBy, []);
@@ -309,7 +319,7 @@ describe(buildReport, () => {
309
319
  issue(4, [100, 101], [7]),
310
320
  ],
311
321
  issuesLimit: 30,
312
- references: new Map([
322
+ references: new Map<number, ReferenceStatus>([
313
323
  [100, 'done'],
314
324
  [101, 'abandoned'],
315
325
  ]),
@@ -369,6 +379,36 @@ describe(buildReport, () => {
369
379
 
370
380
  assert.strictEqual(report.issues[0]?.start, 'attention');
371
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
+ });
372
412
  });
373
413
 
374
414
  const issue = (
@@ -388,3 +428,13 @@ const issue = (
388
428
  startAfter,
389
429
  misplacedMergeAfter,
390
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;
@@ -96,7 +96,7 @@ describe(findStackParents, () => {
96
96
 
97
97
  describe(stackDescendants, () => {
98
98
  test('every layer above, each after the one it is on', () => {
99
- const parents = new Map([
99
+ const parents = new Map<number, number>([
100
100
  [2, 1],
101
101
  [3, 2],
102
102
  [4, 1],
@@ -114,7 +114,7 @@ describe(stackDescendants, () => {
114
114
  // Two pull requests opened onto each other's branches. Nothing lands
115
115
  // either, and the merge order reports it as a cycle; the walk only has to
116
116
  // stop.
117
- const parents = new Map([
117
+ const parents = new Map<number, number>([
118
118
  [1, 2],
119
119
  [2, 1],
120
120
  ]);
@@ -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 = (