Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
-- Add linked PR health fields to Issue (nullable / defaulted, no destructive changes)
ALTER TABLE "Issue"
ADD COLUMN IF NOT EXISTS "linkedPrNumber" INTEGER,
ADD COLUMN IF NOT EXISTS "linkedPrUrl" TEXT,
ADD COLUMN IF NOT EXISTS "linkedPrNeedsFollowup" BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN IF NOT EXISTS "linkedPrFollowupReasons" TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[],
ADD COLUMN IF NOT EXISTS "linkedPrReviewDecision" TEXT,
ADD COLUMN IF NOT EXISTS "linkedPrMergeState" TEXT,
ADD COLUMN IF NOT EXISTS "linkedPrHealthCheckedAt" TIMESTAMP(3);

CREATE INDEX IF NOT EXISTS "Issue_linkedPrNeedsFollowup_idx" ON "Issue"("linkedPrNeedsFollowup");
10 changes: 10 additions & 0 deletions prisma/schema.prisma
Original file line numberDiff line numberDiff line change
Expand Up@@ -57,9 +57,19 @@ model Issue {
needsInfoReason String? @db.Text
nextGroomingAction String? @db.Text

// Linked PR health (persisted by reconcile, refreshable on demand)
linkedPrNumber Int?
linkedPrUrl String?
linkedPrNeedsFollowup Boolean @default(false)
linkedPrFollowupReasons String[] @default([])
linkedPrReviewDecision String?
linkedPrMergeState String?
linkedPrHealthCheckedAt DateTime?

@@unique([repositoryId, number])
@@index([state])
@@index([labels])
@@index([linkedPrNeedsFollowup])
}

