@revisium/orchestrator 0.1.0-alpha.9 → 0.1.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.
Files changed (103) hide show
  1. package/README.md +143 -157
  2. package/control-plane/default-playbook/catalog/pipelines.json +217 -8
  3. package/control-plane/default-playbook/package.json +1 -1
  4. package/control-plane/default-playbook/prompts/developer.md +1 -0
  5. package/control-plane/default-playbook/prompts/triager.md +3 -0
  6. package/dist/api/graphql-api/graphql-ws/subscription-mappers.js +3 -0
  7. package/dist/api/graphql-api/graphql-ws/subscription-mappers.js.map +1 -1
  8. package/dist/api/graphql-api/pr/inputs/pr-readiness.input.js +6 -0
  9. package/dist/api/graphql-api/pr/inputs/pr-readiness.input.js.map +1 -1
  10. package/dist/api/graphql-api/pr/model/pr-readiness.model.js +30 -0
  11. package/dist/api/graphql-api/pr/model/pr-readiness.model.js.map +1 -1
  12. package/dist/api/graphql-api/runs/inputs/create-run.input.js +6 -0
  13. package/dist/api/graphql-api/runs/inputs/create-run.input.js.map +1 -1
  14. package/dist/api/graphql-api/runs/model/run.model.js +6 -0
  15. package/dist/api/graphql-api/runs/model/run.model.js.map +1 -1
  16. package/dist/api/graphql-api/share/model/issue-ref.model.js +53 -0
  17. package/dist/api/graphql-api/share/model/issue-ref.model.js.map +1 -0
  18. package/dist/control-plane/bootstrap.js +14 -1
  19. package/dist/control-plane/bootstrap.js.map +1 -1
  20. package/dist/control-plane/client-transport.js +1 -1
  21. package/dist/control-plane/client-transport.js.map +1 -1
  22. package/dist/control-plane/default-playbook-policy.js +593 -0
  23. package/dist/control-plane/default-playbook-policy.js.map +1 -0
  24. package/dist/control-plane/seed-default-playbook.js +77 -8
  25. package/dist/control-plane/seed-default-playbook.js.map +1 -1
  26. package/dist/e2e/kit/agents.js +15 -21
  27. package/dist/e2e/kit/agents.js.map +1 -1
  28. package/dist/e2e/kit/assertions.js +12 -8
  29. package/dist/e2e/kit/assertions.js.map +1 -1
  30. package/dist/e2e/kit/fake-integrator.js +11 -2
  31. package/dist/e2e/kit/fake-integrator.js.map +1 -1
  32. package/dist/e2e/kit/git-target-repo.js +1 -1
  33. package/dist/engine/dbos.service.js +3 -0
  34. package/dist/engine/dbos.service.js.map +1 -1
  35. package/dist/features/pr/queries/impl/get-pr-readiness.query.js.map +1 -1
  36. package/dist/features/runs/commands/impl/create-run.command.js.map +1 -1
  37. package/dist/features/runs/queries/handlers/runs-query.handlers.js +1 -0
  38. package/dist/features/runs/queries/handlers/runs-query.handlers.js.map +1 -1
  39. package/dist/mcp/mcp-capabilities.js +14 -1
  40. package/dist/mcp/mcp-capabilities.js.map +1 -1
  41. package/dist/mcp/mcp-facade.service.js +306 -11
  42. package/dist/mcp/mcp-facade.service.js.map +1 -1
  43. package/dist/mcp/mcp-tools.js +41 -14
  44. package/dist/mcp/mcp-tools.js.map +1 -1
  45. package/dist/observability/activity-signal.js +107 -0
  46. package/dist/observability/activity-signal.js.map +1 -0
  47. package/dist/observability/index.js +1 -0
  48. package/dist/observability/index.js.map +1 -1
  49. package/dist/pipeline/data-driven-task.workflow.js +482 -40
  50. package/dist/pipeline/data-driven-task.workflow.js.map +1 -1
  51. package/dist/pipeline/pipeline.service.js +195 -101
  52. package/dist/pipeline/pipeline.service.js.map +1 -1
  53. package/dist/pipeline/route-contract.js +4 -3
  54. package/dist/pipeline/route-contract.js.map +1 -1
  55. package/dist/pipeline-core/kit/fixtures.js +72 -21
  56. package/dist/pipeline-core/kit/fixtures.js.map +1 -1
  57. package/dist/playbook/import-mapper.js +8 -1
  58. package/dist/playbook/import-mapper.js.map +1 -1
  59. package/dist/poller/pr-readiness-core.js +299 -28
  60. package/dist/poller/pr-readiness-core.js.map +1 -1
  61. package/dist/revisium/playbooks.service.js +17 -2
  62. package/dist/revisium/playbooks.service.js.map +1 -1
  63. package/dist/revisium/run.service.js +1 -0
  64. package/dist/revisium/run.service.js.map +1 -1
  65. package/dist/run/append-event.js +8 -5
  66. package/dist/run/append-event.js.map +1 -1
  67. package/dist/run/create-run.js +5 -1
  68. package/dist/run/create-run.js.map +1 -1
  69. package/dist/run/inspect-run.js +10 -3
  70. package/dist/run/inspect-run.js.map +1 -1
  71. package/dist/run/issue-ref.js +88 -0
  72. package/dist/run/issue-ref.js.map +1 -0
  73. package/dist/runners/claude-code.service.js +2 -2
  74. package/dist/runners/claude-code.service.js.map +1 -1
  75. package/dist/runners/codex.service.js +2 -2
  76. package/dist/runners/codex.service.js.map +1 -1
  77. package/dist/runners/integrator-branch-naming.js +13 -8
  78. package/dist/runners/integrator-branch-naming.js.map +1 -1
  79. package/dist/runners/integrator.js +254 -89
  80. package/dist/runners/integrator.js.map +1 -1
  81. package/dist/runners/worktree.service.js +2 -2
  82. package/dist/runners/worktree.service.js.map +1 -1
  83. package/dist/task-control-plane/pr-readiness.service.js +15 -13
  84. package/dist/task-control-plane/pr-readiness.service.js.map +1 -1
  85. package/dist/task-control-plane/run-watch.service.js +234 -3
  86. package/dist/task-control-plane/run-watch.service.js.map +1 -1
  87. package/dist/task-control-plane/task-control-plane-api.service.js +272 -25
  88. package/dist/task-control-plane/task-control-plane-api.service.js.map +1 -1
  89. package/dist/worker/artifact-store.js +21 -3
  90. package/dist/worker/artifact-store.js.map +1 -1
  91. package/dist/worker/build-context.js +19 -3
  92. package/dist/worker/build-context.js.map +1 -1
  93. package/dist/worker/claude-code-runner.js +76 -15
  94. package/dist/worker/claude-code-runner.js.map +1 -1
  95. package/dist/worker/codex-runner.js +40 -13
  96. package/dist/worker/codex-runner.js.map +1 -1
  97. package/dist/worker/process-executor.js +149 -20
  98. package/dist/worker/process-executor.js.map +1 -1
  99. package/dist/worker/runner-common.js +29 -0
  100. package/dist/worker/runner-common.js.map +1 -1
  101. package/dist/worker/runner.js +16 -1
  102. package/dist/worker/runner.js.map +1 -1
  103. package/package.json +1 -1
