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.
- package/dist/checks.d.mts.map +1 -1
- package/dist/checks.mjs +8 -8
- package/dist/checks.mjs.map +1 -1
- package/dist/code-owners.d.mts +24 -24
- package/dist/code-owners.d.mts.map +1 -1
- package/dist/code-owners.mjs +42 -42
- package/dist/code-owners.mjs.map +1 -1
- package/dist/labels.d.mts +18 -0
- package/dist/labels.d.mts.map +1 -1
- package/dist/labels.mjs +18 -0
- package/dist/labels.mjs.map +1 -1
- package/dist/merge-after.d.mts.map +1 -1
- package/dist/merge-after.mjs +13 -13
- package/dist/merge-after.mjs.map +1 -1
- package/dist/queue-order.d.mts +14 -1
- package/dist/queue-order.d.mts.map +1 -1
- package/dist/queue-order.mjs +34 -17
- package/dist/queue-order.mjs.map +1 -1
- package/dist/queue-owner.d.mts +17 -17
- package/dist/queue-owner.d.mts.map +1 -1
- package/dist/queue-owner.mjs +51 -51
- package/dist/queue-owner.mjs.map +1 -1
- package/dist/report.d.mts +5 -5
- package/dist/report.d.mts.map +1 -1
- package/dist/report.mjs +24 -24
- package/dist/report.mjs.map +1 -1
- package/dist/start-after.d.mts +32 -7
- package/dist/start-after.d.mts.map +1 -1
- package/dist/start-after.mjs +20 -1
- package/dist/start-after.mjs.map +1 -1
- package/package.json +5 -5
- package/src/checks.mts +10 -10
- package/src/code-owners.mts +81 -81
- package/src/labels.mts +20 -0
- package/src/merge-after.mts +23 -23
- package/src/queue-order.mts +49 -27
- package/src/queue-order.test.mts +37 -0
- package/src/queue-owner.mts +84 -84
- package/src/report.mts +44 -43
- package/src/report.test.mts +40 -0
- package/src/start-after.mts +47 -8
- 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';
|
package/src/merge-after.mts
CHANGED
|
@@ -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;
|
package/src/queue-order.mts
CHANGED
|
@@ -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);
|
package/src/queue-order.test.mts
CHANGED
|
@@ -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 = (
|
package/src/queue-owner.mts
CHANGED
|
@@ -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>(),
|
package/src/report.test.mts
CHANGED
|
@@ -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;
|
package/src/start-after.mts
CHANGED
|
@@ -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
|
|
52
|
-
*
|
|
53
|
-
* state in the rules (`docs/issue-start-after.md`): one that is blocked
|
|
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
|
-
/**
|
|
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
|
-
|
|
98
|
+
progress: IssueProgress,
|
|
62
99
|
): IssueStartState =>
|
|
63
|
-
|
|
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 = (
|