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 number Diff line number Diff line change
@@ -0,0 +1,5 @@
-- Native GitHub blocked_by dependencies for dependency gating (#1086).
-- Additive and backfill-free: existing issues get an empty array until the
-- next sync ingests their native blocked_by links.
ALTER TABLE "Issue"
ADD COLUMN IF NOT EXISTS "nativeBlockedBy" TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[];
5 changes: 5 additions & 0 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,11 @@ model Issue {
decomposedNote String? @db.Text
followUpUrls String[] @default([])

// Native GitHub blocked_by dependencies (#1086): canonical `owner/repo#N`
// keys for issues that natively block this one. Ingested by sync; merged
// with body-parsed deps by the queue gate and board annotation.
nativeBlockedBy String[] @default([])

// Backlog grooming state
groomedAt DateTime?
groomedBy String? @db.Text
Expand Down
1 change: 1 addition & 0 deletions src/app/api/agents/[agentName]/queue-admission.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,7 @@ const PRE_ADMISSION_SELECT_KEYS = [
"createdAt",
"title",
"body",
"nativeBlockedBy",
"url",
"labels",
"currentLane",
Expand Down
5 changes: 4 additions & 1 deletion src/app/api/issues/groom/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -392,13 +392,16 @@ describe("POST /api/issues/groom — promote_to_ready", () => {
updated_at: "2026-08-15T04:00:00Z",
closed_at: null,
state: "open",
nativeBlockedBy: ["misospace/dispatch#41"],
});
const res = await groomRequest({ issueId: "i1", repoFullName: "r/r", issueNumber: 42, action: "promote_to_ready" });
expect(res.status).toBe(200);
expect(mocks.fetchIssue).toHaveBeenCalledWith("misospace/dispatch", 42);
expect(mocks.fetchIssue).toHaveBeenCalledWith("misospace/dispatch", 42, { includeNativeBlockedBy: true });
Comment thread
itsmiso-ai marked this conversation as resolved.
// The refresh writes the freshly-fetched labels back to the cached row.
const refreshCall = mocks.updateIssue.mock.calls.at(-1)![0];
expect(refreshCall.data!.labels).toEqual(["status/ready", "priority/p2"]);
// ...and the freshly-fetched native blocked_by keys.
expect(refreshCall.data!.nativeBlockedBy).toEqual(["misospace/dispatch#41"]);
});
});