@@ -115,7 +115,7 @@ function resolvePrByBranch(repo, headBranch, baseBranch, execGh) {
115
115
  function fetchPrView(prNumber, repo, execGh) {
116
116
  const raw = execGh([
117
117
  'pr', 'view', String(prNumber), '--repo', repo,
118
- '--json', 'number,url,state,isDraft,baseRefName,headRefName,headRefOid,statusCheckRollup,mergeStateStatus,reviewDecision,mergeable',
118
+ '--json', 'number,url,state,isDraft,baseRefName,headRefName,headRefOid,title,statusCheckRollup,mergeStateStatus,reviewDecision,mergeable',
119
119
  ]);
120
120
  return parseGhJson(raw, `pr view #${prNumber}`);
121
121
  }
@@ -253,6 +253,48 @@ function fetchReviewThreads(repo, prNumber, execGh) {
253
253
  ]);
254
254
  return mapReviewThreads(parseGhJson(raw, `review-threads #${prNumber}`));
255
255
  }
256
+ /** Map the statusCheckRollup contexts GraphQL payload to the SET of check names branch protection
257
+ * marks REQUIRED. The check name matches {@link checkName}'s convention so it joins cleanly against
258
+ * CI failure names: CheckRun → `name`, StatusContext → `context`. */
259
+ function mapRequiredCheckNames(raw) {
260
+ const root = asRecord(raw);
261
+ const repository = asRecord(root?.['repository']);
262
+ const pullRequest = asRecord(repository?.['pullRequest']);
263
+ const rollup = asRecord(pullRequest?.['statusCheckRollup']);
264
+ const contexts = asRecord(rollup?.['contexts']);
265
+ const nodes = Array.isArray(contexts?.['nodes']) ? contexts.nodes : [];
266
+ const required = new Set();
267
+ for (const node of nodes) {
268
+ const ctx = asRecord(node);
269
+ if (!ctx || ctx['isRequired'] !== true)
270
+ continue;
271
+ // CheckRun carries `name`; StatusContext carries `context` — mirror checkName().
272
+ const checkName = asStr(ctx['name']) || asStr(ctx['context']);
273
+ if (checkName)
274
+ required.add(checkName);
275
+ }
276
+ return required;
277
+ }
278
+ /**
279
+ * fetchRequiredCheckNames — the SET of check names branch protection marks REQUIRED for this PR.
280
+ *
281
+ * `gh pr view --json statusCheckRollup` (fetchPrView) does NOT carry isRequired, so this issues a
282
+ * dedicated GraphQL read mirroring {@link fetchReviewThreads}. `isRequired` lives on
283
+ * StatusCheckRollupContext and needs the `pullRequestNumber:` arg. Used by pollPr to limit the
284
+ * `ci_changes` verdict to required-check failures (advisory checks — Sonar/CodeRabbit — must not
285
+ * trigger a rework loop). Throws on a gh error so callers can apply the fail-safe fallback.
286
+ */
287
+ export function fetchRequiredCheckNames(repo, prNumber, execGh) {
288
+ const { owner, name } = splitRepo(repo);
289
+ const raw = execGh([
290
+ 'api', 'graphql',
291
+ '-f', 'query=query($owner:String!,$name:String!,$number:Int!){repository(owner:$owner,name:$name){pullRequest(number:$number){statusCheckRollup{contexts(first:100){nodes{__typename ... on CheckRun{name isRequired(pullRequestNumber:$number)} ... on StatusContext{context isRequired(pullRequestNumber:$number)}}}}}}}',
292
+ '-f', `owner=${owner}`,
293
+ '-f', `name=${name}`,
294
+ '-F', `number=${prNumber}`,
295
+ ]);
296
+ return mapRequiredCheckNames(parseGhJson(raw, `required-checks #${prNumber}`));
297
+ }
256
298
  function collectReviewThreads(input, prNumber, execGh) {
257
299
  const threads = input.includeReviewThreads === false ? [] : fetchReviewThreads(input.repo, prNumber, execGh);
258
300
  const unresolved = threads.filter((thread) => !thread.isResolved && !thread.isOutdated);
@@ -264,7 +306,7 @@ function collectReviewThreads(input, prNumber, execGh) {
264
306
  }
265
307
  function fetchComments(input, prNumber, execGh) {
266
308
  if (input.includeComments === false) {
267
- return { human_reviews: [], human_comments: [], bot_comments: [] };
309
+ return { human_reviews: [], human_comments: [], bot_comments: [], coderabbit_reviews: [] };
268
310
  }
269
311
  const reviewsRaw = execGh(['api', `repos/${input.repo}/pulls/${prNumber}/reviews`]);
270
312
  const reviews = parseGhJson(reviewsRaw, `reviews #${prNumber}`);
@@ -273,36 +315,149 @@ function fetchComments(input, prNumber, execGh) {
273
315
  const issueCommentsRaw = execGh(['api', `repos/${input.repo}/issues/${prNumber}/comments`]);
274
316
  const issueComments = parseGhJson(issueCommentsRaw, `issue-comments #${prNumber}`);
275
317
  const allComments = [...reviewComments, ...issueComments];
318
+ // reviews come back chronological; keep only the latest per author so a stale review never
319
+ // overrides a newer one. Mirror this for CodeRabbit (a Bot author, hence excluded from the human
320
+ // map) — otherwise a stale actionable CodeRabbit body keeps a PR stuck even after a newer clean
321
+ // review (#142 Finding #2).
276
322
  const latestHumanReviewByAuthor = new Map();
323
+ const latestCodeRabbitReviewByAuthor = new Map();
277
324
  for (const review of reviews) {
278
- if (!review.user || isBot(review.user))
325
+ if (!review.user)
326
+ continue;
327
+ if (isBot(review.user)) {
328
+ if (isCodeRabbit(review.user))
329
+ latestCodeRabbitReviewByAuthor.set(review.user.login, review);
279
330
  continue;
331
+ }
280
332
  latestHumanReviewByAuthor.set(review.user.login, review);
281
333
  }
282
334
  return {
283
335
  human_reviews: [...latestHumanReviewByAuthor.values()],
284
336
  human_comments: allComments.filter((comment) => !isBot(comment.user)),
285
337
  bot_comments: allComments.filter((comment) => isBot(comment.user)),
338
+ // CodeRabbit review bodies are excluded from human_reviews above (they are Bot-authored). They
339
+ // carry actionable findings as a top-level review body, so keep them for buildFeedback (#142).
340
+ coderabbit_reviews: [...latestCodeRabbitReviewByAuthor.values()],
286
341
  };
287
342
  }
343
+ function isCodeRabbit(user) {
344
+ return (user?.login.toLowerCase() ?? '').includes('coderabbit');
345
+ }
346
+ /** Classify a single CodeRabbit body into a non-actionable provider-WAIT reason (or '' for none).
347
+ * Single source of truth shared by codeRabbitCommentReason (drives providerWait) and
348
+ * isCodeRabbitActionable (so a provider-wait body is never ALSO emitted as a developerFix — #144). */
349
+ function codeRabbitProviderWaitReason(rawBody) {
350
+ const body = rawBody.toLowerCase();
351
+ if (/rate.limit|provider.limit|quota|capacity|temporar/.test(body) && /did not start|could not start|not start|unable to start|paused/.test(body)) {
352
+ return 'provider_limit';
353
+ }
354
+ if (/review in progress|reviewing|processing/.test(body))
355
+ return 'review_in_progress';
356
+ if (/skipped|did not review|no files to review/.test(body))
357
+ return 'skipped';
358
+ if (/no actionable comments|no issues found|looks good/.test(body))
359
+ return 'no_actionable_comments';
360
+ return '';
361
+ }
288
362
  function codeRabbitCommentReason(comments) {
289
363
  for (const comment of comments) {
290
- const login = comment.user?.login.toLowerCase() ?? '';
291
- if (!login.includes('coderabbit'))
364
+ if (!isCodeRabbit(comment.user))
292
365
  continue;
293
- const body = comment.body.toLowerCase();
294
- if (/rate.limit|provider.limit|quota|capacity|temporar/.test(body) && /did not start|could not start|not start|unable to start|paused/.test(body)) {
295
- return 'provider_limit';
296
- }
297
- if (/review in progress|reviewing|processing/.test(body))
298
- return 'review_in_progress';
299
- if (/skipped|did not review|no files to review/.test(body))
300
- return 'skipped';
301
- if (/no actionable comments|no issues found|looks good/.test(body))
302
- return 'no_actionable_comments';
366
+ const reason = codeRabbitProviderWaitReason(comment.body);
367
+ if (reason)
368
+ return reason;
303
369
  }
304
370
  return '';
305
371
  }
372
+ /** Best-effort extraction of a "retry in N <unit>" hint from a bot comment body, e.g.
373
+ * "retry in 10 min". Returns null when no hint is present — the caller must keep the
374
+ * raw evidence either way. Case-insensitive and intentionally lenient. */
375
+ function extractResumeHint(comments) {
376
+ for (const comment of comments) {
377
+ if (!isCodeRabbit(comment.user))
378
+ continue;
379
+ const match = comment.body.match(/\/retry[^.]*?(\d+)\s*(min|minute|hour|sec)/i);
380
+ if (match) {
381
+ const unit = match[2].toLowerCase().startsWith('min') ? 'min' : match[2].toLowerCase().startsWith('hour') ? 'hour' : 'sec';
382
+ return `retry in ${match[1]} ${unit}`;
383
+ }
384
+ }
385
+ return null;
386
+ }
387
+ // CodeRabbit's own structured markers for a finding that still needs developer action. A review
388
+ // body or comment carrying any of these is an actionable blocker — UNLESS it is one of the
389
+ // non-actionable states classified by codeRabbitCommentReason (provider_limit / review_in_progress
390
+ // / skipped / no_actionable_comments), or it has been addressed/resolved/outdated.
391
+ const CODERABBIT_FINDING_MARKERS = [
392
+ /⚠️\s*potential issue/i,
393
+ /🛠️\s*refactor suggestion/i,
394
+ /⚠️\s*outside diff range/i,
395
+ /🧹\s*nitpick/i,
396
+ ];
397
+ // "Actionable comments posted: N" — the count CodeRabbit prints atop a review summary. N>0 means
398
+ // the review found work; N=0 (handled as no_actionable_comments) is explicitly NOT a blocker.
399
+ const CODERABBIT_ACTIONABLE_COUNT_RE = /actionable comments posted:\s*(\d+)/i;
400
+ // A finding CodeRabbit reports as already handled. Anchored to CodeRabbit's own addressed-markers
401
+ // rather than bare words so a live finding whose prose merely mentions e.g. "remove these outdated
402
+ // comments" or "unresolved promise" survives. Such evidence is stale and must NOT remain a blocker.
403
+ const CODERABBIT_ADDRESSED_RE = /✅\s*addressed|\baddressed in\b|marked as resolved|now outdated|marked (?:as )?outdated/i;
404
+ /** True when a CodeRabbit review-body / comment text reports a finding that still needs developer
405
+ * action. Conservative — returns false (item dropped, not kept as a blocker) when the body is:
406
+ * an explicit `Actionable comments posted: 0`; a non-actionable provider-WAIT state (so a wait is
407
+ * never ALSO a developerFix — #144); or carries an addressed/resolved/outdated marker. */
408
+ function isCodeRabbitActionable(body) {
409
+ const countMatch = body.match(CODERABBIT_ACTIONABLE_COUNT_RE);
410
+ if (countMatch && Number(countMatch[1]) === 0)
411
+ return false;
412
+ if (codeRabbitProviderWaitReason(body) !== '')
413
+ return false;
414
+ if (CODERABBIT_ADDRESSED_RE.test(body))
415
+ return false;
416
+ if (countMatch && Number(countMatch[1]) > 0)
417
+ return true;
418
+ return CODERABBIT_FINDING_MARKERS.some((marker) => marker.test(body));
419
+ }
420
+ /** Actionable CodeRabbit feedback that arrives as a top-level review body or bot comment rather
421
+ * than a resolvable GraphQL review thread. `fetchComments` already retrieves this data (bot reviews
422
+ * and bot comments) but buildFeedback never routed it to developerFixes; an actual finding therefore
423
+ * vanished (#142). Review bodies come from the `reviews` REST call independent of includeReviewThreads,
424
+ * so this also covers the poll path. The non-actionable provider states (provider_limit /
425
+ * review_in_progress / skipped / no_actionable_comments) stay with providerWait — they are excluded
426
+ * here so the WAIT and FIXES buckets never collapse (#144).
427
+ *
428
+ * `threadLocations` carries the path:line of unresolved review threads already emitted as the
429
+ * richer `review_thread` developerFix; a bot comment at the same location is the SAME inline
430
+ * finding (the thread's first comment), so it is skipped to avoid emitting it twice (#142
431
+ * Finding #1). Only non-empty locations dedup — a comment with no path:line is never matched. */
432
+ function codeRabbitActionableFeedback(reviews, botComments, threadLocations = new Set()) {
433
+ const items = [];
434
+ for (const review of reviews) {
435
+ if (!isCodeRabbit(review.user) || !isCodeRabbitActionable(review.body))
436
+ continue;
437
+ items.push({
438
+ source: 'coderabbit_review_body',
439
+ summary: compactBody(review.body),
440
+ author: review.user?.login ?? '',
441
+ location: '',
442
+ evidence: compactBody(review.body),
443
+ });
444
+ }
445
+ for (const comment of botComments) {
446
+ if (!isCodeRabbit(comment.user) || !isCodeRabbitActionable(comment.body))
447
+ continue;
448
+ const location = locationOf(comment);
449
+ if (location && threadLocations.has(location))
450
+ continue;
451
+ items.push({
452
+ source: 'coderabbit_comment',
453
+ summary: compactBody(comment.body),
454
+ author: comment.user?.login ?? '',
455
+ location,
456
+ evidence: compactBody(comment.body),
457
+ });
458
+ }
459
+ return items;
460
+ }
306
461
  function compactCheckLists(checks) {
307
462
  const terminal = [];
308
463
  const pending = [];
@@ -359,11 +514,63 @@ function locationOf(item) {
359
514
  const path = item.path ?? item.component ?? '';
360
515
  return item.line ? `${path}:${item.line}` : path;
361
516
  }
362
- function providerWaitFeedback(state) {
517
+ function providerWaitFeedback(state, botComments) {
363
518
  const codeRabbit = state.codeRabbit;
364
519
  if (codeRabbit?.state !== 'waiting')
365
520
  return [];
366
- return [{ provider: 'CodeRabbit', reason: codeRabbit.reason, evidence: codeRabbit.evidence ?? codeRabbit.statusContext }];
521
+ // This function is reached on the TERMINAL path (collectPrReadiness only calls buildFeedback
522
+ // after the ci.pending gate). By then any CodeRabbit check is terminal, so a rate-limit /
523
+ // review-in-progress BOT COMMENT is necessarily stale relative to the current green head —
524
+ // it is informational, NOT a readiness blocker. A genuinely-pending CodeRabbit check is
525
+ // caught earlier by the pending gate and surfaced as a blocking providerWait (see
526
+ // buildWaitingReadiness), so the two cases are distinguishable via `blocking`.
527
+ return [{
528
+ provider: 'CodeRabbit',
529
+ reason: codeRabbit.reason,
530
+ evidence: codeRabbit.evidence ?? codeRabbit.statusContext,
531
+ blocking: false,
532
+ nature: 'informational',
533
+ resumeAfter: extractResumeHint(botComments),
534
+ }];
535
+ }
536
+ function escapeRegExp(value) {
537
+ return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
538
+ }
539
+ function issueRefTitleTag(issueRef, repo) {
540
+ return issueRef.repo.toLowerCase() === repo.toLowerCase()
541
+ ? `#${issueRef.number}`
542
+ : `${issueRef.repo}#${issueRef.number}`;
543
+ }
544
+ function hasIssueBranchToken(head, issueRef) {
545
+ return new RegExp(`(?:^|[/-])issue-${issueRef.number}(?:-|$)`).test(head ?? '');
546
+ }
547
+ function hasIssueTitleToken(title, issueRef, repo) {
548
+ const boundaryBefore = String.raw `(?:^|[\s([{"'])`;
549
+ const boundaryAfter = String.raw `(?=$|[^A-Za-z0-9_-])`;
550
+ const qualified = new RegExp(`${boundaryBefore}${escapeRegExp(`${issueRef.repo}#${issueRef.number}`)}${boundaryAfter}`, 'i');
551
+ if (qualified.test(title ?? ''))
552
+ return true;
553
+ if (issueRef.repo.toLowerCase() !== repo.toLowerCase())
554
+ return false;
555
+ return new RegExp(`${boundaryBefore}#${issueRef.number}${boundaryAfter}`).test(title ?? '');
556
+ }
557
+ function issueLinkageFeedback(issueRef, repo, pr) {
558
+ if (!issueRef)
559
+ return [];
560
+ const issueBranch = `issue-${issueRef.number}`;
561
+ const issueTag = issueRefTitleTag(issueRef, repo);
562
+ const missing = [];
563
+ if (!hasIssueBranchToken(pr.head, issueRef))
564
+ missing.push(`branch missing ${issueBranch}`);
565
+ if (!hasIssueTitleToken(pr.title, issueRef, repo))
566
+ missing.push(`title missing ${issueTag}`);
567
+ if (missing.length === 0)
568
+ return [];
569
+ return [{
570
+ source: 'issue_ref_linkage',
571
+ summary: `PR is missing issueRef linkage for ${issueTag}`,
572
+ evidence: `${missing.join('; ')} (head=${pr.head ?? ''}, title=${pr.title ?? ''})`,
573
+ }];
367
574
  }
368
575
  function buildFeedback(input) {
369
576
  const developerFixes = [
@@ -397,6 +604,12 @@ function buildFeedback(input) {
397
604
  location: locationOf(thread),
398
605
  evidence: thread.url ?? thread.id,
399
606
  })),
607
+ // Actionable CodeRabbit feedback delivered as a review body / bot comment rather than a
608
+ // resolvable review thread (#142). Appended after review_thread items so the thread-aware
609
+ // classification keeps producing the leading developerFix where threads are present. Pass the
610
+ // thread path:line locations so a bot comment for a thread already emitted above is not
611
+ // emitted a second time (#142 Finding #1).
612
+ ...codeRabbitActionableFeedback(input.coderabbitReviews ?? [], input.botComments, new Set(input.reviewThreads.items.map((thread) => locationOf(thread)).filter((loc) => loc !== ''))),
400
613
  ];
401
614
  const reviewerQuestions = input.humanComments
402
615
  .filter((comment) => comment.body.includes('?'))
@@ -406,12 +619,13 @@ function buildFeedback(input) {
406
619
  author: comment.user?.login ?? '',
407
620
  location: locationOf(comment),
408
621
  }));
409
- const providerWait = providerWaitFeedback(input.providerState);
622
+ const providerWait = providerWaitFeedback(input.providerState, input.botComments);
410
623
  const humanDecisions = [
411
624
  ...(input.reviewDecision && input.reviewDecision !== 'APPROVED'
412
625
  ? [{ source: 'github_review_decision', summary: `Review decision is ${input.reviewDecision}` }]
413
626
  : []),
414
627
  ...(input.sonar.unavailable ? [{ source: 'sonar', summary: 'Sonar was configured but unavailable.' }] : []),
628
+ ...issueLinkageFeedback(input.issueRef, input.repo, input.pr ?? {}),
415
629
  ];
416
630
  const ignoredNoise = input.botComments
417
631
  .filter((comment) => !comment.user?.login.toLowerCase().includes('coderabbit'))
@@ -431,6 +645,7 @@ function emptyPr(prNumber, state = '') {
431
645
  base: '',
432
646
  head: '',
433
647
  headSha: '',
648
+ title: '',
434
649
  mergeState: '',
435
650
  };
436
651
  }
@@ -443,6 +658,7 @@ function prFromView(prNumber, view) {
443
658
  base: view?.baseRefName ?? '',
444
659
  head: view?.headRefName ?? '',
445
660
  headSha: view?.headRefOid ?? '',
661
+ title: view?.title ?? '',
446
662
  mergeState: view?.mergeStateStatus ?? '',
447
663
  };
448
664
  }
