@neriros/ralphy 2.18.0 → 2.20.0
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 +20 -11
- package/dist/cli/index.js +397 -22
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -169,6 +169,10 @@ A default `ralphy.config.json` is written on first run with every defaulted sett
|
|
|
169
169
|
"assignee": "me",
|
|
170
170
|
"postComments": true,
|
|
171
171
|
"updateEveryIterations": 10,
|
|
172
|
+
"mentionTrigger": true,
|
|
173
|
+
"mentionHandle": "@ralphy",
|
|
174
|
+
"codeReviewTrigger": true,
|
|
175
|
+
"codeReviewStaleHours": 24,
|
|
172
176
|
"indicators": {
|
|
173
177
|
"getTodo": { "filter": [{ "type": "status", "value": "Todo" }] },
|
|
174
178
|
"getInProgress": { "filter": [{ "type": "status", "value": "In Progress" }] },
|
|
@@ -213,6 +217,10 @@ Linear is the source of truth for which issues Ralph has touched. Each `linear.i
|
|
|
213
217
|
|
|
214
218
|
**Review follow-ups.** When a Linear issue is in a "done" state and a reviewer adds the `getReview` marker (typically a label like `ralph:review` after leaving comments), Ralph picks it up, applies `setInProgress`, removes the `clearReview` label so the same trigger doesn't re-fire, fetches the comment thread, filters out Ralph's own comments, and prepends those reviewer comments as a new task at the top of `tasks.md`. The worker addresses them in the same change branch and `setDone` is re-applied on success.
|
|
215
219
|
|
|
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.
|
|
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
|
+
|
|
216
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.
|
|
217
225
|
|
|
218
226
|
#### Per-task git worktrees
|
|
@@ -256,17 +264,18 @@ Failed workers (non-zero exit) are not marked processed, so they'll be retried o
|
|
|
256
264
|
|
|
257
265
|
### Agent mode flags
|
|
258
266
|
|
|
259
|
-
| Option | Description
|
|
260
|
-
| ------------------------- |
|
|
261
|
-
| `--linear-team <key>` | Linear team key (e.g. `ENG`)
|
|
262
|
-
| `--linear-assignee <id>` | Filter by assignee (user id, email, or `me`)
|
|
263
|
-
| `--poll-interval <s>` | Seconds between Linear polls (default: 60)
|
|
264
|
-
| `--concurrency <n>` | Max concurrent task loops (default: 1)
|
|
265
|
-
| `--max-tickets <n>` | Stop picking up new issues after N have been started this run (0 = unlimited)
|
|
266
|
-
| `--worktree` | Run each task in its own git worktree
|
|
267
|
-
| `--indicator <k>:<t>:<v>` | Override a `linear.indicators` entry; repeatable (e.g. `setDone:status:Done`)
|
|
268
|
-
| `--create-pr` | Push worker branch + open a GitHub PR on success (needs `--worktree`)
|
|
269
|
-
| `--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 |
|
|
270
279
|
|
|
271
280
|
#### `--max-tickets`
|
|
272
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.0")
|
|
35033
|
+
return "2.20.0";
|
|
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
|
"",
|
|
@@ -59621,6 +59626,18 @@ var MarkerSchema, GetIndicatorSchema, SetIndicatorSchema, IndicatorsSchema, Ralp
|
|
|
59621
59626
|
// Post a progress update every N iterations. 0 disables. Requires postComments.
|
|
59622
59627
|
"updateEveryIterations": 10,
|
|
59623
59628
|
|
|
59629
|
+
// Watch done-issue Linear comments AND their linked GitHub PR comments
|
|
59630
|
+
// for "@ralphy" mentions. New mentions enqueue the issue as a review run
|
|
59631
|
+
// with the mention text as the prepended task.
|
|
59632
|
+
"mentionTrigger": false,
|
|
59633
|
+
// "mentionHandle": "@ralphy",
|
|
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
|
+
|
|
59624
59641
|
// Indicators map Ralph lifecycle events to Linear labels/statuses.
|
|
59625
59642
|
// WARNING: activating indicators will query AND mutate your Linear workspace.
|
|
59626
59643
|
// Uncomment each entry after confirming the label/status names match your workspace.
|
|
@@ -59730,8 +59747,20 @@ var init_config = __esm(() => {
|
|
|
59730
59747
|
assignee: exports_external.string().optional(),
|
|
59731
59748
|
postComments: exports_external.boolean().default(true),
|
|
59732
59749
|
updateEveryIterations: exports_external.number().int().nonnegative().default(10),
|
|
59750
|
+
mentionTrigger: exports_external.boolean().default(false),
|
|
59751
|
+
mentionHandle: exports_external.string().default("@ralphy"),
|
|
59752
|
+
codeReviewTrigger: exports_external.boolean().default(false),
|
|
59753
|
+
codeReviewStaleHours: exports_external.number().nonnegative().default(24),
|
|
59733
59754
|
indicators: IndicatorsSchema.default({})
|
|
59734
|
-
}).strict().default({
|
|
59755
|
+
}).strict().default({
|
|
59756
|
+
postComments: true,
|
|
59757
|
+
updateEveryIterations: 10,
|
|
59758
|
+
mentionTrigger: false,
|
|
59759
|
+
mentionHandle: "@ralphy",
|
|
59760
|
+
codeReviewTrigger: false,
|
|
59761
|
+
codeReviewStaleHours: 24,
|
|
59762
|
+
indicators: {}
|
|
59763
|
+
})
|
|
59735
59764
|
}).default({
|
|
59736
59765
|
concurrency: 1,
|
|
59737
59766
|
pollIntervalSeconds: 60,
|
|
@@ -59740,7 +59769,15 @@ var init_config = __esm(() => {
|
|
|
59740
59769
|
enableManualTest: false,
|
|
59741
59770
|
engine: "claude",
|
|
59742
59771
|
model: "opus",
|
|
59743
|
-
linear: {
|
|
59772
|
+
linear: {
|
|
59773
|
+
postComments: true,
|
|
59774
|
+
updateEveryIterations: 10,
|
|
59775
|
+
mentionTrigger: false,
|
|
59776
|
+
mentionHandle: "@ralphy",
|
|
59777
|
+
codeReviewTrigger: false,
|
|
59778
|
+
codeReviewStaleHours: 24,
|
|
59779
|
+
indicators: {}
|
|
59780
|
+
}
|
|
59744
59781
|
});
|
|
59745
59782
|
});
|
|
59746
59783
|
|
|
@@ -60070,20 +60107,22 @@ class AgentCoordinator {
|
|
|
60070
60107
|
let inProgress = [];
|
|
60071
60108
|
let conflicted = [];
|
|
60072
60109
|
let review = [];
|
|
60110
|
+
let mentions = [];
|
|
60073
60111
|
try {
|
|
60074
|
-
[todo, inProgress, conflicted, review] = await Promise.all([
|
|
60112
|
+
[todo, inProgress, conflicted, review, mentions] = await Promise.all([
|
|
60075
60113
|
this.deps.fetchTodo(),
|
|
60076
60114
|
this.deps.fetchInProgress(),
|
|
60077
60115
|
this.deps.fetchConflicted(),
|
|
60078
|
-
this.deps.fetchReview()
|
|
60116
|
+
this.deps.fetchReview(),
|
|
60117
|
+
this.deps.fetchMentions()
|
|
60079
60118
|
]);
|
|
60080
60119
|
} catch (err) {
|
|
60081
60120
|
this.deps.onLog(`! Linear poll failed: ${err.message}`, "red");
|
|
60082
60121
|
capture("agent_linear_poll_failed", { error: err.message });
|
|
60083
60122
|
return { found: 0, added: 0 };
|
|
60084
60123
|
}
|
|
60085
|
-
if (todo.length + inProgress.length + conflicted.length + review.length > 0) {
|
|
60086
|
-
this.deps.onLog(` poll: ${todo.length} todo, ${inProgress.length} in-progress, ${conflicted.length} conflicted, ${review.length} review`, "gray");
|
|
60124
|
+
if (todo.length + inProgress.length + conflicted.length + review.length + mentions.length > 0) {
|
|
60125
|
+
this.deps.onLog(` poll: ${todo.length} todo, ${inProgress.length} in-progress, ${conflicted.length} conflicted, ${review.length} review, ${mentions.length} mention`, "gray");
|
|
60087
60126
|
}
|
|
60088
60127
|
const queuedIds = new Set(this.queue.map((q) => q.issue.id));
|
|
60089
60128
|
const activeIds = new Set(this.workers.map((w) => w.issueId));
|
|
@@ -60128,6 +60167,16 @@ class AgentCoordinator {
|
|
|
60128
60167
|
added += 1;
|
|
60129
60168
|
this.deps.onLog(` \u21B3 ${issue.identifier} queued (review)`, "gray");
|
|
60130
60169
|
}
|
|
60170
|
+
for (const { issue, trigger } of mentions) {
|
|
60171
|
+
if (atTicketLimit())
|
|
60172
|
+
break;
|
|
60173
|
+
if (!eligible(issue.id))
|
|
60174
|
+
continue;
|
|
60175
|
+
this.queue.push({ issue, mode: "review", trigger });
|
|
60176
|
+
queuedIds.add(issue.id);
|
|
60177
|
+
added += 1;
|
|
60178
|
+
this.deps.onLog(` \u21B3 ${issue.identifier} queued (review via ${trigger.source} mention)`, "gray");
|
|
60179
|
+
}
|
|
60131
60180
|
for (const issue of todo) {
|
|
60132
60181
|
if (atTicketLimit())
|
|
60133
60182
|
break;
|
|
@@ -60158,7 +60207,7 @@ class AgentCoordinator {
|
|
|
60158
60207
|
this.spawnNext();
|
|
60159
60208
|
await this.scanDoneForConflicts();
|
|
60160
60209
|
await this.reportProgress();
|
|
60161
|
-
const found = todo.length + inProgress.length + conflicted.length + review.length;
|
|
60210
|
+
const found = todo.length + inProgress.length + conflicted.length + review.length + mentions.length;
|
|
60162
60211
|
return { found, added };
|
|
60163
60212
|
}
|
|
60164
60213
|
dependenciesResolved(issue) {
|
|
@@ -60267,13 +60316,13 @@ class AgentCoordinator {
|
|
|
60267
60316
|
while (this.workers.length + this.pendingIds.size < this.opts.concurrency && this.queue.length > 0) {
|
|
60268
60317
|
const next = this.queue.shift();
|
|
60269
60318
|
this.pendingIds.add(next.issue.id);
|
|
60270
|
-
this.launchWorker(next.issue, next.mode);
|
|
60319
|
+
this.launchWorker(next.issue, next.mode, next.trigger);
|
|
60271
60320
|
}
|
|
60272
60321
|
}
|
|
60273
|
-
async launchWorker(issue, mode) {
|
|
60322
|
+
async launchWorker(issue, mode, trigger) {
|
|
60274
60323
|
let prep;
|
|
60275
60324
|
try {
|
|
60276
|
-
prep = await this.deps.prepare(issue, mode);
|
|
60325
|
+
prep = await this.deps.prepare(issue, mode, trigger);
|
|
60277
60326
|
} catch (err) {
|
|
60278
60327
|
this.pendingIds.delete(issue.id);
|
|
60279
60328
|
this.deps.onLog(`! prepare(${mode}) failed for ${issue.identifier}: ${err.message}`, "red");
|
|
@@ -60316,8 +60365,9 @@ class AgentCoordinator {
|
|
|
60316
60365
|
}
|
|
60317
60366
|
}
|
|
60318
60367
|
if (mode === "review" && this.opts.postComments !== false) {
|
|
60368
|
+
const sourceTag = trigger ? trigger.source === "github" ? " (GitHub @mention)" : trigger.source === "github-review" ? " (GitHub code review)" : " (Linear @mention)" : "";
|
|
60319
60369
|
try {
|
|
60320
|
-
await this.deps.postComment(issue, `\uD83D\uDD01 Ralph picked up new review comments. Tracking change: \`${prep.changeName}\``);
|
|
60370
|
+
await this.deps.postComment(issue, `\uD83D\uDD01 Ralph picked up new review comments${sourceTag}. Tracking change: \`${prep.changeName}\``);
|
|
60321
60371
|
} catch (err) {
|
|
60322
60372
|
this.deps.onLog(`! Linear review comment failed for ${issue.identifier}: ${err.message}`, "yellow");
|
|
60323
60373
|
}
|
|
@@ -61108,6 +61158,45 @@ ${c.body.trim()}`;
|
|
|
61108
61158
|
].join(`
|
|
61109
61159
|
`);
|
|
61110
61160
|
}
|
|
61161
|
+
function escapeRegex(s) {
|
|
61162
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
61163
|
+
}
|
|
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
|
+
}
|
|
61185
|
+
const sourceLabel = trigger.source === "github" ? "GitHub PR" : "Linear issue";
|
|
61186
|
+
const permalink = trigger.url ?? issueUrl;
|
|
61187
|
+
const header = `${trigger.author ?? "unknown"} \u2014 ${trigger.createdAt} (${sourceLabel})`;
|
|
61188
|
+
return [
|
|
61189
|
+
`An @ralphy mention was left on ${sourceLabel} (${permalink}):`,
|
|
61190
|
+
"",
|
|
61191
|
+
`**${header}**`,
|
|
61192
|
+
"",
|
|
61193
|
+
trigger.body.trim(),
|
|
61194
|
+
"",
|
|
61195
|
+
"Treat this comment as the next concrete request. If it's ambiguous,",
|
|
61196
|
+
"note your interpretation in proposal.md `## Steering` before acting."
|
|
61197
|
+
].join(`
|
|
61198
|
+
`);
|
|
61199
|
+
}
|
|
61111
61200
|
function unionMarkers(...sets) {
|
|
61112
61201
|
const out = [];
|
|
61113
61202
|
const seen = new Set;
|
|
@@ -61268,6 +61357,7 @@ function buildAgentCoordinator(input) {
|
|
|
61268
61357
|
const issueByChange = new Map;
|
|
61269
61358
|
const prByChange = new Map;
|
|
61270
61359
|
const prUnavailable = new Set;
|
|
61360
|
+
const stalePingedAt = new Map;
|
|
61271
61361
|
const useWorktree = args.worktree || cfg.useWorktree;
|
|
61272
61362
|
const scriptRunner = input.runners?.runScript ?? (async (cmd, cwd2) => {
|
|
61273
61363
|
const proc = Bun.spawn({
|
|
@@ -61318,7 +61408,7 @@ function buildAgentCoordinator(input) {
|
|
|
61318
61408
|
}
|
|
61319
61409
|
return { workerCwd, scaffoldTasksDir, scaffoldStatesDir, branch };
|
|
61320
61410
|
}
|
|
61321
|
-
async function prepare(issue, mode) {
|
|
61411
|
+
async function prepare(issue, mode, trigger) {
|
|
61322
61412
|
const { workerCwd, scaffoldTasksDir, scaffoldStatesDir, branch } = await setupWorktree(issue);
|
|
61323
61413
|
let changeName;
|
|
61324
61414
|
const isFresh = mode === "fresh";
|
|
@@ -61345,16 +61435,24 @@ function buildAgentCoordinator(input) {
|
|
|
61345
61435
|
if (mode === "review") {
|
|
61346
61436
|
const wtLayout = projectLayout(workerCwd);
|
|
61347
61437
|
const tasksFile = join16(wtLayout.changeDir(changeName), "tasks.md");
|
|
61348
|
-
let
|
|
61349
|
-
|
|
61350
|
-
|
|
61351
|
-
|
|
61352
|
-
|
|
61438
|
+
let body;
|
|
61439
|
+
let heading;
|
|
61440
|
+
if (trigger) {
|
|
61441
|
+
heading = trigger.source === "github" ? "Address GitHub @ralphy mention" : "Address Linear @ralphy mention";
|
|
61442
|
+
body = buildMentionTaskBody(trigger, issue.url);
|
|
61443
|
+
} else {
|
|
61444
|
+
heading = "Address reviewer comments";
|
|
61445
|
+
let comments = [];
|
|
61446
|
+
try {
|
|
61447
|
+
comments = await fetchIssueComments(apiKey, issue.id);
|
|
61448
|
+
} catch (err) {
|
|
61449
|
+
onLog(`! Linear comment fetch failed for ${issue.identifier}: ${err.message}`, "yellow");
|
|
61450
|
+
}
|
|
61451
|
+
const reviewerComments = comments.filter((c) => !isRalphComment(c.body));
|
|
61452
|
+
body = buildReviewTaskBody(reviewerComments, issue.url);
|
|
61353
61453
|
}
|
|
61354
|
-
const reviewerComments = comments.filter((c) => !isRalphComment(c.body));
|
|
61355
|
-
const body = buildReviewTaskBody(reviewerComments, issue.url);
|
|
61356
61454
|
try {
|
|
61357
|
-
await prependFixTask(tasksFile,
|
|
61455
|
+
await prependFixTask(tasksFile, heading, body);
|
|
61358
61456
|
} catch (err) {
|
|
61359
61457
|
onLog(`! could not prepend review task: ${err.message}`, "red");
|
|
61360
61458
|
}
|
|
@@ -61630,11 +61728,288 @@ PR: ${prUrl}` : ""
|
|
|
61630
61728
|
return [];
|
|
61631
61729
|
return fetchOpenIssues(apiKey, { team, assignee, include, exclude });
|
|
61632
61730
|
}
|
|
61731
|
+
async function fetchMentions() {
|
|
61732
|
+
const wantMention = cfg.linear.mentionTrigger;
|
|
61733
|
+
const wantCodeReview = args.codeReview || cfg.linear.codeReviewTrigger;
|
|
61734
|
+
if (!wantMention && !wantCodeReview)
|
|
61735
|
+
return [];
|
|
61736
|
+
const handle = cfg.linear.mentionHandle;
|
|
61737
|
+
let candidates = [];
|
|
61738
|
+
try {
|
|
61739
|
+
candidates = await fetchDoneCandidates();
|
|
61740
|
+
} catch (err) {
|
|
61741
|
+
onLog(`! mention scan: fetchDoneCandidates failed: ${err.message}`, "yellow");
|
|
61742
|
+
return [];
|
|
61743
|
+
}
|
|
61744
|
+
const out = [];
|
|
61745
|
+
const queued = new Set;
|
|
61746
|
+
for (const issue of candidates) {
|
|
61747
|
+
let comments = [];
|
|
61748
|
+
try {
|
|
61749
|
+
comments = await fetchIssueComments(apiKey, issue.id);
|
|
61750
|
+
} catch (err) {
|
|
61751
|
+
onLog(`! mention scan: Linear comments failed for ${issue.identifier}: ${err.message}`, "yellow");
|
|
61752
|
+
continue;
|
|
61753
|
+
}
|
|
61754
|
+
const lastRalphPickup = findLastRalphPickupISO(comments);
|
|
61755
|
+
if (wantMention) {
|
|
61756
|
+
for (const c of comments) {
|
|
61757
|
+
if (isRalphComment(c.body))
|
|
61758
|
+
continue;
|
|
61759
|
+
if (!containsHandle(c.body, handle))
|
|
61760
|
+
continue;
|
|
61761
|
+
if (lastRalphPickup && c.createdAt <= lastRalphPickup)
|
|
61762
|
+
continue;
|
|
61763
|
+
out.push({
|
|
61764
|
+
issue,
|
|
61765
|
+
trigger: {
|
|
61766
|
+
source: "linear",
|
|
61767
|
+
body: c.body,
|
|
61768
|
+
createdAt: c.createdAt,
|
|
61769
|
+
...c.user?.name ? { author: c.user.name } : {},
|
|
61770
|
+
url: issue.url
|
|
61771
|
+
}
|
|
61772
|
+
});
|
|
61773
|
+
queued.add(issue.id);
|
|
61774
|
+
break;
|
|
61775
|
+
}
|
|
61776
|
+
if (queued.has(issue.id))
|
|
61777
|
+
continue;
|
|
61778
|
+
}
|
|
61779
|
+
const prUrl = await resolvePrUrlForIssue(issue);
|
|
61780
|
+
if (!prUrl)
|
|
61781
|
+
continue;
|
|
61782
|
+
if (wantMention) {
|
|
61783
|
+
const ghComments = await fetchPrIssueComments(prUrl);
|
|
61784
|
+
for (const c of ghComments) {
|
|
61785
|
+
if (!containsHandle(c.body, handle))
|
|
61786
|
+
continue;
|
|
61787
|
+
if (lastRalphPickup && c.createdAt <= lastRalphPickup)
|
|
61788
|
+
continue;
|
|
61789
|
+
out.push({
|
|
61790
|
+
issue,
|
|
61791
|
+
trigger: {
|
|
61792
|
+
source: "github",
|
|
61793
|
+
body: c.body,
|
|
61794
|
+
createdAt: c.createdAt,
|
|
61795
|
+
...c.author ? { author: c.author } : {},
|
|
61796
|
+
url: c.url
|
|
61797
|
+
}
|
|
61798
|
+
});
|
|
61799
|
+
queued.add(issue.id);
|
|
61800
|
+
break;
|
|
61801
|
+
}
|
|
61802
|
+
if (queued.has(issue.id))
|
|
61803
|
+
continue;
|
|
61804
|
+
}
|
|
61805
|
+
if (wantCodeReview) {
|
|
61806
|
+
const trigger = await scanCodeReview(issue, prUrl, lastRalphPickup);
|
|
61807
|
+
if (trigger) {
|
|
61808
|
+
out.push({ issue, trigger });
|
|
61809
|
+
queued.add(issue.id);
|
|
61810
|
+
}
|
|
61811
|
+
}
|
|
61812
|
+
}
|
|
61813
|
+
return out;
|
|
61814
|
+
}
|
|
61815
|
+
async function scanCodeReview(issue, prUrl, lastRalphPickup) {
|
|
61816
|
+
const state = await fetchPrReviewState(prUrl);
|
|
61817
|
+
if (!state || !state.isOpen || state.merged || state.approved)
|
|
61818
|
+
return null;
|
|
61819
|
+
const unresolved = state.threads.filter((t) => !t.isResolved && t.comments.length > 0);
|
|
61820
|
+
if (unresolved.length === 0)
|
|
61821
|
+
return null;
|
|
61822
|
+
const newestReviewerActivity = unresolved.reduce((acc, t) => {
|
|
61823
|
+
const last2 = t.comments[t.comments.length - 1].createdAt;
|
|
61824
|
+
return last2 > acc ? last2 : acc;
|
|
61825
|
+
}, "");
|
|
61826
|
+
if (!lastRalphPickup || newestReviewerActivity > lastRalphPickup) {
|
|
61827
|
+
const body = unresolved.map((t) => {
|
|
61828
|
+
const head3 = t.path ? `_${t.path}${t.line ? `:${t.line}` : ""}_` : "_(general)_";
|
|
61829
|
+
const lines = t.comments.map((c) => `> **${c.author ?? "reviewer"}** (${c.createdAt})
|
|
61830
|
+
>
|
|
61831
|
+
> ${c.body.trim().replace(/\n/g, `
|
|
61832
|
+
> `)}`);
|
|
61833
|
+
return [head3, "", ...lines].join(`
|
|
61834
|
+
`);
|
|
61835
|
+
}).join(`
|
|
61836
|
+
|
|
61837
|
+
---
|
|
61838
|
+
|
|
61839
|
+
`);
|
|
61840
|
+
return {
|
|
61841
|
+
source: "github-review",
|
|
61842
|
+
body,
|
|
61843
|
+
createdAt: newestReviewerActivity || new Date().toISOString(),
|
|
61844
|
+
...state.lastReviewer ? { author: state.lastReviewer } : {},
|
|
61845
|
+
url: prUrl
|
|
61846
|
+
};
|
|
61847
|
+
}
|
|
61848
|
+
await maybePingStaleReviewer(issue, prUrl, state, newestReviewerActivity);
|
|
61849
|
+
return null;
|
|
61850
|
+
}
|
|
61851
|
+
async function maybePingStaleReviewer(issue, prUrl, state, newestReviewerActivity) {
|
|
61852
|
+
const staleHours = cfg.linear.codeReviewStaleHours;
|
|
61853
|
+
if (staleHours <= 0)
|
|
61854
|
+
return;
|
|
61855
|
+
const reviewer = state.requestedReviewer ?? state.lastReviewer;
|
|
61856
|
+
if (!reviewer)
|
|
61857
|
+
return;
|
|
61858
|
+
const lastPinged = stalePingedAt.get(prUrl);
|
|
61859
|
+
const now2 = Date.now();
|
|
61860
|
+
if (lastPinged && now2 - lastPinged < staleHours * 3600000)
|
|
61861
|
+
return;
|
|
61862
|
+
const elapsedH = newestReviewerActivity ? (now2 - Date.parse(newestReviewerActivity)) / 3600000 : Infinity;
|
|
61863
|
+
if (elapsedH < staleHours)
|
|
61864
|
+
return;
|
|
61865
|
+
const m = /^https:\/\/github\.com\/([^/]+)\/([^/]+)\/pull\/(\d+)/.exec(prUrl);
|
|
61866
|
+
if (!m)
|
|
61867
|
+
return;
|
|
61868
|
+
const [, owner, repo, num] = m;
|
|
61869
|
+
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?`;
|
|
61870
|
+
try {
|
|
61871
|
+
await cmdRunner.run(["gh", "api", `repos/${owner}/${repo}/issues/${num}/comments`, "-f", `body=${body}`], projectRoot);
|
|
61872
|
+
stalePingedAt.set(prUrl, now2);
|
|
61873
|
+
onLog(` ${issue.identifier}: pinged reviewer @${reviewer} on ${prUrl}`, "gray");
|
|
61874
|
+
} catch (err) {
|
|
61875
|
+
onLog(`! reviewer ping failed for ${prUrl}: ${err.message}`, "yellow");
|
|
61876
|
+
}
|
|
61877
|
+
}
|
|
61878
|
+
async function fetchPrReviewState(prUrl) {
|
|
61879
|
+
const m = /^https:\/\/github\.com\/([^/]+)\/([^/]+)\/pull\/(\d+)/.exec(prUrl);
|
|
61880
|
+
if (!m)
|
|
61881
|
+
return null;
|
|
61882
|
+
const [, owner, repo, num] = m;
|
|
61883
|
+
const query = `query($owner:String!,$repo:String!,$num:Int!){
|
|
61884
|
+
repository(owner:$owner,name:$repo){
|
|
61885
|
+
pullRequest(number:$num){
|
|
61886
|
+
state merged reviewDecision
|
|
61887
|
+
reviewRequests(first:5){nodes{requestedReviewer{... on User{login}}}}
|
|
61888
|
+
latestReviews(first:5){nodes{author{login} state submittedAt}}
|
|
61889
|
+
reviewThreads(first:50){nodes{
|
|
61890
|
+
isResolved path line
|
|
61891
|
+
comments(first:20){nodes{body author{login} createdAt url}}
|
|
61892
|
+
}}
|
|
61893
|
+
}
|
|
61894
|
+
}
|
|
61895
|
+
}`;
|
|
61896
|
+
try {
|
|
61897
|
+
const res = await cmdRunner.run([
|
|
61898
|
+
"gh",
|
|
61899
|
+
"api",
|
|
61900
|
+
"graphql",
|
|
61901
|
+
"-f",
|
|
61902
|
+
`query=${query}`,
|
|
61903
|
+
"-F",
|
|
61904
|
+
`owner=${owner}`,
|
|
61905
|
+
"-F",
|
|
61906
|
+
`repo=${repo}`,
|
|
61907
|
+
"-F",
|
|
61908
|
+
`num=${num}`
|
|
61909
|
+
], projectRoot);
|
|
61910
|
+
const parsed = JSON.parse(res.stdout);
|
|
61911
|
+
const pr = parsed.data?.repository?.pullRequest;
|
|
61912
|
+
if (!pr)
|
|
61913
|
+
return null;
|
|
61914
|
+
const requested = pr.reviewRequests?.nodes.map((n) => n.requestedReviewer?.login).filter((x) => !!x)[0];
|
|
61915
|
+
const latestReviews = pr.latestReviews?.nodes ?? [];
|
|
61916
|
+
const lastReviewer = latestReviews.slice().sort((a, b) => b.submittedAt > a.submittedAt ? 1 : -1).map((n) => n.author?.login).filter((x) => !!x)[0];
|
|
61917
|
+
return {
|
|
61918
|
+
isOpen: pr.state === "OPEN",
|
|
61919
|
+
merged: pr.merged,
|
|
61920
|
+
approved: pr.reviewDecision === "APPROVED",
|
|
61921
|
+
threads: (pr.reviewThreads?.nodes ?? []).map((t) => ({
|
|
61922
|
+
isResolved: t.isResolved,
|
|
61923
|
+
...t.path ? { path: t.path } : {},
|
|
61924
|
+
...t.line != null ? { line: t.line } : {},
|
|
61925
|
+
comments: t.comments.nodes.map((c) => ({
|
|
61926
|
+
...c.author?.login ? { author: c.author.login } : {},
|
|
61927
|
+
body: c.body,
|
|
61928
|
+
createdAt: c.createdAt,
|
|
61929
|
+
...c.url ? { url: c.url } : {}
|
|
61930
|
+
}))
|
|
61931
|
+
})),
|
|
61932
|
+
...requested ? { requestedReviewer: requested } : {},
|
|
61933
|
+
...lastReviewer ? { lastReviewer } : {}
|
|
61934
|
+
};
|
|
61935
|
+
} catch (err) {
|
|
61936
|
+
onLog(`! gh graphql review-state failed for ${prUrl}: ${err.message}`, "yellow");
|
|
61937
|
+
return null;
|
|
61938
|
+
}
|
|
61939
|
+
}
|
|
61940
|
+
function findLastRalphPickupISO(comments) {
|
|
61941
|
+
let latest = null;
|
|
61942
|
+
for (const c of comments) {
|
|
61943
|
+
if (!/^\uD83D\uDD01\s*Ralph picked up/.test(c.body.trimStart()))
|
|
61944
|
+
continue;
|
|
61945
|
+
if (latest === null || c.createdAt > latest)
|
|
61946
|
+
latest = c.createdAt;
|
|
61947
|
+
}
|
|
61948
|
+
return latest;
|
|
61949
|
+
}
|
|
61950
|
+
function containsHandle(body, handle) {
|
|
61951
|
+
const re = new RegExp(`(^|\\s|[^A-Za-z0-9_])${escapeRegex(handle)}\\b`, "i");
|
|
61952
|
+
return re.test(body);
|
|
61953
|
+
}
|
|
61954
|
+
async function resolvePrUrlForIssue(issue) {
|
|
61955
|
+
const changeName = changeNameForIssue(issue);
|
|
61956
|
+
if (prUnavailable.has(changeName))
|
|
61957
|
+
return null;
|
|
61958
|
+
const cached = prByChange.get(changeName);
|
|
61959
|
+
if (cached)
|
|
61960
|
+
return cached;
|
|
61961
|
+
const branch = branchForChange(changeName);
|
|
61962
|
+
try {
|
|
61963
|
+
const res = await cmdRunner.run([
|
|
61964
|
+
"gh",
|
|
61965
|
+
"pr",
|
|
61966
|
+
"list",
|
|
61967
|
+
"--head",
|
|
61968
|
+
branch,
|
|
61969
|
+
"--state",
|
|
61970
|
+
"all",
|
|
61971
|
+
"--json",
|
|
61972
|
+
"url",
|
|
61973
|
+
"--jq",
|
|
61974
|
+
".[0].url // empty"
|
|
61975
|
+
], projectRoot);
|
|
61976
|
+
const found = res.stdout.trim();
|
|
61977
|
+
if (!found) {
|
|
61978
|
+
prUnavailable.add(changeName);
|
|
61979
|
+
return null;
|
|
61980
|
+
}
|
|
61981
|
+
prByChange.set(changeName, found);
|
|
61982
|
+
return found;
|
|
61983
|
+
} catch {
|
|
61984
|
+
return null;
|
|
61985
|
+
}
|
|
61986
|
+
}
|
|
61987
|
+
async function fetchPrIssueComments(prUrl) {
|
|
61988
|
+
const m = /^https:\/\/github\.com\/([^/]+)\/([^/]+)\/pull\/(\d+)/.exec(prUrl);
|
|
61989
|
+
if (!m)
|
|
61990
|
+
return [];
|
|
61991
|
+
const [, owner, repo, num] = m;
|
|
61992
|
+
try {
|
|
61993
|
+
const res = await cmdRunner.run([
|
|
61994
|
+
"gh",
|
|
61995
|
+
"api",
|
|
61996
|
+
`repos/${owner}/${repo}/issues/${num}/comments`,
|
|
61997
|
+
"--jq",
|
|
61998
|
+
"[.[] | {body: .body, createdAt: .created_at, author: .user.login, url: .html_url}]"
|
|
61999
|
+
], projectRoot);
|
|
62000
|
+
const parsed = JSON.parse(res.stdout || "[]");
|
|
62001
|
+
return parsed;
|
|
62002
|
+
} catch (err) {
|
|
62003
|
+
onLog(`! mention scan: gh comments failed for ${prUrl}: ${err.message}`, "yellow");
|
|
62004
|
+
return [];
|
|
62005
|
+
}
|
|
62006
|
+
}
|
|
61633
62007
|
const coord = new AgentCoordinator({
|
|
61634
62008
|
fetchTodo: () => fetchByGet(indicators.getTodo, excludeFromTodo),
|
|
61635
62009
|
fetchInProgress: () => fetchByGet(indicators.getInProgress, []),
|
|
61636
62010
|
fetchConflicted: () => fetchByGet(indicators.getConflicted, []),
|
|
61637
62011
|
fetchReview: () => fetchByGet(indicators.getReview, excludeFromReview),
|
|
62012
|
+
fetchMentions,
|
|
61638
62013
|
fetchDoneCandidates,
|
|
61639
62014
|
prepare,
|
|
61640
62015
|
spawnWorker,
|