pr-report-core 0.2.1 → 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.
- package/README.md +5 -2
- package/dist/branch-rules.d.mts +30 -0
- package/dist/branch-rules.d.mts.map +1 -0
- package/dist/branch-rules.mjs +71 -0
- package/dist/branch-rules.mjs.map +1 -0
- package/dist/claim.d.mts +15 -8
- package/dist/claim.d.mts.map +1 -1
- package/dist/claim.mjs +2 -1
- package/dist/claim.mjs.map +1 -1
- package/dist/index.d.mts +3 -0
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +3 -0
- package/dist/index.mjs.map +1 -1
- package/dist/issue-claim.d.mts +56 -0
- package/dist/issue-claim.d.mts.map +1 -0
- package/dist/issue-claim.mjs +87 -0
- package/dist/issue-claim.mjs.map +1 -0
- package/dist/issue-priority.d.mts +44 -0
- package/dist/issue-priority.d.mts.map +1 -0
- package/dist/issue-priority.mjs +91 -0
- package/dist/issue-priority.mjs.map +1 -0
- package/dist/labels.d.mts +6 -3
- package/dist/labels.d.mts.map +1 -1
- package/dist/labels.mjs +3 -0
- package/dist/labels.mjs.map +1 -1
- package/dist/queue-owner.d.mts +1 -1
- package/dist/queue-owner.d.mts.map +1 -1
- package/dist/queue-owner.mjs +18 -0
- package/dist/queue-owner.mjs.map +1 -1
- package/dist/ruleset.d.mts +6 -6
- package/dist/set-aside.d.mts +2 -1
- package/dist/set-aside.d.mts.map +1 -1
- package/dist/set-aside.mjs +2 -0
- package/dist/set-aside.mjs.map +1 -1
- package/package.json +9 -9
- package/src/branch-rules.mts +105 -0
- package/src/branch-rules.test.mts +221 -0
- package/src/claim.mts +10 -9
- package/src/index.mts +3 -0
- package/src/issue-claim.mts +138 -0
- package/src/issue-claim.test.mts +149 -0
- package/src/issue-priority.mts +116 -0
- package/src/issue-priority.test.mts +71 -0
- package/src/labels.mts +6 -3
- package/src/queue-owner.mts +21 -0
- package/src/queue-owner.test.mts +2 -0
- package/src/ruleset.mts +6 -6
- package/src/set-aside.mts +3 -1
- package/src/set-aside.test.mts +22 -0
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Who has started an issue, or means to, written where every session can read
|
|
3
|
+
* it.
|
|
4
|
+
*
|
|
5
|
+
* The `in-progress` and `planned` labels (`labels.mts`) say that someone is on
|
|
6
|
+
* an issue, not who: every session writes as the same account, so neither the
|
|
7
|
+
* labels nor the assignees tell two of them apart, and a session refused by a
|
|
8
|
+
* label cannot say whose it is. So `start-issue` also writes a comment whose
|
|
9
|
+
* first line is a hidden record this module writes and reads, naming the
|
|
10
|
+
* claimant (a session URL, or any other word unique to it; `claim.mts`'s
|
|
11
|
+
* `isClaimant`).
|
|
12
|
+
*
|
|
13
|
+
* **A start lasts until it is released**, as the label always has: the pull
|
|
14
|
+
* request that closes the issue is what ends it. **A plan lapses** after
|
|
15
|
+
* {@link PLANNED_CLAIM_LIFETIME_HOURS}, since it holds nothing back and a
|
|
16
|
+
* session that meant to start next may never get there; a claimant still
|
|
17
|
+
* meaning to writes it again.
|
|
18
|
+
*
|
|
19
|
+
* **One comment per issue, edited in place**, as for the pull request claim; of
|
|
20
|
+
* several, the oldest is the one, so two claimants writing at once both read
|
|
21
|
+
* back the same answer — which a label cannot give, having no order to read.
|
|
22
|
+
*
|
|
23
|
+
* **Only the comments the reader's own account wrote count**: the repository is
|
|
24
|
+
* public.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import { CLOCK_SKEW_MINUTES } from './claim.mjs';
|
|
28
|
+
import type { OwnComment, QueueComment } from './queue-comments.mjs';
|
|
29
|
+
import { epochMsOf } from './timestamp.mjs';
|
|
30
|
+
|
|
31
|
+
/** How long a plan lasts when it is not written again. */
|
|
32
|
+
export const PLANNED_CLAIM_LIFETIME_HOURS = 24;
|
|
33
|
+
|
|
34
|
+
export type IssueClaim = Readonly<{
|
|
35
|
+
/** Started (`in-progress`) or meant to be started next (`planned`). */
|
|
36
|
+
state: 'planned' | 'started';
|
|
37
|
+
/** Who claimed it: a session URL, or any other word unique to the claimant. */
|
|
38
|
+
by: string;
|
|
39
|
+
/** When, as a UTC instant to the second (`2026-10-12T01:00:00Z`). */
|
|
40
|
+
at: string;
|
|
41
|
+
}>;
|
|
42
|
+
|
|
43
|
+
export type IssueClaimComment = Readonly<
|
|
44
|
+
{ kind: 'released' } | { kind: 'standing'; claim: IssueClaim }
|
|
45
|
+
>;
|
|
46
|
+
|
|
47
|
+
/** The record line, then `prose`. */
|
|
48
|
+
export const writeIssueClaimComment = (
|
|
49
|
+
claim: IssueClaim,
|
|
50
|
+
prose: string,
|
|
51
|
+
): string =>
|
|
52
|
+
[
|
|
53
|
+
`${MARKER} state=${claim.state} by=${claim.by} at=${claim.at} -->`,
|
|
54
|
+
'',
|
|
55
|
+
prose.trim(),
|
|
56
|
+
].join('\n');
|
|
57
|
+
|
|
58
|
+
/** The record line saying the claim was given up, and `prose`. */
|
|
59
|
+
export const writeReleasedIssueClaimComment = (prose: string): string =>
|
|
60
|
+
[`${MARKER} released -->`, '', prose.trim()].join('\n');
|
|
61
|
+
|
|
62
|
+
/** What the comment says, or `undefined` for a comment this module did not write. */
|
|
63
|
+
export const parseIssueClaimComment = (
|
|
64
|
+
body: string,
|
|
65
|
+
): IssueClaimComment | undefined => {
|
|
66
|
+
const first = body.trim().split(/\r?\n/u, 1)[0]?.trim();
|
|
67
|
+
|
|
68
|
+
if (first === undefined) {
|
|
69
|
+
return undefined;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (first === `${MARKER} released -->`) {
|
|
73
|
+
return { kind: 'released' };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const groups = STANDING_LINE.exec(first)?.groups;
|
|
77
|
+
|
|
78
|
+
const state = groups?.['state'];
|
|
79
|
+
|
|
80
|
+
const by = groups?.['by'];
|
|
81
|
+
|
|
82
|
+
const at = groups?.['at'];
|
|
83
|
+
|
|
84
|
+
return by === undefined ||
|
|
85
|
+
at === undefined ||
|
|
86
|
+
(state !== 'planned' && state !== 'started')
|
|
87
|
+
? undefined
|
|
88
|
+
: { kind: 'standing', claim: { state, by, at } };
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
/** Whether `claim` still holds the issue at `nowEpochMs`. */
|
|
92
|
+
export const issueClaimApplies = (
|
|
93
|
+
claim: IssueClaim,
|
|
94
|
+
nowEpochMs: number,
|
|
95
|
+
): boolean => {
|
|
96
|
+
const at = epochMsOf(claim.at);
|
|
97
|
+
|
|
98
|
+
return (
|
|
99
|
+
at !== undefined &&
|
|
100
|
+
at - CLOCK_SKEW_MINUTES * MS_PER_MINUTE <= nowEpochMs &&
|
|
101
|
+
(claim.state === 'started' ||
|
|
102
|
+
nowEpochMs < at + PLANNED_CLAIM_LIFETIME_HOURS * MS_PER_HOUR)
|
|
103
|
+
);
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Reads `comments`, oldest first, for the oldest claim comment, and whether it
|
|
108
|
+
* still holds; `undefined` when there is none.
|
|
109
|
+
*/
|
|
110
|
+
export const readIssueClaim = (
|
|
111
|
+
comments: readonly OwnComment[],
|
|
112
|
+
nowEpochMs: number,
|
|
113
|
+
): QueueComment<IssueClaimComment> | undefined => {
|
|
114
|
+
for (const { id, body } of comments) {
|
|
115
|
+
const says = parseIssueClaimComment(body);
|
|
116
|
+
|
|
117
|
+
if (says !== undefined) {
|
|
118
|
+
return {
|
|
119
|
+
commentId: id,
|
|
120
|
+
says,
|
|
121
|
+
applies:
|
|
122
|
+
says.kind === 'standing' && issueClaimApplies(says.claim, nowEpochMs),
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return undefined;
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
const MS_PER_MINUTE = 60_000;
|
|
131
|
+
|
|
132
|
+
const MS_PER_HOUR = 60 * MS_PER_MINUTE;
|
|
133
|
+
|
|
134
|
+
/** The first line starts with this, so the comment is found by it. */
|
|
135
|
+
const MARKER = '<!-- start-issue:claim';
|
|
136
|
+
|
|
137
|
+
const STANDING_LINE =
|
|
138
|
+
/^<!-- start-issue:claim state=(?<state>planned|started) by=(?<by>[^\s>]+) at=(?<at>\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z) -->$/u;
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import {
|
|
2
|
+
issueClaimApplies,
|
|
3
|
+
parseIssueClaimComment,
|
|
4
|
+
PLANNED_CLAIM_LIFETIME_HOURS,
|
|
5
|
+
readIssueClaim,
|
|
6
|
+
writeIssueClaimComment,
|
|
7
|
+
writeReleasedIssueClaimComment,
|
|
8
|
+
type IssueClaim,
|
|
9
|
+
} from './issue-claim.mjs';
|
|
10
|
+
|
|
11
|
+
const STARTED: IssueClaim = {
|
|
12
|
+
state: 'started',
|
|
13
|
+
by: 'https://claude.ai/code/session_01abc',
|
|
14
|
+
at: '2026-10-12T01:00:00Z',
|
|
15
|
+
} as const;
|
|
16
|
+
|
|
17
|
+
const PLANNED: IssueClaim = { ...STARTED, state: 'planned' } as const;
|
|
18
|
+
|
|
19
|
+
/** `STARTED.at`. */
|
|
20
|
+
const AT = 1_791_766_800_000;
|
|
21
|
+
|
|
22
|
+
const MINUTE = 60_000;
|
|
23
|
+
|
|
24
|
+
const HOUR = 60 * MINUTE;
|
|
25
|
+
|
|
26
|
+
describe(writeIssueClaimComment, () => {
|
|
27
|
+
test('says who claimed it, how and when, in a form it can read back', () => {
|
|
28
|
+
assert.deepStrictEqual(
|
|
29
|
+
parseIssueClaimComment(writeIssueClaimComment(STARTED, 'Started.')),
|
|
30
|
+
{ kind: 'standing', claim: STARTED },
|
|
31
|
+
);
|
|
32
|
+
|
|
33
|
+
assert.deepStrictEqual(
|
|
34
|
+
parseIssueClaimComment(writeIssueClaimComment(PLANNED, 'Planned.')),
|
|
35
|
+
{ kind: 'standing', claim: PLANNED },
|
|
36
|
+
);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test('keeps the record out of the prose', () => {
|
|
40
|
+
const lines = writeIssueClaimComment(STARTED, 'Started.').split('\n');
|
|
41
|
+
|
|
42
|
+
assert.match(lines[0] ?? '', /^<!--.*-->$/u);
|
|
43
|
+
|
|
44
|
+
assert.strictEqual(lines[2], 'Started.');
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
describe(parseIssueClaimComment, () => {
|
|
49
|
+
test('reads a released claim', () => {
|
|
50
|
+
assert.deepStrictEqual(
|
|
51
|
+
parseIssueClaimComment(writeReleasedIssueClaimComment('Dropped.')),
|
|
52
|
+
{ kind: 'released' },
|
|
53
|
+
);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test('is not a pull request claim, and a pull request claim is not one', () => {
|
|
57
|
+
assert.isUndefined(
|
|
58
|
+
parseIssueClaimComment(
|
|
59
|
+
`<!-- unblock-prs:claim head=${'c'.repeat(40)} by=x at=2026-10-12T01:00:00Z -->`,
|
|
60
|
+
),
|
|
61
|
+
);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test('reads nothing from a comment it did not write', () => {
|
|
65
|
+
assert.isUndefined(parseIssueClaimComment('I will take this one.'));
|
|
66
|
+
|
|
67
|
+
assert.isUndefined(
|
|
68
|
+
parseIssueClaimComment(
|
|
69
|
+
'<!-- start-issue:claim state=done by=x at=2026-10-12T01:00:00Z -->',
|
|
70
|
+
),
|
|
71
|
+
);
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
describe(issueClaimApplies, () => {
|
|
76
|
+
test('a start holds until it is released, however old', () => {
|
|
77
|
+
assert.isTrue(issueClaimApplies(STARTED, AT + 90 * 24 * HOUR));
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test('a plan lapses after its lifetime', () => {
|
|
81
|
+
assert.isTrue(
|
|
82
|
+
issueClaimApplies(PLANNED, AT + PLANNED_CLAIM_LIFETIME_HOURS * HOUR - 1),
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
assert.isFalse(
|
|
86
|
+
issueClaimApplies(PLANNED, AT + PLANNED_CLAIM_LIFETIME_HOURS * HOUR),
|
|
87
|
+
);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test('neither counts when dated well ahead of the clock', () => {
|
|
91
|
+
assert.isTrue(issueClaimApplies(STARTED, AT - 5 * MINUTE));
|
|
92
|
+
|
|
93
|
+
assert.isFalse(issueClaimApplies(STARTED, AT - 6 * MINUTE));
|
|
94
|
+
|
|
95
|
+
assert.isFalse(issueClaimApplies(PLANNED, AT - 6 * MINUTE));
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
describe(readIssueClaim, () => {
|
|
100
|
+
test('is the oldest claim comment, whatever else is around it', () => {
|
|
101
|
+
assert.deepStrictEqual(
|
|
102
|
+
readIssueClaim(
|
|
103
|
+
[
|
|
104
|
+
{ id: 1, body: 'LGTM' },
|
|
105
|
+
{ id: 2, body: writeIssueClaimComment(STARTED, 'Started.') },
|
|
106
|
+
{
|
|
107
|
+
id: 3,
|
|
108
|
+
body: writeIssueClaimComment(
|
|
109
|
+
{ ...STARTED, by: 'another-session' },
|
|
110
|
+
'Started.',
|
|
111
|
+
),
|
|
112
|
+
},
|
|
113
|
+
],
|
|
114
|
+
AT,
|
|
115
|
+
),
|
|
116
|
+
{
|
|
117
|
+
commentId: 2,
|
|
118
|
+
says: { kind: 'standing', claim: STARTED },
|
|
119
|
+
applies: true,
|
|
120
|
+
},
|
|
121
|
+
);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test('says when a claim no longer holds', () => {
|
|
125
|
+
assert.deepStrictEqual(
|
|
126
|
+
readIssueClaim(
|
|
127
|
+
[{ id: 4, body: writeReleasedIssueClaimComment('Dropped.') }],
|
|
128
|
+
AT,
|
|
129
|
+
),
|
|
130
|
+
{ commentId: 4, says: { kind: 'released' }, applies: false },
|
|
131
|
+
);
|
|
132
|
+
|
|
133
|
+
assert.deepStrictEqual(
|
|
134
|
+
readIssueClaim(
|
|
135
|
+
[{ id: 5, body: writeIssueClaimComment(PLANNED, 'Planned.') }],
|
|
136
|
+
AT + PLANNED_CLAIM_LIFETIME_HOURS * HOUR,
|
|
137
|
+
),
|
|
138
|
+
{
|
|
139
|
+
commentId: 5,
|
|
140
|
+
says: { kind: 'standing', claim: PLANNED },
|
|
141
|
+
applies: false,
|
|
142
|
+
},
|
|
143
|
+
);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
test('is undefined with no claim comment', () => {
|
|
147
|
+
assert.isUndefined(readIssueClaim([{ id: 1, body: 'LGTM' }], AT));
|
|
148
|
+
});
|
|
149
|
+
});
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How soon an open issue should be started: one `priority:<tier>` label each,
|
|
3
|
+
* decided when the issue is filed. What each tier means, and how an issue is
|
|
4
|
+
* placed in one, is `docs/issue-priority.md`.
|
|
5
|
+
*
|
|
6
|
+
* These are issue labels. A pull request's place in the merge queue is
|
|
7
|
+
* `priority:high` / `priority:low` (`labels.mts`), which no issue carries.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/** The tier the one `priority:<tier>` label names, `none`, or `several`. */
|
|
11
|
+
export const issuePriorityOfLabels = (
|
|
12
|
+
labels: readonly string[],
|
|
13
|
+
): IssuePriority | 'none' | 'several' => {
|
|
14
|
+
const carried = ISSUE_PRIORITIES.filter((priority) =>
|
|
15
|
+
labels.includes(issuePriorityLabel(priority)),
|
|
16
|
+
);
|
|
17
|
+
|
|
18
|
+
switch (carried.length) {
|
|
19
|
+
case 0:
|
|
20
|
+
return 'none';
|
|
21
|
+
|
|
22
|
+
case 1:
|
|
23
|
+
return carried[0] ?? 'none';
|
|
24
|
+
|
|
25
|
+
default:
|
|
26
|
+
return 'several';
|
|
27
|
+
}
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Where an issue goes in the order it is started in, as a sort key: `S` first,
|
|
32
|
+
* then `A`, `B` and `C`, then one with no tier or several, then `D`. What
|
|
33
|
+
* nobody has placed is not known to matter less than what was placed last.
|
|
34
|
+
* `hold` and `close` rank with no tier here; whoever takes issues in this order
|
|
35
|
+
* leaves them out first.
|
|
36
|
+
*/
|
|
37
|
+
export const issueStartRank = (labels: readonly string[]): number => {
|
|
38
|
+
const priority = issuePriorityOfLabels(labels);
|
|
39
|
+
|
|
40
|
+
return priority === 'none' || priority === 'several'
|
|
41
|
+
? UNPLACED_RANK
|
|
42
|
+
: rankOf(priority);
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
/** The tier as written on a command line, or `undefined` for anything else. */
|
|
46
|
+
export const parseIssuePriority = (raw: string): IssuePriority | undefined =>
|
|
47
|
+
ISSUE_PRIORITIES.find((priority) => priority === raw);
|
|
48
|
+
|
|
49
|
+
export const issuePriorityLabel = (
|
|
50
|
+
priority: IssuePriority,
|
|
51
|
+
): `priority:${IssuePriority}` => `priority:${priority}` as const;
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Every tier, the order `docs/issue-priority.md` lists them in.
|
|
55
|
+
*
|
|
56
|
+
* - `S` — speeds up the work on everything else now: CI, `check-changed`, the
|
|
57
|
+
* merge queue, the review loop, a step a person does by hand most days.
|
|
58
|
+
* - `A` — what the rest of that stands on, a step a person does by hand on
|
|
59
|
+
* every release or deploy, a security issue, or a bug real users meet that
|
|
60
|
+
* stops what they came to do.
|
|
61
|
+
* - `B` — the other bugs in an app with users or in a published package, and
|
|
62
|
+
* smaller gains in speed.
|
|
63
|
+
* - `C` — documents, refactoring, small improvements and features.
|
|
64
|
+
* - `D` — an app nobody uses but its author, and large features.
|
|
65
|
+
* - `hold` — not to be started as things stand: code about to be replaced, or
|
|
66
|
+
* work waiting on a decision.
|
|
67
|
+
* - `close` — to be closed or folded into another issue at the next triage.
|
|
68
|
+
*
|
|
69
|
+
* `pr-manager`'s tier tooltips (`issue-tier-group.tsx`) repeat this list.
|
|
70
|
+
*/
|
|
71
|
+
export const ISSUE_PRIORITIES = [
|
|
72
|
+
'S',
|
|
73
|
+
'A',
|
|
74
|
+
'B',
|
|
75
|
+
'C',
|
|
76
|
+
'D',
|
|
77
|
+
'hold',
|
|
78
|
+
'close',
|
|
79
|
+
] as const;
|
|
80
|
+
|
|
81
|
+
export type IssuePriority = (typeof ISSUE_PRIORITIES)[number];
|
|
82
|
+
|
|
83
|
+
/** The tiers a new issue may be filed under: `close` is a verdict on one. */
|
|
84
|
+
export const FILING_PRIORITIES = [
|
|
85
|
+
'S',
|
|
86
|
+
'A',
|
|
87
|
+
'B',
|
|
88
|
+
'C',
|
|
89
|
+
'D',
|
|
90
|
+
'hold',
|
|
91
|
+
] as const satisfies readonly IssuePriority[];
|
|
92
|
+
|
|
93
|
+
const UNPLACED_RANK = 4;
|
|
94
|
+
|
|
95
|
+
const rankOf = (priority: IssuePriority): number => {
|
|
96
|
+
switch (priority) {
|
|
97
|
+
case 'S':
|
|
98
|
+
return 0;
|
|
99
|
+
|
|
100
|
+
case 'A':
|
|
101
|
+
return 1;
|
|
102
|
+
|
|
103
|
+
case 'B':
|
|
104
|
+
return 2;
|
|
105
|
+
|
|
106
|
+
case 'C':
|
|
107
|
+
return 3;
|
|
108
|
+
|
|
109
|
+
case 'D':
|
|
110
|
+
return 5;
|
|
111
|
+
|
|
112
|
+
case 'hold':
|
|
113
|
+
case 'close':
|
|
114
|
+
return UNPLACED_RANK;
|
|
115
|
+
}
|
|
116
|
+
};
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import {
|
|
2
|
+
issuePriorityLabel,
|
|
3
|
+
issuePriorityOfLabels,
|
|
4
|
+
issueStartRank,
|
|
5
|
+
parseIssuePriority,
|
|
6
|
+
} from './issue-priority.mjs';
|
|
7
|
+
|
|
8
|
+
describe(issuePriorityOfLabels, () => {
|
|
9
|
+
test('reads the one priority label an issue carries', () => {
|
|
10
|
+
assert.strictEqual(
|
|
11
|
+
issuePriorityOfLabels(['bug', 'priority:S', 'pending']),
|
|
12
|
+
'S',
|
|
13
|
+
);
|
|
14
|
+
|
|
15
|
+
assert.strictEqual(issuePriorityOfLabels(['priority:hold']), 'hold');
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
test('says none for no priority label, and the pull request ones are not one', () => {
|
|
19
|
+
assert.strictEqual(issuePriorityOfLabels([]), 'none');
|
|
20
|
+
|
|
21
|
+
assert.strictEqual(
|
|
22
|
+
issuePriorityOfLabels(['priority:high', 'priority:low']),
|
|
23
|
+
'none',
|
|
24
|
+
);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test('says several for more than one, which says nothing', () => {
|
|
28
|
+
assert.strictEqual(
|
|
29
|
+
issuePriorityOfLabels(['priority:A', 'priority:C']),
|
|
30
|
+
'several',
|
|
31
|
+
);
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
describe(issueStartRank, () => {
|
|
36
|
+
test('ranks S first, then A, B and C, then what has no tier, then D', () => {
|
|
37
|
+
const ranks = [
|
|
38
|
+
['priority:D'],
|
|
39
|
+
['priority:S'],
|
|
40
|
+
[],
|
|
41
|
+
['priority:C'],
|
|
42
|
+
['priority:A', 'priority:B'],
|
|
43
|
+
['priority:B'],
|
|
44
|
+
['priority:A'],
|
|
45
|
+
].map(issueStartRank);
|
|
46
|
+
|
|
47
|
+
assert.deepStrictEqual(ranks, [5, 0, 4, 3, 4, 2, 1]);
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
describe(parseIssuePriority, () => {
|
|
52
|
+
test('takes a tier as written', () => {
|
|
53
|
+
assert.strictEqual(parseIssuePriority('B'), 'B');
|
|
54
|
+
|
|
55
|
+
assert.strictEqual(parseIssuePriority('hold'), 'hold');
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test('refuses anything else, the pull request priorities included', () => {
|
|
59
|
+
assert.isUndefined(parseIssuePriority('b'));
|
|
60
|
+
|
|
61
|
+
assert.isUndefined(parseIssuePriority('high'));
|
|
62
|
+
|
|
63
|
+
assert.isUndefined(parseIssuePriority('priority:S'));
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
describe(issuePriorityLabel, () => {
|
|
68
|
+
test('is the tier under priority:', () => {
|
|
69
|
+
assert.strictEqual(issuePriorityLabel('close'), 'priority:close');
|
|
70
|
+
});
|
|
71
|
+
});
|
package/src/labels.mts
CHANGED
|
@@ -55,7 +55,8 @@ export const AUTO_REBASE_LABEL = 'auto-rebase';
|
|
|
55
55
|
* labelled `priority:low` last, the version pull request included.
|
|
56
56
|
* They decide nothing else — what `Merge-After:`, a stack or `blocks-release`
|
|
57
57
|
* holds back stays held, and a pull request already in flight is watched
|
|
58
|
-
* whatever it carries. One carrying both counts as neither.
|
|
58
|
+
* whatever it carries. One carrying both counts as neither. They are pull
|
|
59
|
+
* request labels: an issue's is a tier (`issue-priority.mts`).
|
|
59
60
|
* {@link PRIORITY_MID_LABEL} is not read there: it is the same as carrying
|
|
60
61
|
* neither.
|
|
61
62
|
*/
|
|
@@ -79,8 +80,9 @@ export const PENDING_LABEL = 'pending';
|
|
|
79
80
|
|
|
80
81
|
/**
|
|
81
82
|
* The label that says someone has started an issue, before there is a pull
|
|
82
|
-
* request to say so. `start-issue` puts it on,
|
|
83
|
-
*
|
|
83
|
+
* request to say so. `start-issue` puts it on, with a claim comment saying who
|
|
84
|
+
* (`issue-claim.mts`), and refuses an issue someone else claimed or that
|
|
85
|
+
* carries it unclaimed, so that two sessions do not pick up the same one; the
|
|
84
86
|
* Pull Requests Manager page and `pr-report` show a startable issue carrying it
|
|
85
87
|
* as in progress, as they show one an open pull request closes
|
|
86
88
|
* (`start-after.mts`'s `progressOf`). Nothing takes it off when the work
|
|
@@ -92,6 +94,7 @@ export const IN_PROGRESS_LABEL = 'in-progress';
|
|
|
92
94
|
* The label that says someone means to start an issue next. It holds nothing
|
|
93
95
|
* back — `start-issue` takes it off as it puts {@link IN_PROGRESS_LABEL} on —
|
|
94
96
|
* and only tells whoever is choosing what to start that this one is spoken for;
|
|
97
|
+
* `start-issue --plan` puts it on with a claim comment saying by whom;
|
|
95
98
|
* the page and `pr-report` show a startable issue carrying it as planned.
|
|
96
99
|
*/
|
|
97
100
|
export const PLANNED_LABEL = 'planned';
|
package/src/queue-owner.mts
CHANGED
|
@@ -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',
|
|
@@ -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',
|
package/src/queue-owner.test.mts
CHANGED
|
@@ -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,
|
package/src/ruleset.mts
CHANGED
|
@@ -21,12 +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
|
|
25
|
-
* `
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
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.
|
|
30
30
|
*
|
|
31
31
|
* `source` names the file in the messages.
|
|
32
32
|
*/
|
package/src/set-aside.mts
CHANGED
|
@@ -152,7 +152,8 @@ export const setAsideApplies = (
|
|
|
152
152
|
* more — except two whose head a rebase would only put through the same matrix
|
|
153
153
|
* again: `checks-failed`, which the push that carries the fix clears, and
|
|
154
154
|
* `awaiting-bypass`, green and waiting for a ruleset bypass merge, which does
|
|
155
|
-
* not wait for the base.
|
|
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. */
|
package/src/set-aside.test.mts
CHANGED
|
@@ -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
|
});
|