@@ -465,6 +681,8 @@ function buildEmptyFeedback(input) {
465
681
  humanReviews: [],
466
682
  humanComments: [],
467
683
  botComments: [],
684
+ issueRef: input.issueRef,
685
+ repo: '',
468
686
  });
469
687
  }
470
688
  function buildMergedReadiness(input, resolved) {
@@ -481,7 +699,8 @@ function buildMergedReadiness(input, resolved) {
481
699
  nextAction: 'none',
482
700
  evidence: [`PR #${resolved.prNumber} is merged.`],
483
701
  feedback: buildEmptyFeedback({ sonarConfigured, includeReviewThreads }),
484
- ciSummary: emptyCiSummary(true),
702
+ ciSummary: { ...emptyCiSummary(true), ...(input.issueRef ? { issueRef: input.issueRef } : {}) },
703
+ ...(input.issueRef ? { issueRef: input.issueRef } : {}),
485
704
  };
486
705
  }
487
706
  function buildNeedsHumanReadiness(input, resolved) {
@@ -499,47 +718,83 @@ function buildNeedsHumanReadiness(input, resolved) {
499
718
  nextAction: 'human_decision',
500
719
  evidence: [resolved.lesson],
501
720
  feedback: { ...feedback, humanDecisions: [{ source: 'pr_resolution', summary: resolved.lesson }] },
502
- ciSummary: emptyCiSummary(false),
721
+ ciSummary: { ...emptyCiSummary(false), ...(input.issueRef ? { issueRef: input.issueRef } : {}) },
722
+ ...(input.issueRef ? { issueRef: input.issueRef } : {}),
503
723
  };
504
724
  }
