pr-shepherd 0.58.0 → 0.59.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/.claude-plugin/plugin.json +1 -1
- package/README.md +38 -45
- package/bin/classify/standard/coderabbit-notices.d.mts +2 -2
- package/bin/classify/standard/coderabbit-notices.mjs +50 -6
- package/bin/classify/standard/codex-notices.mjs +1 -1
- package/bin/classify/standard/gemini-notices.d.mts +1 -1
- package/bin/classify/standard/gemini-notices.mjs +6 -5
- package/bin/classify/standard/kilo-no-issues.d.mts +1 -1
- package/bin/classify/standard/kilo-no-issues.mjs +12 -8
- package/bin/classify/standard/sourcery-notices.d.mts +1 -5
- package/bin/classify/standard/sourcery-notices.mjs +6 -9
- package/bin/commands/iterate/index.mjs +13 -6
- package/bin/github/poll-summary-review.mjs +1 -2
- package/bin/journal/journal-container.d.mts +6 -0
- package/bin/journal/journal-container.mjs +33 -0
- package/bin/journal/markdown-details.d.mts +15 -0
- package/bin/journal/markdown-details.mjs +92 -0
- package/bin/journal/markdown-line.d.mts +2 -0
- package/bin/journal/markdown-line.mjs +3 -3
- package/bin/journal/reconcile.mjs +14 -31
- package/package.json +1 -1
- package/plugins/pr-shepherd/.claude-plugin/plugin.json +1 -1
- package/plugins/pr-shepherd/.codex-plugin/plugin.json +1 -1
- package/plugins/pr-shepherd/.codex.mcp.json +1 -1
- package/plugins/pr-shepherd/.mcp.json +1 -1
package/README.md
CHANGED
|
@@ -4,6 +4,43 @@ Drive a pull request to merge-ready with a coding agent. It works with Claude Co
|
|
|
4
4
|
|
|
5
5
|
**Estimated per PR session: −42% cost and −19% turns vs. `gh`, −77% cost vs. the GitHub MCP server. While waiting on CI: ~60 GraphQL points an hour vs. ~360 for `gh pr checks --watch`.**
|
|
6
6
|
|
|
7
|
+
## What it does
|
|
8
|
+
|
|
9
|
+
- Gathers the PR's review items, CI failures, and merge requirements in one call.
|
|
10
|
+
- Returns one action with numbered instructions and exact commands. `CANCEL` ends the loop and carries none.
|
|
11
|
+
- Never mutates git. The agent edits, commits, pushes, and decides what each comment deserves.
|
|
12
|
+
|
|
13
|
+
```text
|
|
14
|
+
$ pr-shepherd 123
|
|
15
|
+
|
|
16
|
+
# PR #123 [FIX_CODE]
|
|
17
|
+
|
|
18
|
+
**status** `UNRESOLVED_COMMENTS` · **repo** `owner/repo`
|
|
19
|
+
**summary** 3 passing
|
|
20
|
+
|
|
21
|
+
## Review threads
|
|
22
|
+
|
|
23
|
+
### `threadId=PRRT_kwDOSGizTs58XB1L` — `src/commands/iterate/index.mts:42` (@alice · MEMBER)
|
|
24
|
+
|
|
25
|
+
> The variable name is misleading.
|
|
26
|
+
|
|
27
|
+
## Failing checks
|
|
28
|
+
|
|
29
|
+
- `24697658766` — `CI › lint / typecheck / test (22.x)` [conclusion: FAILURE]
|
|
30
|
+
> oxfmt
|
|
31
|
+
|
|
32
|
+
## Instructions
|
|
33
|
+
|
|
34
|
+
1. Fix each warranted item.
|
|
35
|
+
2. Triage `## Failing checks`. Playbook: "CI failure triage".
|
|
36
|
+
3. Commit and push any changes.
|
|
37
|
+
4. Journal key decisions or rejections with URLs or IDs: `pr-shepherd apply journal 123 '- <decision>'`
|
|
38
|
+
5. `$DISMISS_MESSAGE`: one sentence on what changed. Run even if no code changed: `pr-shepherd apply review 123 --reply-thread-ids PRRT_kwDOSGizTs58XB1L --message "$DISMISS_MESSAGE" --require-sha HEAD`
|
|
39
|
+
6. Rerun Shepherd now.
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
More features are in [docs/features.md](docs/features.md).
|
|
43
|
+
|
|
7
44
|
## Outcomes
|
|
8
45
|
|
|
9
46
|
| Outcome | Headline | Evidence |
|
|
@@ -17,23 +54,6 @@ Drive a pull request to merge-ready with a coding agent. It works with Claude Co
|
|
|
17
54
|
|
|
18
55
|
**[jongleberry.com/pr-shepherd](https://jongleberry.com/pr-shepherd/)** covers the why and the design principles.
|
|
19
56
|
|
|
20
|
-
## What it does
|
|
21
|
-
|
|
22
|
-
- Gathers the PR's review items, CI failures, and merge requirements in one call.
|
|
23
|
-
- Returns one action with numbered instructions and exact commands. `CANCEL` ends the loop and carries none.
|
|
24
|
-
- Never mutates git. The agent edits, commits, pushes, and decides what each comment deserves.
|
|
25
|
-
|
|
26
|
-
```mermaid
|
|
27
|
-
flowchart LR
|
|
28
|
-
A[Agent] -->|iterate PR| S[pr-shepherd]
|
|
29
|
-
S -->|batched, conditional reads| G[(GitHub)]
|
|
30
|
-
G --> S
|
|
31
|
-
S -->|one action + numbered instructions| A
|
|
32
|
-
A -->|fix, push, run the printed commands| G
|
|
33
|
-
```
|
|
34
|
-
|
|
35
|
-
Without Shepherd, an agent fans out across `gh`, the GitHub MCP server, and GraphQL to rebuild PR state, then guesses the next step. Details for each outcome follow; more features are in [docs/features.md](docs/features.md).
|
|
36
|
-
|
|
37
57
|
## Outcome details
|
|
38
58
|
|
|
39
59
|
### Context and correctness
|
|
@@ -179,34 +199,7 @@ Requirements:
|
|
|
179
199
|
pr-shepherd 42 # shell: poll for work (270s cap; --until-terminal removes it)
|
|
180
200
|
```
|
|
181
201
|
|
|
182
|
-
Leave out the PR number to use the current branch.
|
|
183
|
-
|
|
184
|
-
```text
|
|
185
|
-
# PR #123 [FIX_CODE]
|
|
186
|
-
|
|
187
|
-
**status** `UNRESOLVED_COMMENTS` · **repo** `owner/repo`
|
|
188
|
-
**summary** 3 passing
|
|
189
|
-
|
|
190
|
-
## Review threads
|
|
191
|
-
|
|
192
|
-
### `threadId=PRRT_kwDOSGizTs58XB1L` — `src/commands/iterate/index.mts:42` (@alice · MEMBER)
|
|
193
|
-
|
|
194
|
-
> The variable name is misleading.
|
|
195
|
-
|
|
196
|
-
## Failing checks
|
|
197
|
-
|
|
198
|
-
- `24697658766` — `CI › lint / typecheck / test (22.x)` [conclusion: FAILURE]
|
|
199
|
-
> oxfmt
|
|
200
|
-
|
|
201
|
-
## Instructions
|
|
202
|
-
|
|
203
|
-
1. Fix each warranted item.
|
|
204
|
-
2. Triage `## Failing checks`. Playbook: "CI failure triage".
|
|
205
|
-
3. Commit and push any changes.
|
|
206
|
-
4. Journal key decisions or rejections with URLs or IDs: `pr-shepherd apply journal 123 '- <decision>'`
|
|
207
|
-
5. `$DISMISS_MESSAGE`: one sentence on what changed. Run even if no code changed: `pr-shepherd apply review 123 --reply-thread-ids PRRT_kwDOSGizTs58XB1L --message "$DISMISS_MESSAGE" --require-sha HEAD`
|
|
208
|
-
6. Rerun Shepherd now.
|
|
209
|
-
```
|
|
202
|
+
Leave out the PR number to use the current branch.
|
|
210
203
|
|
|
211
204
|
## Documentation
|
|
212
205
|
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import type { ClassifyRule } from "../types.mts";
|
|
2
2
|
/**
|
|
3
3
|
* CodeRabbit progress, rate-limit, review-skipped and draft notices, plus its "Action not
|
|
4
|
-
* completed" command replies.
|
|
5
|
-
*
|
|
4
|
+
* completed" command replies. If the comment is later edited into a PR walkthrough, its new
|
|
5
|
+
* body no longer matches and Shepherd surfaces the edited minimized comment to the agent.
|
|
6
6
|
*/
|
|
7
7
|
declare const rule: ClassifyRule;
|
|
8
8
|
export default rule;
|
|
@@ -1,14 +1,54 @@
|
|
|
1
1
|
import { isBotItem } from "./author.mjs";
|
|
2
2
|
const NOTICE_ENVELOPE = /^<!-- This is an auto-generated comment: (rate limited|skip review|review in progress) by coderabbit.ai -->\n([\s\S]*?)\n<!-- end of auto-generated comment: \1 by coderabbit.ai -->$/;
|
|
3
3
|
const COMMAND_REPLY = /^<!-- This is an auto-generated reply by CodeRabbit -->\n<!-- CodeRabbit review command invocation: v2:[a-f0-9]+ -->\n<details>\n<summary>⚠️ Action not completed<\/summary>\n\n(?:Review rate limited\.|Review skipped: \d+ files exceed the limit of \d+\.)\n\n(?:> Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits\. This command is applicable only when automatic reviews are paused\.|---\n\nYour included review limit is currently reached under our \[Fair Usage Limits Policy\]\(https:\/\/docs.coderabbit.ai\/management\/plans#fair-usage-limits-policy\)\. This review may still proceed through usage-based billing if eligible\. Your next included review will be available in \d+ minutes?\.)\n\n<\/details>$/;
|
|
4
|
+
const FINISHING_TOUCH_BLOCK = `<!-- finishing_touch_checkbox_start -->
|
|
5
|
+
|
|
6
|
+
<details>
|
|
7
|
+
<summary><strong>✨ Finishing Touches</strong></summary>
|
|
8
|
+
<dl>
|
|
9
|
+
<dd>
|
|
10
|
+
|
|
11
|
+
<details open>
|
|
12
|
+
<summary><strong>📝 Generate docstrings</strong></summary>
|
|
13
|
+
<dl>
|
|
14
|
+
<dd>
|
|
15
|
+
|
|
16
|
+
- [ ] <!-- {"checkboxId":"3e1879ae-f29b-4d0d-8e06-d12b7ba33d98"} --> Commit to this branch
|
|
17
|
+
- [ ] <!-- {"checkboxId":"7962f53c-55bc-4827-bfbf-6a18da830691"} --> Create a new PR
|
|
18
|
+
|
|
19
|
+
</dd>
|
|
20
|
+
</dl>
|
|
21
|
+
</details>
|
|
22
|
+
<details open>
|
|
23
|
+
<summary><strong>🧪 Generate unit tests (beta)</strong></summary>
|
|
24
|
+
<dl>
|
|
25
|
+
<dd>
|
|
26
|
+
|
|
27
|
+
- [ ] <!-- {"checkboxId": "6ba7b810-9dad-11d1-80b4-00c04fd430c8", "radioGroupId": "utg-output-choice-group-unknown_comment_id"} --> Commit to this branch
|
|
28
|
+
- [ ] <!-- {"checkboxId": "f47ac10b-58cc-4372-a567-0e02b2c3d479", "radioGroupId": "utg-output-choice-group-unknown_comment_id"} --> Create a new PR
|
|
29
|
+
|
|
30
|
+
</dd>
|
|
31
|
+
</dl>
|
|
32
|
+
</details>
|
|
33
|
+
|
|
34
|
+
</dd>
|
|
35
|
+
</dl>
|
|
36
|
+
</details>
|
|
37
|
+
|
|
38
|
+
<!-- finishing_touch_checkbox_end -->`;
|
|
39
|
+
const AUTOPILOT_BLOCK = /\n<!-- autopilot:start -->\n- \[ \] <!-- \{"checkboxId":"2708ad07-9f24-4260-9c11-7dc76a49f2e3"\} --> <strong title="Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts">Autofix<\/strong> · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts\n<!-- autopilot:end -->$/;
|
|
4
40
|
/** Strips CodeRabbit's footer and header blocks so only the notice envelope remains. */
|
|
5
41
|
function stripChrome(body) {
|
|
6
|
-
|
|
42
|
+
const withoutFooter = body
|
|
7
43
|
.trim()
|
|
8
44
|
.replace(/\n<!-- tips_start -->[\s\S]*?<!-- tips_end -->$/, "")
|
|
9
45
|
.trim()
|
|
10
|
-
.replace(
|
|
11
|
-
.trim()
|
|
46
|
+
.replace(AUTOPILOT_BLOCK, "")
|
|
47
|
+
.trim();
|
|
48
|
+
const withoutFinishingTouch = withoutFooter.endsWith(`\n\n${FINISHING_TOUCH_BLOCK}`)
|
|
49
|
+
? withoutFooter.slice(0, -FINISHING_TOUCH_BLOCK.length).trim()
|
|
50
|
+
: withoutFooter;
|
|
51
|
+
return withoutFinishingTouch
|
|
12
52
|
.replace(/^<!-- This is an auto-generated comment: summarize by coderabbit.ai -->\s*/, "")
|
|
13
53
|
.replace(/^<!-- review_stack_entry_start -->[\s\S]*?<!-- review_stack_entry_end -->\s*/, "");
|
|
14
54
|
}
|
|
@@ -23,8 +63,8 @@ function isQuotedNotice(quoted) {
|
|
|
23
63
|
}
|
|
24
64
|
/**
|
|
25
65
|
* CodeRabbit progress, rate-limit, review-skipped and draft notices, plus its "Action not
|
|
26
|
-
* completed" command replies.
|
|
27
|
-
*
|
|
66
|
+
* completed" command replies. If the comment is later edited into a PR walkthrough, its new
|
|
67
|
+
* body no longer matches and Shepherd surfaces the edited minimized comment to the agent.
|
|
28
68
|
*/
|
|
29
69
|
const rule = (item) => {
|
|
30
70
|
if (!isBotItem(item, ["coderabbitai"], ["pr-comment"]))
|
|
@@ -33,6 +73,10 @@ const rule = (item) => {
|
|
|
33
73
|
const quoted = body.match(NOTICE_ENVELOPE)?.[2]?.trim();
|
|
34
74
|
if (!(quoted && isQuotedNotice(quoted)) && !COMMAND_REPLY.test(body))
|
|
35
75
|
return null;
|
|
36
|
-
return {
|
|
76
|
+
return {
|
|
77
|
+
autoResolve: true,
|
|
78
|
+
suppress: true,
|
|
79
|
+
reason: "CodeRabbit progress, review-limit, or skipped-review notice",
|
|
80
|
+
};
|
|
37
81
|
};
|
|
38
82
|
export default rule;
|
|
@@ -6,7 +6,7 @@ const QUOTA_NOTICES = [
|
|
|
6
6
|
/^Codex usage limits have been reached\. Please try again later\.$/,
|
|
7
7
|
];
|
|
8
8
|
const ABOUT_BLOCK = /\n\n<details> <summary>ℹ️ About Codex (?:in GitHub|security reviews in GitHub)<\/summary>[\s\S]*?<\/details>$/;
|
|
9
|
-
const ACTIVITY_SUMMARY = /^<!-- codex-pull-request-review-summary -->\n(?:<!-- codex-security-review:v1 \{[^\n]*\} -->\n)?\n?## Codex Review Summary\n\nThis comment shows the latest Codex review activity on this pull request\.\n\n\| Review \| Status \| Commit \| Review trigger \|\n\| --- \| --- \| --- \| --- \|\n(?:\|[^\n]+\|\n?)+$/;
|
|
9
|
+
const ACTIVITY_SUMMARY = /^<!-- codex-pull-request-review-summary -->\n(?:<!-- codex-review-state:v1 \{[^\n]*\} -->\n)?(?:<!-- codex-security-review:v1 \{[^\n]*\} -->\n)?\n?## Codex Review Summary\n\nThis comment shows the latest Codex review activity on this pull request\.\n\n\| Review \| Status \| Commit \| Review trigger \|\n\| --- \| --- \| --- \| --- \|\n(?:\|[^\n]+\|\n?)+$/;
|
|
10
10
|
const CLEAN_REVIEW = /^Codex Review: Didn't find any major issues\. (?:Bravo\.|Breezy!|Hooray!|:\+1:)\n\n\*\*Reviewed commit:\*\* `[a-f0-9]+`$/;
|
|
11
11
|
const CLEAN_SECURITY = /^### 🛡️ Codex Security Review(?: · _Automatically triggered_)?\n\nSecurity review completed\. No security issues were found in this pull request\.\n\n\*\*Reviewed commit:\*\* `[a-f0-9]+`\n\n\[View security finding report\]\(https:\/\/chatgpt.com\/codex\/cloud\/tasks\/[a-zA-Z0-9_]+\)\n\n_Only the user who started this review can view the report in Codex\._$/;
|
|
12
12
|
/**
|
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
import type { ClassifyRule } from "../types.mts";
|
|
2
|
-
/** Gemini Code Assist
|
|
2
|
+
/** Complete Gemini Code Assist quota and review-limit notices. */
|
|
3
3
|
declare const rule: ClassifyRule;
|
|
4
4
|
export default rule;
|
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
import { isBotItem } from "./author.mjs";
|
|
2
|
-
|
|
2
|
+
const NOTICES = [
|
|
3
|
+
"You have reached your daily quota limit. Please wait up to 24 hours and I will start processing your requests again!",
|
|
4
|
+
"Review limit reached. I'll be able to review this pull request again tomorrow.",
|
|
5
|
+
];
|
|
6
|
+
/** Complete Gemini Code Assist quota and review-limit notices. */
|
|
3
7
|
const rule = (item) => {
|
|
4
8
|
if (!isBotItem(item, ["gemini-code-assist"], ["pr-comment"]))
|
|
5
9
|
return null;
|
|
6
|
-
if (
|
|
10
|
+
if (!NOTICES.includes(item.body.trim()))
|
|
7
11
|
return null;
|
|
8
|
-
if (!/You have reached your daily quota limit|Review limit reached/i.test(item.body)) {
|
|
9
|
-
return null;
|
|
10
|
-
}
|
|
11
12
|
return { autoResolve: true, suppress: true, reason: "Gemini quota or review-limit notice" };
|
|
12
13
|
};
|
|
13
14
|
export default rule;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
import type { ClassifyRule } from "../types.mts";
|
|
2
|
-
/** Kilo Code
|
|
2
|
+
/** Complete Kilo Code no-issues status or report with a file list and no extra prose. */
|
|
3
3
|
declare const rule: ClassifyRule;
|
|
4
4
|
export default rule;
|
|
@@ -1,16 +1,20 @@
|
|
|
1
1
|
import { isBotItem } from "./author.mjs";
|
|
2
|
-
const
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
/** Kilo Code conversation comment whose status line reads "No Issues Found" and that carries no findings. */
|
|
2
|
+
const PLAIN_STATUS = /^Status: ✅ No Issues Found \| Recommendation: Merge$/;
|
|
3
|
+
const REVIEW_REPORT = /^## Code Review Summary\n\n\*\*Status:\*\* No Issues Found \| \*\*Recommendation:\*\* Merge(?:\n\n### Files Reviewed \((\d+) files\)\n((?:- `[^`\n]+`(?:\n|$))+))?$/;
|
|
4
|
+
/** Complete Kilo Code no-issues status or report with a file list and no extra prose. */
|
|
6
5
|
const rule = (item) => {
|
|
7
6
|
if (!isBotItem(item, ["kilo-code-bot"], ["pr-comment"]))
|
|
8
7
|
return null;
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
if (!
|
|
8
|
+
const body = item.body.trim();
|
|
9
|
+
if (!PLAIN_STATUS.test(body)) {
|
|
10
|
+
const report = body.match(REVIEW_REPORT);
|
|
11
|
+
if (!report)
|
|
13
12
|
return null;
|
|
13
|
+
if (report[1] !== undefined) {
|
|
14
|
+
const listed = report[2].trimEnd().split("\n").length;
|
|
15
|
+
if (listed !== Number(report[1]))
|
|
16
|
+
return null;
|
|
17
|
+
}
|
|
14
18
|
}
|
|
15
19
|
return { autoResolve: true, suppress: true, reason: "Kilo Code no-issues report" };
|
|
16
20
|
};
|
|
@@ -1,8 +1,4 @@
|
|
|
1
1
|
import type { ClassifyRule } from "../types.mts";
|
|
2
|
-
/**
|
|
3
|
-
* Sourcery's private-repo upsell and weekly rate-limit notices on conversation comments. A real review opens
|
|
4
|
-
* with a Markdown heading ("Reviewer's Guide", "Summary by Sourcery"); the notices carry none,
|
|
5
|
-
* so a heading means genuine feedback that merely quotes the phrase.
|
|
6
|
-
*/
|
|
2
|
+
/** Complete Sourcery private-repo upsell and weekly rate-limit notices. */
|
|
7
3
|
declare const rule: ClassifyRule;
|
|
8
4
|
export default rule;
|
|
@@ -1,18 +1,15 @@
|
|
|
1
1
|
import { isBotItem } from "./author.mjs";
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
* so a heading means genuine feedback that merely quotes the phrase.
|
|
6
|
-
*/
|
|
2
|
+
const NO_ACCESS = /^Hi @[A-Za-z0-9-]+! 👋\n\nYour private repo does not have access to Sourcery\.\n\nPlease \[upgrade\]\(https:\/\/app\.sourcery\.ai\/login\?connection=github(?:&git_namespace_id=\d+)?&from_surface=github_bot&intent=private_repo_upsell\) to continue using Sourcery ✨$/;
|
|
3
|
+
const RATE_LIMIT = /^Sorry @[A-Za-z0-9-]+, you have reached your weekly rate limit for Sourcery\. Please try again later\.$/;
|
|
4
|
+
/** Complete Sourcery private-repo upsell and weekly rate-limit notices. */
|
|
7
5
|
const rule = (item) => {
|
|
8
6
|
if (!isBotItem(item, ["sourcery-ai"], ["pr-comment"]))
|
|
9
7
|
return null;
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
if (/does not have access to Sourcery/i.test(item.body)) {
|
|
8
|
+
const body = item.body.trim();
|
|
9
|
+
if (NO_ACCESS.test(body)) {
|
|
13
10
|
return { autoResolve: true, suppress: true, reason: "Sourcery no-access upsell" };
|
|
14
11
|
}
|
|
15
|
-
if (
|
|
12
|
+
if (RATE_LIMIT.test(body)) {
|
|
16
13
|
return { autoResolve: true, suppress: true, reason: "Sourcery rate-limit notice" };
|
|
17
14
|
}
|
|
18
15
|
return null;
|
|
@@ -9,7 +9,7 @@ import { classifyReviewSummaries } from "./classify.mjs";
|
|
|
9
9
|
import { applyStallGuard } from "./stall.mjs";
|
|
10
10
|
import { clearStallState } from "../../state/iterate-stall.mjs";
|
|
11
11
|
import { handleFixCode } from "./fix-code.mjs";
|
|
12
|
-
import { normalizeBotUsernames } from "../../comments/authors.mjs";
|
|
12
|
+
import { isConfiguredBotAuthor, normalizeBotUsernames } from "../../comments/authors.mjs";
|
|
13
13
|
import { autoMinimizeComments } from "../../comments/resolve.mjs";
|
|
14
14
|
import { hasCheckDrivenActionableWork } from "../check-annotations.mjs";
|
|
15
15
|
import { buildReadyMergeOutcome, handleActiveMergeState } from "./merge-state.mjs";
|
|
@@ -95,14 +95,20 @@ async function runIterateCore(opts) {
|
|
|
95
95
|
if (unminimized.length > 0)
|
|
96
96
|
reviewSummaryIds = [...reviewSummaryIds, ...unminimized];
|
|
97
97
|
}
|
|
98
|
-
//
|
|
99
|
-
//
|
|
100
|
-
//
|
|
98
|
+
// A first look at an already-minimized comment is only an acknowledgment.
|
|
99
|
+
// A non-bot edit that no longer matches a noise rule may carry new feedback;
|
|
100
|
+
// it must restart the ready delay and void an existing READY receipt. Bots
|
|
101
|
+
// regenerate minimized summaries on their own, so their edits surface for
|
|
102
|
+
// acknowledgment without restarting the countdown.
|
|
103
|
+
const editedMinimizedFeedback = report.comments.firstLook.some((comment) => comment.isMinimized === true &&
|
|
104
|
+
comment.edited === true &&
|
|
105
|
+
!isConfiguredBotAuthor(comment, botUsernames));
|
|
101
106
|
const hasReadinessWork = report.threads.actionable.length > 0 ||
|
|
102
107
|
report.threads.resolutionOnly.length > 0 ||
|
|
103
108
|
report.threads.firstLook.length > 0 ||
|
|
104
109
|
(report.threads.ruleAutoResolveIds?.length ?? 0) > 0 ||
|
|
105
110
|
report.comments.actionable.length > 0 ||
|
|
111
|
+
editedMinimizedFeedback ||
|
|
106
112
|
(report.comments.minimizeIds?.length ?? 0) > 0 ||
|
|
107
113
|
report.changesRequestedReviews.length > 0 ||
|
|
108
114
|
hasCheckDrivenActionableWork(reportForWork.checks, report.mergeStatus.status) ||
|
|
@@ -123,7 +129,7 @@ async function runIterateCore(opts) {
|
|
|
123
129
|
!hasReadinessWork &&
|
|
124
130
|
!activeMerge &&
|
|
125
131
|
staleAncestry === null;
|
|
126
|
-
const receiptCurrent = await revalidateReadyReceipt(receiptKey, report, hasReadinessWork, checkContext);
|
|
132
|
+
const receiptCurrent = await revalidateReadyReceipt(receiptKey, report, hasReadinessWork, editedMinimizedFeedback, checkContext);
|
|
127
133
|
const headSha = report.headSha ?? "unknown";
|
|
128
134
|
// The elapsed marker survives a hidden-comment acknowledgement tick and is
|
|
129
135
|
// consumed only when this tick cancels or merges. A receipt that is still
|
|
@@ -385,7 +391,7 @@ async function recordReadyReceipt(key, report, context) {
|
|
|
385
391
|
}
|
|
386
392
|
}
|
|
387
393
|
/** Clear a stale READY receipt; return whether a current receipt remains. */
|
|
388
|
-
async function revalidateReadyReceipt(key, report, hasReadinessWork, context) {
|
|
394
|
+
async function revalidateReadyReceipt(key, report, hasReadinessWork, editedMinimizedFeedback, context) {
|
|
389
395
|
const receipt = await readReadyReceipt(key);
|
|
390
396
|
if (!receipt)
|
|
391
397
|
return false;
|
|
@@ -398,6 +404,7 @@ async function revalidateReadyReceipt(key, report, hasReadinessWork, context) {
|
|
|
398
404
|
!report.headSha ||
|
|
399
405
|
!report.baseRefOid ||
|
|
400
406
|
transportReadinessGaps(report, report.mergeStatus.mergeStateStatus).length > 0 ||
|
|
407
|
+
editedMinimizedFeedback ||
|
|
401
408
|
(!retainQueuedReceipt && (report.status !== "READY" || hasReadinessWork))) {
|
|
402
409
|
await clearReadyReceipt(key);
|
|
403
410
|
return false;
|
|
@@ -18,8 +18,7 @@ export async function summarizePollSummaryReview(raw, seen, viewerCanAdminister)
|
|
|
18
18
|
raw.reviews.pageInfo.hasPreviousPage ||
|
|
19
19
|
raw.reviewThreads.pageInfo.hasPreviousPage;
|
|
20
20
|
for (const comment of raw.comments.nodes) {
|
|
21
|
-
if (!comment.isMinimized &&
|
|
22
|
-
!isSuppressed(rules, "pr-comment", comment) &&
|
|
21
|
+
if (!isSuppressed(rules, "pr-comment", comment, comment.id, undefined, comment.body, comment.isMinimized) &&
|
|
23
22
|
classifyItem(comment.id, comment.body, seen) !== "unchanged")
|
|
24
23
|
actionable += 1;
|
|
25
24
|
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { scanMarkdownLines } from "./markdown-line.mts";
|
|
2
|
+
export declare const LEGACY: RegExp;
|
|
3
|
+
export declare const JOURNAL_SUMMARY: RegExp;
|
|
4
|
+
export declare const CLOSE = "</details>";
|
|
5
|
+
/** Return the line of the canonical `</details>` closing a journal container, or null. */
|
|
6
|
+
export declare function closeJournalContainer(lines: string[], syntax: ReturnType<typeof scanMarkdownLines>, start: number): number | null;
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { detailsTags, rawDetailsTags, rawHtmlState } from "./markdown-details.mjs";
|
|
2
|
+
export const LEGACY = /^ {0,3}##[ \t]+Shepherd[ \t]+Journal(?:[ \t]+#+)?[ \t]*$/;
|
|
3
|
+
export const JOURNAL_SUMMARY = /^<summary>\s*Shepherd\s+Journal\b/i;
|
|
4
|
+
export const CLOSE = "</details>";
|
|
5
|
+
/** Return the line of the canonical `</details>` closing a journal container, or null. */
|
|
6
|
+
export function closeJournalContainer(lines, syntax, start) {
|
|
7
|
+
let depth = 1;
|
|
8
|
+
let html = rawHtmlState();
|
|
9
|
+
for (let i = start; i < lines.length; i++) {
|
|
10
|
+
if (syntax[i].nested)
|
|
11
|
+
continue;
|
|
12
|
+
if (syntax[i].rawHtml) {
|
|
13
|
+
for (const tag of rawDetailsTags(lines[i], html))
|
|
14
|
+
if ((depth += tag) === 0)
|
|
15
|
+
return null;
|
|
16
|
+
continue;
|
|
17
|
+
}
|
|
18
|
+
html = rawHtmlState();
|
|
19
|
+
if (syntax[i].ignored)
|
|
20
|
+
continue;
|
|
21
|
+
const visible = syntax[i].visiblePrefix;
|
|
22
|
+
if (JOURNAL_SUMMARY.test(visible.trimStart()) || LEGACY.test(visible))
|
|
23
|
+
return null;
|
|
24
|
+
depth += detailsTags(visible, false);
|
|
25
|
+
const closes = detailsTags(visible, true);
|
|
26
|
+
if (closes) {
|
|
27
|
+
depth -= closes;
|
|
28
|
+
if (depth <= 0)
|
|
29
|
+
return depth === 0 && lines[i] === CLOSE ? i : null;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export declare function detailsTags(line: string, closing: boolean): number;
|
|
2
|
+
/** Tokenizer state carried across the lines of one raw HTML block. */
|
|
3
|
+
export type RawHtmlState = {
|
|
4
|
+
mode: "comment" | "data" | "raw-text" | "tag";
|
|
5
|
+
quote: '"' | "'" | null;
|
|
6
|
+
rawTag: string | null;
|
|
7
|
+
terminator: RegExp;
|
|
8
|
+
};
|
|
9
|
+
export declare const rawHtmlState: () => RawHtmlState;
|
|
10
|
+
/**
|
|
11
|
+
* Return `1` per `<details>` opener and `-1` per closer that the browser
|
|
12
|
+
* creates from a raw HTML block line. Tags inside comments, CDATA, raw-text
|
|
13
|
+
* elements, and (possibly multiline) attribute values do not count.
|
|
14
|
+
*/
|
|
15
|
+
export declare function rawDetailsTags(line: string, state: RawHtmlState): Array<1 | -1>;
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { inQuotedHtmlAttribute } from "./markdown-html.mjs";
|
|
2
|
+
import { structuralDetailsStart } from "./markdown-structure.mjs";
|
|
3
|
+
export function detailsTags(line, closing) {
|
|
4
|
+
if (structuralDetailsStart(line) === null)
|
|
5
|
+
return 0;
|
|
6
|
+
const expression = closing ? /<\/details>/gi : /<details(?:\s+[^>]*)?\/?>/gi;
|
|
7
|
+
return [...line.matchAll(expression)].filter((match) => !inQuotedHtmlAttribute(line, match.index)).length;
|
|
8
|
+
}
|
|
9
|
+
const RAW_TEXT = new Set([
|
|
10
|
+
"iframe",
|
|
11
|
+
"noembed",
|
|
12
|
+
"noframes",
|
|
13
|
+
"script",
|
|
14
|
+
"style",
|
|
15
|
+
"textarea",
|
|
16
|
+
"title",
|
|
17
|
+
"xmp",
|
|
18
|
+
]);
|
|
19
|
+
export const rawHtmlState = () => ({
|
|
20
|
+
mode: "data",
|
|
21
|
+
quote: null,
|
|
22
|
+
rawTag: null,
|
|
23
|
+
terminator: /--!?>/,
|
|
24
|
+
});
|
|
25
|
+
// Comments, CDATA sections, and bogus comments such as `<!x>`, `<?x>`, and `</ x>`.
|
|
26
|
+
const COMMENTS = [
|
|
27
|
+
[/^<!--/, /--!?>/],
|
|
28
|
+
[/^<!\[CDATA\[/, /\]\]>/],
|
|
29
|
+
[/^<(?:[!?]|\/[^A-Za-z>])/, />/],
|
|
30
|
+
];
|
|
31
|
+
/**
|
|
32
|
+
* Return `1` per `<details>` opener and `-1` per closer that the browser
|
|
33
|
+
* creates from a raw HTML block line. Tags inside comments, CDATA, raw-text
|
|
34
|
+
* elements, and (possibly multiline) attribute values do not count.
|
|
35
|
+
*/
|
|
36
|
+
export function rawDetailsTags(line, state) {
|
|
37
|
+
const tags = [];
|
|
38
|
+
let i = 0;
|
|
39
|
+
while (i < line.length) {
|
|
40
|
+
if (state.mode === "comment") {
|
|
41
|
+
const end = state.terminator.exec(line.slice(i));
|
|
42
|
+
if (!end)
|
|
43
|
+
break;
|
|
44
|
+
state.mode = "data";
|
|
45
|
+
i += end.index + end[0].length;
|
|
46
|
+
}
|
|
47
|
+
else if (state.mode === "raw-text") {
|
|
48
|
+
const end = new RegExp(`</${state.rawTag}(?=[\\s/>]|$)`, "i").exec(line.slice(i));
|
|
49
|
+
if (!end)
|
|
50
|
+
break;
|
|
51
|
+
state.mode = "tag";
|
|
52
|
+
state.rawTag = null;
|
|
53
|
+
i += end.index + end[0].length;
|
|
54
|
+
}
|
|
55
|
+
else if (state.mode === "tag") {
|
|
56
|
+
const char = line[i++];
|
|
57
|
+
if (state.quote) {
|
|
58
|
+
if (char === state.quote)
|
|
59
|
+
state.quote = null;
|
|
60
|
+
}
|
|
61
|
+
else if (char === '"' || char === "'")
|
|
62
|
+
state.quote = char;
|
|
63
|
+
else if (char === ">")
|
|
64
|
+
state.mode = state.rawTag ? "raw-text" : "data";
|
|
65
|
+
}
|
|
66
|
+
else {
|
|
67
|
+
const open = line.indexOf("<", i);
|
|
68
|
+
if (open === -1)
|
|
69
|
+
break;
|
|
70
|
+
const comment = COMMENTS.map(([start, end]) => [start.exec(line.slice(open)), end]).find(([start]) => start);
|
|
71
|
+
if (comment) {
|
|
72
|
+
state.mode = "comment";
|
|
73
|
+
state.terminator = comment[1];
|
|
74
|
+
i = open + comment[0][0].length;
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
// Tag names run until whitespace, `/`, or `>`, so `details:x` is not `details`.
|
|
78
|
+
const tag = /^<(\/?)([A-Za-z][^\s/>]*)/.exec(line.slice(open));
|
|
79
|
+
if (!tag) {
|
|
80
|
+
i = open + 1;
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
const name = tag[2].toLowerCase();
|
|
84
|
+
if (name === "details")
|
|
85
|
+
tags.push(tag[1] ? -1 : 1);
|
|
86
|
+
state.mode = "tag";
|
|
87
|
+
state.rawTag = !tag[1] && RAW_TEXT.has(name) ? name : null;
|
|
88
|
+
i = open + tag[0].length;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return tags;
|
|
92
|
+
}
|
|
@@ -1,6 +1,8 @@
|
|
|
1
|
+
/** `rawHtml` marks lines inside a blank-line-terminated raw HTML block, whose tags still render. */
|
|
1
2
|
type MarkdownLine = {
|
|
2
3
|
ignored: boolean;
|
|
3
4
|
nested: boolean;
|
|
5
|
+
rawHtml: boolean;
|
|
4
6
|
visiblePrefix: string;
|
|
5
7
|
};
|
|
6
8
|
/** Return whether a Markdown line starts a block that interrupts inline content. */
|
|
@@ -35,7 +35,7 @@ function scanMarkdown(lines) {
|
|
|
35
35
|
const container = resolveMarkdownContainer(line, activeContainer);
|
|
36
36
|
activeContainer = container.tokens;
|
|
37
37
|
const nested = container.tokens.length > 0;
|
|
38
|
-
const push = (ignored, visiblePrefix) => result.push({ ignored, nested, visiblePrefix });
|
|
38
|
+
const push = (ignored, visiblePrefix, rawHtml = false) => result.push({ ignored, nested, rawHtml, visiblePrefix });
|
|
39
39
|
if (indentedCodeContainer) {
|
|
40
40
|
if (stripMarkdownContainer(line, indentedCodeContainer) !== null) {
|
|
41
41
|
push(true, "");
|
|
@@ -53,7 +53,7 @@ function scanMarkdown(lines) {
|
|
|
53
53
|
else {
|
|
54
54
|
const end = rawHtmlEnd(html, content);
|
|
55
55
|
if (end === null) {
|
|
56
|
-
push(true, "");
|
|
56
|
+
push(true, "", html.kind === "blank-line");
|
|
57
57
|
continue;
|
|
58
58
|
}
|
|
59
59
|
html = null;
|
|
@@ -100,7 +100,7 @@ function scanMarkdown(lines) {
|
|
|
100
100
|
const end = rawHtmlEnd(openingHtml, content);
|
|
101
101
|
if (end === null) {
|
|
102
102
|
html = openingHtml;
|
|
103
|
-
push(true, "");
|
|
103
|
+
push(true, "", openingHtml.kind === "blank-line");
|
|
104
104
|
continue;
|
|
105
105
|
}
|
|
106
106
|
allowCodeOpeners = false;
|
|
@@ -1,42 +1,16 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { detailsTags, rawDetailsTags, rawHtmlState } from "./markdown-details.mjs";
|
|
2
|
+
import { CLOSE, closeJournalContainer, JOURNAL_SUMMARY, LEGACY } from "./journal-container.mjs";
|
|
2
3
|
import { isSafeMarkdownInsertionPoint, scanMarkdownLines } from "./markdown-line.mjs";
|
|
3
4
|
import { setextParagraphStart } from "./markdown-setext.mjs";
|
|
4
|
-
import { structuralDetailsStart } from "./markdown-structure.mjs";
|
|
5
|
-
const LEGACY = /^ {0,3}##[ \t]+Shepherd[ \t]+Journal(?:[ \t]+#+)?[ \t]*$/;
|
|
6
|
-
const JOURNAL_SUMMARY = /^<summary>\s*Shepherd\s+Journal\b/i;
|
|
7
5
|
const SETEXT = /^ {0,3}(?:=+|-+)[ \t]*$/;
|
|
8
|
-
const CLOSE = "</details>";
|
|
9
6
|
const OPEN = "<details>";
|
|
10
7
|
const SUMMARY = "<summary>Shepherd Journal</summary>";
|
|
11
8
|
const stripCr = (s) => s.replace(/\r$/, "");
|
|
12
|
-
function detailsTags(line, closing) {
|
|
13
|
-
if (structuralDetailsStart(line) === null)
|
|
14
|
-
return 0;
|
|
15
|
-
const expression = closing ? /<\/details>/gi : /<details(?:\s+[^>]*)?\/?>/gi;
|
|
16
|
-
return [...line.matchAll(expression)].filter((match) => !inQuotedHtmlAttribute(line, match.index)).length;
|
|
17
|
-
}
|
|
18
|
-
function close(lines, syntax, start) {
|
|
19
|
-
let depth = 1;
|
|
20
|
-
for (let i = start; i < lines.length; i++) {
|
|
21
|
-
if (syntax[i].ignored || syntax[i].nested)
|
|
22
|
-
continue;
|
|
23
|
-
const visible = syntax[i].visiblePrefix;
|
|
24
|
-
if (JOURNAL_SUMMARY.test(visible.trimStart()) || LEGACY.test(visible))
|
|
25
|
-
return null;
|
|
26
|
-
depth += detailsTags(visible, false);
|
|
27
|
-
const closes = detailsTags(visible, true);
|
|
28
|
-
if (closes) {
|
|
29
|
-
depth -= closes;
|
|
30
|
-
if (depth <= 0)
|
|
31
|
-
return depth === 0 && lines[i] === CLOSE ? i : null;
|
|
32
|
-
}
|
|
33
|
-
}
|
|
34
|
-
return null;
|
|
35
|
-
}
|
|
36
9
|
export function scanShepherdJournal(lines) {
|
|
37
10
|
const syntax = scanMarkdownLines(lines);
|
|
38
11
|
const found = [];
|
|
39
12
|
let detailsDepth = 0;
|
|
13
|
+
let html = rawHtmlState();
|
|
40
14
|
let legacy = null;
|
|
41
15
|
for (let i = 0; i < lines.length; i++) {
|
|
42
16
|
if (!syntax[i].ignored &&
|
|
@@ -48,7 +22,16 @@ export function scanShepherdJournal(lines) {
|
|
|
48
22
|
legacy.contentEnd = legacy.end = i;
|
|
49
23
|
continue;
|
|
50
24
|
}
|
|
51
|
-
if (syntax[i].
|
|
25
|
+
if (syntax[i].nested)
|
|
26
|
+
continue;
|
|
27
|
+
if (syntax[i].rawHtml) {
|
|
28
|
+
// Raw-HTML details stay open past the block; stray closers are ignored, as in the browser.
|
|
29
|
+
for (const tag of rawDetailsTags(lines[i], html))
|
|
30
|
+
detailsDepth = Math.max(0, detailsDepth + tag);
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
html = rawHtmlState();
|
|
34
|
+
if (syntax[i].ignored)
|
|
52
35
|
continue;
|
|
53
36
|
const visible = syntax[i].visiblePrefix;
|
|
54
37
|
detailsDepth += detailsTags(visible, false);
|
|
@@ -66,7 +49,7 @@ export function scanShepherdJournal(lines) {
|
|
|
66
49
|
lines[i - 1] !== OPEN ||
|
|
67
50
|
lines[i + 1] !== "")
|
|
68
51
|
return "error";
|
|
69
|
-
const end =
|
|
52
|
+
const end = closeJournalContainer(lines, syntax, i + 2);
|
|
70
53
|
if (end === null)
|
|
71
54
|
return "error";
|
|
72
55
|
detailsDepth--;
|
package/package.json
CHANGED