@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.
Files changed (3) hide show
  1. package/README.md +16 -11
  2. package/dist/cli/index.js +301 -94
  3. 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.19.0")
35033
- return "2.19.0";
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 Set;
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 (prUnavailable.has(changeName))
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
- "gh",
61653
- "pr",
61654
- "list",
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
- prUrl = found;
61670
- prByChange.set(changeName, prUrl);
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
- try {
61677
- const res = await cmdRunner.run(["gh", "pr", "view", prUrl, "--json", "mergeable", "--jq", ".mergeable"], projectRoot);
61678
- const mergeable = res.stdout.trim();
61679
- return { url: prUrl, conflicting: mergeable === "CONFLICTING" };
61680
- } catch {
61681
- return null;
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
- if (!cfg.linear.mentionTrigger)
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
- for (const c of comments) {
61715
- if (isRalphComment(c.body))
61716
- continue;
61717
- if (!containsHandle(c.body, handle))
61718
- continue;
61719
- if (lastRalphPickup && c.createdAt <= lastRalphPickup)
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
- const ghComments = await fetchPrIssueComments(prUrl);
61739
- for (const c of ghComments) {
61740
- if (!containsHandle(c.body, handle))
61741
- continue;
61742
- if (lastRalphPickup && c.createdAt <= lastRalphPickup)
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
- out.push({
61745
- issue,
61746
- trigger: {
61747
- source: "github",
61748
- body: c.body,
61749
- createdAt: c.createdAt,
61750
- ...c.author ? { author: c.author } : {},
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 (prUnavailable.has(changeName))
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 branch = branchForChange(changeName);
61781
- try {
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
- return found;
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);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@neriros/ralphy",
3
- "version": "2.19.0",
3
+ "version": "2.20.1",
4
4
  "description": "An iterative AI task execution framework. Orchestrates multi-phase autonomous work using Claude or Codex engines.",
5
5
  "keywords": [
6
6
  "agent",