diff --git a/prisma/migrations/20261002000000_add_issue_native_blocked_by/migration.sql b/prisma/migrations/20261002000000_add_issue_native_blocked_by/migration.sql new file mode 100644 index 00000000..e991d140 --- /dev/null +++ b/prisma/migrations/20261002000000_add_issue_native_blocked_by/migration.sql @@ -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[]; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index f338fc12..4b67fe2a 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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 diff --git a/src/app/api/agents/[agentName]/queue-admission.test.ts b/src/app/api/agents/[agentName]/queue-admission.test.ts index 1097b2cb..b4654599 100644 --- a/src/app/api/agents/[agentName]/queue-admission.test.ts +++ b/src/app/api/agents/[agentName]/queue-admission.test.ts @@ -166,6 +166,7 @@ const PRE_ADMISSION_SELECT_KEYS = [ "createdAt", "title", "body", + "nativeBlockedBy", "url", "labels", "currentLane", diff --git a/src/app/api/issues/groom/route.test.ts b/src/app/api/issues/groom/route.test.ts index c01ca461..15b5d780 100644 --- a/src/app/api/issues/groom/route.test.ts +++ b/src/app/api/issues/groom/route.test.ts @@ -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 }); // 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"]); }); }); diff --git a/src/app/api/issues/groom/route.ts b/src/app/api/issues/groom/route.ts index ec57b0e7..0833d95c 100644 --- a/src/app/api/issues/groom/route.ts +++ b/src/app/api/issues/groom/route.ts @@ -201,7 +201,11 @@ 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({ @@ -209,6 +213,7 @@ export async function POST(request: Request) { data: { title: issueData.title, body: issueData.body, + nativeBlockedBy: issueData.nativeBlockedBy, url: issueData.url, labels: issueData.labels, assignees: issueData.assignees, diff --git a/src/app/api/issues/refresh/route.test.ts b/src/app/api/issues/refresh/route.test.ts index 0122a721..9316b339 100644 --- a/src/app/api/issues/refresh/route.test.ts +++ b/src/app/api/issues/refresh/route.test.ts @@ -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"], + }), }, })); @@ -25,6 +39,10 @@ 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([]), @@ -32,25 +50,33 @@ vi.mock("@/lib/config", () => ({ 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) => { + 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"; @@ -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"], + }), }); }); @@ -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"], + }), + }), + ); }); }); diff --git a/src/app/api/issues/refresh/route.ts b/src/app/api/issues/refresh/route.ts index 99074a89..6dfdcf56 100644 --- a/src/app/api/issues/refresh/route.ts +++ b/src/app/api/issues/refresh/route.ts @@ -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( + repoFullName, + issueNumber, + (repo, num) => fetchIssueFromGitHub(repo, num, { includeNativeBlockedBy: true }), + ); if (!refreshResult.success) { return NextResponse.json( @@ -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, diff --git a/src/app/api/sync/scheduled/route.test.ts b/src/app/api/sync/scheduled/route.test.ts index 51331345..ea851d41 100644 --- a/src/app/api/sync/scheduled/route.test.ts +++ b/src/app/api/sync/scheduled/route.test.ts @@ -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" })); }); diff --git a/src/lib/agent-queue-fetch.ts b/src/lib/agent-queue-fetch.ts index 0336d246..8f34a2dd 100644 --- a/src/lib/agent-queue-fetch.ts +++ b/src/lib/agent-queue-fetch.ts @@ -122,6 +122,7 @@ export async function fetchAgentQueueData( createdAt: true, title: true, body: true, + nativeBlockedBy: true, url: true, labels: true, currentLane: true, diff --git a/src/lib/agent-queue.test.ts b/src/lib/agent-queue.test.ts index a9e67e77..d0bed70a 100644 --- a/src/lib/agent-queue.test.ts +++ b/src/lib/agent-queue.test.ts @@ -3,7 +3,7 @@ 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", @@ -11,6 +11,7 @@ const makeIssue = (overrides: Partial<{ number: number; title: string; url: stri lane: overrides.lane, body: overrides.body, repoFullName: overrides.repoFullName, + nativeBlockedBy: overrides.nativeBlockedBy, }); describe("isRenovateIssue", () => { @@ -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(), + }); + 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); diff --git a/src/lib/agent-queue.ts b/src/lib/agent-queue.ts index f35a3256..de0045d2 100644 --- a/src/lib/agent-queue.ts +++ b/src/lib/agent-queue.ts @@ -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"; @@ -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. @@ -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). */ @@ -243,6 +261,7 @@ export function buildAgentQueue( issueId?: string; repoFullName?: string; body?: string | null; + nativeBlockedBy?: string[] | null; linkedPrHealth?: QueueLinkedPrHealth | null; createdAt?: Date | string | null; }>, @@ -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 }, @@ -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 }, diff --git a/src/lib/github-facades.test.ts b/src/lib/github-facades.test.ts index d54eb211..f62d48d1 100644 --- a/src/lib/github-facades.test.ts +++ b/src/lib/github-facades.test.ts @@ -22,6 +22,7 @@ describe("github domain modules expose expected exports", () => { "createIssue", "fetchIssue", "fetchIssueComments", + "fetchIssueNativeBlockers", "fetchIssues", "removeIssueLabel", "syncStatusLabels", diff --git a/src/lib/github-issues.test.ts b/src/lib/github-issues.test.ts new file mode 100644 index 00000000..2d909ce5 --- /dev/null +++ b/src/lib/github-issues.test.ts @@ -0,0 +1,250 @@ +// @vitest-environment node +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { GitHubIssue } from "@/types"; +import { fetchIssue, fetchIssueNativeBlockers, fetchIssues } from "./github-issues"; + +const fetchWithRetry = vi.fn(); +const fetchPaginated = vi.fn(); + +vi.mock("./github-auth", () => ({ + GITHUB_API: "https://api.github.com", + getHeadersAsync: async () => ({}), + fetchPaginated: (...args: unknown[]) => fetchPaginated(...args), + fetchWithRetry: (...args: unknown[]) => fetchWithRetry(...args), +})); + +const okJson = (data: unknown) => ({ ok: true, status: 200, json: async () => data }); +const notOk = { ok: false, status: 404, json: async () => ({}), text: async () => "" }; + +function makeIssue( + overrides: Partial & { number: number }, +): GitHubIssue { + return { + title: `issue ${overrides.number}`, + body: null, + state: "open", + html_url: `https://github.com/acme/app/issues/${overrides.number}`, + labels: [], + assignees: [], + comments: 0, + created_at: "2025-01-01T00:00:00Z", + updated_at: "2025-01-01T00:00:00Z", + closed_at: null, + ...overrides, + }; +} + +const blockedByUrl = (n: number) => + `https://api.github.com/repos/acme/app/issues/${n}/dependencies/blocked_by?per_page=100`; + +beforeEach(() => { + vi.clearAllMocks(); + // Silence the [dispatch] native blocked_by warn noise from failure paths. + vi.spyOn(console, "warn").mockImplementation(() => {}); +}); + +describe("fetchIssues native blocker ingestion", () => { + it("makes no blocked_by call and sets no nativeBlockedBy without includeNativeBlockers", async () => { + fetchPaginated.mockResolvedValue([ + // blocked_by = 2 must NOT trigger a fetch while enrichment is off. + makeIssue({ number: 1, issue_dependencies_summary: { blocked_by: 2, blocking: 0 } }), + makeIssue({ number: 2, issue_dependencies_summary: { blocked_by: 0, blocking: 0 } }), + makeIssue({ number: 3 }), + ]); + fetchWithRetry.mockImplementation(async (url: string) => { + if (url.includes("/dependencies/blocked_by")) { + throw new Error("blocked_by endpoint should not be called"); + } + throw new Error("unexpected fetch"); + }); + + const issues = await fetchIssues("acme/app"); + + const blockedByCalls = fetchWithRetry.mock.calls.filter( + (call) => typeof call[0] === "string" && call[0].includes("/dependencies/blocked_by"), + ); + expect(blockedByCalls).toHaveLength(0); + for (const issue of issues) { + expect((issue as any).nativeBlockedBy).toBeUndefined(); + } + expect(issues).toHaveLength(3); + }); + + it("with includeNativeBlockers, skips the call for zero/absent summaries and fetches once for blockers", async () => { + fetchPaginated.mockResolvedValue([ + makeIssue({ number: 5, issue_dependencies_summary: { blocked_by: 1, blocking: 0 } }), + makeIssue({ number: 6, issue_dependencies_summary: { blocked_by: 0, blocking: 0 } }), + makeIssue({ number: 7 }), + ]); + fetchWithRetry.mockImplementation(async (url: string) => { + if (url.includes("/dependencies/blocked_by")) { + return okJson([ + { + number: 7, + repository_url: "https://api.github.com/repositories/123", + html_url: "https://github.com/acme/other/issues/7", + }, + ]); + } + throw new Error("unexpected fetch"); + }); + + const issues = await fetchIssues("acme/app", { includeNativeBlockers: true }); + + const blockedByCalls = fetchWithRetry.mock.calls.filter( + (call) => typeof call[0] === "string" && call[0].includes("/dependencies/blocked_by"), + ); + expect(blockedByCalls).toHaveLength(1); + expect(blockedByCalls[0][0]).toBe(blockedByUrl(5)); + + expect(issues.find((i) => i.number === 5)?.nativeBlockedBy).toEqual(["acme/other#7"]); + expect(issues.find((i) => i.number === 6)?.nativeBlockedBy).toEqual([]); + // No summary → unknown, not none: nativeBlockedBy must stay unset. + expect(issues.find((i) => i.number === 7)?.nativeBlockedBy).toBeUndefined(); + }); + + it("treats absent and null issue_dependencies_summary as unknown (no call, no nativeBlockedBy)", async () => { + fetchPaginated.mockResolvedValue([ + // No issue_dependencies_summary field at all. + makeIssue({ number: 10 }), + // Explicit null summary. + makeIssue({ number: 11, issue_dependencies_summary: null }), + ]); + fetchWithRetry.mockImplementation(async (url: string) => { + if (url.includes("/dependencies/blocked_by")) { + throw new Error("blocked_by endpoint should not be called"); + } + throw new Error("unexpected fetch"); + }); + + const issues = await fetchIssues("acme/app", { includeNativeBlockers: true }); + + const blockedByCalls = fetchWithRetry.mock.calls.filter( + (call) => typeof call[0] === "string" && call[0].includes("/dependencies/blocked_by"), + ); + expect(blockedByCalls).toHaveLength(0); + for (const issue of issues) { + expect((issue as any).nativeBlockedBy).toBeUndefined(); + } + expect(issues).toHaveLength(2); + }); +}); + +describe("fetchIssueNativeBlockers", () => { + it("skips PR items and unparseable items, and dedupes", async () => { + fetchWithRetry.mockResolvedValue( + okJson([ + // html_url is slug form; repository_url is the id form (no slug). + { + number: 7, + repository_url: "https://api.github.com/repositories/123", + html_url: "https://github.com/acme/other/issues/7", + }, + { + number: 8, + repository_url: "https://api.github.com/repositories/456", + html_url: "https://github.com/acme/other/issues/8", + pull_request: { url: "https://api.github.com/repos/acme/other/pull/8" }, + }, + // id-form repository_url only, no html_url → unparseable, dropped. + { number: 9, repository_url: "https://api.github.com/repositories/789" }, + { + number: 7, + repository_url: "https://api.github.com/repositories/123", + html_url: "https://github.com/acme/other/issues/7", + }, + { + number: 0, + repository_url: "https://api.github.com/repositories/123", + html_url: "https://github.com/acme/other/issues/0", + }, + ]), + ); + + const keys = await fetchIssueNativeBlockers("acme/app", 1); + + expect(keys).toEqual(["acme/other#7"]); + }); + + it("returns null when the response is not ok", async () => { + fetchWithRetry.mockResolvedValue(notOk); + + expect(await fetchIssueNativeBlockers("acme/app", 1)).toBeNull(); + }); + + it("returns null when fetchWithRetry throws", async () => { + fetchWithRetry.mockRejectedValue(new Error("network down")); + + expect(await fetchIssueNativeBlockers("acme/app", 1)).toBeNull(); + }); + + it("returns [] when the response is ok with an empty list", async () => { + fetchWithRetry.mockResolvedValue(okJson([])); + + expect(await fetchIssueNativeBlockers("acme/app", 1)).toEqual([]); + }); + + it("returns null when the response body is not an array", async () => { + fetchWithRetry.mockResolvedValue(okJson({ message: "not an array" })); + + expect(await fetchIssueNativeBlockers("acme/app", 1)).toBeNull(); + }); +}); + +describe("fetchIssue enrichment opt-in", () => { + it("does not call blocked_by without the flag", async () => { + fetchWithRetry.mockImplementation(async (url: string) => { + if (url.includes("/dependencies/blocked_by")) { + throw new Error("blocked_by endpoint should not be called"); + } + return okJson(makeIssue({ number: 4 })); + }); + + const issue = await fetchIssue("acme/app", 4); + + expect(issue.number).toBe(4); + expect(issue.nativeBlockedBy).toBeUndefined(); + const blockedByCalls = fetchWithRetry.mock.calls.filter( + (call) => typeof call[0] === "string" && call[0].includes("/dependencies/blocked_by"), + ); + expect(blockedByCalls).toHaveLength(0); + }); + + it("attaches nativeBlockedBy when the flag is set and the fetch succeeds", async () => { + fetchWithRetry.mockImplementation(async (url: string) => { + if (url.includes("/dependencies/blocked_by")) { + return okJson([ + { + number: 7, + repository_url: "https://api.github.com/repositories/123", + html_url: "https://github.com/acme/other/issues/7", + }, + ]); + } + return okJson(makeIssue({ number: 4 })); + }); + + const issue = await fetchIssue("acme/app", 4, { includeNativeBlockedBy: true }); + + expect(issue.nativeBlockedBy).toEqual(["acme/other#7"]); + const blockedByCalls = fetchWithRetry.mock.calls.filter( + (call) => typeof call[0] === "string" && call[0].includes("/dependencies/blocked_by"), + ); + expect(blockedByCalls).toHaveLength(1); + expect(blockedByCalls[0][0]).toBe(blockedByUrl(4)); + }); + + it("leaves nativeBlockedBy unset when the blocked_by call fails", async () => { + fetchWithRetry.mockImplementation(async (url: string) => { + if (url.includes("/dependencies/blocked_by")) { + return notOk; + } + return okJson(makeIssue({ number: 4 })); + }); + + const issue = await fetchIssue("acme/app", 4, { includeNativeBlockedBy: true }); + + expect(issue.number).toBe(4); + expect((issue as any).nativeBlockedBy).toBeUndefined(); + }); +}); diff --git a/src/lib/github-issues.ts b/src/lib/github-issues.ts index 33d0dfa6..6ade37cf 100644 --- a/src/lib/github-issues.ts +++ b/src/lib/github-issues.ts @@ -1,9 +1,32 @@ import { GitHubIssue } from "@/types"; import { GITHUB_API, getHeadersAsync, fetchPaginated, fetchWithRetry } from "./github-auth"; +import { dependencyKey } from "./issue-dependencies"; + +interface BlockedByItem { + number?: number; + repository_url?: string; + html_url?: string; + pull_request?: { url?: string }; +} + +function repoFromBlockedByItem(item: BlockedByItem): string | null { + // Prefer html_url — always slug form: https://github.com/{owner}/{repo}/issues/{n}. + const html = /github\.com\/([^/]+\/[^/]+)\/issues\/\d+/i.exec(item.html_url ?? ""); + if (html) return html[1]; + // Fallback: slug-form repository_url. Many payloads use the id form + // (https://api.github.com/repositories/{id}) which has no slug here (#1086). + const repo = /\/repos\/([^/]+\/[^/]+)/.exec(item.repository_url ?? ""); + return repo ? repo[1] : null; +} export async function fetchIssues( repoFullName: string, - options?: { includeClosed?: boolean; state?: "open" | "closed" | "all"; since?: Date }, + options?: { + includeClosed?: boolean; + state?: "open" | "closed" | "all"; + since?: Date; + includeNativeBlockers?: boolean; + }, ): Promise { const [owner, repo] = repoFullName.split("/"); // `state` wins when given (lets callers fetch only the closed tail); @@ -15,10 +38,38 @@ export async function fetchIssues( } const all = await fetchPaginated(url); - return all.filter((issue: GitHubIssue) => !issue.pull_request); + const issues = all.filter((issue: GitHubIssue) => !issue.pull_request); + return options?.includeNativeBlockers ? enrichNativeBlockers(repoFullName, issues) : issues; } -export async function fetchIssue(repoFullName: string, issueNumber: number): Promise { +async function enrichNativeBlockers(repoFullName: string, issues: GitHubIssue[]): Promise { + const out: GitHubIssue[] = []; + for (const issue of issues) { + // Absent/null issue_dependencies_summary means the count is unknown, not + // zero: leave nativeBlockedBy unset (no fetch) so sync preserves + // last-known keys (#1086). + const summary = issue.issue_dependencies_summary; + if (summary === null || summary === undefined) { + out.push(issue); + continue; + } + const blocked = summary.blocked_by ?? 0; + if (blocked <= 0) { + out.push({ ...issue, nativeBlockedBy: [] }); + continue; + } + const fetched = await fetchIssueNativeBlockers(repoFullName, issue.number); + // null (fetch failed) → leave nativeBlockedBy unset so sync preserves last-known keys (#1086). + out.push(fetched === null ? issue : { ...issue, nativeBlockedBy: fetched }); + } + return out; +} + +export async function fetchIssue( + repoFullName: string, + issueNumber: number, + options?: { includeNativeBlockedBy?: boolean }, +): Promise { const [owner, repo] = repoFullName.split("/"); const url = `${GITHUB_API}/repos/${owner}/${repo}/issues/${issueNumber}`; const response = await fetchWithRetry(url, { headers: await getHeadersAsync() }); @@ -34,9 +85,59 @@ export async function fetchIssue(repoFullName: string, issueNumber: number): Pro throw new Error(`#${issueNumber} is a pull request, not an issue`); } + if (options?.includeNativeBlockedBy) { + const fetched = await fetchIssueNativeBlockers(repoFullName, issueNumber); + return fetched === null ? data : { ...data, nativeBlockedBy: fetched }; + } return data; } +/** + * Fetch native `blocked_by` links for one issue as canonical `owner/repo#N` + * keys (repo lowercased via dependencyKey). Returns `string[] | null`: null = + * fetch failed (caller must preserve last-known keys); [] = authoritatively + * known-none. Best-effort, never throws; pull-request blockers (GitHub allows + * PR dependencies) are skipped, and items whose repo cannot be derived are + * skipped rather than mis-attributed to the caller's repo. Results are + * deduped. + */ +export async function fetchIssueNativeBlockers( + repoFullName: string, + issueNumber: number, +): Promise { + const [owner, repo] = repoFullName.split("/"); + const url = `${GITHUB_API}/repos/${owner}/${repo}/issues/${issueNumber}/dependencies/blocked_by?per_page=100`; + try { + const response = await fetchWithRetry(url, { headers: await getHeadersAsync() }); + if (!response.ok) { + console.warn(`[dispatch] native blocked_by fetch failed for ${repoFullName}#${issueNumber}: HTTP ${response.status}`); + return null; + } + const data = await response.json(); + if (!Array.isArray(data)) { + console.warn(`[dispatch] native blocked_by unexpected payload for ${repoFullName}#${issueNumber}`); + return null; + } + const keys = new Set(); + for (const item of data as BlockedByItem[]) { + if (!item || typeof item.number !== "number" || item.number <= 0 || item.pull_request) continue; + const depRepo = repoFromBlockedByItem(item); + if (!depRepo) { + console.warn(`[dispatch] native blocked_by item with unparseable repo for ${repoFullName}#${issueNumber}`); + continue; + } + keys.add(dependencyKey(depRepo, item.number)); + } + return Array.from(keys); + } catch (error) { + console.warn( + `[dispatch] native blocked_by fetch errored for ${repoFullName}#${issueNumber}:`, + error instanceof Error ? error.message : String(error), + ); + return null; + } +} + export async function updateIssueLabels( repoFullName: string, issueNumber: number, diff --git a/src/lib/issue-dependencies.test.ts b/src/lib/issue-dependencies.test.ts index 9b554f66..ea75027f 100644 --- a/src/lib/issue-dependencies.test.ts +++ b/src/lib/issue-dependencies.test.ts @@ -2,8 +2,10 @@ import { describe, expect, it } from "vitest"; import { dependencyKey, formatDependencyBlockReason, + mergeDependencyRefs, normalizeRepoKey, parseIssueDependencies, + parseNativeBlockedBy, resolveOpenBlockers, } from "./issue-dependencies"; @@ -294,3 +296,97 @@ describe("parseIssueDependencies negation guard", () => { ]); }); }); + +describe("parseNativeBlockedBy", () => { + it("keeps a cross-repo key with its repo", () => { + expect(parseNativeBlockedBy(["bar/repo#20"], "foo/repo")).toEqual([ + { repo: "bar/repo", number: 20 }, + ]); + }); + + it("collapses a same-repo key to repo: null", () => { + expect(parseNativeBlockedBy(["foo/repo#20"], "foo/repo")).toEqual([ + { repo: null, number: 20 }, + ]); + }); + + it("treats a bare #N key as same-repo", () => { + expect(parseNativeBlockedBy(["#20"], "foo/repo")).toEqual([{ repo: null, number: 20 }]); + expect(parseNativeBlockedBy(["#20"])).toEqual([{ repo: null, number: 20 }]); + }); + + it("matches the same-repo collapse case-insensitively", () => { + expect(parseNativeBlockedBy(["Foo/Repo#20"], "foo/repo")).toEqual([ + { repo: null, number: 20 }, + ]); + }); + + it("skips invalid keys", () => { + expect(parseNativeBlockedBy(["garbage", "foo/repo#0", "foo/repo#abc", ""])).toEqual([]); + }); + + it("dedupes duplicates by dependencyKey", () => { + expect(parseNativeBlockedBy(["foo/repo#20", "foo/repo#20", "#20"], "foo/repo")).toEqual([ + { repo: null, number: 20 }, + ]); + expect(parseNativeBlockedBy(["bar/repo#20", "bar/repo#20"], "foo/repo")).toEqual([ + { repo: "bar/repo", number: 20 }, + ]); + }); + + it("returns [] for null, undefined, and empty input", () => { + expect(parseNativeBlockedBy(null)).toEqual([]); + expect(parseNativeBlockedBy(undefined)).toEqual([]); + expect(parseNativeBlockedBy([])).toEqual([]); + }); + + it("uses the last # so odd repo parts do not break parsing", () => { + expect(parseNativeBlockedBy(["weird#name/repo#30"], "other/repo")).toEqual([ + { repo: "weird#name/repo", number: 30 }, + ]); + }); +}); + +describe("mergeDependencyRefs", () => { + it("dedupes identical native and body refs by dependencyKey", () => { + expect( + mergeDependencyRefs( + [{ repo: null, number: 5 }], + [{ repo: null, number: 5 }], + ), + ).toEqual([{ repo: null, number: 5 }]); + }); + + it("keeps distinct cross-repo refs, first occurrence wins", () => { + expect( + mergeDependencyRefs( + [{ repo: "bar/repo", number: 20 }, { repo: null, number: 5 }], + [{ repo: "BAR/repo", number: 20 }, { repo: null, number: 6 }], + ), + ).toEqual([ + { repo: "bar/repo", number: 20 }, + { repo: null, number: 5 }, + { repo: null, number: 6 }, + ]); + }); + + it("treats null and undefined lists as empty", () => { + expect(mergeDependencyRefs(null, undefined, [{ repo: null, number: 5 }])).toEqual([ + { repo: null, number: 5 }, + ]); + expect(mergeDependencyRefs()).toEqual([]); + }); + + it("merges parsed native keys and body refs into one deduped list", () => { + const merged = mergeDependencyRefs( + parseNativeBlockedBy(["test/repo#5"], "test/repo"), + parseIssueDependencies("depends on #5"), + ); + expect(merged).toHaveLength(1); + expect(merged[0]).toEqual({ repo: null, number: 5 }); + // Resolved against the depending issue's repo (as the gate does): + expect(dependencyKey(merged[0].repo ?? "test/repo", merged[0].number)).toBe( + dependencyKey("test/repo", 5), + ); + }); +}); diff --git a/src/lib/issue-dependencies.ts b/src/lib/issue-dependencies.ts index 6d8163b1..31933bfa 100644 --- a/src/lib/issue-dependencies.ts +++ b/src/lib/issue-dependencies.ts @@ -98,10 +98,10 @@ const NEGATION_PATTERN = /\b(?:no|not|never|without|neither|nor|cannot)\b|'t\b/; * - Negated phrases are not blockers: if the clause preceding a trigger (up to * the nearest clause boundary) contains a negation, that trigger is skipped, * so "does not depend on #5" or "no dependencies on #5" register nothing. - * - GitHub native `blocked_by` / sub-issue links are not fetched by sync today - * (the Issue schema has no such field), so only body-text refs are operative. - * The resolver accepts refs from any source, so native links can be fed into - * the same gate later without changing this contract. + * - GitHub native `blocked_by` links ARE ingested by sync into + * `Issue.nativeBlockedBy` and fed through `parseNativeBlockedBy` + + * `mergeDependencyRefs` into the same gate (#1086). This function itself + * still only parses body text. */ export function parseIssueDependencies(body: string | null | undefined): DependencyRef[] { if (body == null) return []; @@ -202,3 +202,59 @@ export function formatDependencyBlockReason( } return `Blocked by open ${parts.join(", ")}`; } + +/** + * Parse stored native `blocked_by` keys (`owner/repo#N`, or bare `#N`) into + * DependencyRef[]. A ref whose repo equals `defaultRepo` (normalized) collapses + * to `repo: null`, matching parseIssueDependencies' same-repo convention so + * native and body refs for the same blocker dedupe to one dependencyKey and + * render identically. Cross-repo refs keep their repo. Never throws; null/ + * undefined/empty input yields []. Invalid keys (no `#`, non-positive or + * non-integer number) are skipped; duplicates (by dependencyKey) are dropped. + */ +export function parseNativeBlockedBy( + keys: string[] | null | undefined, + defaultRepo?: string | null, +): DependencyRef[] { + if (keys == null) return []; + const refs: DependencyRef[] = []; + const seen = new Set(); + for (const raw of keys) { + if (typeof raw !== "string") continue; + const key = raw.trim(); + const hashIndex = key.lastIndexOf("#"); + if (hashIndex < 0) continue; + const repoPart = key.slice(0, hashIndex).trim(); + const number = Number(key.slice(hashIndex + 1)); + if (!Number.isInteger(number) || number <= 0) continue; + const repoKey = normalizeRepoKey(repoPart); + const repo = repoKey && repoKey !== normalizeRepoKey(defaultRepo) ? repoPart : null; + const dedupeKey = dependencyKey(repo, number); + if (seen.has(dedupeKey)) continue; + seen.add(dedupeKey); + refs.push({ repo, number }); + } + return refs; +} + +/** + * Merge dependency-ref sources (e.g. native `blocked_by` refs and body-parsed + * refs) into one list, deduped by dependencyKey. Order: earlier lists win, and + * within the merged list the first occurrence of each key is kept. Null/ + * undefined lists are treated as empty. + */ +export function mergeDependencyRefs( + ...lists: Array +): DependencyRef[] { + const refs: DependencyRef[] = []; + const seen = new Set(); + for (const list of lists) { + for (const ref of list ?? []) { + const key = dependencyKey(ref.repo, ref.number); + if (seen.has(key)) continue; + seen.add(key); + refs.push(ref); + } + } + return refs; +} diff --git a/src/lib/issue-dependency-annotation.test.ts b/src/lib/issue-dependency-annotation.test.ts index 8e44db9f..4a76cf10 100644 --- a/src/lib/issue-dependency-annotation.test.ts +++ b/src/lib/issue-dependency-annotation.test.ts @@ -111,3 +111,55 @@ describe("withDependencyBlockReasons", () => { expect(annotated[1]).toMatchObject({ ...input[1], dependencyBlockReason: "Blocked by open #20" }); }); }); + +describe("withDependencyBlockReasons native blocked_by (issue #1086)", () => { + const nativeIssue = ( + repo: string, + number: number, + body: string | null, + nativeBlockedBy: string[], + state = "open", + ) => ({ id: `${repo}#${number}`, number, state, body, nativeBlockedBy, repository: { fullName: repo } }); + + beforeEach(() => { + vi.clearAllMocks(); + mocks.findManyIssues.mockResolvedValue([]); + }); + + it("reports a same-repo native blocker", async () => { + mocks.findManyIssues.mockResolvedValue([openRow("foo/repo", 20)]); + + const [annotated] = await withDependencyBlockReasons([ + nativeIssue("foo/repo", 10, null, ["foo/repo#20"]), + ]); + + expect(annotated.dependencyBlockReason).toBe("Blocked by open #20"); + }); + + it("reports a cross-repo native blocker under the same universe rule", async () => { + mocks.findManyIssues.mockResolvedValue([openRow("bar/repo", 20)]); + + const [annotated] = await withDependencyBlockReasons([ + nativeIssue("foo/repo", 10, null, ["bar/repo#20"]), + ]); + + expect(annotated.dependencyBlockReason).toBe("Blocked by open bar/repo#20"); + }); + + it("returns null once the native blocker is closed", async () => { + const [annotated] = await withDependencyBlockReasons([ + nativeIssue("foo/repo", 10, null, ["foo/repo#20"]), + ]); + + expect(annotated.dependencyBlockReason).toBeNull(); + }); + + it("skips the lookup when no issue declares a dependency", async () => { + const annotated = await withDependencyBlockReasons([ + nativeIssue("foo/repo", 10, null, []), + ]); + + expect(annotated.map((i) => i.dependencyBlockReason)).toEqual([null]); + expect(mocks.findManyIssues).not.toHaveBeenCalled(); + }); +}); diff --git a/src/lib/issue-dependency-annotation.ts b/src/lib/issue-dependency-annotation.ts index fa13e890..f5883d41 100644 --- a/src/lib/issue-dependency-annotation.ts +++ b/src/lib/issue-dependency-annotation.ts @@ -3,6 +3,8 @@ import { dependencyKey, formatDependencyBlockReason, parseIssueDependencies, + parseNativeBlockedBy, + mergeDependencyRefs, resolveOpenBlockers, } from "@/lib/issue-dependencies"; @@ -10,6 +12,7 @@ interface AnnotatableIssue { number: number; state: string; body?: string | null; + nativeBlockedBy?: string[] | null; repository: { fullName: string }; } @@ -50,9 +53,18 @@ export async function findOpenIssueKeys(numbers: number[], client: typeof prisma export async function withDependencyBlockReasons( issues: T[], ): Promise> { - const refsByIssue = issues.map((issue) => - issue.state === "open" ? parseIssueDependencies(issue.body) : [], - ); + const refsByIssue = issues.map((issue) => { + if (issue.state !== "open") return []; + const bodyRefs = parseIssueDependencies(issue.body); + const nativeKeys = issue.nativeBlockedBy; + if (!nativeKeys || nativeKeys.length === 0) return bodyRefs; + // `repository` is only needed to resolve native refs; keep it unaccessed for + // issues with no native keys, matching the prior lazy contract. + return mergeDependencyRefs( + parseNativeBlockedBy(nativeKeys, issue.repository.fullName), + bodyRefs, + ); + }); const referencedNumbers = new Set(); for (const refs of refsByIssue) { diff --git a/src/lib/issue-sync.test.ts b/src/lib/issue-sync.test.ts index 396ac08a..e0da5166 100644 --- a/src/lib/issue-sync.test.ts +++ b/src/lib/issue-sync.test.ts @@ -271,6 +271,71 @@ describe("syncIssuesForRepos", () => { }); }); +describe("syncIssuesForRepos native blocked_by persistence", () => { + let store: IssueStore; + + beforeEach(() => { + vi.spyOn(console, "error").mockImplementation(() => {}); + }); + + it("writes native keys on create", async () => { + const createMock = vi.fn().mockResolvedValue(undefined); + store = { + findIssue: vi.fn().mockResolvedValue(null), + updateIssue: vi.fn().mockResolvedValue(undefined), + createIssue: createMock, + }; + + await syncIssuesForRepos( + [{ id: "repo-1", fullName: "org/repo" }], + async () => [githubIssue(1, { nativeBlockedBy: ["org/repo#5", "other/repo#9"] })], + store, + ); + + expect(createMock).toHaveBeenCalledTimes(1); + expect(createMock.mock.calls[0][1].nativeBlockedBy).toEqual(["org/repo#5", "other/repo#9"]); + }); + + it("does not clear stored keys when the field is absent/unknown", async () => { + const updateMock = vi.fn().mockResolvedValue(undefined); + store = { + findIssue: vi.fn().mockResolvedValue({ id: "existing-2", labels: [] }), + updateIssue: updateMock, + createIssue: vi.fn().mockResolvedValue(undefined), + }; + + await syncIssuesForRepos( + [{ id: "repo-1", fullName: "org/repo" }], + async () => [githubIssue(2)], + store, + ); + + expect(updateMock).toHaveBeenCalledTimes(1); + // undefined → Prisma omits the field, so stored keys are preserved. + expect(updateMock.mock.calls[0][1].nativeBlockedBy).toBeUndefined(); + }); + + it("overwrites stored keys when the fetch returns a new non-empty set", async () => { + const updateMock = vi.fn().mockResolvedValue(undefined); + store = { + findIssue: vi.fn().mockResolvedValue({ id: "existing-2", labels: [] }), + updateIssue: updateMock, + createIssue: vi.fn().mockResolvedValue(undefined), + }; + + // A blocker added after creation: the freshly-fetched non-empty set is + // authoritative and replaces the stored keys. + await syncIssuesForRepos( + [{ id: "repo-1", fullName: "org/repo" }], + async () => [githubIssue(2, { nativeBlockedBy: ["org/repo#10", "other/repo#4"] })], + store, + ); + + expect(updateMock).toHaveBeenCalledTimes(1); + expect(updateMock.mock.calls[0][1].nativeBlockedBy).toEqual(["org/repo#10", "other/repo#4"]); + }); +}); + describe("reconcileClosedIssues", () => { beforeEach(() => { vi.spyOn(console, "error").mockImplementation(() => {}); @@ -407,7 +472,10 @@ describe("fetchAllStateIssues", () => { where: { repositoryId: "repo-1" }, _max: { lastSyncedAt: true }, }); - expect(mocks.fetchIssues).toHaveBeenCalledWith("org/repo", { state: "open" }); + expect(mocks.fetchIssues).toHaveBeenCalledWith("org/repo", { + state: "open", + includeNativeBlockers: true, + }); expect(mocks.fetchIssues).toHaveBeenCalledWith("org/repo", { state: "closed", since: undefined }); }); @@ -420,7 +488,10 @@ describe("fetchAllStateIssues", () => { // dispatch#991: an open issue missed by the initial fetch and never // updated_at-touched must not be stranded — open issues are fetched in // full every sync, never narrowed by `since`. - expect(mocks.fetchIssues).toHaveBeenCalledWith("org/repo", { state: "open" }); + expect(mocks.fetchIssues).toHaveBeenCalledWith("org/repo", { + state: "open", + includeNativeBlockers: true, + }); }); it("narrows the closed-issue tail with since = anchor - SYNC_OVERLAP_BUFFER_MS when the repo has cached issues", async () => { diff --git a/src/lib/issue-sync.ts b/src/lib/issue-sync.ts index f9f33e03..25ceb015 100644 --- a/src/lib/issue-sync.ts +++ b/src/lib/issue-sync.ts @@ -50,6 +50,12 @@ export interface SyncedIssueData { closedAt: Date | null; lastSyncedAt: Date; state: string; + /** + * Native GitHub blocked_by keys (canonical `owner/repo#N`). `[]` means + * authoritatively none; `undefined` means unknown (not enriched, or the + * dependency fetch failed) so the store update leaves the column unchanged. + */ + nativeBlockedBy?: string[]; } export interface IssueStore { @@ -110,6 +116,7 @@ export function githubIssueToSyncedIssueData(ghIssue: GitHubIssue, lastSyncedAt closedAt: ghIssue.closed_at ? new Date(ghIssue.closed_at) : null, lastSyncedAt, state: ghIssue.state, + nativeBlockedBy: ghIssue.nativeBlockedBy, }; } @@ -406,7 +413,7 @@ export const fetchAllStateIssues = async (repo: SyncRepo): Promise 0` means native blockers exist and must be fetched per-issue (#1086). */ + issue_dependencies_summary?: { blocked_by: number; blocking: number } | null; } export interface Repository {