pr-report-core 0.1.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 (83) hide show
  1. package/dist/checks.d.mts +61 -62
  2. package/dist/checks.d.mts.map +1 -1
  3. package/dist/checks.mjs +8 -9
  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 +48 -49
  14. package/dist/code-owners.d.mts.map +1 -1
  15. package/dist/code-owners.mjs +41 -43
  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 +97 -34
  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 +43 -30
  27. package/dist/queue-order.d.mts.map +1 -1
  28. package/dist/queue-order.mjs +33 -18
  29. package/dist/queue-order.mjs.map +1 -1
  30. package/dist/queue-owner.d.mts +30 -30
  31. package/dist/queue-owner.d.mts.map +1 -1
  32. package/dist/queue-owner.mjs +51 -51
  33. package/dist/queue-owner.mjs.map +1 -1
  34. package/dist/report.d.mts +9 -9
  35. package/dist/report.d.mts.map +1 -1
  36. package/dist/report.mjs +23 -23
  37. package/dist/report.mjs.map +1 -1
  38. package/dist/review-hold.d.mts +14 -14
  39. package/dist/ruleset.d.mts +10 -11
  40. package/dist/ruleset.d.mts.map +1 -1
  41. package/dist/ruleset.mjs +0 -1
  42. package/dist/ruleset.mjs.map +1 -1
  43. package/dist/set-aside.d.mts +28 -28
  44. package/dist/stack.d.mts +14 -15
  45. package/dist/stack.d.mts.map +1 -1
  46. package/dist/stack.mjs +0 -1
  47. package/dist/stack.mjs.map +1 -1
  48. package/dist/start-after.d.mts +20 -21
  49. package/dist/start-after.d.mts.map +1 -1
  50. package/dist/start-after.mjs +0 -1
  51. package/dist/start-after.mjs.map +1 -1
  52. package/dist/summarize.d.mts +2 -2
  53. package/dist/timestamp.d.mts +10 -10
  54. package/dist/tree.d.mts +5 -5
  55. package/dist/types.d.mts +32 -33
  56. package/dist/types.d.mts.map +1 -1
  57. package/package.json +8 -8
  58. package/src/checks.mts +74 -75
  59. package/src/checks.test.mts +30 -31
  60. package/src/claim.mts +13 -14
  61. package/src/closing-issues.mts +18 -19
  62. package/src/closing-issues.test.mts +2 -2
  63. package/src/code-owners.mts +107 -109
  64. package/src/labels.mts +38 -40
  65. package/src/merge-after.mts +164 -71
  66. package/src/merge-after.test.mts +80 -3
  67. package/src/queue-comments.mts +2 -2
  68. package/src/queue-order.mts +103 -83
  69. package/src/queue-order.test.mts +41 -4
  70. package/src/queue-owner.mts +103 -103
  71. package/src/queue-owner.test.mts +2 -2
  72. package/src/report.mts +49 -49
  73. package/src/review-hold.mts +14 -14
  74. package/src/review-hold.test.mts +3 -3
  75. package/src/ruleset.mts +10 -11
  76. package/src/set-aside.mts +28 -28
  77. package/src/stack.mts +14 -15
  78. package/src/stack.test.mts +4 -4
  79. package/src/start-after.mts +20 -21
  80. package/src/summarize.mts +2 -2
  81. package/src/timestamp.mts +14 -14
  82. package/src/tree.mts +5 -5
  83. package/src/types.mts +32 -33
@@ -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,76 +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
- * `<Key>: #1901, #1903` in a body, anywhere in it, on as many lines as it
48
- * likes. Only this exact trailer is read — a sentence mentioning another pull
49
- * request is prose, and treating it as a declaration would make every
50
- * cross-reference a constraint.
51
- */
52
- const parseDeclaration = (
53
- body: string,
54
- key: 'merge-after' | 'start-after',
55
- ): readonly number[] =>
56
- Arr.uniq(
57
- outsideCodeFences(body)
58
- .matchAll(DECLARATION_LINE[key])
59
- .flatMap((line) =>
60
- (line.groups?.['refs'] ?? '').matchAll(NUMBER_REF).flatMap((ref) => {
61
- const parsed = Num.safeParseInt(ref.groups?.['number'] ?? '');
62
-
63
- return Result.isOk(parsed) ? [parsed.value] : [];
64
- }),
65
- )
66
- .toArray(),
67
- );
68
-
69
- /**
70
- * The body with the contents of its fenced code blocks blanked out, line
71
- * count preserved.
49
+ * The body with the contents of its fenced code blocks blanked out, line count
50
+ * preserved.
72
51
  *
73
52
  * A declaration and an example of one are the same text, and the pull request
74
53
  * that introduced this feature had to describe it: written in a fence, the
75
- * example would have been read as a constraint on the pull request
76
- * documenting it. A fence is how every document here already says "this is a
77
- * 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.
78
57
  *
79
- * Exported because `pr-report` reads the closing keywords (`Closes #12`) out
80
- * 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
81
60
  * showing what a declaration looks like must not become one.
82
61
  */