505
725
  function buildWaitingReadiness(input) {
726
+ // The pending path previously left providerState empty and built providerWait from it (also
727
+ // empty), so a genuinely-pending CodeRabbit check was never explained as a provider wait.
728
+ // Synthesize a blocking providerState/providerWait from the pending check names so the output
729
+ // surfaces WHY the wait is blocking — mirroring the informational entry emitted on the
730
+ // terminal path. This does not change the verdict (still waiting/watcher_wait).
731
+ const pendingCodeRabbit = input.ci.pendingNames.find((name) => name.toLowerCase().includes('coderabbit'));
732
+ const pendingProviderState = pendingCodeRabbit
733
+ ? {
734
+ codeRabbit: {
735
+ state: 'waiting',
736
+ reason: 'check_pending',
737
+ statusContext: 'IN_PROGRESS',
738
+ evidence: 'CodeRabbit check is still pending for the current head — waiting is blocking.',
739
+ },
740
+ }
741
+ : {};
506
742
  const feedback = buildFeedback({
507
743
  checks: input.checkLists,
508
- providerState: {},
744
+ providerState: pendingProviderState,
509
745
  sonar: emptySonar(input.sonarConfigured),
510
746
  reviewDecision: input.prView.reviewDecision ?? '',
511
747
  reviewThreads: input.reviewThreads,
512
748
  humanReviews: [],
513
749
  humanComments: [],
514
750
  botComments: [],
751
+ issueRef: input.issueRef,
752
+ repo: input.repo,
753
+ pr: { head: input.prView.headRefName, title: input.prView.title },
515
754
  });
755
+ // buildFeedback -> providerWaitFeedback classifies from providerState, but on the pending path
756
+ // the wait IS blocking (a live check), so emit a blocking item in place of the synthesized one.
757
+ const providerWait = pendingCodeRabbit
758
+ ? [{
759
+ provider: 'CodeRabbit',
760
+ reason: 'check_pending',
761
+ evidence: 'CodeRabbit check is still pending for the current head — waiting is blocking.',
762
+ blocking: true,
763
+ nature: 'blocking',
764
+ resumeAfter: null,
765
+ }]
766
+ : feedback.providerWait;
516
767
  return {
517
768
  verdict: 'waiting',
518
769
  pr: prFromView(input.prNumber, input.prView),
519
770
  checks: input.checkLists,
520
771
  reviewDecision: input.prView.reviewDecision ?? '',
521
772
  reviewThreads: input.reviewThreads,
522
- providerState: {},
773
+ providerState: pendingProviderState,
523
774
  sonar: emptySonar(input.sonarConfigured),
524
775
  nextAction: 'watcher_wait',
525
776
  evidence: input.evidence,
526
- feedback,
777
+ feedback: { ...feedback, providerWait },
527
778
  ciSummary: {
528
779
  ci_passed: false,
529
780
  checks: input.ci.checks,
530
781
  ...(input.isDraft ? { isDraft: true } : {}),
782
+ ...(input.issueRef ? { issueRef: input.issueRef } : {}),
531
783
  sonar_issues: [],
532
784
  sonar_hotspots_to_review: [],
533
785
  human_reviews: [],
534
786
  human_comments: [],
535
787
  bot_comments: [],
536
788
  },
789
+ ...(input.issueRef ? { issueRef: input.issueRef } : {}),
537
790
  };
538
791
  }
