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.
Files changed (77) hide show
  1. package/dist/checks.d.mts +61 -62
  2. package/dist/checks.d.mts.map +1 -1
  3. package/dist/checks.mjs +0 -1
  4. package/dist/checks.mjs.map +1 -1
  5. package/dist/claim.d.mts +13 -14
  6. package/dist/claim.d.mts.map +1 -1
  7. package/dist/claim.mjs +0 -1
  8. package/dist/claim.mjs.map +1 -1
  9. package/dist/closing-issues.d.mts +18 -19
  10. package/dist/closing-issues.d.mts.map +1 -1
  11. package/dist/closing-issues.mjs +0 -1
  12. package/dist/closing-issues.mjs.map +1 -1
  13. package/dist/code-owners.d.mts +33 -34
  14. package/dist/code-owners.d.mts.map +1 -1
  15. package/dist/code-owners.mjs +0 -2
  16. package/dist/code-owners.mjs.map +1 -1
  17. package/dist/labels.d.mts +38 -40
  18. package/dist/labels.d.mts.map +1 -1
  19. package/dist/labels.mjs +0 -2
  20. package/dist/labels.mjs.map +1 -1
  21. package/dist/merge-after.d.mts +22 -16
  22. package/dist/merge-after.d.mts.map +1 -1
  23. package/dist/merge-after.mjs +87 -24
  24. package/dist/merge-after.mjs.map +1 -1
  25. package/dist/queue-comments.d.mts +2 -2
  26. package/dist/queue-order.d.mts +32 -32
  27. package/dist/queue-order.mjs +0 -2
  28. package/dist/queue-order.mjs.map +1 -1
  29. package/dist/queue-owner.d.mts +13 -13
  30. package/dist/queue-owner.mjs.map +1 -1
  31. package/dist/report.d.mts +4 -4
  32. package/dist/review-hold.d.mts +14 -14
  33. package/dist/ruleset.d.mts +10 -11
  34. package/dist/ruleset.d.mts.map +1 -1
  35. package/dist/ruleset.mjs +0 -1
  36. package/dist/ruleset.mjs.map +1 -1
  37. package/dist/set-aside.d.mts +28 -28
  38. package/dist/stack.d.mts +14 -15
  39. package/dist/stack.d.mts.map +1 -1
  40. package/dist/stack.mjs +0 -1
  41. package/dist/stack.mjs.map +1 -1
  42. package/dist/start-after.d.mts +20 -21
  43. package/dist/start-after.d.mts.map +1 -1
  44. package/dist/start-after.mjs +0 -1
  45. package/dist/start-after.mjs.map +1 -1
  46. package/dist/summarize.d.mts +2 -2
  47. package/dist/timestamp.d.mts +10 -10
  48. package/dist/tree.d.mts +5 -5
  49. package/dist/types.d.mts +32 -33
  50. package/dist/types.d.mts.map +1 -1
  51. package/package.json +8 -8
  52. package/src/checks.mts +64 -65
  53. package/src/checks.test.mts +30 -31
  54. package/src/claim.mts +13 -14
  55. package/src/closing-issues.mts +18 -19
  56. package/src/closing-issues.test.mts +2 -2
  57. package/src/code-owners.mts +39 -41
  58. package/src/labels.mts +38 -40
  59. package/src/merge-after.mts +142 -49
  60. package/src/merge-after.test.mts +80 -3
  61. package/src/queue-comments.mts +2 -2
  62. package/src/queue-order.mts +60 -62
  63. package/src/queue-order.test.mts +6 -6
  64. package/src/queue-owner.mts +25 -25
  65. package/src/queue-owner.test.mts +2 -2
  66. package/src/report.mts +9 -9
  67. package/src/review-hold.mts +14 -14
  68. package/src/review-hold.test.mts +3 -3
  69. package/src/ruleset.mts +10 -11
  70. package/src/set-aside.mts +28 -28
  71. package/src/stack.mts +14 -15
  72. package/src/stack.test.mts +4 -4
  73. package/src/start-after.mts +20 -21
  74. package/src/summarize.mts +2 -2
  75. package/src/timestamp.mts +14 -14
  76. package/src/tree.mts +5 -5
  77. 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