Expand Down
7 changes: 6 additions & 1 deletion src/app/api/issues/groom/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -201,14 +201,19 @@ export async function POST(request: Request) {
// Best-effort: a refresh failure must not fail the groom, since the
// label change itself already succeeded.
try {
const refreshResult = await refreshSingleIssue(effectiveRepo, effectiveNumber, fetchIssueFromGitHub);
const refreshResult = await refreshSingleIssue(
effectiveRepo,
effectiveNumber,
(repo, num) => fetchIssueFromGitHub(repo, num, { includeNativeBlockedBy: true }),
);
if (refreshResult.success && refreshResult.issueData) {
const issueData = refreshResult.issueData;
await prisma.issue.update({
where: { id: effectiveIssueId },
data: {
title: issueData.title,
body: issueData.body,
nativeBlockedBy: issueData.nativeBlockedBy,
url: issueData.url,
labels: issueData.labels,
assignees: issueData.assignees,
Expand Down
79 changes: 59 additions & 20 deletions src/app/api/issues/refresh/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,20 @@ const { mocks } = vi.hoisted(() => ({
findUnique: vi.fn(),
update: vi.fn().mockResolvedValue(undefined),
create: vi.fn().mockResolvedValue({ id: "issue-1" }),
fetchIssue: vi.fn().mockResolvedValue({
number: 42,
title: "Test Issue",
body: "Body",
html_url: "https://example.com",
labels: [],
assignees: [],
comments: 0,
created_at: "2026-08-15T03:00:00Z",
updated_at: "2026-08-15T04:00:00Z",
closed_at: null,
state: "open",
nativeBlockedBy: ["org/repo#7"],
}),
},
}));

Expand All @@ -25,32 +39,44 @@ vi.mock("@/lib/prisma", () => ({
},
}));

vi.mock("@/lib/github", () => ({
fetchIssue: mocks.fetchIssue,
}));

vi.mock("@/lib/config", () => ({
getSyncRepos: vi.fn().mockResolvedValue([{ id: "repo-1", fullName: "org/repo" }]),
parseExcludedLabels: vi.fn().mockReturnValue([]),
}));

vi.mock("@/lib/issue-sync", () => ({
defaultCurrentLane: vi.fn().mockReturnValue("default"),
refreshSingleIssue: vi.fn().mockResolvedValue({
success: true,
repo: { id: "repo-1", fullName: "org/repo" },
issueNumber: 42,
issueData: {
repositoryId: "repo-1",
number: 42,
title: "Test Issue",
body: "Body",
url: "https://example.com",
labels: [],
assignees: [],
commentsCount: 0,
updatedAt: new Date(),
closedAt: null,
state: "open",
lastSyncedAt: new Date(),
// Invoke the fetcher the route passes in so the includeNativeBlockedBy
// option is observable on the mocked fetchIssue.
refreshSingleIssue: vi.fn().mockImplementation(
async (repo: string, issueNumber: number, fetchIssueFn: (repo: string, issueNumber: number) => Promise<unknown>) => {
await fetchIssueFn(repo, issueNumber);
return {
success: true,
repo: { id: "repo-1", fullName: repo },
issueNumber,
issueData: {
repositoryId: "repo-1",
number: issueNumber,
title: "Test Issue",
body: "Body",
url: "https://example.com",
labels: [],
assignees: [],
commentsCount: 0,
updatedAt: new Date(),
closedAt: null,
state: "open",
lastSyncedAt: new Date(),
nativeBlockedBy: ["org/repo#7"],
},
};
},
}),
),
}));

import { POST } from "./route";
Expand Down Expand Up @@ -124,9 +150,15 @@ describe("POST /api/issues/refresh — business logic", () => {
const body = await res.json();
expect(body.success).toBe(true);
expect(body.action).toBe("updated");
// The single-issue fetch is enriched with native blocked_by keys.
expect(mocks.fetchIssue).toHaveBeenCalledWith("org/repo", 42, { includeNativeBlockedBy: true });
// ...and the fetched keys are persisted with the refresh.
expect(mocks.update).toHaveBeenCalledWith({
where: { id: "issue-existing" },
data: expect.objectContaining({ title: "Test Issue" }),
data: expect.objectContaining({
title: "Test Issue",
nativeBlockedBy: ["org/repo#7"],
}),
});
});

Expand All @@ -136,6 +168,13 @@ describe("POST /api/issues/refresh — business logic", () => {
expect(res.status).toBe(200);
const body = await res.json();
expect(body.action).toBe("created");
expect(mocks.create).toHaveBeenCalled();
expect(mocks.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
number: 99,
nativeBlockedBy: ["org/repo#7"],
}),
}),
);
});
});
7 changes: 6 additions & 1 deletion src/app/api/issues/refresh/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,11 @@ export async function POST(request: NextRequest) {
return errorResponse(`Repo ${repoFullName} is not tracked. Track it first via /api/repos or the UI.`, 404);
}

const refreshResult = await refreshSingleIssue(repoFullName, issueNumber, fetchIssueFromGitHub);
const refreshResult = await refreshSingleIssue(
Comment thread
itsmiso-ai marked this conversation as resolved.
repoFullName,
issueNumber,
(repo, num) => fetchIssueFromGitHub(repo, num, { includeNativeBlockedBy: true }),
);

