@neriros/ralphy 2.19.0 → 2.20.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/README.md +16 -11
- package/dist/cli/index.js +301 -94
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -171,6 +171,8 @@ A default `ralphy.config.json` is written on first run with every defaulted sett
|
|
|
171
171
|
"updateEveryIterations": 10,
|
|
172
172
|
"mentionTrigger": true,
|
|
173
173
|
"mentionHandle": "@ralphy",
|
|
174
|
+
"codeReviewTrigger": true,
|
|
175
|
+
"codeReviewStaleHours": 24,
|
|
174
176
|
"indicators": {
|
|
175
177
|
"getTodo": { "filter": [{ "type": "status", "value": "Todo" }] },
|
|
176
178
|
"getInProgress": { "filter": [{ "type": "status", "value": "In Progress" }] },
|
|
@@ -217,6 +219,8 @@ Linear is the source of truth for which issues Ralph has touched. Each `linear.i
|
|
|
217
219
|
|
|
218
220
|
**`@ralphy` mention trigger.** Set `linear.mentionTrigger: true` (default `false`) to scan done-issue comments on both Linear and their linked GitHub PR for `@ralphy` mentions (configurable via `linear.mentionHandle`). New mentions enqueue the issue as a review run with the mention text used verbatim as the prepended task. Idempotency: a mention is considered processed when its `createdAt` is older than the most recent Ralph `🔁 picked up` comment on the Linear issue, so the same comment never re-fires across polls. Requires the `gh` CLI authenticated for the GitHub side.
|
|
219
221
|
|
|
222
|
+
**Code-review iteration.** Set `linear.codeReviewTrigger: true` (or pass `--code-review`) to watch open, unmerged, unapproved tracked PRs for unresolved review-thread comments. When any unresolved thread has activity newer than Ralph's last `🔁 picked up` ack, the issue is queued as a review run whose prepended task is a digest of every unresolved thread plus instructions: fix-and-push for comments Ralph agrees with (resolve the thread after the commit lands), or reply on GitHub with reasoning for ones it disagrees with. The cycle repeats every poll until the PR is approved or merged. If the reviewer has been silent for more than `linear.codeReviewStaleHours` (default 24) while Ralph is the last actor, a one-shot `@`-mention ping comment is posted on the GitHub PR.
|
|
223
|
+
|
|
220
224
|
Marker types are `"label"` or `"status"`. Combine markers under `apply` when one event needs to set multiple — e.g. `setDone` flipping a status _and_ adding a "shipped" label.
|
|
221
225
|
|
|
222
226
|
#### Per-task git worktrees
|
|
@@ -260,17 +264,18 @@ Failed workers (non-zero exit) are not marked processed, so they'll be retried o
|
|
|
260
264
|
|
|
261
265
|
### Agent mode flags
|
|
262
266
|
|
|
263
|
-
| Option | Description
|
|
264
|
-
| ------------------------- |
|
|
265
|
-
| `--linear-team <key>` | Linear team key (e.g. `ENG`)
|
|
266
|
-
| `--linear-assignee <id>` | Filter by assignee (user id, email, or `me`)
|
|
267
|
-
| `--poll-interval <s>` | Seconds between Linear polls (default: 60)
|
|
268
|
-
| `--concurrency <n>` | Max concurrent task loops (default: 1)
|
|
269
|
-
| `--max-tickets <n>` | Stop picking up new issues after N have been started this run (0 = unlimited)
|
|
270
|
-
| `--worktree` | Run each task in its own git worktree
|
|
271
|
-
| `--indicator <k>:<t>:<v>` | Override a `linear.indicators` entry; repeatable (e.g. `setDone:status:Done`)
|
|
272
|
-
| `--create-pr` | Push worker branch + open a GitHub PR on success (needs `--worktree`)
|
|
273
|
-
| `--fix-ci` | After PR opens, re-run task on CI failures until green (needs `--create-pr`)
|
|
267
|
+
| Option | Description |
|
|
268
|
+
| ------------------------- | ------------------------------------------------------------------------------------ |
|
|
269
|
+
| `--linear-team <key>` | Linear team key (e.g. `ENG`) |
|
|
270
|
+
| `--linear-assignee <id>` | Filter by assignee (user id, email, or `me`) |
|
|
271
|
+
| `--poll-interval <s>` | Seconds between Linear polls (default: 60) |
|
|
272
|
+
| `--concurrency <n>` | Max concurrent task loops (default: 1) |
|
|
273
|
+
| `--max-tickets <n>` | Stop picking up new issues after N have been started this run (0 = unlimited) |
|
|
274
|
+
| `--worktree` | Run each task in its own git worktree |
|
|
275
|
+
| `--indicator <k>:<t>:<v>` | Override a `linear.indicators` entry; repeatable (e.g. `setDone:status:Done`) |
|
|
276
|
+
| `--create-pr` | Push worker branch + open a GitHub PR on success (needs `--worktree`) |
|
|
277
|
+
| `--fix-ci` | After PR opens, re-run task on CI failures until green (needs `--create-pr`) |
|
|
278
|
+
| `--code-review` | Watch open tracked PRs for unresolved review comments and prepend a code-review task |
|
|
274
279
|
|
|
275
280
|
#### `--max-tickets`
|
|
276
281
|
|
package/dist/cli/index.js
CHANGED
|
@@ -35029,8 +35029,8 @@ import { readFileSync as readFileSync2 } from "fs";
|
|
|
35029
35029
|
import { resolve } from "path";
|
|
35030
35030
|
function getVersion() {
|
|
35031
35031
|
try {
|
|
35032
|
-
if ("2.
|
|
35033
|
-
return "2.
|
|
35032
|
+
if ("2.20.1")
|
|
35033
|
+
return "2.20.1";
|
|
35034
35034
|
} catch {}
|
|
35035
35035
|
const dirsToTry = [];
|
|
35036
35036
|
try {
|
|
@@ -35130,6 +35130,7 @@ async function parseArgs(argv) {
|
|
|
35130
35130
|
indicators: {},
|
|
35131
35131
|
createPr: false,
|
|
35132
35132
|
fixCi: false,
|
|
35133
|
+
codeReview: false,
|
|
35133
35134
|
maxTickets: 0,
|
|
35134
35135
|
projectRoot: undefined,
|
|
35135
35136
|
jsonOutput: false
|
|
@@ -35323,6 +35324,9 @@ async function parseArgs(argv) {
|
|
|
35323
35324
|
case "--fix-ci":
|
|
35324
35325
|
result2.fixCi = true;
|
|
35325
35326
|
break;
|
|
35327
|
+
case "--code-review":
|
|
35328
|
+
result2.codeReview = true;
|
|
35329
|
+
break;
|
|
35326
35330
|
case "--json-output":
|
|
35327
35331
|
result2.jsonOutput = true;
|
|
35328
35332
|
break;
|
|
@@ -35417,6 +35421,7 @@ var init_cli = __esm(() => {
|
|
|
35417
35421
|
" Types: label, status",
|
|
35418
35422
|
" --create-pr Push the worker branch and open a GitHub PR on success (needs --worktree)",
|
|
35419
35423
|
" --fix-ci After opening the PR, re-run on CI failures until green (needs --create-pr)",
|
|
35424
|
+
" --code-review Watch open tracked PRs for unresolved review comments and prepend a code-review task",
|
|
35420
35425
|
" --max-tickets <n> Stop picking up new issues after N have been started (0 = unlimited)",
|
|
35421
35426
|
" --json-output Emit JSONL to stdout instead of the Ink dashboard (for scripting/CI)",
|
|
35422
35427
|
"",
|
|
@@ -59627,6 +59632,12 @@ var MarkerSchema, GetIndicatorSchema, SetIndicatorSchema, IndicatorsSchema, Ralp
|
|
|
59627
59632
|
"mentionTrigger": false,
|
|
59628
59633
|
// "mentionHandle": "@ralphy",
|
|
59629
59634
|
|
|
59635
|
+
// Watch open tracked PRs for unresolved review-thread comments and
|
|
59636
|
+
// prepend a code-review task. Pings the reviewer on the GitHub PR
|
|
59637
|
+
// when stalled for more than codeReviewStaleHours.
|
|
59638
|
+
"codeReviewTrigger": false,
|
|
59639
|
+
"codeReviewStaleHours": 24,
|
|
59640
|
+
|
|
59630
59641
|
// Indicators map Ralph lifecycle events to Linear labels/statuses.
|
|
59631
59642
|
// WARNING: activating indicators will query AND mutate your Linear workspace.
|
|
59632
59643
|
// Uncomment each entry after confirming the label/status names match your workspace.
|
|
@@ -59738,12 +59749,16 @@ var init_config = __esm(() => {
|
|
|
59738
59749
|
updateEveryIterations: exports_external.number().int().nonnegative().default(10),
|
|
59739
59750
|
mentionTrigger: exports_external.boolean().default(false),
|
|
59740
59751
|
mentionHandle: exports_external.string().default("@ralphy"),
|
|
59752
|
+
codeReviewTrigger: exports_external.boolean().default(false),
|
|
59753
|
+
codeReviewStaleHours: exports_external.number().nonnegative().default(24),
|
|
59741
59754
|
indicators: IndicatorsSchema.default({})
|
|
59742
59755
|
}).strict().default({
|
|
59743
59756
|
postComments: true,
|
|
59744
59757
|
updateEveryIterations: 10,
|
|
59745
59758
|
mentionTrigger: false,
|
|
59746
59759
|
mentionHandle: "@ralphy",
|
|
59760
|
+
codeReviewTrigger: false,
|
|
59761
|
+
codeReviewStaleHours: 24,
|
|
59747
59762
|
indicators: {}
|
|
59748
59763
|
})
|
|
59749
59764
|
}).default({
|
|
@@ -59759,6 +59774,8 @@ var init_config = __esm(() => {
|
|
|
59759
59774
|
updateEveryIterations: 10,
|
|
59760
59775
|
mentionTrigger: false,
|
|
59761
59776
|
mentionHandle: "@ralphy",
|
|
59777
|
+
codeReviewTrigger: false,
|
|
59778
|
+
codeReviewStaleHours: 24,
|
|
59762
59779
|
indicators: {}
|
|
59763
59780
|
}
|
|
59764
59781
|
});
|
|
@@ -60348,7 +60365,7 @@ class AgentCoordinator {
|
|
|
60348
60365
|
}
|
|
60349
60366
|
}
|
|
60350
60367
|
if (mode === "review" && this.opts.postComments !== false) {
|
|
60351
|
-
const sourceTag = trigger ? trigger.source === "github" ? " (GitHub @mention)" : " (Linear @mention)" : "";
|
|
60368
|
+
const sourceTag = trigger ? trigger.source === "github" ? " (GitHub @mention)" : trigger.source === "github-review" ? " (GitHub code review)" : " (Linear @mention)" : "";
|
|
60352
60369
|
try {
|
|
60353
60370
|
await this.deps.postComment(issue, `\uD83D\uDD01 Ralph picked up new review comments${sourceTag}. Tracking change: \`${prep.changeName}\``);
|
|
60354
60371
|
} catch (err) {
|
|
@@ -61145,6 +61162,26 @@ function escapeRegex(s) {
|
|
|
61145
61162
|
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
61146
61163
|
}
|
|
61147
61164
|
function buildMentionTaskBody(trigger, issueUrl) {
|
|
61165
|
+
if (trigger.source === "github-review") {
|
|
61166
|
+
return [
|
|
61167
|
+
`Open code-review on ${trigger.url ?? issueUrl} has unresolved comments:`,
|
|
61168
|
+
"",
|
|
61169
|
+
trigger.body.trim(),
|
|
61170
|
+
"",
|
|
61171
|
+
"For every comment above, decide:",
|
|
61172
|
+
"- If you agree, fix the code, commit, and push. The push will surface",
|
|
61173
|
+
" the new commit on the PR; the worker should then resolve the thread",
|
|
61174
|
+
" via `gh api graphql` (`resolveReviewThread`) \u2014 see GitHub docs.",
|
|
61175
|
+
"- If you disagree, post a polite reply on the thread explaining your",
|
|
61176
|
+
" reasoning via `gh api repos/{owner}/{repo}/pulls/{num}/comments/{id}/replies`,",
|
|
61177
|
+
" and leave the thread unresolved.",
|
|
61178
|
+
"",
|
|
61179
|
+
"When this round is done the loop exits; the agent will re-poll the",
|
|
61180
|
+
"PR on the next cycle and pick up any new reviewer activity until the",
|
|
61181
|
+
"PR is approved or merged."
|
|
61182
|
+
].join(`
|
|
61183
|
+
`);
|
|
61184
|
+
}
|
|
61148
61185
|
const sourceLabel = trigger.source === "github" ? "GitHub PR" : "Linear issue";
|
|
61149
61186
|
const permalink = trigger.url ?? issueUrl;
|
|
61150
61187
|
const header = `${trigger.author ?? "unknown"} \u2014 ${trigger.createdAt} (${sourceLabel})`;
|
|
@@ -61319,7 +61356,9 @@ function buildAgentCoordinator(input) {
|
|
|
61319
61356
|
const branchByChange = new Map;
|
|
61320
61357
|
const issueByChange = new Map;
|
|
61321
61358
|
const prByChange = new Map;
|
|
61322
|
-
const prUnavailable = new
|
|
61359
|
+
const prUnavailable = new Map;
|
|
61360
|
+
const PR_UNAVAILABLE_TTL_MS = 10 * 60 * 1000;
|
|
61361
|
+
const stalePingedAt = new Map;
|
|
61323
61362
|
const useWorktree = args.worktree || cfg.useWorktree;
|
|
61324
61363
|
const scriptRunner = input.runners?.runScript ?? (async (cmd, cwd2) => {
|
|
61325
61364
|
const proc = Bun.spawn({
|
|
@@ -61642,44 +61681,90 @@ PR: ${prUrl}` : ""
|
|
|
61642
61681
|
}
|
|
61643
61682
|
async function checkPrConflict(issue) {
|
|
61644
61683
|
const changeName = changeNameForIssue(issue);
|
|
61645
|
-
if (
|
|
61684
|
+
if (isPrUnavailable(changeName))
|
|
61646
61685
|
return null;
|
|
61647
|
-
const branch = branchForChange(changeName);
|
|
61648
61686
|
let prUrl = prByChange.get(changeName);
|
|
61649
61687
|
if (!prUrl) {
|
|
61688
|
+
const found = await discoverPrUrl(issue, changeName);
|
|
61689
|
+
if (!found)
|
|
61690
|
+
return null;
|
|
61691
|
+
prUrl = found;
|
|
61692
|
+
prByChange.set(changeName, prUrl);
|
|
61693
|
+
}
|
|
61694
|
+
for (let attempt2 = 0;attempt2 < 3; attempt2++) {
|
|
61650
61695
|
try {
|
|
61651
|
-
const res = await cmdRunner.run([
|
|
61652
|
-
|
|
61653
|
-
|
|
61654
|
-
"
|
|
61655
|
-
"--head",
|
|
61656
|
-
branch,
|
|
61657
|
-
"--state",
|
|
61658
|
-
"open",
|
|
61659
|
-
"--json",
|
|
61660
|
-
"url",
|
|
61661
|
-
"--jq",
|
|
61662
|
-
".[0].url // empty"
|
|
61663
|
-
], projectRoot);
|
|
61664
|
-
const found = res.stdout.trim();
|
|
61665
|
-
if (!found) {
|
|
61666
|
-
prUnavailable.add(changeName);
|
|
61667
|
-
return null;
|
|
61696
|
+
const res = await cmdRunner.run(["gh", "pr", "view", prUrl, "--json", "mergeable", "--jq", ".mergeable"], projectRoot);
|
|
61697
|
+
const mergeable = res.stdout.trim();
|
|
61698
|
+
if (mergeable !== "UNKNOWN") {
|
|
61699
|
+
return { url: prUrl, conflicting: mergeable === "CONFLICTING" };
|
|
61668
61700
|
}
|
|
61669
|
-
|
|
61670
|
-
|
|
61671
|
-
} catch {
|
|
61672
|
-
prUnavailable.add(changeName);
|
|
61701
|
+
} catch (err) {
|
|
61702
|
+
onLog(`! gh pr view ${prUrl} failed (conflict scan): ${err.message}`, "yellow");
|
|
61673
61703
|
return null;
|
|
61674
61704
|
}
|
|
61705
|
+
await new Promise((r) => setTimeout(r, 2000));
|
|
61675
61706
|
}
|
|
61676
|
-
|
|
61677
|
-
|
|
61678
|
-
|
|
61679
|
-
|
|
61680
|
-
|
|
61681
|
-
|
|
61707
|
+
onLog(` ${issue.identifier}: mergeability still UNKNOWN after retries (${prUrl}) \u2014 will recheck next poll`, "gray");
|
|
61708
|
+
return null;
|
|
61709
|
+
}
|
|
61710
|
+
function isPrUnavailable(changeName) {
|
|
61711
|
+
const expiry = prUnavailable.get(changeName);
|
|
61712
|
+
if (expiry === undefined)
|
|
61713
|
+
return false;
|
|
61714
|
+
if (Date.now() >= expiry) {
|
|
61715
|
+
prUnavailable.delete(changeName);
|
|
61716
|
+
return false;
|
|
61682
61717
|
}
|
|
61718
|
+
return true;
|
|
61719
|
+
}
|
|
61720
|
+
function markPrUnavailable(changeName) {
|
|
61721
|
+
prUnavailable.set(changeName, Date.now() + PR_UNAVAILABLE_TTL_MS);
|
|
61722
|
+
}
|
|
61723
|
+
async function discoverPrUrl(issue, changeName) {
|
|
61724
|
+
const branch = branchForChange(changeName);
|
|
61725
|
+
const tryGh = async (args2) => {
|
|
61726
|
+
try {
|
|
61727
|
+
const res = await cmdRunner.run(args2, projectRoot);
|
|
61728
|
+
const found = res.stdout.trim();
|
|
61729
|
+
return found || null;
|
|
61730
|
+
} catch (err) {
|
|
61731
|
+
onLog(`! gh ${args2[1] ?? ""} failed for ${issue.identifier}: ${err.message}`, "yellow");
|
|
61732
|
+
return null;
|
|
61733
|
+
}
|
|
61734
|
+
};
|
|
61735
|
+
const byBranch = await tryGh([
|
|
61736
|
+
"gh",
|
|
61737
|
+
"pr",
|
|
61738
|
+
"list",
|
|
61739
|
+
"--head",
|
|
61740
|
+
branch,
|
|
61741
|
+
"--state",
|
|
61742
|
+
"open",
|
|
61743
|
+
"--json",
|
|
61744
|
+
"url",
|
|
61745
|
+
"--jq",
|
|
61746
|
+
".[0].url // empty"
|
|
61747
|
+
]);
|
|
61748
|
+
if (byBranch)
|
|
61749
|
+
return byBranch;
|
|
61750
|
+
const byIdentifier = await tryGh([
|
|
61751
|
+
"gh",
|
|
61752
|
+
"pr",
|
|
61753
|
+
"list",
|
|
61754
|
+
"--search",
|
|
61755
|
+
`${issue.identifier} in:title state:open`,
|
|
61756
|
+
"--json",
|
|
61757
|
+
"url",
|
|
61758
|
+
"--jq",
|
|
61759
|
+
".[0].url // empty"
|
|
61760
|
+
]);
|
|
61761
|
+
if (byIdentifier) {
|
|
61762
|
+
onLog(` ${issue.identifier}: PR discovered via title search (${byIdentifier})`, "gray");
|
|
61763
|
+
return byIdentifier;
|
|
61764
|
+
}
|
|
61765
|
+
onLog(` ${issue.identifier}: no open PR found on head=${branch} or title-search; conflict scan skipped for ${PR_UNAVAILABLE_TTL_MS / 60000}m`, "gray");
|
|
61766
|
+
markPrUnavailable(changeName);
|
|
61767
|
+
return null;
|
|
61683
61768
|
}
|
|
61684
61769
|
async function fetchDoneCandidates() {
|
|
61685
61770
|
if (!indicators.setDone)
|
|
@@ -61691,7 +61776,9 @@ PR: ${prUrl}` : ""
|
|
|
61691
61776
|
return fetchOpenIssues(apiKey, { team, assignee, include, exclude });
|
|
61692
61777
|
}
|
|
61693
61778
|
async function fetchMentions() {
|
|
61694
|
-
|
|
61779
|
+
const wantMention = cfg.linear.mentionTrigger;
|
|
61780
|
+
const wantCodeReview = args.codeReview || cfg.linear.codeReviewTrigger;
|
|
61781
|
+
if (!wantMention && !wantCodeReview)
|
|
61695
61782
|
return [];
|
|
61696
61783
|
const handle = cfg.linear.mentionHandle;
|
|
61697
61784
|
let candidates = [];
|
|
@@ -61702,6 +61789,7 @@ PR: ${prUrl}` : ""
|
|
|
61702
61789
|
return [];
|
|
61703
61790
|
}
|
|
61704
61791
|
const out = [];
|
|
61792
|
+
const queued = new Set;
|
|
61705
61793
|
for (const issue of candidates) {
|
|
61706
61794
|
let comments = [];
|
|
61707
61795
|
try {
|
|
@@ -61711,51 +61799,191 @@ PR: ${prUrl}` : ""
|
|
|
61711
61799
|
continue;
|
|
61712
61800
|
}
|
|
61713
61801
|
const lastRalphPickup = findLastRalphPickupISO(comments);
|
|
61714
|
-
|
|
61715
|
-
|
|
61716
|
-
|
|
61717
|
-
|
|
61718
|
-
|
|
61719
|
-
|
|
61802
|
+
if (wantMention) {
|
|
61803
|
+
for (const c of comments) {
|
|
61804
|
+
if (isRalphComment(c.body))
|
|
61805
|
+
continue;
|
|
61806
|
+
if (!containsHandle(c.body, handle))
|
|
61807
|
+
continue;
|
|
61808
|
+
if (lastRalphPickup && c.createdAt <= lastRalphPickup)
|
|
61809
|
+
continue;
|
|
61810
|
+
out.push({
|
|
61811
|
+
issue,
|
|
61812
|
+
trigger: {
|
|
61813
|
+
source: "linear",
|
|
61814
|
+
body: c.body,
|
|
61815
|
+
createdAt: c.createdAt,
|
|
61816
|
+
...c.user?.name ? { author: c.user.name } : {},
|
|
61817
|
+
url: issue.url
|
|
61818
|
+
}
|
|
61819
|
+
});
|
|
61820
|
+
queued.add(issue.id);
|
|
61821
|
+
break;
|
|
61822
|
+
}
|
|
61823
|
+
if (queued.has(issue.id))
|
|
61720
61824
|
continue;
|
|
61721
|
-
out.push({
|
|
61722
|
-
issue,
|
|
61723
|
-
trigger: {
|
|
61724
|
-
source: "linear",
|
|
61725
|
-
body: c.body,
|
|
61726
|
-
createdAt: c.createdAt,
|
|
61727
|
-
...c.user?.name ? { author: c.user.name } : {},
|
|
61728
|
-
url: issue.url
|
|
61729
|
-
}
|
|
61730
|
-
});
|
|
61731
|
-
break;
|
|
61732
61825
|
}
|
|
61733
|
-
if (out.length > 0 && out[out.length - 1].issue.id === issue.id)
|
|
61734
|
-
continue;
|
|
61735
61826
|
const prUrl = await resolvePrUrlForIssue(issue);
|
|
61736
61827
|
if (!prUrl)
|
|
61737
61828
|
continue;
|
|
61738
|
-
|
|
61739
|
-
|
|
61740
|
-
|
|
61741
|
-
|
|
61742
|
-
|
|
61829
|
+
if (wantMention) {
|
|
61830
|
+
const ghComments = await fetchPrIssueComments(prUrl);
|
|
61831
|
+
for (const c of ghComments) {
|
|
61832
|
+
if (!containsHandle(c.body, handle))
|
|
61833
|
+
continue;
|
|
61834
|
+
if (lastRalphPickup && c.createdAt <= lastRalphPickup)
|
|
61835
|
+
continue;
|
|
61836
|
+
out.push({
|
|
61837
|
+
issue,
|
|
61838
|
+
trigger: {
|
|
61839
|
+
source: "github",
|
|
61840
|
+
body: c.body,
|
|
61841
|
+
createdAt: c.createdAt,
|
|
61842
|
+
...c.author ? { author: c.author } : {},
|
|
61843
|
+
url: c.url
|
|
61844
|
+
}
|
|
61845
|
+
});
|
|
61846
|
+
queued.add(issue.id);
|
|
61847
|
+
break;
|
|
61848
|
+
}
|
|
61849
|
+
if (queued.has(issue.id))
|
|
61743
61850
|
continue;
|
|
61744
|
-
|
|
61745
|
-
|
|
61746
|
-
|
|
61747
|
-
|
|
61748
|
-
|
|
61749
|
-
|
|
61750
|
-
|
|
61751
|
-
url: c.url
|
|
61752
|
-
}
|
|
61753
|
-
});
|
|
61754
|
-
break;
|
|
61851
|
+
}
|
|
61852
|
+
if (wantCodeReview) {
|
|
61853
|
+
const trigger = await scanCodeReview(issue, prUrl, lastRalphPickup);
|
|
61854
|
+
if (trigger) {
|
|
61855
|
+
out.push({ issue, trigger });
|
|
61856
|
+
queued.add(issue.id);
|
|
61857
|
+
}
|
|
61755
61858
|
}
|
|
61756
61859
|
}
|
|
61757
61860
|
return out;
|
|
61758
61861
|
}
|
|
61862
|
+
async function scanCodeReview(issue, prUrl, lastRalphPickup) {
|
|
61863
|
+
const state = await fetchPrReviewState(prUrl);
|
|
61864
|
+
if (!state || !state.isOpen || state.merged || state.approved)
|
|
61865
|
+
return null;
|
|
61866
|
+
const unresolved = state.threads.filter((t) => !t.isResolved && t.comments.length > 0);
|
|
61867
|
+
if (unresolved.length === 0)
|
|
61868
|
+
return null;
|
|
61869
|
+
const newestReviewerActivity = unresolved.reduce((acc, t) => {
|
|
61870
|
+
const last2 = t.comments[t.comments.length - 1].createdAt;
|
|
61871
|
+
return last2 > acc ? last2 : acc;
|
|
61872
|
+
}, "");
|
|
61873
|
+
if (!lastRalphPickup || newestReviewerActivity > lastRalphPickup) {
|
|
61874
|
+
const body = unresolved.map((t) => {
|
|
61875
|
+
const head3 = t.path ? `_${t.path}${t.line ? `:${t.line}` : ""}_` : "_(general)_";
|
|
61876
|
+
const lines = t.comments.map((c) => `> **${c.author ?? "reviewer"}** (${c.createdAt})
|
|
61877
|
+
>
|
|
61878
|
+
> ${c.body.trim().replace(/\n/g, `
|
|
61879
|
+
> `)}`);
|
|
61880
|
+
return [head3, "", ...lines].join(`
|
|
61881
|
+
`);
|
|
61882
|
+
}).join(`
|
|
61883
|
+
|
|
61884
|
+
---
|
|
61885
|
+
|
|
61886
|
+
`);
|
|
61887
|
+
return {
|
|
61888
|
+
source: "github-review",
|
|
61889
|
+
body,
|
|
61890
|
+
createdAt: newestReviewerActivity || new Date().toISOString(),
|
|
61891
|
+
...state.lastReviewer ? { author: state.lastReviewer } : {},
|
|
61892
|
+
url: prUrl
|
|
61893
|
+
};
|
|
61894
|
+
}
|
|
61895
|
+
await maybePingStaleReviewer(issue, prUrl, state, newestReviewerActivity);
|
|
61896
|
+
return null;
|
|
61897
|
+
}
|
|
61898
|
+
async function maybePingStaleReviewer(issue, prUrl, state, newestReviewerActivity) {
|
|
61899
|
+
const staleHours = cfg.linear.codeReviewStaleHours;
|
|
61900
|
+
if (staleHours <= 0)
|
|
61901
|
+
return;
|
|
61902
|
+
const reviewer = state.requestedReviewer ?? state.lastReviewer;
|
|
61903
|
+
if (!reviewer)
|
|
61904
|
+
return;
|
|
61905
|
+
const lastPinged = stalePingedAt.get(prUrl);
|
|
61906
|
+
const now2 = Date.now();
|
|
61907
|
+
if (lastPinged && now2 - lastPinged < staleHours * 3600000)
|
|
61908
|
+
return;
|
|
61909
|
+
const elapsedH = newestReviewerActivity ? (now2 - Date.parse(newestReviewerActivity)) / 3600000 : Infinity;
|
|
61910
|
+
if (elapsedH < staleHours)
|
|
61911
|
+
return;
|
|
61912
|
+
const m = /^https:\/\/github\.com\/([^/]+)\/([^/]+)\/pull\/(\d+)/.exec(prUrl);
|
|
61913
|
+
if (!m)
|
|
61914
|
+
return;
|
|
61915
|
+
const [, owner, repo, num] = m;
|
|
61916
|
+
const body = `\uD83D\uDD14 @${reviewer} \u2014 Ralph has been waiting ${elapsedH.toFixed(0)}h on a re-review for ${prUrl}. Could you take another look when you have a moment?`;
|
|
61917
|
+
try {
|
|
61918
|
+
await cmdRunner.run(["gh", "api", `repos/${owner}/${repo}/issues/${num}/comments`, "-f", `body=${body}`], projectRoot);
|
|
61919
|
+
stalePingedAt.set(prUrl, now2);
|
|
61920
|
+
onLog(` ${issue.identifier}: pinged reviewer @${reviewer} on ${prUrl}`, "gray");
|
|
61921
|
+
} catch (err) {
|
|
61922
|
+
onLog(`! reviewer ping failed for ${prUrl}: ${err.message}`, "yellow");
|
|
61923
|
+
}
|
|
61924
|
+
}
|
|
61925
|
+
async function fetchPrReviewState(prUrl) {
|
|
61926
|
+
const m = /^https:\/\/github\.com\/([^/]+)\/([^/]+)\/pull\/(\d+)/.exec(prUrl);
|
|
61927
|
+
if (!m)
|
|
61928
|
+
return null;
|
|
61929
|
+
const [, owner, repo, num] = m;
|
|
61930
|
+
const query = `query($owner:String!,$repo:String!,$num:Int!){
|
|
61931
|
+
repository(owner:$owner,name:$repo){
|
|
61932
|
+
pullRequest(number:$num){
|
|
61933
|
+
state merged reviewDecision
|
|
61934
|
+
reviewRequests(first:5){nodes{requestedReviewer{... on User{login}}}}
|
|
61935
|
+
latestReviews(first:5){nodes{author{login} state submittedAt}}
|
|
61936
|
+
reviewThreads(first:50){nodes{
|
|
61937
|
+
isResolved path line
|
|
61938
|
+
comments(first:20){nodes{body author{login} createdAt url}}
|
|
61939
|
+
}}
|
|
61940
|
+
}
|
|
61941
|
+
}
|
|
61942
|
+
}`;
|
|
61943
|
+
try {
|
|
61944
|
+
const res = await cmdRunner.run([
|
|
61945
|
+
"gh",
|
|
61946
|
+
"api",
|
|
61947
|
+
"graphql",
|
|
61948
|
+
"-f",
|
|
61949
|
+
`query=${query}`,
|
|
61950
|
+
"-F",
|
|
61951
|
+
`owner=${owner}`,
|
|
61952
|
+
"-F",
|
|
61953
|
+
`repo=${repo}`,
|
|
61954
|
+
"-F",
|
|
61955
|
+
`num=${num}`
|
|
61956
|
+
], projectRoot);
|
|
61957
|
+
const parsed = JSON.parse(res.stdout);
|
|
61958
|
+
const pr = parsed.data?.repository?.pullRequest;
|
|
61959
|
+
if (!pr)
|
|
61960
|
+
return null;
|
|
61961
|
+
const requested = pr.reviewRequests?.nodes.map((n) => n.requestedReviewer?.login).filter((x) => !!x)[0];
|
|
61962
|
+
const latestReviews = pr.latestReviews?.nodes ?? [];
|
|
61963
|
+
const lastReviewer = latestReviews.slice().sort((a, b) => b.submittedAt > a.submittedAt ? 1 : -1).map((n) => n.author?.login).filter((x) => !!x)[0];
|
|
61964
|
+
return {
|
|
61965
|
+
isOpen: pr.state === "OPEN",
|
|
61966
|
+
merged: pr.merged,
|
|
61967
|
+
approved: pr.reviewDecision === "APPROVED",
|
|
61968
|
+
threads: (pr.reviewThreads?.nodes ?? []).map((t) => ({
|
|
61969
|
+
isResolved: t.isResolved,
|
|
61970
|
+
...t.path ? { path: t.path } : {},
|
|
61971
|
+
...t.line != null ? { line: t.line } : {},
|
|
61972
|
+
comments: t.comments.nodes.map((c) => ({
|
|
61973
|
+
...c.author?.login ? { author: c.author.login } : {},
|
|
61974
|
+
body: c.body,
|
|
61975
|
+
createdAt: c.createdAt,
|
|
61976
|
+
...c.url ? { url: c.url } : {}
|
|
61977
|
+
}))
|
|
61978
|
+
})),
|
|
61979
|
+
...requested ? { requestedReviewer: requested } : {},
|
|
61980
|
+
...lastReviewer ? { lastReviewer } : {}
|
|
61981
|
+
};
|
|
61982
|
+
} catch (err) {
|
|
61983
|
+
onLog(`! gh graphql review-state failed for ${prUrl}: ${err.message}`, "yellow");
|
|
61984
|
+
return null;
|
|
61985
|
+
}
|
|
61986
|
+
}
|
|
61759
61987
|
function findLastRalphPickupISO(comments) {
|
|
61760
61988
|
let latest = null;
|
|
61761
61989
|
for (const c of comments) {
|
|
@@ -61772,36 +62000,15 @@ PR: ${prUrl}` : ""
|
|
|
61772
62000
|
}
|
|
61773
62001
|
async function resolvePrUrlForIssue(issue) {
|
|
61774
62002
|
const changeName = changeNameForIssue(issue);
|
|
61775
|
-
if (
|
|
62003
|
+
if (isPrUnavailable(changeName))
|
|
61776
62004
|
return null;
|
|
61777
62005
|
const cached = prByChange.get(changeName);
|
|
61778
62006
|
if (cached)
|
|
61779
62007
|
return cached;
|
|
61780
|
-
const
|
|
61781
|
-
|
|
61782
|
-
const res = await cmdRunner.run([
|
|
61783
|
-
"gh",
|
|
61784
|
-
"pr",
|
|
61785
|
-
"list",
|
|
61786
|
-
"--head",
|
|
61787
|
-
branch,
|
|
61788
|
-
"--state",
|
|
61789
|
-
"all",
|
|
61790
|
-
"--json",
|
|
61791
|
-
"url",
|
|
61792
|
-
"--jq",
|
|
61793
|
-
".[0].url // empty"
|
|
61794
|
-
], projectRoot);
|
|
61795
|
-
const found = res.stdout.trim();
|
|
61796
|
-
if (!found) {
|
|
61797
|
-
prUnavailable.add(changeName);
|
|
61798
|
-
return null;
|
|
61799
|
-
}
|
|
62008
|
+
const found = await discoverPrUrl(issue, changeName);
|
|
62009
|
+
if (found)
|
|
61800
62010
|
prByChange.set(changeName, found);
|
|
61801
|
-
|
|
61802
|
-
} catch {
|
|
61803
|
-
return null;
|
|
61804
|
-
}
|
|
62011
|
+
return found;
|
|
61805
62012
|
}
|
|
61806
62013
|
async function fetchPrIssueComments(prUrl) {
|
|
61807
62014
|
const m = /^https:\/\/github\.com\/([^/]+)\/([^/]+)\/pull\/(\d+)/.exec(prUrl);
|