539
792
  function readinessAction(input) {
540
- if (input.providers.codeRabbit?.reason === 'provider_limit' || input.providers.codeRabbit?.reason === 'review_in_progress') {
541
- return { verdict: 'waiting', nextAction: 'watcher_wait' };
542
- }
793
+ // NOTE: a CodeRabbit provider_limit / review_in_progress bot comment is intentionally NOT a
794
+ // `waiting` verdict here. This function is only reached after the ci.pending gate (see
795
+ // collectPrReadiness), so every check is already terminal — such a comment is stale relative
796
+ // to the current green head and is surfaced as an informational providerWait instead. A
797
+ // genuinely-pending CodeRabbit check is handled by the pending gate before this point.
543
798
  if (!input.ci.ci_passed || input.feedback.developerFixes.length > 0) {
544
799
  return { verdict: 'needs_work', nextAction: 'developer_fix' };
545
800
  }
@@ -568,24 +823,28 @@ export async function collectPrReadiness(input, execGh = defaultExecGh, fetchSon
568
823
  const sonarConfigured = Boolean(input.sonarProject);
569
824
  if (prView.isDraft === true) {
570
825
  return buildWaitingReadiness({
826
+ repo: input.repo,
571
827
  prNumber,
572
828
  prView,
573
829
  checkLists,
574
830
  ci,
575
831
  reviewThreads,
576
832
  sonarConfigured,
833
+ issueRef: input.issueRef,
577
834
  evidence: [`PR #${prNumber} is still draft.`],
578
835
  isDraft: true,
579
836
  });
580
837
  }
581
838
  if (ci.pending) {
582
839
  return buildWaitingReadiness({
840
+ repo: input.repo,
583
841
  prNumber,
584
842
  prView,
585
843
  checkLists,
586
844
  ci,
587
845
  reviewThreads,
588
846
  sonarConfigured,
847
+ issueRef: input.issueRef,
589
848
  evidence: ci.pendingNames.length > 0 ? [`Pending checks: ${ci.pendingNames.join(', ')}`] : ['No check rollup entries are registered yet.'],
590
849
  });
591
850
  }
@@ -609,6 +868,10 @@ export async function collectPrReadiness(input, execGh = defaultExecGh, fetchSon
609
868
  humanReviews: comments.human_reviews,
610
869
  humanComments: comments.human_comments,
611
870
  botComments: comments.bot_comments,
871
+ coderabbitReviews: comments.coderabbit_reviews,
872
+ issueRef: input.issueRef,
873
+ repo: input.repo,
874
+ pr: { head: prView.headRefName, title: prView.title },
612
875
  });
613
876
  const ciSummary = {
614
877
  ci_passed: ci.ci_passed,
@@ -616,6 +879,7 @@ export async function collectPrReadiness(input, execGh = defaultExecGh, fetchSon
616
879
  mergeStateStatus: prView.mergeStateStatus,
617
880
  reviewDecision: prView.reviewDecision,
618
881
  mergeable: prView.mergeable,
882
+ ...(input.issueRef ? { issueRef: input.issueRef } : {}),
619
883
  sonar_issues: sonar.issues,
620
884
  sonar_hotspots_to_review: sonar.hotspots,
621
885
  human_reviews: comments.human_reviews,
@@ -623,7 +887,8 @@ export async function collectPrReadiness(input, execGh = defaultExecGh, fetchSon
623
887
  bot_comments: comments.bot_comments,
624
888
  ...(sonar.unavailable ? { sonar_unavailable: true } : {}),
625
889
  };
626
- const { verdict, nextAction } = readinessAction({ ci, feedback, providers });
890
+ const { verdict, nextAction } = readinessAction({ ci, feedback });
891
+ const hasInformationalProviderWait = feedback.providerWait.some((item) => item.blocking === false);
627
892
  return {
628
893
  verdict,
629
894
  pr: prFromView(prNumber, prView),
@@ -636,10 +901,15 @@ export async function collectPrReadiness(input, execGh = defaultExecGh, fetchSon
636
901
  evidence: [
637
902
  `PR #${prNumber} state=${prView.state ?? 'unknown'} draft=${Boolean(prView.isDraft)}`,
638
903
  `checks pass=${checkLists.pass.length} fail=${checkLists.fail.length} pending=${checkLists.pending.length}`,
639
- ...(providers.codeRabbit?.reason === 'provider_limit' ? ['CodeRabbit provider/rate limit comment overrides green status.'] : []),
904
+ ...(input.issueRef ? [`issueRef ${issueRefTitleTag(input.issueRef, input.repo)} expected in branch and title`] : []),
905
+ ...(providers.codeRabbit?.reason === 'provider_limit' ? ['CodeRabbit provider/rate limit comment detected.'] : []),
906
+ ...(verdict === 'ready' && hasInformationalProviderWait
907
+ ? ['Provider wait is informational (stale CodeRabbit rate-limit comment); not blocking readiness.']
908
+ : []),
640
909
  ],
641
910
  feedback,
642
911
  ciSummary,
912
+ ...(input.issueRef ? { issueRef: input.issueRef } : {}),
643
913
  };
644
914
  }
645
915
  export function toReadinessInput(input) {
@@ -649,6 +919,7 @@ export function toReadinessInput(input) {
649
919
  headBranch: input.head_branch,
650
920
  baseBranch: input.base_branch,
651
921
  sonarProject: input.sonar_project,
922
+ issueRef: input.issue_ref,
652
923
  includeComments: true,
653
924
  includeReviewThreads: false,
654
925
  };