enum PrFixLane {
Expand Down
16 changes: 16 additions & 0 deletions src/app/api/agents/[agentName]/queue/route.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -28,6 +28,13 @@ export async function GET(request: Request, { params }: { params: Promise<{ agen
currentLane: true,
decomposed: true,
repository: { select: { fullName: true } },
linkedPrNumber: true,
linkedPrUrl: true,
linkedPrNeedsFollowup: true,
linkedPrFollowupReasons: true,
linkedPrReviewDecision: true,
linkedPrMergeState: true,
linkedPrHealthCheckedAt: true,
},
});

Expand All@@ -51,6 +58,15 @@ export async function GET(request: Request, { params }: { params: Promise<{ agen
lane: issue.currentLane ?? undefined,
issueId: issue.id,
repoFullName: issue.repository.fullName,
linkedPrHealth: {
number: issue.linkedPrNumber,
url: issue.linkedPrUrl,
needsFollowup: issue.linkedPrNeedsFollowup,
followupReasons: issue.linkedPrFollowupReasons,
reviewDecision: issue.linkedPrReviewDecision,
mergeState: issue.linkedPrMergeState,
checkedAt: issue.linkedPrHealthCheckedAt?.toISOString() ?? null,
},
})),
agentName,
{
Expand Down
58 changes: 58 additions & 0 deletions src/app/api/issues/[issueId]/pr-health/refresh/route.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { authorizeRequest } from "@/lib/auth";
import { fetchPullRequests, fetchLinkedPrHealthInput } from "@/lib/github";
import { computeLinkedPrHealth, toPersistedLinkedPrHealth } from "@/lib/linked-pr-health";

/**
* POST /api/issues/[issueId]/pr-health/refresh
*
* Recompute and persist linked PR health for a single issue on demand. The
* periodic reconcile job keeps this fresh in the background (Option B); this
* endpoint lets an operator or worker force an immediate refresh for one issue.
*
* Finds the issue's linked open PR by branch-name convention (issue-<number>),
* computes the health snapshot, and writes it to the Issue row. If no linked
* open PR exists, any stale snapshot is cleared.
*/
export async function POST(request: NextRequest, context: { params: Promise<{ issueId: string }> }) {
if (!(await authorizeRequest(request)).authorized) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}

try {
const { issueId } = await context.params;

const issue = await prisma.issue.findUnique({
where: { id: issueId },
include: { repository: true },
});

if (!issue) {
return NextResponse.json({ error: "Issue not found in local cache" }, { status: 404 });
}

// Find the linked open PR by branch-name convention (matches reconcile).
const openPrs = await fetchPullRequests(issue.repository.fullName, 100);
const linkedPr = openPrs.find((pr) => {
const match = (pr.head?.ref ?? "").match(/issue[-_/]?(\d+)/i);
return match ? parseInt(match[1], 10) === issue.number : false;
});

const health = linkedPr
? computeLinkedPrHealth(await fetchLinkedPrHealthInput(issue.repository.fullName, linkedPr))
: null;

const persisted = toPersistedLinkedPrHealth(health);

await prisma.issue.update({
where: { id: issueId },
data: persisted,
});

return NextResponse.json({ success: true, ...persisted });
} catch (error) {
console.error("Linked PR health refresh failed:", error);
return NextResponse.json({ error: "Failed to refresh linked PR health" }, { status: 500 });
}
}
38 changes: 30 additions & 8 deletions src/app/api/issues/reconcile/route.ts
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { fetchPullRequests, fetchClosedPullRequests, fetchIssues, fetchPullRequestHealthSignals } from "@/lib/github";
import { fetchPullRequests, fetchClosedPullRequests, fetchIssues, fetchLinkedPrHealthInput } from "@/lib/github";
import { getSyncRepos } from "@/lib/config";
import {
extractFixingIssueNumbers,
Expand All@@ -9,6 +9,7 @@ import {
classifyLaneByHeuristics,
executeActions,
} from "@/lib/issue-reconciliation";
import { computeLinkedPrHealth, toPersistedLinkedPrHealth, type LinkedPrHealth } from "@/lib/linked-pr-health";
import { authorizeRequest } from "@/lib/auth";

/**
Expand DownExpand Up@@ -112,13 +113,16 @@ export async function POST(request: Request) {
}
}

// The PR list endpoint omits reviewDecision and mergeStateStatus, so
// enrich each issue-linked open PR with a per-PR health fetch. Without
// this, checkPrHealth always sees null signals and reports "healthy".
for (const pr of openPrToIssue.values()) {
const signals = await fetchPullRequestHealthSignals(repo.fullName, pr.number);
pr.reviewDecision = signals.reviewDecision;
pr.mergeStateStatus = signals.mergeStateStatus;
// The PR list endpoint omits reviewDecision/mergeStateStatus/checks, so
// fetch a full health input per issue-linked open PR. This both feeds
// checkPrHealth (which needs review + merge signals) and produces the
// linked-PR-health snapshot we persist on the issue below.
const linkedPrHealthByIssue = new Map<number, LinkedPrHealth | null>();
for (const [issueNum, pr] of openPrToIssue) {
const input = await fetchLinkedPrHealthInput(repo.fullName, pr);
pr.reviewDecision = input.reviewDecision;
pr.mergeStateStatus = input.mergeStateStatus;
linkedPrHealthByIssue.set(issueNum, computeLinkedPrHealth(input));
}

// Fetch all issues for this repo
Expand DownExpand Up@@ -196,6 +200,24 @@ export async function POST(request: Request) {
totalLaneClassified++;
}

// Persist linked PR health. Write when the issue has a linked open PR;
// otherwise clear any stale snapshot left from a PR that has since
// closed or merged. Skip the write when there's nothing to clear.
if (existingIssue) {
const hasLinkedPr = openPrToIssue.has(issue.number);
if (hasLinkedPr) {
await prisma.issue.update({
where: { repositoryId_number: { repositoryId: repo.id, number: issue.number } },
data: toPersistedLinkedPrHealth(linkedPrHealthByIssue.get(issue.number) ?? null),
});
} else if (existingIssue.linkedPrNumber !== null) {
await prisma.issue.update({
where: { repositoryId_number: { repositoryId: repo.id, number: issue.number } },
data: toPersistedLinkedPrHealth(null),
});
}
}

totalIssuesReconciled++;
}
} catch (error) {
Expand Down
27 changes: 27 additions & 0 deletions src/components/issue-card.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -14,6 +14,20 @@ interface IssueCardProps {
onIssueUpdate?: (updatedIssue: Issue) => void;
}

/** Turn a linked-PR-health follow-up reason code into human-readable text. */
function formatFollowupReason(reason: string): string {
switch (reason) {
case "changes_requested":
return "changes requested";
case "failing_checks":
return "failing checks";
case "merge_conflict":
return "merge conflict";
default:
return reason.startsWith("merge_state_") ? reason.replace("merge_state_", "merge state: ") : reason;
}
}

export function IssueCard({ issue, isDragging, onIssueUpdate }: IssueCardProps) {
const {
attributes,
Expand DownExpand Up@@ -530,6 +544,19 @@ export function IssueCard({ issue, isDragging, onIssueUpdate }: IssueCardProps)
{priorityLabel.replace("priority/", "p")}
</span>
)}
{issue.linkedPrNeedsFollowup && (
<a
href={issue.linkedPrUrl ?? issue.url}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()}
className="inline-flex items-center gap-1 px-1.5 py-0.5 text-xs rounded bg-amber-100 text-amber-700 hover:bg-amber-200"
title={(issue.linkedPrFollowupReasons ?? []).map(formatFollowupReason).join(", ")}
>
<GitPullRequest className="h-3 w-3" />
PR needs work
</a>
)}
</div>
{(issue.notReadyReason || issue.blockedReason || issue.needsInfoReason || issue.groomingSummary) && (
<div className="mt-2 space-y-1">
Expand Down
17 changes: 17 additions & 0 deletions src/lib/agent-queue.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -12,6 +12,20 @@ const IN_PROGRESS_STATUS: string = "status/in-progress";
const BACKLOG_STATUS: string = "status/backlog";
const READY_STATUS: string = "status/ready";

/**
* Compact linked-PR-health summary carried on queue items, sourced from the
* persisted Issue columns (populated by reconcile / the refresh endpoint).
*/
export interface QueueLinkedPrHealth {
number: number | null;
url: string | null;
needsFollowup: boolean;
followupReasons: string[];
reviewDecision: string | null;
mergeState: string | null;
checkedAt: string | null;
}

export interface RankedIssue {
type?: "issue";
number: number;
Expand All@@ -27,6 +41,7 @@ export interface RankedIssue {
issueId?: string;
repoFullName?: string;
claimable?: boolean;
linkedPrHealth?: QueueLinkedPrHealth | null;
}

/**
Expand DownExpand Up@@ -141,6 +156,7 @@ export function buildAgentQueue(
decomposed?: boolean;
issueId?: string;
repoFullName?: string;
linkedPrHealth?: QueueLinkedPrHealth | null;
}>,
agentName: string,
options?: {
Expand DownExpand Up@@ -218,6 +234,7 @@ export function buildAgentQueue(
issueId: item.issueId,
repoFullName: item.repoFullName,
claimable: status !== BACKLOG_STATUS,
linkedPrHealth: item.linkedPrHealth ?? null,
};
});
}
51 changes: 51 additions & 0 deletions src/lib/github.ts
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
import { GitHubIssue } from "@/types";
import type { CheckFailure, PrHealthInput } from "./linked-pr-health";

const GITHUB_API = "https://api.github.com";

Expand DownExpand Up@@ -597,6 +598,56 @@ export async function fetchPullRequestHealthSignals(
return { reviewDecision, mergeStateStatus };
}

/**
* Fetch failing CI check runs for a PR's head ref.
*
* Uses the check-runs endpoint for the head branch. Only completed runs with a
* failure-type conclusion are returned. Transient failures yield an empty list
* rather than throwing, so health computation degrades gracefully.
*/
export async function fetchPullRequestCheckFailures(repoFullName: string, ref: string): Promise<CheckFailure[]> {
const FAILURE_CONCLUSIONS = new Set(["failure", "cancelled", "timed_out", "action_required"]);
try {
const response = await fetch(
`${GITHUB_API}/repos/${repoFullName}/commits/${encodeURIComponent(ref)}/check-runs?per_page=100`,
{ headers: await getHeadersAsync() },
);
if (!response.ok) return [];
const data = (await response.json()) as { check_runs?: Array<{ name?: string; conclusion?: string | null }> };
return (data.check_runs ?? [])
.filter((run) => run.conclusion && FAILURE_CONCLUSIONS.has(run.conclusion.toLowerCase()))
.map((run) => ({ name: run.name ?? "unknown", conclusion: run.conclusion as string }));
} catch {
return [];
}
}

/**
* Assemble a full PrHealthInput for a PR by combining review decision + merge
* state (fetchPullRequestHealthSignals) with failing check runs. This is the
* single source the linked-PR-health feature uses to compute a snapshot, from
* both the reconcile job and the on-demand refresh endpoint.
*/
export async function fetchLinkedPrHealthInput(repoFullName: string, pr: GithubPR): Promise<PrHealthInput> {
const [signals, checkFailures] = await Promise.all([
fetchPullRequestHealthSignals(repoFullName, pr.number),
fetchPullRequestCheckFailures(repoFullName, pr.head?.ref ?? ""),
]);

const state: PrHealthInput["state"] = pr.merged_at ? "merged" : pr.state === "closed" ? "closed" : "open";

return {
url: pr.url,
number: pr.number,
state,
draft: pr.draft,
mergedAt: pr.merged_at,
mergeStateStatus: signals.mergeStateStatus,
reviewDecision: signals.reviewDecision,
checkFailures,
};
}

export interface GithubPackageInfo {
name: string;
package_type: string;
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
-- Add linked PR health fields to Issue (nullable / defaulted, no destructive changes)
ALTER TABLE "Issue"
ADD COLUMN IF NOT EXISTS "linkedPrNumber" INTEGER,
ADD COLUMN IF NOT EXISTS "linkedPrUrl" TEXT,
ADD COLUMN IF NOT EXISTS "linkedPrNeedsFollowup" BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN IF NOT EXISTS "linkedPrFollowupReasons" TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[],
ADD COLUMN IF NOT EXISTS "linkedPrReviewDecision" TEXT,
ADD COLUMN IF NOT EXISTS "linkedPrMergeState" TEXT,
ADD COLUMN IF NOT EXISTS "linkedPrHealthCheckedAt" TIMESTAMP(3);

CREATE INDEX IF NOT EXISTS "Issue_linkedPrNeedsFollowup_idx" ON "Issue"("linkedPrNeedsFollowup");
10 changes: 10 additions & 0 deletions prisma/schema.prisma
Original file line numberDiff line numberDiff line change
Expand Up@@ -57,9 +57,19 @@ model Issue {
needsInfoReason String? @db.Text
nextGroomingAction String? @db.Text

// Linked PR health (persisted by reconcile, refreshable on demand)
linkedPrNumber Int?
linkedPrUrl String?
linkedPrNeedsFollowup Boolean @default(false)
linkedPrFollowupReasons String[] @default([])
linkedPrReviewDecision String?
linkedPrMergeState String?
linkedPrHealthCheckedAt DateTime?

@@unique([repositoryId, number])
@@index([state])
@@index([labels])
@@index([linkedPrNeedsFollowup])
}

enum PrFixLane {
Expand Down
16 changes: 16 additions & 0 deletions src/app/api/agents/[agentName]/queue/route.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -28,6 +28,13 @@ export async function GET(request: Request, { params }: { params: Promise<{ agen
currentLane: true,
decomposed: true,
repository: { select: { fullName: true } },
linkedPrNumber: true,
linkedPrUrl: true,
linkedPrNeedsFollowup: true,
linkedPrFollowupReasons: true,
linkedPrReviewDecision: true,
linkedPrMergeState: true,
linkedPrHealthCheckedAt: true,
},
});

Expand All@@ -51,6 +58,15 @@ export async function GET(request: Request, { params }: { params: Promise<{ agen
lane: issue.currentLane ?? undefined,
issueId: issue.id,
repoFullName: issue.repository.fullName,
linkedPrHealth: {
number: issue.linkedPrNumber,
url: issue.linkedPrUrl,
needsFollowup: issue.linkedPrNeedsFollowup,
followupReasons: issue.linkedPrFollowupReasons,
reviewDecision: issue.linkedPrReviewDecision,
mergeState: issue.linkedPrMergeState,
checkedAt: issue.linkedPrHealthCheckedAt?.toISOString() ?? null,
},
})),
agentName,
{
Expand Down
58 changes: 58 additions & 0 deletions src/app/api/issues/[issueId]/pr-health/refresh/route.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { authorizeRequest } from "@/lib/auth";
import { fetchPullRequests, fetchLinkedPrHealthInput } from "@/lib/github";
import { computeLinkedPrHealth, toPersistedLinkedPrHealth } from "@/lib/linked-pr-health";

/**
* POST /api/issues/[issueId]/pr-health/refresh
*
* Recompute and persist linked PR health for a single issue on demand. The
* periodic reconcile job keeps this fresh in the background (Option B); this
* endpoint lets an operator or worker force an immediate refresh for one issue.
*
* Finds the issue's linked open PR by branch-name convention (issue-<number>),
* computes the health snapshot, and writes it to the Issue row. If no linked
* open PR exists, any stale snapshot is cleared.
*/
export async function POST(request: NextRequest, context: { params: Promise<{ issueId: string }> }) {
if (!(await authorizeRequest(request)).authorized) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}

try {
const { issueId } = await context.params;

const issue = await prisma.issue.findUnique({
where: { id: issueId },
include: { repository: true },
});

if (!issue) {
return NextResponse.json({ error: "Issue not found in local cache" }, { status: 404 });
}

// Find the linked open PR by branch-name convention (matches reconcile).
const openPrs = await fetchPullRequests(issue.repository.fullName, 100);
const linkedPr = openPrs.find((pr) => {
const match = (pr.head?.ref ?? "").match(/issue[-_/]?(\d+)/i);
return match ? parseInt(match[1], 10) === issue.number : false;
});

const health = linkedPr
? computeLinkedPrHealth(await fetchLinkedPrHealthInput(issue.repository.fullName, linkedPr))
: null;

const persisted = toPersistedLinkedPrHealth(health);

await prisma.issue.update({
where: { id: issueId },
data: persisted,
});

return NextResponse.json({ success: true, ...persisted });
} catch (error) {
console.error("Linked PR health refresh failed:", error);
return NextResponse.json({ error: "Failed to refresh linked PR health" }, { status: 500 });
}
}
38 changes: 30 additions & 8 deletions src/app/api/issues/reconcile/route.ts
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { fetchPullRequests, fetchClosedPullRequests, fetchIssues, fetchPullRequestHealthSignals } from "@/lib/github";
import { fetchPullRequests, fetchClosedPullRequests, fetchIssues, fetchLinkedPrHealthInput } from "@/lib/github";
import { getSyncRepos } from "@/lib/config";
import {
extractFixingIssueNumbers,
Expand All@@ -9,6 +9,7 @@ import {
classifyLaneByHeuristics,
executeActions,
} from "@/lib/issue-reconciliation";
import { computeLinkedPrHealth, toPersistedLinkedPrHealth, type LinkedPrHealth } from "@/lib/linked-pr-health";
import { authorizeRequest } from "@/lib/auth";

/**
Expand DownExpand Up@@ -112,13 +113,16 @@ export async function POST(request: Request) {
}
}

// The PR list endpoint omits reviewDecision and mergeStateStatus, so
// enrich each issue-linked open PR with a per-PR health fetch. Without
// this, checkPrHealth always sees null signals and reports "healthy".
for (const pr of openPrToIssue.values()) {
const signals = await fetchPullRequestHealthSignals(repo.fullName, pr.number);
pr.reviewDecision = signals.reviewDecision;
pr.mergeStateStatus = signals.mergeStateStatus;
// The PR list endpoint omits reviewDecision/mergeStateStatus/checks, so
// fetch a full health input per issue-linked open PR. This both feeds
// checkPrHealth (which needs review + merge signals) and produces the
// linked-PR-health snapshot we persist on the issue below.
const linkedPrHealthByIssue = new Map<number, LinkedPrHealth | null>();
for (const [issueNum, pr] of openPrToIssue) {
const input = await fetchLinkedPrHealthInput(repo.fullName, pr);
pr.reviewDecision = input.reviewDecision;
pr.mergeStateStatus = input.mergeStateStatus;
linkedPrHealthByIssue.set(issueNum, computeLinkedPrHealth(input));
}

// Fetch all issues for this repo
Expand DownExpand Up@@ -196,6 +200,24 @@ export async function POST(request: Request) {
totalLaneClassified++;
}

// Persist linked PR health. Write when the issue has a linked open PR;
// otherwise clear any stale snapshot left from a PR that has since
// closed or merged. Skip the write when there's nothing to clear.
if (existingIssue) {
const hasLinkedPr = openPrToIssue.has(issue.number);
if (hasLinkedPr) {
await prisma.issue.update({
where: { repositoryId_number: { repositoryId: repo.id, number: issue.number } },
data: toPersistedLinkedPrHealth(linkedPrHealthByIssue.get(issue.number) ?? null),
});
} else if (existingIssue.linkedPrNumber !== null) {
await prisma.issue.update({
where: { repositoryId_number: { repositoryId: repo.id, number: issue.number } },
data: toPersistedLinkedPrHealth(null),
});
}
}

totalIssuesReconciled++;
}
} catch (error) {
Expand Down
27 changes: 27 additions & 0 deletions src/components/issue-card.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -14,6 +14,20 @@ interface IssueCardProps {
onIssueUpdate?: (updatedIssue: Issue) => void;
}

/** Turn a linked-PR-health follow-up reason code into human-readable text. */
function formatFollowupReason(reason: string): string {
switch (reason) {
case "changes_requested":
return "changes requested";
case "failing_checks":
return "failing checks";
case "merge_conflict":
return "merge conflict";
default:
return reason.startsWith("merge_state_") ? reason.replace("merge_state_", "merge state: ") : reason;
}
}

export function IssueCard({ issue, isDragging, onIssueUpdate }: IssueCardProps) {
const {
attributes,
Expand DownExpand Up@@ -530,6 +544,19 @@ export function IssueCard({ issue, isDragging, onIssueUpdate }: IssueCardProps)
{priorityLabel.replace("priority/", "p")}
</span>
)}
{issue.linkedPrNeedsFollowup && (
<a
href={issue.linkedPrUrl ?? issue.url}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()}
className="inline-flex items-center gap-1 px-1.5 py-0.5 text-xs rounded bg-amber-100 text-amber-700 hover:bg-amber-200"
title={(issue.linkedPrFollowupReasons ?? []).map(formatFollowupReason).join(", ")}
>
<GitPullRequest className="h-3 w-3" />
PR needs work
</a>
)}
</div>
{(issue.notReadyReason || issue.blockedReason || issue.needsInfoReason || issue.groomingSummary) && (
<div className="mt-2 space-y-1">
Expand Down
17 changes: 17 additions & 0 deletions src/lib/agent-queue.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -12,6 +12,20 @@ const IN_PROGRESS_STATUS: string = "status/in-progress";
const BACKLOG_STATUS: string = "status/backlog";
const READY_STATUS: string = "status/ready";

/**
* Compact linked-PR-health summary carried on queue items, sourced from the
* persisted Issue columns (populated by reconcile / the refresh endpoint).
*/
export interface QueueLinkedPrHealth {
number: number | null;
url: string | null;
needsFollowup: boolean;
followupReasons: string[];
reviewDecision: string | null;
mergeState: string | null;
checkedAt: string | null;
}

export interface RankedIssue {
type?: "issue";
number: number;
Expand All@@ -27,6 +41,7 @@ export interface RankedIssue {
issueId?: string;
repoFullName?: string;
claimable?: boolean;
linkedPrHealth?: QueueLinkedPrHealth | null;
}

/**
Expand DownExpand Up@@ -141,6 +156,7 @@ export function buildAgentQueue(
decomposed?: boolean;
issueId?: string;
repoFullName?: string;
linkedPrHealth?: QueueLinkedPrHealth | null;
}>,
agentName: string,
options?: {
Expand DownExpand Up@@ -218,6 +234,7 @@ export function buildAgentQueue(
issueId: item.issueId,
repoFullName: item.repoFullName,
claimable: status !== BACKLOG_STATUS,
linkedPrHealth: item.linkedPrHealth ?? null,
};
});
}
51 changes: 51 additions & 0 deletions src/lib/github.ts
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
import { GitHubIssue } from "@/types";
import type { CheckFailure, PrHealthInput } from "./linked-pr-health";

const GITHUB_API = "https://api.github.com";

Expand DownExpand Up@@ -597,6 +598,56 @@ export async function fetchPullRequestHealthSignals(
return { reviewDecision, mergeStateStatus };
}

/**
* Fetch failing CI check runs for a PR's head ref.
*
* Uses the check-runs endpoint for the head branch. Only completed runs with a
* failure-type conclusion are returned. Transient failures yield an empty list
* rather than throwing, so health computation degrades gracefully.
*/
export async function fetchPullRequestCheckFailures(repoFullName: string, ref: string): Promise<CheckFailure[]> {
const FAILURE_CONCLUSIONS = new Set(["failure", "cancelled", "timed_out", "action_required"]);
try {
const response = await fetch(
`${GITHUB_API}/repos/${repoFullName}/commits/${encodeURIComponent(ref)}/check-runs?per_page=100`,
{ headers: await getHeadersAsync() },
);
if (!response.ok) return [];
const data = (await response.json()) as { check_runs?: Array<{ name?: string; conclusion?: string | null }> };
return (data.check_runs ?? [])
.filter((run) => run.conclusion && FAILURE_CONCLUSIONS.has(run.conclusion.toLowerCase()))
.map((run) => ({ name: run.name ?? "unknown", conclusion: run.conclusion as string }));
} catch {
return [];
}
}

/**
* Assemble a full PrHealthInput for a PR by combining review decision + merge
* state (fetchPullRequestHealthSignals) with failing check runs. This is the
* single source the linked-PR-health feature uses to compute a snapshot, from
* both the reconcile job and the on-demand refresh endpoint.
*/
export async function fetchLinkedPrHealthInput(repoFullName: string, pr: GithubPR): Promise<PrHealthInput> {
const [signals, checkFailures] = await Promise.all([
fetchPullRequestHealthSignals(repoFullName, pr.number),
fetchPullRequestCheckFailures(repoFullName, pr.head?.ref ?? ""),
]);

const state: PrHealthInput["state"] = pr.merged_at ? "merged" : pr.state === "closed" ? "closed" : "open";

return {
url: pr.url,
number: pr.number,
state,
draft: pr.draft,
mergedAt: pr.merged_at,
mergeStateStatus: signals.mergeStateStatus,
reviewDecision: signals.reviewDecision,
checkFailures,
};
}

export interface GithubPackageInfo {
name: string;
package_type: string;
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
-- Add linked PR health fields to Issue (nullable / defaulted, no destructive changes)
ALTER TABLE "Issue"
ADD COLUMN IF NOT EXISTS "linkedPrNumber" INTEGER,
ADD COLUMN IF NOT EXISTS "linkedPrUrl" TEXT,
ADD COLUMN IF NOT EXISTS "linkedPrNeedsFollowup" BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN IF NOT EXISTS "linkedPrFollowupReasons" TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[],
ADD COLUMN IF NOT EXISTS "linkedPrReviewDecision" TEXT,
ADD COLUMN IF NOT EXISTS "linkedPrMergeState" TEXT,
ADD COLUMN IF NOT EXISTS "linkedPrHealthCheckedAt" TIMESTAMP(3);

CREATE INDEX IF NOT EXISTS "Issue_linkedPrNeedsFollowup_idx" ON "Issue"("linkedPrNeedsFollowup");
10 changes: 10 additions & 0 deletions prisma/schema.prisma
Original file line numberDiff line numberDiff line change
Expand Up@@ -57,9 +57,19 @@ model Issue {
needsInfoReason String? @db.Text
nextGroomingAction String? @db.Text

// Linked PR health (persisted by reconcile, refreshable on demand)
linkedPrNumber Int?
linkedPrUrl String?
linkedPrNeedsFollowup Boolean @default(false)
linkedPrFollowupReasons String[] @default([])
linkedPrReviewDecision String?
linkedPrMergeState String?
linkedPrHealthCheckedAt DateTime?

@@unique([repositoryId, number])
@@index([state])
@@index([labels])
@@index([linkedPrNeedsFollowup])
}

enum PrFixLane {
Expand Down
16 changes: 16 additions & 0 deletions src/app/api/agents/[agentName]/queue/route.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -28,6 +28,13 @@ export async function GET(request: Request, { params }: { params: Promise<{ agen
currentLane: true,
decomposed: true,
repository: { select: { fullName: true } },
linkedPrNumber: true,
linkedPrUrl: true,
linkedPrNeedsFollowup: true,
linkedPrFollowupReasons: true,
linkedPrReviewDecision: true,
linkedPrMergeState: true,
linkedPrHealthCheckedAt: true,
},
});

Expand All@@ -51,6 +58,15 @@ export async function GET(request: Request, { params }: { params: Promise<{ agen
lane: issue.currentLane ?? undefined,
issueId: issue.id,
repoFullName: issue.repository.fullName,
linkedPrHealth: {
number: issue.linkedPrNumber,
url: issue.linkedPrUrl,
needsFollowup: issue.linkedPrNeedsFollowup,
followupReasons: issue.linkedPrFollowupReasons,
reviewDecision: issue.linkedPrReviewDecision,
mergeState: issue.linkedPrMergeState,
checkedAt: issue.linkedPrHealthCheckedAt?.toISOString() ?? null,
},
})),
agentName,
{
Expand Down
58 changes: 58 additions & 0 deletions src/app/api/issues/[issueId]/pr-health/refresh/route.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { authorizeRequest } from "@/lib/auth";
import { fetchPullRequests, fetchLinkedPrHealthInput } from "@/lib/github";
import { computeLinkedPrHealth, toPersistedLinkedPrHealth } from "@/lib/linked-pr-health";

/**
* POST /api/issues/[issueId]/pr-health/refresh
*
* Recompute and persist linked PR health for a single issue on demand. The
* periodic reconcile job keeps this fresh in the background (Option B); this
* endpoint lets an operator or worker force an immediate refresh for one issue.
*
* Finds the issue's linked open PR by branch-name convention (issue-<number>),
* computes the health snapshot, and writes it to the Issue row. If no linked
* open PR exists, any stale snapshot is cleared.
*/
export async function POST(request: NextRequest, context: { params: Promise<{ issueId: string }> }) {
if (!(await authorizeRequest(request)).authorized) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}

try {
const { issueId } = await context.params;

const issue = await prisma.issue.findUnique({
where: { id: issueId },
include: { repository: true },
});

if (!issue) {
return NextResponse.json({ error: "Issue not found in local cache" }, { status: 404 });
}

// Find the linked open PR by branch-name convention (matches reconcile).
const openPrs = await fetchPullRequests(issue.repository.fullName, 100);
const linkedPr = openPrs.find((pr) => {
const match = (pr.head?.ref ?? "").match(/issue[-_/]?(\d+)/i);
return match ? parseInt(match[1], 10) === issue.number : false;
});

const health = linkedPr
? computeLinkedPrHealth(await fetchLinkedPrHealthInput(issue.repository.fullName, linkedPr))
: null;

const persisted = toPersistedLinkedPrHealth(health);

await prisma.issue.update({
where: { id: issueId },
data: persisted,
});

return NextResponse.json({ success: true, ...persisted });
} catch (error) {
console.error("Linked PR health refresh failed:", error);
return NextResponse.json({ error: "Failed to refresh linked PR health" }, { status: 500 });
}
}
38 changes: 30 additions & 8 deletions src/app/api/issues/reconcile/route.ts
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { fetchPullRequests, fetchClosedPullRequests, fetchIssues, fetchPullRequestHealthSignals } from "@/lib/github";
import { fetchPullRequests, fetchClosedPullRequests, fetchIssues, fetchLinkedPrHealthInput } from "@/lib/github";
import { getSyncRepos } from "@/lib/config";
import {
extractFixingIssueNumbers,
Expand All@@ -9,6 +9,7 @@ import {
classifyLaneByHeuristics,
executeActions,
} from "@/lib/issue-reconciliation";
import { computeLinkedPrHealth, toPersistedLinkedPrHealth, type LinkedPrHealth } from "@/lib/linked-pr-health";
import { authorizeRequest } from "@/lib/auth";

/**
Expand DownExpand Up@@ -112,13 +113,16 @@ export async function POST(request: Request) {
}
}

// The PR list endpoint omits reviewDecision and mergeStateStatus, so
// enrich each issue-linked open PR with a per-PR health fetch. Without
// this, checkPrHealth always sees null signals and reports "healthy".
for (const pr of openPrToIssue.values()) {
const signals = await fetchPullRequestHealthSignals(repo.fullName, pr.number);
pr.reviewDecision = signals.reviewDecision;
pr.mergeStateStatus = signals.mergeStateStatus;
// The PR list endpoint omits reviewDecision/mergeStateStatus/checks, so
// fetch a full health input per issue-linked open PR. This both feeds
// checkPrHealth (which needs review + merge signals) and produces the
// linked-PR-health snapshot we persist on the issue below.
const linkedPrHealthByIssue = new Map<number, LinkedPrHealth | null>();
for (const [issueNum, pr] of openPrToIssue) {
const input = await fetchLinkedPrHealthInput(repo.fullName, pr);
pr.reviewDecision = input.reviewDecision;
pr.mergeStateStatus = input.mergeStateStatus;
linkedPrHealthByIssue.set(issueNum, computeLinkedPrHealth(input));
}

// Fetch all issues for this repo
Expand DownExpand Up@@ -196,6 +200,24 @@ export async function POST(request: Request) {
totalLaneClassified++;
}

// Persist linked PR health. Write when the issue has a linked open PR;
// otherwise clear any stale snapshot left from a PR that has since
// closed or merged. Skip the write when there's nothing to clear.
if (existingIssue) {
const hasLinkedPr = openPrToIssue.has(issue.number);
if (hasLinkedPr) {
await prisma.issue.update({
where: { repositoryId_number: { repositoryId: repo.id, number: issue.number } },
data: toPersistedLinkedPrHealth(linkedPrHealthByIssue.get(issue.number) ?? null),
});
} else if (existingIssue.linkedPrNumber !== null) {
await prisma.issue.update({
where: { repositoryId_number: { repositoryId: repo.id, number: issue.number } },
data: toPersistedLinkedPrHealth(null),
});
}
}

totalIssuesReconciled++;
}
} catch (error) {
Expand Down
27 changes: 27 additions & 0 deletions src/components/issue-card.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -14,6 +14,20 @@ interface IssueCardProps {
onIssueUpdate?: (updatedIssue: Issue) => void;
}

/** Turn a linked-PR-health follow-up reason code into human-readable text. */
function formatFollowupReason(reason: string): string {
switch (reason) {
case "changes_requested":
return "changes requested";
case "failing_checks":
return "failing checks";
case "merge_conflict":
return "merge conflict";
default:
return reason.startsWith("merge_state_") ? reason.replace("merge_state_", "merge state: ") : reason;
}
}

export function IssueCard({ issue, isDragging, onIssueUpdate }: IssueCardProps) {
const {
attributes,
Expand DownExpand Up@@ -530,6 +544,19 @@ export function IssueCard({ issue, isDragging, onIssueUpdate }: IssueCardProps)
{priorityLabel.replace("priority/", "p")}
</span>
)}
{issue.linkedPrNeedsFollowup && (
<a
href={issue.linkedPrUrl ?? issue.url}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()}
className="inline-flex items-center gap-1 px-1.5 py-0.5 text-xs rounded bg-amber-100 text-amber-700 hover:bg-amber-200"
title={(issue.linkedPrFollowupReasons ?? []).map(formatFollowupReason).join(", ")}
>
<GitPullRequest className="h-3 w-3" />
PR needs work
</a>
)}
</div>
{(issue.notReadyReason || issue.blockedReason || issue.needsInfoReason || issue.groomingSummary) && (
<div className="mt-2 space-y-1">
Expand Down
17 changes: 17 additions & 0 deletions src/lib/agent-queue.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -12,6 +12,20 @@ const IN_PROGRESS_STATUS: string = "status/in-progress";
const BACKLOG_STATUS: string = "status/backlog";
const READY_STATUS: string = "status/ready";

/**
* Compact linked-PR-health summary carried on queue items, sourced from the
* persisted Issue columns (populated by reconcile / the refresh endpoint).
*/
export interface QueueLinkedPrHealth {
number: number | null;
url: string | null;
needsFollowup: boolean;
followupReasons: string[];
reviewDecision: string | null;
mergeState: string | null;
checkedAt: string | null;
}

export interface RankedIssue {
type?: "issue";
number: number;
Expand All@@ -27,6 +41,7 @@ export interface RankedIssue {
issueId?: string;
repoFullName?: string;
claimable?: boolean;
linkedPrHealth?: QueueLinkedPrHealth | null;
}

/**
Expand DownExpand Up@@ -141,6 +156,7 @@ export function buildAgentQueue(
decomposed?: boolean;
issueId?: string;
repoFullName?: string;
linkedPrHealth?: QueueLinkedPrHealth | null;
}>,
agentName: string,
options?: {
Expand DownExpand Up@@ -218,6 +234,7 @@ export function buildAgentQueue(
issueId: item.issueId,
repoFullName: item.repoFullName,
claimable: status !== BACKLOG_STATUS,
linkedPrHealth: item.linkedPrHealth ?? null,
};
});
}
51 changes: 51 additions & 0 deletions src/lib/github.ts
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
import { GitHubIssue } from "@/types";
import type { CheckFailure, PrHealthInput } from "./linked-pr-health";

const GITHUB_API = "https://api.github.com";

Expand DownExpand Up@@ -597,6 +598,56 @@ export async function fetchPullRequestHealthSignals(
return { reviewDecision, mergeStateStatus };
}

/**
* Fetch failing CI check runs for a PR's head ref.
*
* Uses the check-runs endpoint for the head branch. Only completed runs with a
* failure-type conclusion are returned. Transient failures yield an empty list
* rather than throwing, so health computation degrades gracefully.
*/
export async function fetchPullRequestCheckFailures(repoFullName: string, ref: string): Promise<CheckFailure[]> {
const FAILURE_CONCLUSIONS = new Set(["failure", "cancelled", "timed_out", "action_required"]);
try {
const response = await fetch(
`${GITHUB_API}/repos/${repoFullName}/commits/${encodeURIComponent(ref)}/check-runs?per_page=100`,
{ headers: await getHeadersAsync() },
);
if (!response.ok) return [];
const data = (await response.json()) as { check_runs?: Array<{ name?: string; conclusion?: string | null }> };
return (data.check_runs ?? [])
.filter((run) => run.conclusion && FAILURE_CONCLUSIONS.has(run.conclusion.toLowerCase()))
.map((run) => ({ name: run.name ?? "unknown", conclusion: run.conclusion as string }));
} catch {
return [];
}
}

/**
* Assemble a full PrHealthInput for a PR by combining review decision + merge
* state (fetchPullRequestHealthSignals) with failing check runs. This is the
* single source the linked-PR-health feature uses to compute a snapshot, from
* both the reconcile job and the on-demand refresh endpoint.
*/
export async function fetchLinkedPrHealthInput(repoFullName: string, pr: GithubPR): Promise<PrHealthInput> {
const [signals, checkFailures] = await Promise.all([
fetchPullRequestHealthSignals(repoFullName, pr.number),
fetchPullRequestCheckFailures(repoFullName, pr.head?.ref ?? ""),
]);

const state: PrHealthInput["state"] = pr.merged_at ? "merged" : pr.state === "closed" ? "closed" : "open";

return {
url: pr.url,
number: pr.number,
state,
draft: pr.draft,
mergedAt: pr.merged_at,
mergeStateStatus: signals.mergeStateStatus,
reviewDecision: signals.reviewDecision,
checkFailures,
};
}

export interface GithubPackageInfo {
name: string;
package_type: string;
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
-- Add linked PR health fields to Issue (nullable / defaulted, no destructive changes)
ALTER TABLE "Issue"
ADD COLUMN IF NOT EXISTS "linkedPrNumber" INTEGER,
ADD COLUMN IF NOT EXISTS "linkedPrUrl" TEXT,
ADD COLUMN IF NOT EXISTS "linkedPrNeedsFollowup" BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN IF NOT EXISTS "linkedPrFollowupReasons" TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[],
ADD COLUMN IF NOT EXISTS "linkedPrReviewDecision" TEXT,
ADD COLUMN IF NOT EXISTS "linkedPrMergeState" TEXT,
ADD COLUMN IF NOT EXISTS "linkedPrHealthCheckedAt" TIMESTAMP(3);

CREATE INDEX IF NOT EXISTS "Issue_linkedPrNeedsFollowup_idx" ON "Issue"("linkedPrNeedsFollowup");
10 changes: 10 additions & 0 deletions prisma/schema.prisma
Original file line numberDiff line numberDiff line change
Expand Up@@ -57,9 +57,19 @@ model Issue {
needsInfoReason String? @db.Text
nextGroomingAction String? @db.Text

// Linked PR health (persisted by reconcile, refreshable on demand)
linkedPrNumber Int?
linkedPrUrl String?
linkedPrNeedsFollowup Boolean @default(false)
linkedPrFollowupReasons String[] @default([])
linkedPrReviewDecision String?
linkedPrMergeState String?
linkedPrHealthCheckedAt DateTime?

@@unique([repositoryId, number])
@@index([state])
@@index([labels])
@@index([linkedPrNeedsFollowup])
}

enum PrFixLane {
Expand Down
16 changes: 16 additions & 0 deletions src/app/api/agents/[agentName]/queue/route.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -28,6 +28,13 @@ export async function GET(request: Request, { params }: { params: Promise<{ agen
currentLane: true,
decomposed: true,
repository: { select: { fullName: true } },
linkedPrNumber: true,
linkedPrUrl: true,
linkedPrNeedsFollowup: true,
linkedPrFollowupReasons: true,
linkedPrReviewDecision: true,
linkedPrMergeState: true,
linkedPrHealthCheckedAt: true,
},
});

Expand All@@ -51,6 +58,15 @@ export async function GET(request: Request, { params }: { params: Promise<{ agen
lane: issue.currentLane ?? undefined,
issueId: issue.id,
repoFullName: issue.repository.fullName,
linkedPrHealth: {
number: issue.linkedPrNumber,
url: issue.linkedPrUrl,
needsFollowup: issue.linkedPrNeedsFollowup,
followupReasons: issue.linkedPrFollowupReasons,
reviewDecision: issue.linkedPrReviewDecision,
mergeState: issue.linkedPrMergeState,
checkedAt: issue.linkedPrHealthCheckedAt?.toISOString() ?? null,
},
})),
agentName,
{
Expand Down
58 changes: 58 additions & 0 deletions src/app/api/issues/[issueId]/pr-health/refresh/route.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { authorizeRequest } from "@/lib/auth";
import { fetchPullRequests, fetchLinkedPrHealthInput } from "@/lib/github";
import { computeLinkedPrHealth, toPersistedLinkedPrHealth } from "@/lib/linked-pr-health";

/**
* POST /api/issues/[issueId]/pr-health/refresh
*
* Recompute and persist linked PR health for a single issue on demand. The
* periodic reconcile job keeps this fresh in the background (Option B); this
* endpoint lets an operator or worker force an immediate refresh for one issue.
*
* Finds the issue's linked open PR by branch-name convention (issue-<number>),
* computes the health snapshot, and writes it to the Issue row. If no linked
* open PR exists, any stale snapshot is cleared.
*/
export async function POST(request: NextRequest, context: { params: Promise<{ issueId: string }> }) {
if (!(await authorizeRequest(request)).authorized) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}

try {
const { issueId } = await context.params;

const issue = await prisma.issue.findUnique({
where: { id: issueId },
include: { repository: true },
});

if (!issue) {
return NextResponse.json({ error: "Issue not found in local cache" }, { status: 404 });
}

// Find the linked open PR by branch-name convention (matches reconcile).
const openPrs = await fetchPullRequests(issue.repository.fullName, 100);
const linkedPr = openPrs.find((pr) => {
const match = (pr.head?.ref ?? "").match(/issue[-_/]?(\d+)/i);
return match ? parseInt(match[1], 10) === issue.number : false;
});

const health = linkedPr
? computeLinkedPrHealth(await fetchLinkedPrHealthInput(issue.repository.fullName, linkedPr))
: null;

const persisted = toPersistedLinkedPrHealth(health);

await prisma.issue.update({
where: { id: issueId },
data: persisted,
});

return NextResponse.json({ success: true, ...persisted });
} catch (error) {
console.error("Linked PR health refresh failed:", error);
return NextResponse.json({ error: "Failed to refresh linked PR health" }, { status: 500 });
}
}
38 changes: 30 additions & 8 deletions src/app/api/issues/reconcile/route.ts
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { fetchPullRequests, fetchClosedPullRequests, fetchIssues, fetchPullRequestHealthSignals } from "@/lib/github";
import { fetchPullRequests, fetchClosedPullRequests, fetchIssues, fetchLinkedPrHealthInput } from "@/lib/github";
import { getSyncRepos } from "@/lib/config";
import {
extractFixingIssueNumbers,
Expand All@@ -9,6 +9,7 @@ import {
classifyLaneByHeuristics,
executeActions,
} from "@/lib/issue-reconciliation";
import { computeLinkedPrHealth, toPersistedLinkedPrHealth, type LinkedPrHealth } from "@/lib/linked-pr-health";
import { authorizeRequest } from "@/lib/auth";

/**
Expand DownExpand Up@@ -112,13 +113,16 @@ export async function POST(request: Request) {
}
}

// The PR list endpoint omits reviewDecision and mergeStateStatus, so
// enrich each issue-linked open PR with a per-PR health fetch. Without
// this, checkPrHealth always sees null signals and reports "healthy".
for (const pr of openPrToIssue.values()) {
const signals = await fetchPullRequestHealthSignals(repo.fullName, pr.number);
pr.reviewDecision = signals.reviewDecision;
pr.mergeStateStatus = signals.mergeStateStatus;
// The PR list endpoint omits reviewDecision/mergeStateStatus/checks, so
// fetch a full health input per issue-linked open PR. This both feeds
// checkPrHealth (which needs review + merge signals) and produces the
// linked-PR-health snapshot we persist on the issue below.
const linkedPrHealthByIssue = new Map<number, LinkedPrHealth | null>();
for (const [issueNum, pr] of openPrToIssue) {
const input = await fetchLinkedPrHealthInput(repo.fullName, pr);
pr.reviewDecision = input.reviewDecision;
pr.mergeStateStatus = input.mergeStateStatus;
linkedPrHealthByIssue.set(issueNum, computeLinkedPrHealth(input));
}

// Fetch all issues for this repo
Expand DownExpand Up@@ -196,6 +200,24 @@ export async function POST(request: Request) {
totalLaneClassified++;
}

// Persist linked PR health. Write when the issue has a linked open PR;
// otherwise clear any stale snapshot left from a PR that has since
// closed or merged. Skip the write when there's nothing to clear.
if (existingIssue) {
const hasLinkedPr = openPrToIssue.has(issue.number);
if (hasLinkedPr) {
await prisma.issue.update({
where: { repositoryId_number: { repositoryId: repo.id, number: issue.number } },
data: toPersistedLinkedPrHealth(linkedPrHealthByIssue.get(issue.number) ?? null),
});
} else if (existingIssue.linkedPrNumber !== null) {
await prisma.issue.update({
where: { repositoryId_number: { repositoryId: repo.id, number: issue.number } },
data: toPersistedLinkedPrHealth(null),
});
}
}

totalIssuesReconciled++;
}
} catch (error) {
Expand Down
27 changes: 27 additions & 0 deletions src/components/issue-card.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -14,6 +14,20 @@ interface IssueCardProps {
onIssueUpdate?: (updatedIssue: Issue) => void;
}

/** Turn a linked-PR-health follow-up reason code into human-readable text. */
function formatFollowupReason(reason: string): string {
switch (reason) {
case "changes_requested":
return "changes requested";
case "failing_checks":
return "failing checks";
case "merge_conflict":
return "merge conflict";
default:
return reason.startsWith("merge_state_") ? reason.replace("merge_state_", "merge state: ") : reason;
}
}

export function IssueCard({ issue, isDragging, onIssueUpdate }: IssueCardProps) {
const {
attributes,
Expand DownExpand Up@@ -530,6 +544,19 @@ export function IssueCard({ issue, isDragging, onIssueUpdate }: IssueCardProps)
{priorityLabel.replace("priority/", "p")}
</span>
)}
{issue.linkedPrNeedsFollowup && (
<a
href={issue.linkedPrUrl ?? issue.url}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()}
className="inline-flex items-center gap-1 px-1.5 py-0.5 text-xs rounded bg-amber-100 text-amber-700 hover:bg-amber-200"
title={(issue.linkedPrFollowupReasons ?? []).map(formatFollowupReason).join(", ")}
>
<GitPullRequest className="h-3 w-3" />
PR needs work
</a>
)}
</div>
{(issue.notReadyReason || issue.blockedReason || issue.needsInfoReason || issue.groomingSummary) && (
<div className="mt-2 space-y-1">
Expand Down
17 changes: 17 additions & 0 deletions src/lib/agent-queue.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -12,6 +12,20 @@ const IN_PROGRESS_STATUS: string = "status/in-progress";
const BACKLOG_STATUS: string = "status/backlog";
const READY_STATUS: string = "status/ready";

/**
* Compact linked-PR-health summary carried on queue items, sourced from the
* persisted Issue columns (populated by reconcile / the refresh endpoint).
*/
export interface QueueLinkedPrHealth {
number: number | null;
url: string | null;
needsFollowup: boolean;
followupReasons: string[];
reviewDecision: string | null;
mergeState: string | null;
checkedAt: string | null;
}

export interface RankedIssue {
type?: "issue";
number: number;
Expand All@@ -27,6 +41,7 @@ export interface RankedIssue {
issueId?: string;
repoFullName?: string;
claimable?: boolean;
linkedPrHealth?: QueueLinkedPrHealth | null;
}

/**
Expand DownExpand Up@@ -141,6 +156,7 @@ export function buildAgentQueue(
decomposed?: boolean;
issueId?: string;
repoFullName?: string;
linkedPrHealth?: QueueLinkedPrHealth | null;
}>,
agentName: string,
options?: {
Expand DownExpand Up@@ -218,6 +234,7 @@ export function buildAgentQueue(
issueId: item.issueId,
repoFullName: item.repoFullName,
claimable: status !== BACKLOG_STATUS,
linkedPrHealth: item.linkedPrHealth ?? null,
};
});
}
51 changes: 51 additions & 0 deletions src/lib/github.ts
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
import { GitHubIssue } from "@/types";
import type { CheckFailure, PrHealthInput } from "./linked-pr-health";

const GITHUB_API = "https://api.github.com";

Expand DownExpand Up@@ -597,6 +598,56 @@ export async function fetchPullRequestHealthSignals(
return { reviewDecision, mergeStateStatus };
}

/**
* Fetch failing CI check runs for a PR's head ref.
*
* Uses the check-runs endpoint for the head branch. Only completed runs with a
* failure-type conclusion are returned. Transient failures yield an empty list
* rather than throwing, so health computation degrades gracefully.
*/
export async function fetchPullRequestCheckFailures(repoFullName: string, ref: string): Promise<CheckFailure[]> {
const FAILURE_CONCLUSIONS = new Set(["failure", "cancelled", "timed_out", "action_required"]);
try {
const response = await fetch(
`${GITHUB_API}/repos/${repoFullName}/commits/${encodeURIComponent(ref)}/check-runs?per_page=100`,
{ headers: await getHeadersAsync() },
);
if (!response.ok) return [];
const data = (await response.json()) as { check_runs?: Array<{ name?: string; conclusion?: string | null }> };
return (data.check_runs ?? [])
.filter((run) => run.conclusion && FAILURE_CONCLUSIONS.has(run.conclusion.toLowerCase()))
.map((run) => ({ name: run.name ?? "unknown", conclusion: run.conclusion as string }));
} catch {
return [];
}
}

/**
* Assemble a full PrHealthInput for a PR by combining review decision + merge
* state (fetchPullRequestHealthSignals) with failing check runs. This is the
* single source the linked-PR-health feature uses to compute a snapshot, from
* both the reconcile job and the on-demand refresh endpoint.
*/
export async function fetchLinkedPrHealthInput(repoFullName: string, pr: GithubPR): Promise<PrHealthInput> {
const [signals, checkFailures] = await Promise.all([
fetchPullRequestHealthSignals(repoFullName, pr.number),
fetchPullRequestCheckFailures(repoFullName, pr.head?.ref ?? ""),
]);

const state: PrHealthInput["state"] = pr.merged_at ? "merged" : pr.state === "closed" ? "closed" : "open";

return {
url: pr.url,
number: pr.number,
state,
draft: pr.draft,
mergedAt: pr.merged_at,
mergeStateStatus: signals.mergeStateStatus,
reviewDecision: signals.reviewDecision,
checkFailures,
};
}

export interface GithubPackageInfo {
name: string;
package_type: string;
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
-- Add linked PR health fields to Issue (nullable / defaulted, no destructive changes)
ALTER TABLE "Issue"
ADD COLUMN IF NOT EXISTS "linkedPrNumber" INTEGER,
ADD COLUMN IF NOT EXISTS "linkedPrUrl" TEXT,
ADD COLUMN IF NOT EXISTS "linkedPrNeedsFollowup" BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN IF NOT EXISTS "linkedPrFollowupReasons" TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[],
ADD COLUMN IF NOT EXISTS "linkedPrReviewDecision" TEXT,
ADD COLUMN IF NOT EXISTS "linkedPrMergeState" TEXT,
ADD COLUMN IF NOT EXISTS "linkedPrHealthCheckedAt" TIMESTAMP(3);

CREATE INDEX IF NOT EXISTS "Issue_linkedPrNeedsFollowup_idx" ON "Issue"("linkedPrNeedsFollowup");
10 changes: 10 additions & 0 deletions prisma/schema.prisma
Original file line numberDiff line numberDiff line change
Expand Up@@ -57,9 +57,19 @@ model Issue {
needsInfoReason String? @db.Text
nextGroomingAction String? @db.Text

// Linked PR health (persisted by reconcile, refreshable on demand)
linkedPrNumber Int?
linkedPrUrl String?
linkedPrNeedsFollowup Boolean @default(false)
linkedPrFollowupReasons String[] @default([])
linkedPrReviewDecision String?
linkedPrMergeState String?
linkedPrHealthCheckedAt DateTime?

@@unique([repositoryId, number])
@@index([state])
@@index([labels])
@@index([linkedPrNeedsFollowup])
}

enum PrFixLane {
Expand Down
16 changes: 16 additions & 0 deletions src/app/api/agents/[agentName]/queue/route.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -28,6 +28,13 @@ export async function GET(request: Request, { params }: { params: Promise<{ agen
currentLane: true,
decomposed: true,
repository: { select: { fullName: true } },
linkedPrNumber: true,
linkedPrUrl: true,
linkedPrNeedsFollowup: true,
linkedPrFollowupReasons: true,
linkedPrReviewDecision: true,
linkedPrMergeState: true,
linkedPrHealthCheckedAt: true,
},
});

Expand All@@ -51,6 +58,15 @@ export async function GET(request: Request, { params }: { params: Promise<{ agen
lane: issue.currentLane ?? undefined,
issueId: issue.id,
repoFullName: issue.repository.fullName,
linkedPrHealth: {
number: issue.linkedPrNumber,
url: issue.linkedPrUrl,
needsFollowup: issue.linkedPrNeedsFollowup,
followupReasons: issue.linkedPrFollowupReasons,
reviewDecision: issue.linkedPrReviewDecision,
mergeState: issue.linkedPrMergeState,
checkedAt: issue.linkedPrHealthCheckedAt?.toISOString() ?? null,
},
})),
agentName,
{
Expand Down
58 changes: 58 additions & 0 deletions src/app/api/issues/[issueId]/pr-health/refresh/route.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { authorizeRequest } from "@/lib/auth";
import { fetchPullRequests, fetchLinkedPrHealthInput } from "@/lib/github";
import { computeLinkedPrHealth, toPersistedLinkedPrHealth } from "@/lib/linked-pr-health";

/**
* POST /api/issues/[issueId]/pr-health/refresh
*
* Recompute and persist linked PR health for a single issue on demand. The
* periodic reconcile job keeps this fresh in the background (Option B); this
* endpoint lets an operator or worker force an immediate refresh for one issue.
*
* Finds the issue's linked open PR by branch-name convention (issue-<number>),
* computes the health snapshot, and writes it to the Issue row. If no linked
* open PR exists, any stale snapshot is cleared.
*/
export async function POST(request: NextRequest, context: { params: Promise<{ issueId: string }> }) {
if (!(await authorizeRequest(request)).authorized) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}

try {
const { issueId } = await context.params;

const issue = await prisma.issue.findUnique({
where: { id: issueId },
include: { repository: true },
});

if (!issue) {
return NextResponse.json({ error: "Issue not found in local cache" }, { status: 404 });
}

// Find the linked open PR by branch-name convention (matches reconcile).
const openPrs = await fetchPullRequests(issue.repository.fullName, 100);
const linkedPr = openPrs.find((pr) => {
const match = (pr.head?.ref ?? "").match(/issue[-_/]?(\d+)/i);
return match ? parseInt(match[1], 10) === issue.number : false;
});

const health = linkedPr
? computeLinkedPrHealth(await fetchLinkedPrHealthInput(issue.repository.fullName, linkedPr))
: null;

const persisted = toPersistedLinkedPrHealth(health);

await prisma.issue.update({
where: { id: issueId },
data: persisted,
});

return NextResponse.json({ success: true, ...persisted });
} catch (error) {
console.error("Linked PR health refresh failed:", error);
return NextResponse.json({ error: "Failed to refresh linked PR health" }, { status: 500 });
}
}
38 changes: 30 additions & 8 deletions src/app/api/issues/reconcile/route.ts
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { fetchPullRequests, fetchClosedPullRequests, fetchIssues, fetchPullRequestHealthSignals } from "@/lib/github";
import { fetchPullRequests, fetchClosedPullRequests, fetchIssues, fetchLinkedPrHealthInput } from "@/lib/github";
import { getSyncRepos } from "@/lib/config";
import {
extractFixingIssueNumbers,
Expand All@@ -9,6 +9,7 @@ import {
classifyLaneByHeuristics,
executeActions,
} from "@/lib/issue-reconciliation";
import { computeLinkedPrHealth, toPersistedLinkedPrHealth, type LinkedPrHealth } from "@/lib/linked-pr-health";
import { authorizeRequest } from "@/lib/auth";

/**
Expand DownExpand Up@@ -112,13 +113,16 @@ export async function POST(request: Request) {
}
}

// The PR list endpoint omits reviewDecision and mergeStateStatus, so
// enrich each issue-linked open PR with a per-PR health fetch. Without
// this, checkPrHealth always sees null signals and reports "healthy".
for (const pr of openPrToIssue.values()) {
const signals = await fetchPullRequestHealthSignals(repo.fullName, pr.number);
pr.reviewDecision = signals.reviewDecision;
pr.mergeStateStatus = signals.mergeStateStatus;
// The PR list endpoint omits reviewDecision/mergeStateStatus/checks, so
// fetch a full health input per issue-linked open PR. This both feeds
// checkPrHealth (which needs review + merge signals) and produces the
// linked-PR-health snapshot we persist on the issue below.
const linkedPrHealthByIssue = new Map<number, LinkedPrHealth | null>();
for (const [issueNum, pr] of openPrToIssue) {
const input = await fetchLinkedPrHealthInput(repo.fullName, pr);
pr.reviewDecision = input.reviewDecision;
pr.mergeStateStatus = input.mergeStateStatus;
linkedPrHealthByIssue.set(issueNum, computeLinkedPrHealth(input));
}

// Fetch all issues for this repo
Expand DownExpand Up@@ -196,6 +200,24 @@ export async function POST(request: Request) {
totalLaneClassified++;
}

// Persist linked PR health. Write when the issue has a linked open PR;
// otherwise clear any stale snapshot left from a PR that has since
// closed or merged. Skip the write when there's nothing to clear.
if (existingIssue) {
const hasLinkedPr = openPrToIssue.has(issue.number);
if (hasLinkedPr) {
await prisma.issue.update({
where: { repositoryId_number: { repositoryId: repo.id, number: issue.number } },
data: toPersistedLinkedPrHealth(linkedPrHealthByIssue.get(issue.number) ?? null),
});
} else if (existingIssue.linkedPrNumber !== null) {
await prisma.issue.update({
where: { repositoryId_number: { repositoryId: repo.id, number: issue.number } },
data: toPersistedLinkedPrHealth(null),
});
}
}

totalIssuesReconciled++;
}
} catch (error) {
Expand Down
27 changes: 27 additions & 0 deletions src/components/issue-card.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -14,6 +14,20 @@ interface IssueCardProps {
onIssueUpdate?: (updatedIssue: Issue) => void;
}

/** Turn a linked-PR-health follow-up reason code into human-readable text. */
function formatFollowupReason(reason: string): string {
switch (reason) {
case "changes_requested":
return "changes requested";
case "failing_checks":
return "failing checks";
case "merge_conflict":
return "merge conflict";
default:
return reason.startsWith("merge_state_") ? reason.replace("merge_state_", "merge state: ") : reason;
}
}

export function IssueCard({ issue, isDragging, onIssueUpdate }: IssueCardProps) {
const {
attributes,
Expand DownExpand Up@@ -530,6 +544,19 @@ export function IssueCard({ issue, isDragging, onIssueUpdate }: IssueCardProps)
{priorityLabel.replace("priority/", "p")}
</span>
)}
{issue.linkedPrNeedsFollowup && (
<a
href={issue.linkedPrUrl ?? issue.url}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()}
className="inline-flex items-center gap-1 px-1.5 py-0.5 text-xs rounded bg-amber-100 text-amber-700 hover:bg-amber-200"
title={(issue.linkedPrFollowupReasons ?? []).map(formatFollowupReason).join(", ")}
>
<GitPullRequest className="h-3 w-3" />
PR needs work
</a>
)}
</div>
{(issue.notReadyReason || issue.blockedReason || issue.needsInfoReason || issue.groomingSummary) && (
<div className="mt-2 space-y-1">
Expand Down
17 changes: 17 additions & 0 deletions src/lib/agent-queue.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -12,6 +12,20 @@ const IN_PROGRESS_STATUS: string = "status/in-progress";
const BACKLOG_STATUS: string = "status/backlog";
const READY_STATUS: string = "status/ready";

/**
* Compact linked-PR-health summary carried on queue items, sourced from the
* persisted Issue columns (populated by reconcile / the refresh endpoint).
*/
export interface QueueLinkedPrHealth {
number: number | null;
url: string | null;
needsFollowup: boolean;
followupReasons: string[];
reviewDecision: string | null;
mergeState: string | null;
checkedAt: string | null;
}

export interface RankedIssue {
type?: "issue";
number: number;
Expand All@@ -27,6 +41,7 @@ export interface RankedIssue {
issueId?: string;
repoFullName?: string;
claimable?: boolean;
linkedPrHealth?: QueueLinkedPrHealth | null;
}

/**
Expand DownExpand Up@@ -141,6 +156,7 @@ export function buildAgentQueue(
decomposed?: boolean;
issueId?: string;
repoFullName?: string;
linkedPrHealth?: QueueLinkedPrHealth | null;
}>,
agentName: string,
options?: {
Expand DownExpand Up@@ -218,6 +234,7 @@ export function buildAgentQueue(
issueId: item.issueId,
repoFullName: item.repoFullName,
claimable: status !== BACKLOG_STATUS,
linkedPrHealth: item.linkedPrHealth ?? null,
};
});
}
51 changes: 51 additions & 0 deletions src/lib/github.ts
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
import { GitHubIssue } from "@/types";
import type { CheckFailure, PrHealthInput } from "./linked-pr-health";

const GITHUB_API = "https://api.github.com";

Expand DownExpand Up@@ -597,6 +598,56 @@ export async function fetchPullRequestHealthSignals(
return { reviewDecision, mergeStateStatus };
}

/**
* Fetch failing CI check runs for a PR's head ref.
*
* Uses the check-runs endpoint for the head branch. Only completed runs with a
* failure-type conclusion are returned. Transient failures yield an empty list
* rather than throwing, so health computation degrades gracefully.
*/
export async function fetchPullRequestCheckFailures(repoFullName: string, ref: string): Promise<CheckFailure[]> {
const FAILURE_CONCLUSIONS = new Set(["failure", "cancelled", "timed_out", "action_required"]);
try {
const response = await fetch(
`${GITHUB_API}/repos/${repoFullName}/commits/${encodeURIComponent(ref)}/check-runs?per_page=100`,
{ headers: await getHeadersAsync() },
);
if (!response.ok) return [];
const data = (await response.json()) as { check_runs?: Array<{ name?: string; conclusion?: string | null }> };
return (data.check_runs ?? [])
.filter((run) => run.conclusion && FAILURE_CONCLUSIONS.has(run.conclusion.toLowerCase()))
.map((run) => ({ name: run.name ?? "unknown", conclusion: run.conclusion as string }));
} catch {
return [];
}
}

/**
* Assemble a full PrHealthInput for a PR by combining review decision + merge
* state (fetchPullRequestHealthSignals) with failing check runs. This is the
* single source the linked-PR-health feature uses to compute a snapshot, from
* both the reconcile job and the on-demand refresh endpoint.
*/
export async function fetchLinkedPrHealthInput(repoFullName: string, pr: GithubPR): Promise<PrHealthInput> {
const [signals, checkFailures] = await Promise.all([
fetchPullRequestHealthSignals(repoFullName, pr.number),
fetchPullRequestCheckFailures(repoFullName, pr.head?.ref ?? ""),
]);

const state: PrHealthInput["state"] = pr.merged_at ? "merged" : pr.state === "closed" ? "closed" : "open";

return {
url: pr.url,
number: pr.number,
state,
draft: pr.draft,
mergedAt: pr.merged_at,
mergeStateStatus: signals.mergeStateStatus,
reviewDecision: signals.reviewDecision,
checkFailures,
};
}

export interface GithubPackageInfo {
name: string;
package_type: string;
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
-- Add linked PR health fields to Issue (nullable / defaulted, no destructive changes)
ALTER TABLE "Issue"
ADD COLUMN IF NOT EXISTS "linkedPrNumber" INTEGER,
ADD COLUMN IF NOT EXISTS "linkedPrUrl" TEXT,
ADD COLUMN IF NOT EXISTS "linkedPrNeedsFollowup" BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN IF NOT EXISTS "linkedPrFollowupReasons" TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[],
ADD COLUMN IF NOT EXISTS "linkedPrReviewDecision" TEXT,
ADD COLUMN IF NOT EXISTS "linkedPrMergeState" TEXT,
ADD COLUMN IF NOT EXISTS "linkedPrHealthCheckedAt" TIMESTAMP(3);

CREATE INDEX IF NOT EXISTS "Issue_linkedPrNeedsFollowup_idx" ON "Issue"("linkedPrNeedsFollowup");
10 changes: 10 additions & 0 deletions prisma/schema.prisma
Original file line numberDiff line numberDiff line change
Expand Up@@ -57,9 +57,19 @@ model Issue {
needsInfoReason String? @db.Text
nextGroomingAction String? @db.Text

// Linked PR health (persisted by reconcile, refreshable on demand)
linkedPrNumber Int?
linkedPrUrl String?
linkedPrNeedsFollowup Boolean @default(false)
linkedPrFollowupReasons String[] @default([])
linkedPrReviewDecision String?
linkedPrMergeState String?
linkedPrHealthCheckedAt DateTime?

@@unique([repositoryId, number])
@@index([state])
@@index([labels])
@@index([linkedPrNeedsFollowup])
}

enum PrFixLane {
Expand Down
16 changes: 16 additions & 0 deletions src/app/api/agents/[agentName]/queue/route.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -28,6 +28,13 @@ export async function GET(request: Request, { params }: { params: Promise<{ agen
currentLane: true,
decomposed: true,
repository: { select: { fullName: true } },
linkedPrNumber: true,
linkedPrUrl: true,
linkedPrNeedsFollowup: true,
linkedPrFollowupReasons: true,
linkedPrReviewDecision: true,
linkedPrMergeState: true,
linkedPrHealthCheckedAt: true,
},
});

Expand All@@ -51,6 +58,15 @@ export async function GET(request: Request, { params }: { params: Promise<{ agen
lane: issue.currentLane ?? undefined,
issueId: issue.id,
repoFullName: issue.repository.fullName,
linkedPrHealth: {
number: issue.linkedPrNumber,
url: issue.linkedPrUrl,
needsFollowup: issue.linkedPrNeedsFollowup,
followupReasons: issue.linkedPrFollowupReasons,
reviewDecision: issue.linkedPrReviewDecision,
mergeState: issue.linkedPrMergeState,
checkedAt: issue.linkedPrHealthCheckedAt?.toISOString() ?? null,
},
})),
agentName,
{
Expand Down
58 changes: 58 additions & 0 deletions src/app/api/issues/[issueId]/pr-health/refresh/route.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { authorizeRequest } from "@/lib/auth";
import { fetchPullRequests, fetchLinkedPrHealthInput } from "@/lib/github";
import { computeLinkedPrHealth, toPersistedLinkedPrHealth } from "@/lib/linked-pr-health";

/**
* POST /api/issues/[issueId]/pr-health/refresh
*
* Recompute and persist linked PR health for a single issue on demand. The
* periodic reconcile job keeps this fresh in the background (Option B); this
* endpoint lets an operator or worker force an immediate refresh for one issue.
*
* Finds the issue's linked open PR by branch-name convention (issue-<number>),
* computes the health snapshot, and writes it to the Issue row. If no linked
* open PR exists, any stale snapshot is cleared.
*/
export async function POST(request: NextRequest, context: { params: Promise<{ issueId: string }> }) {
if (!(await authorizeRequest(request)).authorized) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}

try {
const { issueId } = await context.params;

const issue = await prisma.issue.findUnique({
where: { id: issueId },
include: { repository: true },
});

if (!issue) {
return NextResponse.json({ error: "Issue not found in local cache" }, { status: 404 });
}

// Find the linked open PR by branch-name convention (matches reconcile).
const openPrs = await fetchPullRequests(issue.repository.fullName, 100);
const linkedPr = openPrs.find((pr) => {
const match = (pr.head?.ref ?? "").match(/issue[-_/]?(\d+)/i);
return match ? parseInt(match[1], 10) === issue.number : false;
});

const health = linkedPr
? computeLinkedPrHealth(await fetchLinkedPrHealthInput(issue.repository.fullName, linkedPr))
: null;

const persisted = toPersistedLinkedPrHealth(health);

await prisma.issue.update({
where: { id: issueId },
data: persisted,
});

return NextResponse.json({ success: true, ...persisted });
} catch (error) {
console.error("Linked PR health refresh failed:", error);
return NextResponse.json({ error: "Failed to refresh linked PR health" }, { status: 500 });
}
}
38 changes: 30 additions & 8 deletions src/app/api/issues/reconcile/route.ts
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { fetchPullRequests, fetchClosedPullRequests, fetchIssues, fetchPullRequestHealthSignals } from "@/lib/github";
import { fetchPullRequests, fetchClosedPullRequests, fetchIssues, fetchLinkedPrHealthInput } from "@/lib/github";
import { getSyncRepos } from "@/lib/config";
import {
extractFixingIssueNumbers,
Expand All@@ -9,6 +9,7 @@ import {
classifyLaneByHeuristics,
executeActions,
} from "@/lib/issue-reconciliation";
import { computeLinkedPrHealth, toPersistedLinkedPrHealth, type LinkedPrHealth } from "@/lib/linked-pr-health";
import { authorizeRequest } from "@/lib/auth";

/**
Expand DownExpand Up@@ -112,13 +113,16 @@ export async function POST(request: Request) {
}
}

// The PR list endpoint omits reviewDecision and mergeStateStatus, so
// enrich each issue-linked open PR with a per-PR health fetch. Without
// this, checkPrHealth always sees null signals and reports "healthy".
for (const pr of openPrToIssue.values()) {
const signals = await fetchPullRequestHealthSignals(repo.fullName, pr.number);
pr.reviewDecision = signals.reviewDecision;
pr.mergeStateStatus = signals.mergeStateStatus;
// The PR list endpoint omits reviewDecision/mergeStateStatus/checks, so
// fetch a full health input per issue-linked open PR. This both feeds
// checkPrHealth (which needs review + merge signals) and produces the
// linked-PR-health snapshot we persist on the issue below.
const linkedPrHealthByIssue = new Map<number, LinkedPrHealth | null>();
for (const [issueNum, pr] of openPrToIssue) {
const input = await fetchLinkedPrHealthInput(repo.fullName, pr);
pr.reviewDecision = input.reviewDecision;
pr.mergeStateStatus = input.mergeStateStatus;
linkedPrHealthByIssue.set(issueNum, computeLinkedPrHealth(input));
}

// Fetch all issues for this repo
Expand DownExpand Up@@ -196,6 +200,24 @@ export async function POST(request: Request) {
totalLaneClassified++;
}

// Persist linked PR health. Write when the issue has a linked open PR;
// otherwise clear any stale snapshot left from a PR that has since
// closed or merged. Skip the write when there's nothing to clear.
if (existingIssue) {
const hasLinkedPr = openPrToIssue.has(issue.number);
if (hasLinkedPr) {
await prisma.issue.update({
where: { repositoryId_number: { repositoryId: repo.id, number: issue.number } },
data: toPersistedLinkedPrHealth(linkedPrHealthByIssue.get(issue.number) ?? null),
});
} else if (existingIssue.linkedPrNumber !== null) {
await prisma.issue.update({
where: { repositoryId_number: { repositoryId: repo.id, number: issue.number } },
data: toPersistedLinkedPrHealth(null),
});
}
}

totalIssuesReconciled++;
}
} catch (error) {
Expand Down
27 changes: 27 additions & 0 deletions src/components/issue-card.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -14,6 +14,20 @@ interface IssueCardProps {
onIssueUpdate?: (updatedIssue: Issue) => void;
}

/** Turn a linked-PR-health follow-up reason code into human-readable text. */
function formatFollowupReason(reason: string): string {
switch (reason) {
case "changes_requested":
return "changes requested";
case "failing_checks":
return "failing checks";
case "merge_conflict":
return "merge conflict";
default:
return reason.startsWith("merge_state_") ? reason.replace("merge_state_", "merge state: ") : reason;
}
}

export function IssueCard({ issue, isDragging, onIssueUpdate }: IssueCardProps) {
const {
attributes,
Expand DownExpand Up@@ -530,6 +544,19 @@ export function IssueCard({ issue, isDragging, onIssueUpdate }: IssueCardProps)
{priorityLabel.replace("priority/", "p")}
</span>
)}
{issue.linkedPrNeedsFollowup && (
<a
href={issue.linkedPrUrl ?? issue.url}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()}
className="inline-flex items-center gap-1 px-1.5 py-0.5 text-xs rounded bg-amber-100 text-amber-700 hover:bg-amber-200"
title={(issue.linkedPrFollowupReasons ?? []).map(formatFollowupReason).join(", ")}
>
<GitPullRequest className="h-3 w-3" />
PR needs work
</a>
)}
</div>
{(issue.notReadyReason || issue.blockedReason || issue.needsInfoReason || issue.groomingSummary) && (
<div className="mt-2 space-y-1">
Expand Down
17 changes: 17 additions & 0 deletions src/lib/agent-queue.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -12,6 +12,20 @@ const IN_PROGRESS_STATUS: string = "status/in-progress";
const BACKLOG_STATUS: string = "status/backlog";
const READY_STATUS: string = "status/ready";

/**
* Compact linked-PR-health summary carried on queue items, sourced from the
* persisted Issue columns (populated by reconcile / the refresh endpoint).
*/
export interface QueueLinkedPrHealth {
number: number | null;
url: string | null;
needsFollowup: boolean;
followupReasons: string[];
reviewDecision: string | null;
mergeState: string | null;
checkedAt: string | null;
}

export interface RankedIssue {
type?: "issue";
number: number;
Expand All@@ -27,6 +41,7 @@ export interface RankedIssue {
issueId?: string;
repoFullName?: string;
claimable?: boolean;
linkedPrHealth?: QueueLinkedPrHealth | null;
}

/**
Expand DownExpand Up@@ -141,6 +156,7 @@ export function buildAgentQueue(
decomposed?: boolean;
issueId?: string;
repoFullName?: string;
linkedPrHealth?: QueueLinkedPrHealth | null;
}>,
agentName: string,
options?: {
Expand DownExpand Up@@ -218,6 +234,7 @@ export function buildAgentQueue(
issueId: item.issueId,
repoFullName: item.repoFullName,
claimable: status !== BACKLOG_STATUS,
linkedPrHealth: item.linkedPrHealth ?? null,
};
});
}
51 changes: 51 additions & 0 deletions src/lib/github.ts
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
import { GitHubIssue } from "@/types";
import type { CheckFailure, PrHealthInput } from "./linked-pr-health";

const GITHUB_API = "https://api.github.com";

Expand DownExpand Up@@ -597,6 +598,56 @@ export async function fetchPullRequestHealthSignals(
return { reviewDecision, mergeStateStatus };
}

/**
* Fetch failing CI check runs for a PR's head ref.
*
* Uses the check-runs endpoint for the head branch. Only completed runs with a
* failure-type conclusion are returned. Transient failures yield an empty list
* rather than throwing, so health computation degrades gracefully.
*/
export async function fetchPullRequestCheckFailures(repoFullName: string, ref: string): Promise<CheckFailure[]> {
const FAILURE_CONCLUSIONS = new Set(["failure", "cancelled", "timed_out", "action_required"]);
try {
const response = await fetch(
`${GITHUB_API}/repos/${repoFullName}/commits/${encodeURIComponent(ref)}/check-runs?per_page=100`,
{ headers: await getHeadersAsync() },
);
if (!response.ok) return [];
const data = (await response.json()) as { check_runs?: Array<{ name?: string; conclusion?: string | null }> };
return (data.check_runs ?? [])
.filter((run) => run.conclusion && FAILURE_CONCLUSIONS.has(run.conclusion.toLowerCase()))
.map((run) => ({ name: run.name ?? "unknown", conclusion: run.conclusion as string }));
} catch {
return [];
}
}

/**
* Assemble a full PrHealthInput for a PR by combining review decision + merge
* state (fetchPullRequestHealthSignals) with failing check runs. This is the
* single source the linked-PR-health feature uses to compute a snapshot, from
* both the reconcile job and the on-demand refresh endpoint.
*/
export async function fetchLinkedPrHealthInput(repoFullName: string, pr: GithubPR): Promise<PrHealthInput> {
const [signals, checkFailures] = await Promise.all([
fetchPullRequestHealthSignals(repoFullName, pr.number),
fetchPullRequestCheckFailures(repoFullName, pr.head?.ref ?? ""),
]);

const state: PrHealthInput["state"] = pr.merged_at ? "merged" : pr.state === "closed" ? "closed" : "open";

return {
url: pr.url,
number: pr.number,
state,
draft: pr.draft,
mergedAt: pr.merged_at,
mergeStateStatus: signals.mergeStateStatus,
reviewDecision: signals.reviewDecision,
checkFailures,
};
}

export interface GithubPackageInfo {
name: string;
package_type: string;
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
-- Add linked PR health fields to Issue (nullable / defaulted, no destructive changes)
ALTER TABLE "Issue"
ADD COLUMN IF NOT EXISTS "linkedPrNumber" INTEGER,
ADD COLUMN IF NOT EXISTS "linkedPrUrl" TEXT,
ADD COLUMN IF NOT EXISTS "linkedPrNeedsFollowup" BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN IF NOT EXISTS "linkedPrFollowupReasons" TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[],
ADD COLUMN IF NOT EXISTS "linkedPrReviewDecision" TEXT,
ADD COLUMN IF NOT EXISTS "linkedPrMergeState" TEXT,
ADD COLUMN IF NOT EXISTS "linkedPrHealthCheckedAt" TIMESTAMP(3);

CREATE INDEX IF NOT EXISTS "Issue_linkedPrNeedsFollowup_idx" ON "Issue"("linkedPrNeedsFollowup");
10 changes: 10 additions & 0 deletions prisma/schema.prisma
Original file line numberDiff line numberDiff line change
Expand Up@@ -57,9 +57,19 @@ model Issue {
needsInfoReason String? @db.Text
nextGroomingAction String? @db.Text

// Linked PR health (persisted by reconcile, refreshable on demand)
linkedPrNumber Int?
linkedPrUrl String?
linkedPrNeedsFollowup Boolean @default(false)
linkedPrFollowupReasons String[] @default([])
linkedPrReviewDecision String?
linkedPrMergeState String?
linkedPrHealthCheckedAt DateTime?

@@unique([repositoryId, number])
@@index([state])
@@index([labels])
@@index([linkedPrNeedsFollowup])
}

enum PrFixLane {
Expand Down
16 changes: 16 additions & 0 deletions src/app/api/agents/[agentName]/queue/route.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -28,6 +28,13 @@ export async function GET(request: Request, { params }: { params: Promise<{ agen
currentLane: true,
decomposed: true,
repository: { select: { fullName: true } },
linkedPrNumber: true,
linkedPrUrl: true,
linkedPrNeedsFollowup: true,
linkedPrFollowupReasons: true,
linkedPrReviewDecision: true,
linkedPrMergeState: true,
linkedPrHealthCheckedAt: true,
},
});

Expand All@@ -51,6 +58,15 @@ export async function GET(request: Request, { params }: { params: Promise<{ agen
lane: issue.currentLane ?? undefined,
issueId: issue.id,
repoFullName: issue.repository.fullName,
linkedPrHealth: {
number: issue.linkedPrNumber,
url: issue.linkedPrUrl,
needsFollowup: issue.linkedPrNeedsFollowup,
followupReasons: issue.linkedPrFollowupReasons,
reviewDecision: issue.linkedPrReviewDecision,
mergeState: issue.linkedPrMergeState,
checkedAt: issue.linkedPrHealthCheckedAt?.toISOString() ?? null,
},
})),
agentName,
{
Expand Down
58 changes: 58 additions & 0 deletions src/app/api/issues/[issueId]/pr-health/refresh/route.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { authorizeRequest } from "@/lib/auth";
import { fetchPullRequests, fetchLinkedPrHealthInput } from "@/lib/github";
import { computeLinkedPrHealth, toPersistedLinkedPrHealth } from "@/lib/linked-pr-health";

/**
* POST /api/issues/[issueId]/pr-health/refresh
*
* Recompute and persist linked PR health for a single issue on demand. The
* periodic reconcile job keeps this fresh in the background (Option B); this
* endpoint lets an operator or worker force an immediate refresh for one issue.
*
* Finds the issue's linked open PR by branch-name convention (issue-<number>),
* computes the health snapshot, and writes it to the Issue row. If no linked
* open PR exists, any stale snapshot is cleared.
*/
export async function POST(request: NextRequest, context: { params: Promise<{ issueId: string }> }) {
if (!(await authorizeRequest(request)).authorized) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}

try {
const { issueId } = await context.params;

const issue = await prisma.issue.findUnique({
where: { id: issueId },
include: { repository: true },
});

if (!issue) {
return NextResponse.json({ error: "Issue not found in local cache" }, { status: 404 });
}

// Find the linked open PR by branch-name convention (matches reconcile).
const openPrs = await fetchPullRequests(issue.repository.fullName, 100);
const linkedPr = openPrs.find((pr) => {
const match = (pr.head?.ref ?? "").match(/issue[-_/]?(\d+)/i);
return match ? parseInt(match[1], 10) === issue.number : false;
});

const health = linkedPr
? computeLinkedPrHealth(await fetchLinkedPrHealthInput(issue.repository.fullName, linkedPr))
: null;

const persisted = toPersistedLinkedPrHealth(health);

await prisma.issue.update({
where: { id: issueId },
data: persisted,
});

return NextResponse.json({ success: true, ...persisted });
} catch (error) {
console.error("Linked PR health refresh failed:", error);
return NextResponse.json({ error: "Failed to refresh linked PR health" }, { status: 500 });
}
}
38 changes: 30 additions & 8 deletions src/app/api/issues/reconcile/route.ts
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { fetchPullRequests, fetchClosedPullRequests, fetchIssues, fetchPullRequestHealthSignals } from "@/lib/github";
import { fetchPullRequests, fetchClosedPullRequests, fetchIssues, fetchLinkedPrHealthInput } from "@/lib/github";
import { getSyncRepos } from "@/lib/config";
import {
extractFixingIssueNumbers,
Expand All@@ -9,6 +9,7 @@ import {
classifyLaneByHeuristics,
executeActions,
} from "@/lib/issue-reconciliation";
import { computeLinkedPrHealth, toPersistedLinkedPrHealth, type LinkedPrHealth } from "@/lib/linked-pr-health";
import { authorizeRequest } from "@/lib/auth";

/**
Expand DownExpand Up@@ -112,13 +113,16 @@ export async function POST(request: Request) {
}
}

// The PR list endpoint omits reviewDecision and mergeStateStatus, so
// enrich each issue-linked open PR with a per-PR health fetch. Without
// this, checkPrHealth always sees null signals and reports "healthy".
for (const pr of openPrToIssue.values()) {
const signals = await fetchPullRequestHealthSignals(repo.fullName, pr.number);
pr.reviewDecision = signals.reviewDecision;
pr.mergeStateStatus = signals.mergeStateStatus;
// The PR list endpoint omits reviewDecision/mergeStateStatus/checks, so
// fetch a full health input per issue-linked open PR. This both feeds
// checkPrHealth (which needs review + merge signals) and produces the
// linked-PR-health snapshot we persist on the issue below.
const linkedPrHealthByIssue = new Map<number, LinkedPrHealth | null>();
for (const [issueNum, pr] of openPrToIssue) {
const input = await fetchLinkedPrHealthInput(repo.fullName, pr);
pr.reviewDecision = input.reviewDecision;
pr.mergeStateStatus = input.mergeStateStatus;
linkedPrHealthByIssue.set(issueNum, computeLinkedPrHealth(input));
}

// Fetch all issues for this repo
Expand DownExpand Up@@ -196,6 +200,24 @@ export async function POST(request: Request) {
totalLaneClassified++;
}

// Persist linked PR health. Write when the issue has a linked open PR;
// otherwise clear any stale snapshot left from a PR that has since
// closed or merged. Skip the write when there's nothing to clear.
if (existingIssue) {
const hasLinkedPr = openPrToIssue.has(issue.number);
if (hasLinkedPr) {
await prisma.issue.update({
where: { repositoryId_number: { repositoryId: repo.id, number: issue.number } },
data: toPersistedLinkedPrHealth(linkedPrHealthByIssue.get(issue.number) ?? null),
});
} else if (existingIssue.linkedPrNumber !== null) {
await prisma.issue.update({
where: { repositoryId_number: { repositoryId: repo.id, number: issue.number } },
data: toPersistedLinkedPrHealth(null),
});
}
}

totalIssuesReconciled++;
}
} catch (error) {
Expand Down
27 changes: 27 additions & 0 deletions src/components/issue-card.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -14,6 +14,20 @@ interface IssueCardProps {
onIssueUpdate?: (updatedIssue: Issue) => void;
}

/** Turn a linked-PR-health follow-up reason code into human-readable text. */
function formatFollowupReason(reason: string): string {
switch (reason) {
case "changes_requested":
return "changes requested";
case "failing_checks":
return "failing checks";
case "merge_conflict":
return "merge conflict";
default:
return reason.startsWith("merge_state_") ? reason.replace("merge_state_", "merge state: ") : reason;
}
}

export function IssueCard({ issue, isDragging, onIssueUpdate }: IssueCardProps) {
const {
attributes,
Expand DownExpand Up@@ -530,6 +544,19 @@ export function IssueCard({ issue, isDragging, onIssueUpdate }: IssueCardProps)
{priorityLabel.replace("priority/", "p")}
</span>
)}
{issue.linkedPrNeedsFollowup && (
<a
href={issue.linkedPrUrl ?? issue.url}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()}
className="inline-flex items-center gap-1 px-1.5 py-0.5 text-xs rounded bg-amber-100 text-amber-700 hover:bg-amber-200"
title={(issue.linkedPrFollowupReasons ?? []).map(formatFollowupReason).join(", ")}
>
<GitPullRequest className="h-3 w-3" />
PR needs work
</a>
)}
</div>
{(issue.notReadyReason || issue.blockedReason || issue.needsInfoReason || issue.groomingSummary) && (
<div className="mt-2 space-y-1">
Expand Down
17 changes: 17 additions & 0 deletions src/lib/agent-queue.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -12,6 +12,20 @@ const IN_PROGRESS_STATUS: string = "status/in-progress";
const BACKLOG_STATUS: string = "status/backlog";
const READY_STATUS: string = "status/ready";

/**
* Compact linked-PR-health summary carried on queue items, sourced from the
* persisted Issue columns (populated by reconcile / the refresh endpoint).
*/
export interface QueueLinkedPrHealth {
number: number | null;
url: string | null;
needsFollowup: boolean;
followupReasons: string[];
reviewDecision: string | null;
mergeState: string | null;
checkedAt: string | null;
}

export interface RankedIssue {
type?: "issue";
number: number;
Expand All@@ -27,6 +41,7 @@ export interface RankedIssue {
issueId?: string;
repoFullName?: string;
claimable?: boolean;
linkedPrHealth?: QueueLinkedPrHealth | null;
}

/**
Expand DownExpand Up@@ -141,6 +156,7 @@ export function buildAgentQueue(
decomposed?: boolean;
issueId?: string;
repoFullName?: string;
linkedPrHealth?: QueueLinkedPrHealth | null;
}>,
agentName: string,
options?: {
Expand DownExpand Up@@ -218,6 +234,7 @@ export function buildAgentQueue(
issueId: item.issueId,
repoFullName: item.repoFullName,
claimable: status !== BACKLOG_STATUS,
linkedPrHealth: item.linkedPrHealth ?? null,
};
});
}
51 changes: 51 additions & 0 deletions src/lib/github.ts
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
import { GitHubIssue } from "@/types";
import type { CheckFailure, PrHealthInput } from "./linked-pr-health";

const GITHUB_API = "https://api.github.com";

Expand DownExpand Up@@ -597,6 +598,56 @@ export async function fetchPullRequestHealthSignals(
return { reviewDecision, mergeStateStatus };
}

/**
* Fetch failing CI check runs for a PR's head ref.
*
* Uses the check-runs endpoint for the head branch. Only completed runs with a
* failure-type conclusion are returned. Transient failures yield an empty list
* rather than throwing, so health computation degrades gracefully.
*/
export async function fetchPullRequestCheckFailures(repoFullName: string, ref: string): Promise<CheckFailure[]> {
const FAILURE_CONCLUSIONS = new Set(["failure", "cancelled", "timed_out", "action_required"]);
try {
const response = await fetch(
`${GITHUB_API}/repos/${repoFullName}/commits/${encodeURIComponent(ref)}/check-runs?per_page=100`,
{ headers: await getHeadersAsync() },
);
if (!response.ok) return [];
const data = (await response.json()) as { check_runs?: Array<{ name?: string; conclusion?: string | null }> };
return (data.check_runs ?? [])
.filter((run) => run.conclusion && FAILURE_CONCLUSIONS.has(run.conclusion.toLowerCase()))
.map((run) => ({ name: run.name ?? "unknown", conclusion: run.conclusion as string }));
} catch {
return [];
}
}

/**
* Assemble a full PrHealthInput for a PR by combining review decision + merge
* state (fetchPullRequestHealthSignals) with failing check runs. This is the
* single source the linked-PR-health feature uses to compute a snapshot, from
* both the reconcile job and the on-demand refresh endpoint.
*/
export async function fetchLinkedPrHealthInput(repoFullName: string, pr: GithubPR): Promise<PrHealthInput> {
const [signals, checkFailures] = await Promise.all([
fetchPullRequestHealthSignals(repoFullName, pr.number),
fetchPullRequestCheckFailures(repoFullName, pr.head?.ref ?? ""),
]);

const state: PrHealthInput["state"] = pr.merged_at ? "merged" : pr.state === "closed" ? "closed" : "open";

return {
url: pr.url,
number: pr.number,
state,
draft: pr.draft,
mergedAt: pr.merged_at,
mergeStateStatus: signals.mergeStateStatus,
reviewDecision: signals.reviewDecision,
checkFailures,
};
}

export interface GithubPackageInfo {
name: string;
package_type: string;
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
-- Add linked PR health fields to Issue (nullable / defaulted, no destructive changes)
ALTER TABLE "Issue"
ADD COLUMN IF NOT EXISTS "linkedPrNumber" INTEGER,
ADD COLUMN IF NOT EXISTS "linkedPrUrl" TEXT,
ADD COLUMN IF NOT EXISTS "linkedPrNeedsFollowup" BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN IF NOT EXISTS "linkedPrFollowupReasons" TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[],
ADD COLUMN IF NOT EXISTS "linkedPrReviewDecision" TEXT,
ADD COLUMN IF NOT EXISTS "linkedPrMergeState" TEXT,
ADD COLUMN IF NOT EXISTS "linkedPrHealthCheckedAt" TIMESTAMP(3);

CREATE INDEX IF NOT EXISTS "Issue_linkedPrNeedsFollowup_idx" ON "Issue"("linkedPrNeedsFollowup");
10 changes: 10 additions & 0 deletions prisma/schema.prisma
Original file line numberDiff line numberDiff line change
Expand Up@@ -57,9 +57,19 @@ model Issue {
needsInfoReason String? @db.Text
nextGroomingAction String? @db.Text

// Linked PR health (persisted by reconcile, refreshable on demand)
linkedPrNumber Int?
linkedPrUrl String?
linkedPrNeedsFollowup Boolean @default(false)
linkedPrFollowupReasons String[] @default([])
linkedPrReviewDecision String?
linkedPrMergeState String?
linkedPrHealthCheckedAt DateTime?

@@unique([repositoryId, number])
@@index([state])
@@index([labels])
@@index([linkedPrNeedsFollowup])
}

enum PrFixLane {
Expand Down
16 changes: 16 additions & 0 deletions src/app/api/agents/[agentName]/queue/route.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -28,6 +28,13 @@ export async function GET(request: Request, { params }: { params: Promise<{ agen
currentLane: true,
decomposed: true,
repository: { select: { fullName: true } },
linkedPrNumber: true,
linkedPrUrl: true,
linkedPrNeedsFollowup: true,
linkedPrFollowupReasons: true,
linkedPrReviewDecision: true,
linkedPrMergeState: true,
linkedPrHealthCheckedAt: true,
},
});

Expand All@@ -51,6 +58,15 @@ export async function GET(request: Request, { params }: { params: Promise<{ agen
lane: issue.currentLane ?? undefined,
issueId: issue.id,
repoFullName: issue.repository.fullName,
linkedPrHealth: {
number: issue.linkedPrNumber,
url: issue.linkedPrUrl,
needsFollowup: issue.linkedPrNeedsFollowup,
followupReasons: issue.linkedPrFollowupReasons,
reviewDecision: issue.linkedPrReviewDecision,
mergeState: issue.linkedPrMergeState,
checkedAt: issue.linkedPrHealthCheckedAt?.toISOString() ?? null,
},
})),
agentName,
{
Expand Down
58 changes: 58 additions & 0 deletions src/app/api/issues/[issueId]/pr-health/refresh/route.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { authorizeRequest } from "@/lib/auth";
import { fetchPullRequests, fetchLinkedPrHealthInput } from "@/lib/github";
import { computeLinkedPrHealth, toPersistedLinkedPrHealth } from "@/lib/linked-pr-health";

/**
* POST /api/issues/[issueId]/pr-health/refresh
*
* Recompute and persist linked PR health for a single issue on demand. The
* periodic reconcile job keeps this fresh in the background (Option B); this
* endpoint lets an operator or worker force an immediate refresh for one issue.
*
* Finds the issue's linked open PR by branch-name convention (issue-<number>),
* computes the health snapshot, and writes it to the Issue row. If no linked
* open PR exists, any stale snapshot is cleared.
*/
export async function POST(request: NextRequest, context: { params: Promise<{ issueId: string }> }) {
if (!(await authorizeRequest(request)).authorized) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}

try {
const { issueId } = await context.params;

const issue = await prisma.issue.findUnique({
where: { id: issueId },
include: { repository: true },
});

if (!issue) {
return NextResponse.json({ error: "Issue not found in local cache" }, { status: 404 });
}

// Find the linked open PR by branch-name convention (matches reconcile).
const openPrs = await fetchPullRequests(issue.repository.fullName, 100);
const linkedPr = openPrs.find((pr) => {
const match = (pr.head?.ref ?? "").match(/issue[-_/]?(\d+)/i);
return match ? parseInt(match[1], 10) === issue.number : false;
});

const health = linkedPr
? computeLinkedPrHealth(await fetchLinkedPrHealthInput(issue.repository.fullName, linkedPr))
: null;

const persisted = toPersistedLinkedPrHealth(health);

await prisma.issue.update({
where: { id: issueId },
data: persisted,
});

return NextResponse.json({ success: true, ...persisted });
} catch (error) {
console.error("Linked PR health refresh failed:", error);
return NextResponse.json({ error: "Failed to refresh linked PR health" }, { status: 500 });
}
}
38 changes: 30 additions & 8 deletions src/app/api/issues/reconcile/route.ts
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { fetchPullRequests, fetchClosedPullRequests, fetchIssues, fetchPullRequestHealthSignals } from "@/lib/github";
import { fetchPullRequests, fetchClosedPullRequests, fetchIssues, fetchLinkedPrHealthInput } from "@/lib/github";
import { getSyncRepos } from "@/lib/config";
import {
extractFixingIssueNumbers,
Expand All@@ -9,6 +9,7 @@ import {
classifyLaneByHeuristics,
executeActions,
} from "@/lib/issue-reconciliation";
import { computeLinkedPrHealth, toPersistedLinkedPrHealth, type LinkedPrHealth } from "@/lib/linked-pr-health";
import { authorizeRequest } from "@/lib/auth";

/**
Expand DownExpand Up@@ -112,13 +113,16 @@ export async function POST(request: Request) {
}
}

// The PR list endpoint omits reviewDecision and mergeStateStatus, so
// enrich each issue-linked open PR with a per-PR health fetch. Without
// this, checkPrHealth always sees null signals and reports "healthy".
for (const pr of openPrToIssue.values()) {
const signals = await fetchPullRequestHealthSignals(repo.fullName, pr.number);
pr.reviewDecision = signals.reviewDecision;
pr.mergeStateStatus = signals.mergeStateStatus;
// The PR list endpoint omits reviewDecision/mergeStateStatus/checks, so
// fetch a full health input per issue-linked open PR. This both feeds
// checkPrHealth (which needs review + merge signals) and produces the
// linked-PR-health snapshot we persist on the issue below.
const linkedPrHealthByIssue = new Map<number, LinkedPrHealth | null>();
for (const [issueNum, pr] of openPrToIssue) {
const input = await fetchLinkedPrHealthInput(repo.fullName, pr);
pr.reviewDecision = input.reviewDecision;
pr.mergeStateStatus = input.mergeStateStatus;
linkedPrHealthByIssue.set(issueNum, computeLinkedPrHealth(input));
}

// Fetch all issues for this repo
Expand DownExpand Up@@ -196,6 +200,24 @@ export async function POST(request: Request) {
totalLaneClassified++;
}

// Persist linked PR health. Write when the issue has a linked open PR;
// otherwise clear any stale snapshot left from a PR that has since
// closed or merged. Skip the write when there's nothing to clear.
if (existingIssue) {
const hasLinkedPr = openPrToIssue.has(issue.number);
if (hasLinkedPr) {
await prisma.issue.update({
where: { repositoryId_number: { repositoryId: repo.id, number: issue.number } },
data: toPersistedLinkedPrHealth(linkedPrHealthByIssue.get(issue.number) ?? null),
});
} else if (existingIssue.linkedPrNumber !== null) {
await prisma.issue.update({
where: { repositoryId_number: { repositoryId: repo.id, number: issue.number } },
data: toPersistedLinkedPrHealth(null),
});
}
}

totalIssuesReconciled++;
}
} catch (error) {
Expand Down
27 changes: 27 additions & 0 deletions src/components/issue-card.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -14,6 +14,20 @@ interface IssueCardProps {
onIssueUpdate?: (updatedIssue: Issue) => void;
}

/** Turn a linked-PR-health follow-up reason code into human-readable text. */
function formatFollowupReason(reason: string): string {
switch (reason) {
case "changes_requested":
return "changes requested";
case "failing_checks":
return "failing checks";
case "merge_conflict":
return "merge conflict";
default:
return reason.startsWith("merge_state_") ? reason.replace("merge_state_", "merge state: ") : reason;
}
}

export function IssueCard({ issue, isDragging, onIssueUpdate }: IssueCardProps) {
const {
attributes,
Expand DownExpand Up@@ -530,6 +544,19 @@ export function IssueCard({ issue, isDragging, onIssueUpdate }: IssueCardProps)
{priorityLabel.replace("priority/", "p")}
</span>
)}
{issue.linkedPrNeedsFollowup && (
<a
href={issue.linkedPrUrl ?? issue.url}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()}
className="inline-flex items-center gap-1 px-1.5 py-0.5 text-xs rounded bg-amber-100 text-amber-700 hover:bg-amber-200"
title={(issue.linkedPrFollowupReasons ?? []).map(formatFollowupReason).join(", ")}
>
<GitPullRequest className="h-3 w-3" />
PR needs work
</a>
)}
</div>
{(issue.notReadyReason || issue.blockedReason || issue.needsInfoReason || issue.groomingSummary) && (
<div className="mt-2 space-y-1">
Expand Down
17 changes: 17 additions & 0 deletions src/lib/agent-queue.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -12,6 +12,20 @@ const IN_PROGRESS_STATUS: string = "status/in-progress";
const BACKLOG_STATUS: string = "status/backlog";
const READY_STATUS: string = "status/ready";

/**
* Compact linked-PR-health summary carried on queue items, sourced from the
* persisted Issue columns (populated by reconcile / the refresh endpoint).
*/
export interface QueueLinkedPrHealth {
number: number | null;
url: string | null;
needsFollowup: boolean;
followupReasons: string[];
reviewDecision: string | null;
mergeState: string | null;
checkedAt: string | null;
}

export interface RankedIssue {
type?: "issue";
number: number;
Expand All@@ -27,6 +41,7 @@ export interface RankedIssue {
issueId?: string;
repoFullName?: string;
claimable?: boolean;
linkedPrHealth?: QueueLinkedPrHealth | null;
}

/**
Expand DownExpand Up@@ -141,6 +156,7 @@ export function buildAgentQueue(
decomposed?: boolean;
issueId?: string;
repoFullName?: string;
linkedPrHealth?: QueueLinkedPrHealth | null;
}>,
agentName: string,
options?: {
Expand DownExpand Up@@ -218,6 +234,7 @@ export function buildAgentQueue(
issueId: item.issueId,
repoFullName: item.repoFullName,
claimable: status !== BACKLOG_STATUS,
linkedPrHealth: item.linkedPrHealth ?? null,
};
});
}
51 changes: 51 additions & 0 deletions src/lib/github.ts
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
import { GitHubIssue } from "@/types";
import type { CheckFailure, PrHealthInput } from "./linked-pr-health";

const GITHUB_API = "https://api.github.com";

Expand DownExpand Up@@ -597,6 +598,56 @@ export async function fetchPullRequestHealthSignals(
return { reviewDecision, mergeStateStatus };
}

/**
* Fetch failing CI check runs for a PR's head ref.
*
* Uses the check-runs endpoint for the head branch. Only completed runs with a
* failure-type conclusion are returned. Transient failures yield an empty list
* rather than throwing, so health computation degrades gracefully.
*/
export async function fetchPullRequestCheckFailures(repoFullName: string, ref: string): Promise<CheckFailure[]> {
const FAILURE_CONCLUSIONS = new Set(["failure", "cancelled", "timed_out", "action_required"]);
try {
const response = await fetch(
`${GITHUB_API}/repos/${repoFullName}/commits/${encodeURIComponent(ref)}/check-runs?per_page=100`,
{ headers: await getHeadersAsync() },
);
if (!response.ok) return [];
const data = (await response.json()) as { check_runs?: Array<{ name?: string; conclusion?: string | null }> };
return (data.check_runs ?? [])
.filter((run) => run.conclusion && FAILURE_CONCLUSIONS.has(run.conclusion.toLowerCase()))
.map((run) => ({ name: run.name ?? "unknown", conclusion: run.conclusion as string }));
} catch {
return [];
}
}

/**
* Assemble a full PrHealthInput for a PR by combining review decision + merge
* state (fetchPullRequestHealthSignals) with failing check runs. This is the
* single source the linked-PR-health feature uses to compute a snapshot, from
* both the reconcile job and the on-demand refresh endpoint.
*/
export async function fetchLinkedPrHealthInput(repoFullName: string, pr: GithubPR): Promise<PrHealthInput> {
const [signals, checkFailures] = await Promise.all([
fetchPullRequestHealthSignals(repoFullName, pr.number),
fetchPullRequestCheckFailures(repoFullName, pr.head?.ref ?? ""),
]);

const state: PrHealthInput["state"] = pr.merged_at ? "merged" : pr.state === "closed" ? "closed" : "open";

return {
url: pr.url,
number: pr.number,
state,
draft: pr.draft,
mergedAt: pr.merged_at,
mergeStateStatus: signals.mergeStateStatus,
reviewDecision: signals.reviewDecision,
checkFailures,
};
}

export interface GithubPackageInfo {
name: string;
package_type: string;
Expand Down
Loading