- * the attributes in the order GitHub writes them.
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,
@@ -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
- * that wait: `reviewDecision` answers `null` for every pull request here,
8
- * owned paths or not, because it describes branch protection rather than a
9
- * ruleset. 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
11
- * leave a pull request that waits in the queue rather than run its checks.
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
- * this file: a leading `/` or a `/` in the middle anchors a pattern to the
15
- * root, a trailing `/` makes it a directory, `*` and `?` stay inside one path
16
- * segment and `**` crosses them. Negation and character ranges are not
17
- * supported by GitHub here either.
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
- * what an earlier line matched.
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
- * which is how GitHub reads the file — a later line overrides an earlier one
50
- * rather than adding to it.
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
- * latest review of an owner that says so; one dismissed by a later push —
62
- * the ruleset dismisses stale approvals — no longer counts, and GitHub has
63
- * already stopped calling it an approval by the time this reads it.
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
- * them. A team owner (`@org/team`) cannot be: telling who is on the team
67
- * takes `read:org`, which neither reader asks for. So a path a team owns
68
- * waits while nobody at all has approved, is approved by a person owning it
69
- * beside the team, and otherwise — someone approved, and might be on the
70
- * team — cannot be told, which makes the whole `unknown` unless another
71
- * path is plainly still waiting. `CODEOWNERS` in this repository names no
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
- // on the `engines.node` floor (`test-node-versions (minimum)`), which is 22.
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
- // with no trailing slash may be either, so it matches itself and what is
192
- // under it — unless its last segment is a wildcard, which GitHub reads as
193
- // files at that level only: `docs/*` owns `docs/a.md` and not
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
- * waits for ever: GitHub does not let an author approve their own pull
211
- * request, so when the author is the only owner nothing but a ruleset bypass
212
- * will merge it. `unknown` is a pull request whose changed files were more
213
- * than could be read, where the unread ones might be owned — or, with
214
- * `teams`, one approved by someone these teams might have as a member.
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
- * so these strings, and the ones in `.github/workflows/`, are the whole of
4
- * their declaration: `unblock-prs` and `open-pr` act on them, and
5
- * `pr-report` and the Pull Requests Manager page report them.
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
- * and `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
12
- * nothing to any workflow.
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
- * turn. It is the whole of `unblock-prs`'s scope rule: a pull request without
19
- * it is passed over in silence, and one with it is rebased, released from
20
- * `skip-ci` and watched when its turn comes.
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
- * body cannot declare a `Merge-After:` — `changesets/action` overwrites the
30
- * title and body of it on every push to the base — so the ordering is
31
- * declared from the other side, by the pull request the release is waiting
32
- * for. A label rather than a trailer for the same reason `merge-queued` is
33
- * one: this repository is public, and a label needs write access while a
34
- * comment does not.
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
- * pull request merges or is closed, and there is no state left behind to
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
- * every layer of a stack: one above the bottom is carried along by the layer
48
- * below, and needs the label of its own once that layer merges and it is
49
- * moved onto the default branch.
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
- * those labelled `priority:low` last, the version pull request included.
57
- * They decide nothing else — what `Merge-After:`, a stack or
58
- * `blocks-release` holds back stays held, and a pull request already in
59
- * flight is watched whatever it carries. One carrying both counts as
60
- * neither. {@link PRIORITY_MID_LABEL} is not read there: it is the same as
61
- * carrying neither.
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
- * merge order counts it and can hide what carries it. It asks nothing of
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
- * it as in progress, as they show one an open pull request closes
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
- * for; the page and `pr-report` show a startable issue carrying it as
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';
@@ -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
- * as 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 —
14
- * the declaration takes `#N` and nothing else, so there is one way to write
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
- /** The opening or closing line of a fenced code block. */
25
- const FENCE_LINE = /^[^\S\n]*(?:`{3,}|~{3,})/u;
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
- * closed, simply never appears in the open list and so constrains nothing.
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
- * read exactly as {@link parseMergeAfter} reads its trailer
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
- * count preserved.
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
- * documenting it. A fence is how every document here already says "this is a
54
- * sample, not the thing itself", so it is the line to draw.
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
- * of the same bodies and has to draw the line in the same place — a document
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
- let mut_inFence = false;
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
- if (!FENCE_LINE.test(line)) {
67
- return mut_inFence ? '' : line;
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
- mut_inFence = !mut_inFence;
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, one entry per cycle, each listing the numbers in
79
- * the order the edges run and rotated to start at its lowest number so that
80
- * the same cycle is always reported the same way.
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
- * is found by the same walk.
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 mut_finished = new Set<number>();
128
+ const mut_covered = new Set<number>();
93
129
 
94
130
  const mut_cycles = new Map<string, readonly number[]>();
95
131
 
96
- const visit = (node: number, walked: readonly number[]): void => {
97
- const at = walked.indexOf(node);
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
- if (mut_finished.has(node)) {
110
- return;
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 dependency of dependencies.get(node) ?? []) {
114
- visit(dependency, Arr.toPushed(walked, node));
145
+ for (const member of cycle) {
146
+ mut_covered.add(member);
115
147
  }
116
148
 
117
- // Only once every edge out of it has been walked, so no back edge into
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
- for (const node of dependencies.keys()) {
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;
@@ -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
- // about, so the fixtures are arrays of lines rather than a template
56
- // literal: what `dedent` would remove is what is being tested.
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]]])),
@@ -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
- * is one comment edited in place, and of several the oldest is the one.
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[],