if (!refreshResult.success) {
return NextResponse.json(
Expand All @@ -57,6 +61,7 @@ export async function POST(request: NextRequest) {
data: {
title: issueData.title,
body: issueData.body,
nativeBlockedBy: issueData.nativeBlockedBy,
url: issueData.url,
labels: issueData.labels,
assignees: issueData.assignees,
Expand Down
2 changes: 1 addition & 1 deletion src/app/api/sync/scheduled/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -412,7 +412,7 @@ describe("POST /api/sync/scheduled — sync behavior", () => {
await POST(makeRequest());
// Open issues are always full-fetched (dispatch#991); the closed tail is
// fetched separately so closedIssueStatusFix still runs (#521).
expect(github.fetchIssues).toHaveBeenCalledWith(expect.any(String), { state: "open" });
expect(github.fetchIssues).toHaveBeenCalledWith(expect.any(String), { state: "open", includeNativeBlockers: true });
expect(github.fetchIssues).toHaveBeenCalledWith(expect.any(String), expect.objectContaining({ state: "closed" }));
});

Expand Down
1 change: 1 addition & 0 deletions src/lib/agent-queue-fetch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,7 @@ export async function fetchAgentQueueData(
createdAt: true,
title: true,
body: true,
nativeBlockedBy: true,
url: true,
labels: true,
currentLane: true,
Expand Down
77 changes: 76 additions & 1 deletion src/lib/agent-queue.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,15 @@ import { buildAgentQueue, isRenovateIssue, issueAgeDays } from "./agent-queue";
import { setLaneConfig, resetLaneConfig } from "./lane-config";
import { dependencyKey } from "./issue-dependencies";

const makeIssue = (overrides: Partial<{ number: number; title: string; url: string; labels: string[]; lane?: string; body?: string | null; repoFullName?: string }> = {}) => ({
const makeIssue = (overrides: Partial<{ number: number; title: string; url: string; labels: string[]; lane?: string; body?: string | null; repoFullName?: string; nativeBlockedBy?: string[] }> = {}) => ({
number: overrides.number ?? 1,
title: overrides.title ?? "Test issue",
url: overrides.url ?? "https://github.com/test/repo/issues/1",
labels: overrides.labels ?? [],
lane: overrides.lane,
body: overrides.body,
repoFullName: overrides.repoFullName,
nativeBlockedBy: overrides.nativeBlockedBy,
});

describe("isRenovateIssue", () => {
Expand Down Expand Up @@ -1110,6 +1111,80 @@ describe("buildAgentQueue dependency gating (issue #1038)", () => {
});
});

describe("buildAgentQueue native blocked_by gating (issue #1086)", () => {
const nativeBlockedIssue = (overrides: {
number?: number;
nativeBlockedBy?: string[];
body?: string | null;
repoFullName?: string;
} = {}) =>
makeIssue({
number: overrides.number ?? 1,
labels: ["status/ready", "priority/p1"],
body: overrides.body ?? null,
repoFullName: overrides.repoFullName ?? "test/repo",
nativeBlockedBy: overrides.nativeBlockedBy,
});

it("withholds a ready issue with only a native blocker from the claimable queue", () => {
const issues = [nativeBlockedIssue({ nativeBlockedBy: ["test/repo#5"] })];
const result = buildAgentQueue(issues, "worker-agent", {
openIssueKeys: new Set([dependencyKey("test/repo", 5)]),
});
expect(result).toHaveLength(0);
});

it("surfaces blockedBy and dependencyBlockReason with claimableOnly=false", () => {
const issues = [nativeBlockedIssue({ nativeBlockedBy: ["test/repo#5"] })];
const result = buildAgentQueue(issues, "worker-agent", {
openIssueKeys: new Set([dependencyKey("test/repo", 5)]),
claimableOnly: false,
});
expect(result).toHaveLength(1);
expect(result[0].blockedBy).toEqual([5]);
expect(result[0].dependencyBlockReason).toBe("Blocked by open #5");
expect(result[0].claimable).toBe(false);
});

it("becomes claimable once the native blocker closes", () => {
const issues = [nativeBlockedIssue({ nativeBlockedBy: ["test/repo#5"] })];
const result = buildAgentQueue(issues, "worker-agent", {
openIssueKeys: new Set<string>(),
});
expect(result).toHaveLength(1);
expect(result[0].claimable).toBe(true);
expect(result[0].dependencyBlockReason).toBe("");
});

it("dedupes native and body refs for the same blocker", () => {
const issues = [
nativeBlockedIssue({ nativeBlockedBy: ["test/repo#5"], body: "depends on #5" }),
];
const result = buildAgentQueue(issues, "worker-agent", {
openIssueKeys: new Set([dependencyKey("test/repo", 5)]),
claimableOnly: false,
});
expect(result).toHaveLength(1);
expect(result[0].blockedBy).toEqual([5]);
});

it("gates on a native cross-repo blocker", () => {
const issues = [
nativeBlockedIssue({ nativeBlockedBy: ["other/repo#9"], repoFullName: "test/repo" }),
];
const gated = buildAgentQueue(issues, "worker-agent", {
openIssueKeys: new Set([dependencyKey("other/repo", 9)]),
});
expect(gated).toHaveLength(0);

const ranked = buildAgentQueue(issues, "worker-agent", {
openIssueKeys: new Set([dependencyKey("other/repo", 9)]),
claimableOnly: false,
});
expect(ranked[0].dependencyBlockReason).toContain("other/repo#9");
});
});

// ─── Anti-starvation aging ──────────────────────────────────────────────────

const daysAgo = (n: number) => new Date(Date.now() - n * 86_400_000);
Expand Down
32 changes: 26 additions & 6 deletions src/lib/agent-queue.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ import { isIssueExcludedByLabels, isRenovateIssue } from "@/lib/issue-filters";
import { isBacklogLane, resolveLaneId, laneMatchesConfigured } from "@/lib/lane-config";
import {
parseIssueDependencies,
parseNativeBlockedBy,
mergeDependencyRefs,
resolveOpenBlockers,
formatDependencyBlockReason,
} from "@/lib/issue-dependencies";
Expand Down Expand Up @@ -210,6 +212,21 @@ function isClaimableStatus(labels: string[]): boolean {
return true;
}

/**
* All dependency refs for one issue: GitHub-native `blocked_by` links merged
* with body-parsed refs, deduped by dependencyKey (native first).
*/
function issueDependencyRefs(issue: {
body?: string | null;
nativeBlockedBy?: string[] | null;
repoFullName?: string;
}) {
return mergeDependencyRefs(
parseNativeBlockedBy(issue.nativeBlockedBy, issue.repoFullName),
parseIssueDependencies(issue.body),
);
}

/**
* Build the agent queue: filter, rank, and return issues for a given agent.
* Optionally filters by execution lane. By default excludes backlog lane items.
Expand All @@ -227,8 +244,9 @@ function isClaimableStatus(labels: string[]): boolean {
* Pass claimableOnly=false to include all actionable issues including backlog.
* Excludes issues with labels matching DISPATCH_EXCLUDED_LABELS by default.
* Dependency gating: pass openIssueKeys (a Set of dependencyKey strings for
* currently-open issues) to exclude claimable issues whose body declares an
* open blocker (e.g. "depends on #5"). Only applied when claimableOnly is
* currently-open issues) to exclude claimable issues that declare an open
* blocker in its body or via a native GitHub `blocked_by` link (e.g.
* "depends on #5"). Only applied when claimableOnly is
* true; when omitted the queue behaves as before. Every result item carries
* blockedBy (open blocker numbers) and dependencyBlockReason ("" when none).
*/
Expand All @@ -243,6 +261,7 @@ export function buildAgentQueue(
issueId?: string;
repoFullName?: string;
body?: string | null;
nativeBlockedBy?: string[] | null;
linkedPrHealth?: QueueLinkedPrHealth | null;
createdAt?: Date | string | null;
}>,
Expand Down Expand Up @@ -306,12 +325,13 @@ export function buildAgentQueue(
}

// Dependency gating: a claimable issue that declares an open blocker in its
// body (e.g. "depends on #5") is withheld until the blocker closes. Only
// active when openIssueKeys is provided and the claimable filter is on.
// body or via a native `blocked_by` link (e.g. "depends on #5") is withheld
// until the blocker closes. Only active when openIssueKeys is provided and
// the claimable filter is on.
if (openIssueKeys !== undefined && claimableOnly) {
actionable = actionable.filter((issue) => {
const blockers = resolveOpenBlockers(
parseIssueDependencies(issue.body),
issueDependencyRefs(issue),
openIssueKeys,
issue.repoFullName,
{ repo: issue.repoFullName, number: issue.number },
Expand Down Expand Up @@ -358,7 +378,7 @@ export function buildAgentQueue(
const status = getStatusFromLabels(item.labels);
const openBlockers = openIssueKeys
? resolveOpenBlockers(
parseIssueDependencies(item.body),
issueDependencyRefs(item),
openIssueKeys,
item.repoFullName,
{ repo: item.repoFullName, number: item.number },
Expand Down
Loading
Loading