pr-report-core 0.2.0 → 0.2.1
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 +61 -62
- package/dist/checks.d.mts.map +1 -1
- package/dist/checks.mjs +0 -1
- package/dist/checks.mjs.map +1 -1
- package/dist/claim.d.mts +13 -14
- package/dist/claim.d.mts.map +1 -1
- package/dist/claim.mjs +0 -1
- package/dist/claim.mjs.map +1 -1
- package/dist/closing-issues.d.mts +18 -19
- package/dist/closing-issues.d.mts.map +1 -1
- package/dist/closing-issues.mjs +0 -1
- package/dist/closing-issues.mjs.map +1 -1
- package/dist/code-owners.d.mts +33 -34
- package/dist/code-owners.d.mts.map +1 -1
- package/dist/code-owners.mjs +0 -2
- package/dist/code-owners.mjs.map +1 -1
- package/dist/labels.d.mts +38 -40
- package/dist/labels.d.mts.map +1 -1
- package/dist/labels.mjs +0 -2
- package/dist/labels.mjs.map +1 -1
- package/dist/merge-after.d.mts +22 -16
- package/dist/merge-after.d.mts.map +1 -1
- package/dist/merge-after.mjs +87 -24
- package/dist/merge-after.mjs.map +1 -1
- package/dist/queue-comments.d.mts +2 -2
- package/dist/queue-order.d.mts +32 -32
- package/dist/queue-order.mjs +0 -2
- package/dist/queue-order.mjs.map +1 -1
- package/dist/queue-owner.d.mts +13 -13
- package/dist/queue-owner.mjs.map +1 -1
- package/dist/report.d.mts +4 -4
- package/dist/review-hold.d.mts +14 -14
- package/dist/ruleset.d.mts +10 -11
- package/dist/ruleset.d.mts.map +1 -1
- package/dist/ruleset.mjs +0 -1
- package/dist/ruleset.mjs.map +1 -1
- package/dist/set-aside.d.mts +28 -28
- package/dist/stack.d.mts +14 -15
- package/dist/stack.d.mts.map +1 -1
- package/dist/stack.mjs +0 -1
- package/dist/stack.mjs.map +1 -1
- package/dist/start-after.d.mts +20 -21
- package/dist/start-after.d.mts.map +1 -1
- package/dist/start-after.mjs +0 -1
- package/dist/start-after.mjs.map +1 -1
- package/dist/summarize.d.mts +2 -2
- package/dist/timestamp.d.mts +10 -10
- package/dist/tree.d.mts +5 -5
- package/dist/types.d.mts +32 -33
- package/dist/types.d.mts.map +1 -1
- package/package.json +8 -8
- package/src/checks.mts +64 -65
- package/src/checks.test.mts +30 -31
- package/src/claim.mts +13 -14
- package/src/closing-issues.mts +18 -19
- package/src/closing-issues.test.mts +2 -2
- package/src/code-owners.mts +39 -41
- package/src/labels.mts +38 -40
- package/src/merge-after.mts +142 -49
- package/src/merge-after.test.mts +80 -3
- package/src/queue-comments.mts +2 -2
- package/src/queue-order.mts +60 -62
- package/src/queue-order.test.mts +6 -6
- package/src/queue-owner.mts +25 -25
- package/src/queue-owner.test.mts +2 -2
- package/src/report.mts +9 -9
- package/src/review-hold.mts +14 -14
- package/src/review-hold.test.mts +3 -3
- package/src/ruleset.mts +10 -11
- package/src/set-aside.mts +28 -28
- package/src/stack.mts +14 -15
- package/src/stack.test.mts +4 -4
- package/src/start-after.mts +20 -21
- package/src/summarize.mts +2 -2
- package/src/timestamp.mts +14 -14
- package/src/tree.mts +5 -5
- package/src/types.mts +32 -33
|
@@ -145,8 +145,8 @@ describe(closingKeywordIssuesIn, () => {
|
|
|
145
145
|
});
|
|
146
146
|
|
|
147
147
|
/**
|
|
148
|
-
* A reference as GitHub renders it in a pull request's `bodyHTML`, with
|
|
149
|
-
*
|
|
148
|
+
* A reference as GitHub renders it in a pull request's `bodyHTML`, with the
|
|
149
|
+
* attributes in the order GitHub writes them.
|
|
150
150
|
*/
|
|
151
151
|
const link = (
|
|
152
152
|
nameWithOwner: string,
|
package/src/code-owners.mts
CHANGED
|
@@ -3,18 +3,18 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Read because GitHub will not say. `main`'s ruleset has
|
|
5
5
|
* `require_code_owner_review` on, so a pull request touching an owned path
|
|
6
|
-
* waits for an owner to approve it — and nothing on a pull request reports
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
* Pull Requests Manager page, to show the wait, and by `unblock-prs`, to
|
|
11
|
-
*
|
|
6
|
+
* waits for an owner to approve it — and nothing on a pull request reports that
|
|
7
|
+
* wait: `reviewDecision` answers `null` for every pull request here, owned
|
|
8
|
+
* paths or not, because it describes branch protection rather than a ruleset.
|
|
9
|
+
* So it is worked out the way GitHub does, from the file — by the
|
|
10
|
+
* Pull Requests Manager page, to show the wait, and by `unblock-prs`, to leave
|
|
11
|
+
* a pull request that waits in the queue rather than run its checks.
|
|
12
12
|
*
|
|
13
|
-
* The patterns are the subset of `.gitignore` syntax GitHub documents for
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
13
|
+
* The patterns are the subset of `.gitignore` syntax GitHub documents for this
|
|
14
|
+
* file: a leading `/` or a `/` in the middle anchors a pattern to the root, a
|
|
15
|
+
* trailing `/` makes it a directory, `*` and `?` stay inside one path segment
|
|
16
|
+
* and `**` crosses them. Negation and character ranges are not supported by
|
|
17
|
+
* GitHub here either.
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
20
|
import { Arr } from 'ts-data-forge';
|
|
@@ -22,8 +22,8 @@ import { Arr } from 'ts-data-forge';
|
|
|
22
22
|
export type CodeOwnersRule = Readonly<{
|
|
23
23
|
pattern: string;
|
|
24
24
|
/**
|
|
25
|
-
* Without the leading `@`. Empty for a pattern that clears the owners of
|
|
26
|
-
*
|
|
25
|
+
* Without the leading `@`. Empty for a pattern that clears the owners of what
|
|
26
|
+
* an earlier line matched.
|
|
27
27
|
*/
|
|
28
28
|
owners: readonly string[];
|
|
29
29
|
}>;
|
|
@@ -45,9 +45,9 @@ export const parseCodeOwners = (text: string): readonly CodeOwnersRule[] =>
|
|
|
45
45
|
});
|
|
46
46
|
|
|
47
47
|
/**
|
|
48
|
-
* The owners of one path: those of the **last** pattern that matches it,
|
|
49
|
-
*
|
|
50
|
-
*
|
|
48
|
+
* The owners of one path: those of the **last** pattern that matches it, which
|
|
49
|
+
* is how GitHub reads the file — a later line overrides an earlier one rather
|
|
50
|
+
* than adding to it.
|
|
51
51
|
*/
|
|
52
52
|
export const ownersOf = (
|
|
53
53
|
path: string,
|
|
@@ -57,19 +57,18 @@ export const ownersOf = (
|
|
|
57
57
|
|
|
58
58
|
/**
|
|
59
59
|
* What GitHub would say, worked out the way GitHub does: every changed path
|
|
60
|
-
* that has owners needs an approval from one of them. An approval is the
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
-
*
|
|
60
|
+
* that has owners needs an approval from one of them. An approval is the latest
|
|
61
|
+
* review of an owner that says so; one dismissed by a later push — the ruleset
|
|
62
|
+
* dismisses stale approvals — no longer counts, and GitHub has already stopped
|
|
63
|
+
* calling it an approval by the time this reads it.
|
|
64
64
|
*
|
|
65
|
-
* Owners are compared by login, case-insensitively, as GitHub compares
|
|
66
|
-
*
|
|
67
|
-
*
|
|
68
|
-
*
|
|
69
|
-
*
|
|
70
|
-
*
|
|
71
|
-
*
|
|
72
|
-
* team.
|
|
65
|
+
* Owners are compared by login, case-insensitively, as GitHub compares them. A
|
|
66
|
+
* team owner (`@org/team`) cannot be: telling who is on the team takes
|
|
67
|
+
* `read:org`, which neither reader asks for. So a path a team owns waits while
|
|
68
|
+
* nobody at all has approved, is approved by a person owning it beside the
|
|
69
|
+
* team, and otherwise — someone approved, and might be on the team — cannot be
|
|
70
|
+
* told, which makes the whole `unknown` unless another path is plainly still
|
|
71
|
+
* waiting. `CODEOWNERS` in this repository names no team.
|
|
73
72
|
*/
|
|
74
73
|
export const codeOwnerReview = ({
|
|
75
74
|
required,
|
|
@@ -150,8 +149,8 @@ const stripComment = (line: string): string => {
|
|
|
150
149
|
return at === -1 ? line : line.slice(0, at);
|
|
151
150
|
};
|
|
152
151
|
|
|
153
|
-
// Not `RegExp.escape`: it arrived in Node 24, and `ws:check:test` also runs
|
|
154
|
-
//
|
|
152
|
+
// Not `RegExp.escape`: it arrived in Node 24, and `ws:check:test` also runs on
|
|
153
|
+
// the `engines.node` floor (`test-node-versions (minimum)`), which is 22.
|
|
155
154
|
// Under the `u` flag only the syntax characters and `/` may be escaped, and
|
|
156
155
|
// everything else is a literal as it stands.
|
|
157
156
|
const escapeChar = (char: string): string =>
|
|
@@ -187,11 +186,10 @@ const toRegExp = (pattern: string): RegExp => {
|
|
|
187
186
|
|
|
188
187
|
const body = globToRegExpSource(trimmed);
|
|
189
188
|
|
|
190
|
-
// A directory owns what is under it and never matches as a file. A name
|
|
191
|
-
//
|
|
192
|
-
//
|
|
193
|
-
//
|
|
194
|
-
// `docs/build/a.md`.
|
|
189
|
+
// A directory owns what is under it and never matches as a file. A name with
|
|
190
|
+
// no trailing slash may be either, so it matches itself and what is under it
|
|
191
|
+
// — unless its last segment is a wildcard, which GitHub reads as files at
|
|
192
|
+
// that level only: `docs/*` owns `docs/a.md` and not `docs/build/a.md`.
|
|
195
193
|
const tail = directory
|
|
196
194
|
? '/.+'
|
|
197
195
|
: (trimmed.split('/').at(-1) ?? '').includes('*')
|
|
@@ -206,12 +204,12 @@ const toRegExp = (pattern: string): RegExp => {
|
|
|
206
204
|
* Where a pull request stands with the code owners.
|
|
207
205
|
*
|
|
208
206
|
* `required` lists what is still waiting — the owned paths no owner has
|
|
209
|
-
* approved yet, and who could approve them. `authorOwns` is the case that
|
|
210
|
-
*
|
|
211
|
-
*
|
|
212
|
-
*
|
|
213
|
-
*
|
|
214
|
-
*
|
|
207
|
+
* approved yet, and who could approve them. `authorOwns` is the case that waits
|
|
208
|
+
* for ever: GitHub does not let an author approve their own pull request, so
|
|
209
|
+
* when the author is the only owner nothing but a ruleset bypass will merge it.
|
|
210
|
+
* `unknown` is a pull request whose changed files were more than could be read,
|
|
211
|
+
* where the unread ones might be owned — or, with `teams`, one approved by
|
|
212
|
+
* someone these teams might have as a member.
|
|
215
213
|
*/
|
|
216
214
|
export type CodeOwnerReview = Readonly<
|
|
217
215
|
| { state: 'approved' }
|
package/src/labels.mts
CHANGED
|
@@ -1,23 +1,23 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The labels this repository's queue is made of. They exist only on GitHub,
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
2
|
+
* The labels this repository's queue is made of. They exist only on GitHub, so
|
|
3
|
+
* these strings, and the ones in `.github/workflows/`, are the whole of their
|
|
4
|
+
* declaration: `unblock-prs` and `open-pr` act on them, and `pr-report` and the
|
|
5
|
+
* Pull Requests Manager page report them.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
/**
|
|
9
|
-
* The label that says "not yet": the check workflows skip while it is on
|
|
10
|
-
*
|
|
11
|
-
* status. It is a label, deliberately — a `skip-ci` in the title means
|
|
12
|
-
*
|
|
9
|
+
* The label that says "not yet": the check workflows skip while it is on and
|
|
10
|
+
* `skip-ci-label.yml` holds the merge with a `pending` `no-skip-ci-label`
|
|
11
|
+
* status. It is a label, deliberately — a `skip-ci` in the title means nothing
|
|
12
|
+
* to any workflow.
|
|
13
13
|
*/
|
|
14
14
|
export const SKIP_CI_LABEL = 'skip-ci';
|
|
15
15
|
|
|
16
16
|
/**
|
|
17
|
-
* The label that says a pull request has been reviewed and is waiting its
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
17
|
+
* The label that says a pull request has been reviewed and is waiting its turn.
|
|
18
|
+
* It is the whole of `unblock-prs`'s scope rule: a pull request without it is
|
|
19
|
+
* passed over in silence, and one with it is rebased, released from `skip-ci`
|
|
20
|
+
* and watched when its turn comes.
|
|
21
21
|
*/
|
|
22
22
|
export const MERGE_QUEUED_LABEL = 'merge-queued';
|
|
23
23
|
|
|
@@ -25,17 +25,16 @@ export const MERGE_QUEUED_LABEL = 'merge-queued';
|
|
|
25
25
|
* The label a pull request carries to say the next release must contain it:
|
|
26
26
|
* while it is open, the version pull request is not picked.
|
|
27
27
|
*
|
|
28
|
-
* It exists because the version pull request is the one pull request whose
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
28
|
+
* It exists because the version pull request is the one pull request whose body
|
|
29
|
+
* cannot declare a `Merge-After:` — `changesets/action` overwrites the title
|
|
30
|
+
* and body of it on every push to the base — so the ordering is declared from
|
|
31
|
+
* the other side, by the pull request the release is waiting for. A label
|
|
32
|
+
* rather than a trailer for the same reason `merge-queued` is one: this
|
|
33
|
+
* repository is public, and a label needs write access while a comment does
|
|
34
|
+
* not.
|
|
35
35
|
*
|
|
36
|
-
* Nothing ever takes it off. The constraint is "open", so it ends when the
|
|
37
|
-
*
|
|
38
|
-
* clean up.
|
|
36
|
+
* Nothing ever takes it off. The constraint is "open", so it ends when the pull
|
|
37
|
+
* request merges or is closed, and there is no state left behind to clean up.
|
|
39
38
|
*/
|
|
40
39
|
export const BLOCKS_RELEASE_LABEL = 'blocks-release';
|
|
41
40
|
|
|
@@ -43,22 +42,22 @@ export const BLOCKS_RELEASE_LABEL = 'blocks-release';
|
|
|
43
42
|
* The label that asks `unblock-prs` to keep a pull request on the tip of the
|
|
44
43
|
* default branch while it waits, so that its diff stays readable for review.
|
|
45
44
|
* It is honoured only together with `skip-ci`, under which the push runs no
|
|
46
|
-
* checks, and only when the queue has nothing to do. `open-pr` puts it on
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
45
|
+
* checks, and only when the queue has nothing to do. `open-pr` puts it on every
|
|
46
|
+
* layer of a stack: one above the bottom is carried along by the layer below,
|
|
47
|
+
* and needs the label of its own once that layer merges and it is moved onto
|
|
48
|
+
* the default branch.
|
|
50
49
|
*/
|
|
51
50
|
export const AUTO_REBASE_LABEL = 'auto-rebase';
|
|
52
51
|
|
|
53
52
|
/**
|
|
54
53
|
* The labels that move a pull request within `unblock-prs`'s pick order:
|
|
55
|
-
* among the ones it may pick, those labelled `priority:high` go first and
|
|
56
|
-
*
|
|
57
|
-
* They decide nothing else — what `Merge-After:`, a stack or
|
|
58
|
-
*
|
|
59
|
-
*
|
|
60
|
-
*
|
|
61
|
-
*
|
|
54
|
+
* among the ones it may pick, those labelled `priority:high` go first and those
|
|
55
|
+
* labelled `priority:low` last, the version pull request included.
|
|
56
|
+
* They decide nothing else — what `Merge-After:`, a stack or `blocks-release`
|
|
57
|
+
* holds back stays held, and a pull request already in flight is watched
|
|
58
|
+
* whatever it carries. One carrying both counts as neither.
|
|
59
|
+
* {@link PRIORITY_MID_LABEL} is not read there: it is the same as carrying
|
|
60
|
+
* neither.
|
|
62
61
|
*/
|
|
63
62
|
export const PRIORITY_HIGH_LABEL = 'priority:high';
|
|
64
63
|
|
|
@@ -72,8 +71,8 @@ export const PRIORITY_MID_LABEL = 'priority:mid';
|
|
|
72
71
|
|
|
73
72
|
/**
|
|
74
73
|
* The label that says the author is holding a pull request back for now.
|
|
75
|
-
* Also on GitHub, and read only by the Pull Requests Manager page, whose
|
|
76
|
-
*
|
|
74
|
+
* Also on GitHub, and read only by the Pull Requests Manager page, whose merge
|
|
75
|
+
* order counts it and can hide what carries it. It asks nothing of
|
|
77
76
|
* `unblock-prs`: what is held is simply not labelled `merge-queued`.
|
|
78
77
|
*/
|
|
79
78
|
export const PENDING_LABEL = 'pending';
|
|
@@ -82,8 +81,8 @@ export const PENDING_LABEL = 'pending';
|
|
|
82
81
|
* The label that says someone has started an issue, before there is a pull
|
|
83
82
|
* request to say so. `start-issue` puts it on, and refuses an issue that
|
|
84
83
|
* 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
|
-
*
|
|
84
|
+
* Pull Requests Manager page and `pr-report` show a startable issue carrying it
|
|
85
|
+
* as in progress, as they show one an open pull request closes
|
|
87
86
|
* (`start-after.mts`'s `progressOf`). Nothing takes it off when the work
|
|
88
87
|
* merges: the issue closes, and the label with it stops mattering.
|
|
89
88
|
*/
|
|
@@ -92,8 +91,7 @@ export const IN_PROGRESS_LABEL = 'in-progress';
|
|
|
92
91
|
/**
|
|
93
92
|
* The label that says someone means to start an issue next. It holds nothing
|
|
94
93
|
* 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
|
-
*
|
|
97
|
-
* planned.
|
|
94
|
+
* and only tells whoever is choosing what to start that this one is spoken for;
|
|
95
|
+
* the page and `pr-report` show a startable issue carrying it as planned.
|
|
98
96
|
*/
|
|
99
97
|
export const PLANNED_LABEL = 'planned';
|
package/src/merge-after.mts
CHANGED
|
@@ -8,11 +8,10 @@ import { Arr, Num, Result } from 'ts-data-forge';
|
|
|
8
8
|
|
|
9
9
|
/**
|
|
10
10
|
* `#N`, and only when nothing that could end a repository name comes right
|
|
11
|
-
* before the `#`: `other/repo#5` is another repository's #5, and treating it
|
|
12
|
-
*
|
|
13
|
-
* `noshiro-pf/mono#5` is not read either, though it means this repository —
|
|
14
|
-
*
|
|
15
|
-
* it.
|
|
11
|
+
* before the `#`: `other/repo#5` is another repository's #5, and treating it as
|
|
12
|
+
* this one's would wait for a pull request the author never named.
|
|
13
|
+
* `noshiro-pf/mono#5` is not read either, though it means this repository — the
|
|
14
|
+
* declaration takes `#N` and nothing else, so there is one way to write it.
|
|
16
15
|
*/
|
|
17
16
|
const NUMBER_REF = /(?<![\w./-])#(?<number>\d+)\b/gu;
|
|
18
17
|
|
|
@@ -21,53 +20,84 @@ const DECLARATION_LINE = {
|
|
|
21
20
|
'start-after': /^[^\S\n]*start-after[^\S\n]*:(?<refs>[^\n]*)$/gimu,
|
|
22
21
|
} as const;
|
|
23
22
|
|
|
24
|
-
/**
|
|
25
|
-
|
|
23
|
+
/**
|
|
24
|
+
* A line that can open or close a fenced code block: a run of three or more
|
|
25
|
+
* backticks or tildes, and what follows it on the line.
|
|
26
|
+
*/
|
|
27
|
+
const FENCE_LINE = /^[^\S\n]*(?<marker>`{3,}|~{3,})(?<rest>[^\n]*)$/u;
|
|
26
28
|
|
|
27
29
|
/**
|
|
28
30
|
* The pull request numbers a body declares this one must merge after.
|
|
29
31
|
*
|
|
30
32
|
* Duplicates are dropped and the order within the declaration is not kept:
|
|
31
33
|
* what is being read is a set of things that have to have happened, not a
|
|
32
|
-
* sequence. A number that names no pull request, or one that is already
|
|
33
|
-
*
|
|
34
|
+
* sequence. A number that names no pull request, or one that is already closed,
|
|
35
|
+
* simply never appears in the open list and so constrains nothing.
|
|
34
36
|
*/
|
|
35
37
|
export const parseMergeAfter = (body: string): readonly number[] =>
|
|
36
38
|
parseDeclaration(body, 'merge-after');
|
|
37
39
|
|
|
38
40
|
/**
|
|
39
|
-
* The numbers an issue body declares it waits for before it is started,
|
|
40
|
-
*
|
|
41
|
+
* The numbers an issue body declares it waits for before it is started, read
|
|
42
|
+
* exactly as {@link parseMergeAfter} reads its trailer
|
|
41
43
|
* (`docs/issue-start-after.md`).
|
|
42
44
|
*/
|
|
43
45
|
export const parseStartAfter = (body: string): readonly number[] =>
|
|
44
46
|
parseDeclaration(body, 'start-after');
|
|
45
47
|
|
|
46
48
|
/**
|
|
47
|
-
* The body with the contents of its fenced code blocks blanked out, line
|
|
48
|
-
*
|
|
49
|
+
* The body with the contents of its fenced code blocks blanked out, line count
|
|
50
|
+
* preserved.
|
|
49
51
|
*
|
|
50
52
|
* A declaration and an example of one are the same text, and the pull request
|
|
51
53
|
* that introduced this feature had to describe it: written in a fence, the
|
|
52
|
-
* example would have been read as a constraint on the pull request
|
|
53
|
-
*
|
|
54
|
-
*
|
|
54
|
+
* example would have been read as a constraint on the pull request documenting
|
|
55
|
+
* it. A fence is how every document here already says "this is a sample, not
|
|
56
|
+
* the thing itself", so it is the line to draw.
|
|
55
57
|
*
|
|
56
|
-
* Exported because `pr-report` reads the closing keywords (`Closes #12`) out
|
|
57
|
-
*
|
|
58
|
+
* Exported because `pr-report` reads the closing keywords (`Closes #12`) out of
|
|
59
|
+
* the same bodies and has to draw the line in the same place — a document
|
|
58
60
|
* showing what a declaration looks like must not become one.
|
|
59
61
|
*/
|
|
60
62
|
export const outsideCodeFences = (body: string): string => {
|
|
61
|
-
|
|
63
|
+
// The marker that opened the fence the current line is in, if any.
|
|
64
|
+
let mut_openedBy: string | undefined = undefined;
|
|
62
65
|
|
|
63
66
|
return body
|
|
64
67
|
.split('\n')
|
|
65
68
|
.map((line) => {
|
|
66
|
-
|
|
67
|
-
|
|
69
|
+
const fence = FENCE_LINE.exec(line)?.groups;
|
|
70
|
+
|
|
71
|
+
const marker = fence?.['marker'];
|
|
72
|
+
|
|
73
|
+
const rest = fence?.['rest'] ?? '';
|
|
74
|
+
|
|
75
|
+
if (mut_openedBy === undefined) {
|
|
76
|
+
// A backtick fence's info string cannot hold a backtick (CommonMark),
|
|
77
|
+
// so ```` ```x``` ```` is inline code, not an opening line.
|
|
78
|
+
if (
|
|
79
|
+
marker === undefined ||
|
|
80
|
+
(marker.startsWith('`') && rest.includes('`'))
|
|
81
|
+
) {
|
|
82
|
+
return line;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
mut_openedBy = marker;
|
|
86
|
+
|
|
87
|
+
return '';
|
|
68
88
|
}
|
|
69
89
|
|
|
70
|
-
|
|
90
|
+
// Only a run of the opening character, at least as long, with nothing
|
|
91
|
+
// after it, closes the fence (CommonMark); any other fence line is part
|
|
92
|
+
// of the code, so a ``` inside a ```` or ~~~ block does not end it.
|
|
93
|
+
if (
|
|
94
|
+
marker !== undefined &&
|
|
95
|
+
marker.at(0) === mut_openedBy.at(0) &&
|
|
96
|
+
marker.length >= mut_openedBy.length &&
|
|
97
|
+
rest.trim() === ''
|
|
98
|
+
) {
|
|
99
|
+
mut_openedBy = undefined;
|
|
100
|
+
}
|
|
71
101
|
|
|
72
102
|
return '';
|
|
73
103
|
})
|
|
@@ -75,52 +105,50 @@ export const outsideCodeFences = (body: string): string => {
|
|
|
75
105
|
};
|
|
76
106
|
|
|
77
107
|
/**
|
|
78
|
-
* The `Merge-After` cycles,
|
|
79
|
-
*
|
|
80
|
-
*
|
|
108
|
+
* The `Merge-After` cycles, each listing the numbers in the order the edges run
|
|
109
|
+
* and rotated to start at its lowest number so that the same cycle is always
|
|
110
|
+
* reported the same way.
|
|
111
|
+
*
|
|
112
|
+
* Every number that lies on any cycle appears in at least one entry — callers
|
|
113
|
+
* take `cycles.flat()` as the set of numbers on a cycle. Each entry is the
|
|
114
|
+
* shortest cycle through the first number, in key order, that no earlier entry
|
|
115
|
+
* covers, so a component with several loops yields one entry per loop needed to
|
|
116
|
+
* cover it rather than every loop it holds.
|
|
81
117
|
*
|
|
82
118
|
* A cycle is not a state anything here can resolve: every pull request on one
|
|
83
119
|
* waits for another that waits, in the end, for it. Naming it is the whole of
|
|
84
120
|
* what can be done about it — without this the members would each report
|
|
85
121
|
* "waiting on #N" for as long as the loop runs, with nothing saying why that
|
|
86
|
-
* never changes. A pull request that declares itself is a cycle of one, and
|
|
87
|
-
*
|
|
122
|
+
* never changes. A pull request that declares itself is a cycle of one, and is
|
|
123
|
+
* found by the same search.
|
|
88
124
|
*/
|
|
89
125
|
export const findMergeAfterCycles = (
|
|
90
126
|
dependencies: ReadonlyMap<number, readonly number[]>,
|
|
91
127
|
): readonly (readonly number[])[] => {
|
|
92
|
-
const
|
|
128
|
+
const mut_covered = new Set<number>();
|
|
93
129
|
|
|
94
130
|
const mut_cycles = new Map<string, readonly number[]>();
|
|
95
131
|
|
|
96
|
-
const
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
if (at !== -1) {
|
|
100
|
-
// A back edge into the path being walked: everything from there on is
|
|
101
|
-
// the cycle.
|
|
102
|
-
const cycle = rotateToLowest(walked.slice(at));
|
|
103
|
-
|
|
104
|
-
mut_cycles.set(cycle.join(','), cycle);
|
|
105
|
-
|
|
106
|
-
return;
|
|
132
|
+
for (const node of dependencies.keys()) {
|
|
133
|
+
if (mut_covered.has(node)) {
|
|
134
|
+
continue;
|
|
107
135
|
}
|
|
108
136
|
|
|
109
|
-
|
|
110
|
-
|
|
137
|
+
// A search per node rather than one depth-first walk: a walk that skips
|
|
138
|
+
// finished nodes misses a node whose only way back runs through one.
|
|
139
|
+
const cycle = shortestCycleThrough(node, dependencies);
|
|
140
|
+
|
|
141
|
+
if (cycle === undefined) {
|
|
142
|
+
continue;
|
|
111
143
|
}
|
|
112
144
|
|
|
113
|
-
for (const
|
|
114
|
-
|
|
145
|
+
for (const member of cycle) {
|
|
146
|
+
mut_covered.add(member);
|
|
115
147
|
}
|
|
116
148
|
|
|
117
|
-
|
|
118
|
-
// this node's subtree can be missed by the walk that comes next.
|
|
119
|
-
mut_finished.add(node);
|
|
120
|
-
};
|
|
149
|
+
const rotated = rotateToLowest(cycle);
|
|
121
150
|
|
|
122
|
-
|
|
123
|
-
visit(node, []);
|
|
151
|
+
mut_cycles.set(rotated.join(','), rotated);
|
|
124
152
|
}
|
|
125
153
|
|
|
126
154
|
return Array.from(mut_cycles.values());
|
|
@@ -149,6 +177,71 @@ const parseDeclaration = (
|
|
|
149
177
|
.toArray(),
|
|
150
178
|
);
|
|
151
179
|
|
|
180
|
+
/** `start`, …, `end`, following `reachedFrom` back from `end`. */
|
|
181
|
+
const pathFromStart = (
|
|
182
|
+
start: number,
|
|
183
|
+
end: number,
|
|
184
|
+
reachedFrom: ReadonlyMap<number, number>,
|
|
185
|
+
): readonly number[] => {
|
|
186
|
+
const mut_reversed: number[] = [end];
|
|
187
|
+
|
|
188
|
+
let mut_node = end;
|
|
189
|
+
|
|
190
|
+
while (mut_node !== start) {
|
|
191
|
+
const previous = reachedFrom.get(mut_node);
|
|
192
|
+
|
|
193
|
+
if (previous === undefined) {
|
|
194
|
+
break;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
mut_reversed.push(previous);
|
|
198
|
+
|
|
199
|
+
mut_node = previous;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
return mut_reversed.toReversed();
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* The shortest cycle through `start`, from `start` along the edges back to the
|
|
207
|
+
* node whose edge returns to it, or `undefined` when no path leads back.
|
|
208
|
+
*/
|
|
209
|
+
const shortestCycleThrough = (
|
|
210
|
+
start: number,
|
|
211
|
+
dependencies: ReadonlyMap<number, readonly number[]>,
|
|
212
|
+
): readonly number[] | undefined => {
|
|
213
|
+
// Each reached node, with the node it was first reached from.
|
|
214
|
+
const mut_reachedFrom = new Map<number, number>();
|
|
215
|
+
|
|
216
|
+
// Level by level: every node in the frontier is one edge further from `start`
|
|
217
|
+
// than the last, so the first edge back to `start` closes a shortest cycle.
|
|
218
|
+
let mut_frontier: readonly number[] = [start];
|
|
219
|
+
|
|
220
|
+
while (Arr.isNonEmpty(mut_frontier)) {
|
|
221
|
+
const mut_nextFrontier: number[] = [];
|
|
222
|
+
|
|
223
|
+
for (const node of mut_frontier) {
|
|
224
|
+
for (const next of dependencies.get(node) ?? []) {
|
|
225
|
+
if (next === start) {
|
|
226
|
+
return pathFromStart(start, node, mut_reachedFrom);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
if (mut_reachedFrom.has(next)) {
|
|
230
|
+
continue;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
mut_reachedFrom.set(next, node);
|
|
234
|
+
|
|
235
|
+
mut_nextFrontier.push(next);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
mut_frontier = mut_nextFrontier;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
return undefined;
|
|
243
|
+
};
|
|
244
|
+
|
|
152
245
|
const rotateToLowest = (cycle: readonly number[]): readonly number[] => {
|
|
153
246
|
if (!Arr.isNonEmpty(cycle)) {
|
|
154
247
|
return cycle;
|
package/src/merge-after.test.mts
CHANGED
|
@@ -51,9 +51,9 @@ describe(parseMergeAfter, () => {
|
|
|
51
51
|
);
|
|
52
52
|
});
|
|
53
53
|
|
|
54
|
-
// The fence markers and the column they sit in are what these two are
|
|
55
|
-
//
|
|
56
|
-
//
|
|
54
|
+
// The fence markers and the column they sit in are what these two are about,
|
|
55
|
+
// so the fixtures are arrays of lines rather than a template literal: what
|
|
56
|
+
// `dedent` would remove is what is being tested.
|
|
57
57
|
test('ignores a trailer inside a fenced code block', () => {
|
|
58
58
|
assert.deepStrictEqual(
|
|
59
59
|
parseMergeAfter(
|
|
@@ -79,6 +79,64 @@ describe(parseMergeAfter, () => {
|
|
|
79
79
|
);
|
|
80
80
|
});
|
|
81
81
|
|
|
82
|
+
test('ignores the trailers inside a longer fence that holds a shorter one', () => {
|
|
83
|
+
assert.deepStrictEqual(
|
|
84
|
+
parseMergeAfter(
|
|
85
|
+
[
|
|
86
|
+
'````markdown',
|
|
87
|
+
'```',
|
|
88
|
+
'Merge-After: #1901',
|
|
89
|
+
'```',
|
|
90
|
+
'Merge-After: #1902',
|
|
91
|
+
'````',
|
|
92
|
+
'Merge-After: #1903',
|
|
93
|
+
'',
|
|
94
|
+
].join('\n'),
|
|
95
|
+
),
|
|
96
|
+
[1903],
|
|
97
|
+
);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test('ignores a trailer after a backtick fence inside a tilde fence', () => {
|
|
101
|
+
assert.deepStrictEqual(
|
|
102
|
+
parseMergeAfter(
|
|
103
|
+
['~~~', '```', 'Merge-After: #1901', '~~~', ''].join('\n'),
|
|
104
|
+
),
|
|
105
|
+
[],
|
|
106
|
+
);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test('does not close a fence on a line with an info string', () => {
|
|
110
|
+
assert.deepStrictEqual(
|
|
111
|
+
parseMergeAfter(
|
|
112
|
+
['```', '```ts', 'Merge-After: #1901', '```', ''].join('\n'),
|
|
113
|
+
),
|
|
114
|
+
[],
|
|
115
|
+
);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test('closes a fence on a longer run of the same character', () => {
|
|
119
|
+
assert.deepStrictEqual(
|
|
120
|
+
parseMergeAfter(
|
|
121
|
+
['```', 'Merge-After: #1901', '`````', 'Merge-After: #1903', ''].join(
|
|
122
|
+
'\n',
|
|
123
|
+
),
|
|
124
|
+
),
|
|
125
|
+
[1903],
|
|
126
|
+
);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test('closes a fence on a CRLF line', () => {
|
|
130
|
+
assert.deepStrictEqual(
|
|
131
|
+
parseMergeAfter(
|
|
132
|
+
['```', 'Merge-After: #1901', '```', 'Merge-After: #1903', ''].join(
|
|
133
|
+
'\r\n',
|
|
134
|
+
),
|
|
135
|
+
),
|
|
136
|
+
[1903],
|
|
137
|
+
);
|
|
138
|
+
});
|
|
139
|
+
|
|
82
140
|
test('reads a body with the CRLF line endings GitHub returns', () => {
|
|
83
141
|
assert.deepStrictEqual(
|
|
84
142
|
parseMergeAfter('Adds the parser.\r\n\r\nMerge-After: #1901\r\n'),
|
|
@@ -218,6 +276,25 @@ describe(findMergeAfterCycles, () => {
|
|
|
218
276
|
);
|
|
219
277
|
});
|
|
220
278
|
|
|
279
|
+
test('reports a member whose only way back runs through a finished node', () => {
|
|
280
|
+
// 3 → 2 → 1 → 3 is a cycle as well as 1 ⇄ 2; a walk from 1 finishes 2
|
|
281
|
+
// before it reaches 3, so 3's edge into 2 must still put 3 on a cycle.
|
|
282
|
+
const cycles = findMergeAfterCycles(
|
|
283
|
+
new Map<number, readonly number[]>([
|
|
284
|
+
[1, [2, 3]],
|
|
285
|
+
[2, [1]],
|
|
286
|
+
[3, [2]],
|
|
287
|
+
]),
|
|
288
|
+
);
|
|
289
|
+
|
|
290
|
+
assert.deepStrictEqual(cycles, [
|
|
291
|
+
[1, 2],
|
|
292
|
+
[1, 3, 2],
|
|
293
|
+
]);
|
|
294
|
+
|
|
295
|
+
assert.deepStrictEqual(new Set(cycles.flat()), new Set<number>([1, 2, 3]));
|
|
296
|
+
});
|
|
297
|
+
|
|
221
298
|
test('follows an edge to a pull request that declared nothing', () => {
|
|
222
299
|
assert.deepStrictEqual(
|
|
223
300
|
findMergeAfterCycles(new Map<number, readonly number[]>([[1, [2]]])),
|
package/src/queue-comments.mts
CHANGED
|
@@ -41,8 +41,8 @@ export type QueueComment<A> = Readonly<{
|
|
|
41
41
|
}>;
|
|
42
42
|
|
|
43
43
|
/**
|
|
44
|
-
* Reads `comments`, oldest first, for the oldest comment of each kind: each
|
|
45
|
-
*
|
|
44
|
+
* Reads `comments`, oldest first, for the oldest comment of each kind: each is
|
|
45
|
+
* one comment edited in place, and of several the oldest is the one.
|
|
46
46
|
*/
|
|
47
47
|
export const readQueueComments = (
|
|
48
48
|
comments: readonly OwnComment[],
|