83
62
  export const outsideCodeFences = (body: string): string => {
84
- 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;
85
65
 
86
66
  return body
87
67
  .split('\n')
88
68
  .map((line) => {
89
- if (!FENCE_LINE.test(line)) {
90
- 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 '';
91
88
  }
92
89
 
93
- 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
+ }
94
101
 
95
102
  return '';
96
103
  })
@@ -98,55 +105,141 @@ export const outsideCodeFences = (body: string): string => {
98
105
  };
99
106
 
100
107
  /**
101
- * The `Merge-After` cycles, one entry per cycle, each listing the numbers in
102
- * the order the edges run and rotated to start at its lowest number so that
103
- * 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.
104
117
  *
105
118
  * A cycle is not a state anything here can resolve: every pull request on one
106
119
  * waits for another that waits, in the end, for it. Naming it is the whole of
107
120
  * what can be done about it — without this the members would each report
108
121
  * "waiting on #N" for as long as the loop runs, with nothing saying why that
109
- * never changes. A pull request that declares itself is a cycle of one, and
110
- * 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.
111
124
  */
112
125
  export const findMergeAfterCycles = (
113
126
  dependencies: ReadonlyMap<number, readonly number[]>,
114
127
  ): readonly (readonly number[])[] => {
115
- const mut_finished = new Set<number>();
128
+ const mut_covered = new Set<number>();
116
129
 
117
130
  const mut_cycles = new Map<string, readonly number[]>();
118
131
 
119
- const visit = (node: number, walked: readonly number[]): void => {
120
- const at = walked.indexOf(node);
121
-
122
- if (at !== -1) {
123
- // A back edge into the path being walked: everything from there on is
124
- // the cycle.
125
- const cycle = rotateToLowest(walked.slice(at));
132
+ for (const node of dependencies.keys()) {
133
+ if (mut_covered.has(node)) {
134
+ continue;
135
+ }
126
136
 
127
- mut_cycles.set(cycle.join(','), cycle);
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);
128
140
 
129
- return;
141
+ if (cycle === undefined) {
142
+ continue;
130
143
  }
131
144
 
132
- if (mut_finished.has(node)) {
133
- return;
145
+ for (const member of cycle) {
146
+ mut_covered.add(member);
134
147
  }
135
148
 
136
- for (const dependency of dependencies.get(node) ?? []) {
137
- visit(dependency, Arr.toPushed(walked, node));
149
+ const rotated = rotateToLowest(cycle);
150
+
151
+ mut_cycles.set(rotated.join(','), rotated);
152
+ }
153
+
154
+ return Array.from(mut_cycles.values());
155
+ };
156
+
157
+ /**
158
+ * `<Key>: #1901, #1903` in a body, anywhere in it, on as many lines as it
159
+ * likes. Only this exact trailer is read — a sentence mentioning another pull
160
+ * request is prose, and treating it as a declaration would make every
161
+ * cross-reference a constraint.
162
+ */
163
+ const parseDeclaration = (
164
+ body: string,
165
+ key: 'merge-after' | 'start-after',
166
+ ): readonly number[] =>
167
+ Arr.uniq(
168
+ outsideCodeFences(body)
169
+ .matchAll(DECLARATION_LINE[key])
170
+ .flatMap((line) =>
171
+ (line.groups?.['refs'] ?? '').matchAll(NUMBER_REF).flatMap((ref) => {
172
+ const parsed = Num.safeParseInt(ref.groups?.['number'] ?? '');
173
+
174
+ return Result.isOk(parsed) ? [parsed.value] : [];
175
+ }),
176
+ )
177
+ .toArray(),
178
+ );
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;
138
195
  }
139
196
 
140
- // Only once every edge out of it has been walked, so no back edge into
141
- // this node's subtree can be missed by the walk that comes next.
142
- mut_finished.add(node);
143
- };
197
+ mut_reversed.push(previous);
144
198
 
145
- for (const node of dependencies.keys()) {
146
- visit(node, []);
199
+ mut_node = previous;
147
200
  }
148
201
 
149
- return Array.from(mut_cycles.values());
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;
150
243
  };
151
244
 
152
245
  const rotateToLowest = (cycle: readonly number[]): readonly number[] => {
@@ -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[],