From 0369af7b75246c340822f77f94bf2701027f0dd1 Mon Sep 17 00:00:00 2001 From: Bailey Dixon Date: Thu, 16 Jul 2026 16:10:57 -0400 Subject: [PATCH 01/13] fix: make delivery and GitHub status cards truthful --- DEVLOG.md | 25 ++++ docs/engineering/work-management.md | 33 ++++-- docs/plans/github-support-integration.md | 19 ++-- .../migration.sql | 47 ++++++++ prisma/schema.prisma | 8 +- .../action-requests/action-request-card.tsx | 6 +- .../issue-detail/github-link-modal.tsx | 92 ++++++++++----- .../issue-detail/github-links-panel.tsx | 50 +++++--- .../issue-detail/work-coordination-panel.tsx | 89 +++++++++++---- src/server/routers/action-request.ts | 3 +- src/server/routers/work-session.ts | 2 +- .../__tests__/completion-candidate.test.ts | 24 +++- .../__tests__/github-reconciliation.test.ts | 33 +++++- .../github-relation-persistence.test.ts | 107 ++++++++++++++++++ .../github-webhook-hardening.test.ts | 5 +- .../services/__tests__/work-session.test.ts | 62 ++++++++++ src/server/services/completion-candidate.ts | 8 +- src/server/services/github/client.ts | 6 +- src/server/services/github/reconciliation.ts | 9 +- src/server/services/github/relation.ts | 56 +++++++++ src/server/services/github/resource-sync.ts | 37 +++--- src/server/services/github/types.ts | 14 ++- src/server/services/github/webhook.ts | 29 ++--- src/server/services/mcp.ts | 2 +- src/server/services/work-session.ts | 93 +++++++-------- tests/e2e/issue-flow.spec.ts | 14 +++ tests/unit/github-relation.test.ts | 37 ++++++ 27 files changed, 711 insertions(+), 199 deletions(-) create mode 100644 prisma/migrations/20260716200000_delivery_github_relation_truth/migration.sql create mode 100644 src/server/services/__tests__/github-relation-persistence.test.ts create mode 100644 src/server/services/github/relation.ts create mode 100644 tests/unit/github-relation.test.ts diff --git a/DEVLOG.md b/DEVLOG.md index af2b8881..221a93d7 100644 --- a/DEVLOG.md +++ b/DEVLOG.md @@ -2,6 +2,31 @@ > Append-only session log. Read at session start. Update at session end. +## 2026-07-16 — Truthful Delivery provenance and GitHub relations + +AXI-117 audited production Ready to Close, Delivery, and native GitHub cards, +then separated actor, invocation, connector, runtime, implementation PR, and +release-containment facts. Browser claims now persist as manual UI actions and +MCP claims persist as Forge MCP; connector identity comes only from the MCP +initialize `clientInfo`, while runtime attribution requires an output-producing +managed run. Compact Delivery cards expose the full evidence on demand without +guessing Codex Desktop or a runtime from agent identity. + +GitHub webhook matching now derives one native relation per issue/resource: +`IMPLEMENTS`, `FIXES`, `RELEASES`, or `RELATES_TO`. The migration reclassifies +release assembly PRs, removes legacy duplicate relations deterministically, and +enforces the singular relation invariant. Terminal PR synchronization clears +stale mergeability and suppresses pending-check contradictions, while merged +implementation evidence still participates in completion readiness. Passing +completion evidence and READY state now use the semantic success token. + +Verification passed lint (existing warnings only), typecheck, a production +Next build, 1,399 Vitest tests with one intentional skip, focused relation and +provenance coverage, and the AXI-117 Playwright issue journey. The full +single-worker browser run passed 51 journeys before Chromium exited while +opening an unrelated Mission Control context; both affected Mission Control +tests passed immediately in isolation, covering all 53 journeys. + ## 2026-07-16 — v0.26.1 released, deployed, and verified Merged release PR #60 and tagged exact `main` commit diff --git a/docs/engineering/work-management.md b/docs/engineering/work-management.md index 1544e9c6..3a9c25ac 100644 --- a/docs/engineering/work-management.md +++ b/docs/engineering/work-management.md @@ -29,21 +29,28 @@ connection is primary; additional connections must explicitly join as a contributor or reviewer, or receive an audited handoff before changing the branch or advancing delivery state. +Delivery provenance records separate facts: actor/profile, invocation source, +connector/transport, and execution runtime. MCP client identity comes from the +negotiated MCP `clientInfo`; missing identity is displayed as an unidentified +client rather than guessed as Codex Desktop or CLI. A runtime is shown only +when an output-producing `AgentRun` carries both a runtime connection and an +external run id. Direct MCP or UI work does not imply runtime execution. + ## Project branch contract Branch topology is project configuration, not an Axiom-wide constant. Every maintained repository declares these seven facts in `AGENTS.md` or `RELEASE.md`: -| Fact | Meaning | -| --- | --- | -| Integration branch | Normal feature/fix PR target | -| Release branch | Durable released-code history | -| Tag source | Branch/commit from which immutable release tags are cut | -| Staging source | Exact branch SHA or tag deployed to staging | -| Production source | Exact tag/SHA accepted by production | -| Hotfix base | Ref used to begin an emergency fix | -| Back-merge target | Branches that must receive the hotfix afterward | +| Fact | Meaning | +| ------------------ | ------------------------------------------------------- | +| Integration branch | Normal feature/fix PR target | +| Release branch | Durable released-code history | +| Tag source | Branch/commit from which immutable release tags are cut | +| Staging source | Exact branch SHA or tag deployed to staging | +| Production source | Exact tag/SHA accepted by production | +| Hotfix base | Ref used to begin an emergency fix | +| Back-merge target | Branches that must receive the hotfix afterward | Supported shapes include trunk-based (`feature → main → tag`), an integration train (`feature → dev → release PR → main → tag`), upstream-tracking forks, and @@ -57,10 +64,14 @@ integration, scheduled stabilization, or an upstream convention. - Refresh the work-session heartbeat at meaningful phase changes and after commits. Mechanical tool traces do not replace a heartbeat or semantic status. - Push early and open a PR early when overlap is likely. -- Link implementation PRs with Forge's native GitHub relation and kind - `IMPLEMENTS`; never use a generic link attachment for a PR. +- Link implementation PRs with Forge's native GitHub relation: `IMPLEMENTS` + for delivered behavior or `FIXES` for closing/fixing semantics. Bare issue + references are `RELATES_TO`; release assembly PRs use `RELEASES` to mean the + release contains the implementation. Never use a generic link attachment. - GitHub owns PR state, reviews, checks, mergeability, and merge state. Forge mirrors those facts into the delivery lifecycle. +- One issue/resource pair has one native relation. Re-linking reclassifies the + relation instead of duplicating status cards. - Resolve file overlap in the PR. Do not move uncommitted patches between worktrees or share one worktree between tasks. - If dispatch discovers an existing primary connection, block a second execute diff --git a/docs/plans/github-support-integration.md b/docs/plans/github-support-integration.md index 4c2f8414..cc5f8c99 100644 --- a/docs/plans/github-support-integration.md +++ b/docs/plans/github-support-integration.md @@ -135,7 +135,7 @@ model ExternalResourceLink { workspaceId String issueId String externalResourceId String - kind String // SOURCE | IMPLEMENTS | REVIEWS | RELATES_TO + kind String // SOURCE | IMPLEMENTS | FIXES | RELEASES | REVIEWS | RELATES_TO createdById String? createdAt DateTime @default(now()) @@ -154,6 +154,9 @@ Link semantics: - `SOURCE` - Forge issue was created from this GitHub issue. - `IMPLEMENTS` - PR is intended to resolve or implement the Forge issue. +- `FIXES` - PR uses explicit fix/close/resolve semantics for the Forge issue. +- `RELEASES` - release assembly PR contains an already-separate implementation. +- `RELATES_TO` - contextual reference without implementation semantics. - `REVIEWS` - PR/review context requires attention for the Forge issue. - `RELATES_TO` - soft context link. @@ -354,14 +357,14 @@ subscription category. Expose the same core operator/agent actions through `github.*` MCP tools: -| Tool | Scope | Notes | -| --- | --- | --- | -| `github.parseUrl` | `READ_ISSUES` | Normalize a GitHub URL into repo/type/number. | -| `github.listLinked` | `READ_ISSUES` | Return linked resources for a Forge issue. | -| `github.link` | `WRITE_ISSUES` | Link issue/PR URL to a Forge issue with a relation kind. | +| Tool | Scope | Notes | +| -------------------- | -------------- | ------------------------------------------------------------- | +| `github.parseUrl` | `READ_ISSUES` | Normalize a GitHub URL into repo/type/number. | +| `github.listLinked` | `READ_ISSUES` | Return linked resources for a Forge issue. | +| `github.link` | `WRITE_ISSUES` | Link issue/PR URL to a Forge issue with a relation kind. | | `github.importIssue` | `WRITE_ISSUES` | Create or return the Forge issue sourced from a GitHub issue. | -| `github.sync` | `WRITE_ISSUES` | Refresh one linked resource and apply mapping policy. | -| `github.search` | `READ_ISSUES` | Search mapped repo issues/PRs for linking/import. | +| `github.sync` | `WRITE_ISSUES` | Refresh one linked resource and apply mapping policy. | +| `github.search` | `READ_ISSUES` | Search mapped repo issues/PRs for linking/import. | Add linked GitHub resources to `agent.context.bundle({ issueId })`: diff --git a/prisma/migrations/20260716200000_delivery_github_relation_truth/migration.sql b/prisma/migrations/20260716200000_delivery_github_relation_truth/migration.sql new file mode 100644 index 00000000..cbca60a2 --- /dev/null +++ b/prisma/migrations/20260716200000_delivery_github_relation_truth/migration.sql @@ -0,0 +1,47 @@ +-- AXI-117: make GitHub relations singular/truthful and expand delivery source +-- vocabulary without conflating invocation, transport, and runtime. + +ALTER TYPE "WorkSessionSource" ADD VALUE IF NOT EXISTS 'MCP'; +ALTER TYPE "WorkSessionSource" ADD VALUE IF NOT EXISTS 'NATIVE_SESSION'; +ALTER TYPE "WorkSessionSource" ADD VALUE IF NOT EXISTS 'ISSUE_DISPATCH'; +ALTER TYPE "WorkSessionSource" ADD VALUE IF NOT EXISTS 'SCHEDULED'; + +-- Release assembly PRs are containment evidence, not implementation PRs. +UPDATE "ExternalResourceLink" link +SET "kind" = 'RELEASES' +FROM "ExternalResource" resource +WHERE link."externalResourceId" = resource."id" + AND link."kind" = 'IMPLEMENTS' + AND resource."resourceType" = 'PULL_REQUEST' + AND ( + resource."title" ~* '^\s*release\s+v?[0-9]' + OR COALESCE(resource."metadata" #>> '{head,ref}', '') ~* '(^|/)release([-\/]|$)' + ); + +-- Older rows could carry multiple semantic kinds for the same issue/resource. +-- Keep one deterministic relation before enforcing the native invariant. +WITH ranked AS ( + SELECT "id", + ROW_NUMBER() OVER ( + PARTITION BY "issueId", "externalResourceId" + ORDER BY + CASE "kind" + WHEN 'SOURCE' THEN 0 + WHEN 'FIXES' THEN 1 + WHEN 'IMPLEMENTS' THEN 2 + WHEN 'RELEASES' THEN 3 + WHEN 'REVIEWS' THEN 4 + ELSE 5 + END, + "createdAt" ASC, + "id" ASC + ) AS rn + FROM "ExternalResourceLink" +) +DELETE FROM "ExternalResourceLink" link +USING ranked +WHERE link."id" = ranked."id" AND ranked.rn > 1; + +DROP INDEX "ExternalResourceLink_issueId_externalResourceId_kind_key"; +CREATE UNIQUE INDEX "ExternalResourceLink_issueId_externalResourceId_key" + ON "ExternalResourceLink"("issueId", "externalResourceId"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 97853638..0d3baf26 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -290,6 +290,10 @@ enum WorkSessionSource { CODEX_DESKTOP CONTRIBUTOR MANUAL + MCP + NATIVE_SESSION + ISSUE_DISPATCH + SCHEDULED } /// The transport or execution endpoint through which an agent binding acts. @@ -2597,7 +2601,7 @@ model ExternalResourceLink { workspaceId String issueId String externalResourceId String - /// SOURCE | IMPLEMENTS | REVIEWS | RELATES_TO. + /// SOURCE | IMPLEMENTS | FIXES | RELEASES | REVIEWS | RELATES_TO. kind String createdById String? createdAt DateTime @default(now()) @@ -2607,7 +2611,7 @@ model ExternalResourceLink { externalResource ExternalResource @relation(fields: [externalResourceId], references: [id], onDelete: Cascade) createdBy User? @relation("ExternalResourceLinkCreator", fields: [createdById], references: [id], onDelete: SetNull) - @@unique([issueId, externalResourceId, kind]) + @@unique([issueId, externalResourceId]) @@index([workspaceId, issueId]) @@index([workspaceId, externalResourceId]) @@index([createdById]) diff --git a/src/components/action-requests/action-request-card.tsx b/src/components/action-requests/action-request-card.tsx index 30aabad0..8df7a5a0 100644 --- a/src/components/action-requests/action-request-card.tsx +++ b/src/components/action-requests/action-request-card.tsx @@ -952,7 +952,7 @@ function CompletionEvidenceSummary({
onLinked(true)} /> ) : ( - onLinked(false)} /> + onLinked(false)} + /> )}
@@ -235,13 +242,16 @@ function ByUrlTab({ // repo would fire wasted GitHub installation probes. { enabled: !!repoFullName && parsed?.number != null, staleTime: 15_000, retry: false }, ); - const ready = - linkability.data?.status === "ready" ? linkability.data : null; + const ready = linkability.data?.status === "ready" ? linkability.data : null; const preview = trpc.github.preview.useQuery( parsed?.url ? { url: parsed.url, mappingId: ready?.mappingId } - : { repoFullName: repoFullName ?? "", number: parsed?.number ?? 0, mappingId: ready?.mappingId }, + : { + repoFullName: repoFullName ?? "", + number: parsed?.number ?? 0, + mappingId: ready?.mappingId, + }, { enabled: !!ready && !!parsed?.number, staleTime: 15_000, retry: false }, ); @@ -282,8 +292,8 @@ function ByUrlTab({ {parsed && !parsed.number && (

- Add an issue/PR number — {parsed.repoFullName}#123 — - or use Browse a repo to pick one. + Add an issue/PR number — {parsed.repoFullName}#123 — or + use Browse a repo to pick one.

)} @@ -319,7 +329,11 @@ function ByUrlTab({ linkUrl && linkM.mutate({ issueId, url: linkUrl, kind, mappingId: ready.mappingId }) } > - {linkM.isPending ? : } + {linkM.isPending ? ( + + ) : ( + + )} Link as {kindLabel(kind)} @@ -380,7 +394,7 @@ function Remediation({ }); const [connectionId, setConnectionId] = useState( - state.status === "mappable" ? state.connections[0]?.connectionId ?? "" : "", + state.status === "mappable" ? (state.connections[0]?.connectionId ?? "") : "", ); if (state.status === "app_available") { @@ -416,7 +430,9 @@ function Remediation({ Use {app?.name ?? "GitHub App"} ) : ( - connect the workspace GitHub App for linking in Settings → Connections + + connect the workspace GitHub App for linking in Settings → Connections + )} ); @@ -486,7 +502,11 @@ function Remediation({ disabled={mapM.isPending || !connectionId} onClick={() => mapM.mutate({ connectionId, repoFullName: state.repoFullName })} > - {mapM.isPending ? : } + {mapM.isPending ? ( + + ) : ( + + )} Connect repository @@ -581,11 +601,7 @@ function Remediation({ } function AskAdmin({ children }: { children: React.ReactNode }) { - return ( -

- Ask a workspace admin to {children}. -

- ); + return

Ask a workspace admin to {children}.

; } function RemediationCard({ @@ -610,7 +626,8 @@ function RemediationCard({
@@ -677,7 +694,9 @@ function BrowseTab({ }, [current?.repoFullName, current?.mapped, debouncedQuery, isAdmin]); const connecting = - !!current && !current.mapped && (connectRepoM.isPending || autoConnected.has(current.repoFullName)); + !!current && + !current.mapped && + (connectRepoM.isPending || autoConnected.has(current.repoFullName)); const search = trpc.github.search.useQuery( { mappingId, query: debouncedQuery, type: type || undefined }, @@ -696,7 +715,11 @@ function BrowseTab({ }); if (repos.isLoading) { - return Loading repositories…; + return ( + + Loading repositories… + + ); } if (!repos.data || repos.data.length === 0) { return ( @@ -777,10 +800,14 @@ function BrowseTab({ )} {search.isLoading && debouncedQuery.trim() && ( - Searching… + + Searching… + )} {search.isError && ( - {search.error.message} + + {search.error.message} + )} {search.data && search.data.length === 0 && debouncedQuery.trim() && (

No matching open issues or PRs.

@@ -806,13 +833,13 @@ function BrowseTab({

{item.title}

-
+
#{item.number} {stateLabel(item.state)}
{isLinked ? ( - + linked @@ -856,12 +883,23 @@ function ResourcePreview({ }: { loading: boolean; error: string | null; - snapshot: { resourceType: string; title: string; state: string; url: string; repoFullName: string; number: number } | null; + snapshot: { + resourceType: string; + title: string; + state: string; + url: string; + repoFullName: string; + number: number; + } | null; fallbackRepo: string | null; fallbackNumber: number; }) { if (loading) { - return Loading {fallbackRepo}#{fallbackNumber}…; + return ( + + Loading {fallbackRepo}#{fallbackNumber}… + + ); } if (error) { return ( @@ -889,7 +927,7 @@ function ResourcePreview({ > {snapshot.title} -
+
{snapshot.repoFullName}#{snapshot.number} @@ -914,7 +952,7 @@ function StatusLine({ return (

diff --git a/src/components/issue-detail/github-links-panel.tsx b/src/components/issue-detail/github-links-panel.tsx index f929b0a2..832f1a96 100644 --- a/src/components/issue-detail/github-links-panel.tsx +++ b/src/components/issue-detail/github-links-panel.tsx @@ -11,6 +11,8 @@ import { GitHubLinkModal } from "@/components/issue-detail/github-link-modal"; function kindLabel(kind: string): string { if (kind === "SOURCE") return "source"; if (kind === "IMPLEMENTS") return "implements"; + if (kind === "FIXES") return "fixes / closes"; + if (kind === "RELEASES") return "release contains implementation"; if (kind === "REVIEWS") return "reviews"; return "related"; } @@ -73,6 +75,8 @@ export function GitHubLinksPanel({ issueId }: { issueId: string }) { {links.map((link) => { const resource = link.externalResource; const isPr = resource.resourceType === "PULL_REQUEST"; + const terminalPr = + isPr && (resource.state === "merged" || resource.state === "closed"); const metadata = metadataRecord(resource.metadata); const head = metadataRecord(metadata.head); const checks = metadataRecord(metadata.checks); @@ -83,8 +87,9 @@ export function GitHubLinksPanel({ issueId }: { issueId: string }) { : typeof review.decision === "string" ? review.decision : null; - const checksLabel = - checks.partial === true + const checksLabel = terminalPr + ? null + : checks.partial === true ? "checks partial" : typeof checks.conclusion === "string" ? `checks ${checks.conclusion}` @@ -113,27 +118,38 @@ export function GitHubLinksPanel({ issueId }: { issueId: string }) { {resource.repoFullName}#{resource.number} - {kindLabel(link.kind)} + {kindLabel(link.kind)} {stateLabel(resource.state)} {resource.lastSyncedAt && ( synced {relativeTime(resource.lastSyncedAt)} )}

{isPr && ( -
- {typeof head.ref === "string" && ( - - {head.ref} - - )} - {reviewDecision && ( - {reviewDecision.toLowerCase().replaceAll("_", " ")} - )} - {checksLabel && {checksLabel}} - {typeof metadata.mergeableState === "string" && ( - merge {metadata.mergeableState} - )} -
+
+ + Evidence + +
+ {typeof head.ref === "string" && ( + + {head.ref} + + )} + {terminalPr && typeof metadata.mergedAt === "string" ? ( + merged {relativeTime(metadata.mergedAt)} + ) : ( + <> + {reviewDecision && ( + {reviewDecision.toLowerCase().replaceAll("_", " ")} + )} + {checksLabel && {checksLabel}} + {typeof metadata.mergeableState === "string" && ( + merge {metadata.mergeableState} + )} + + )} +
+
)} {resource.syncLastError && (
- {provenance.connectionLabel} + {provenance.invocationLabel} + {provenance.connectorLabel && ( + + {provenance.connectorLabel} + + )} {provenance.statusLabel} @@ -167,17 +172,33 @@ export function WorkCoordinationPanel({ > {latest.repoFullName}:{latest.branch}
-
- base {latest.baseBranch} · activity {relativeTime(latest.lastHeartbeatAt)} -
- {latest.worktreePath && ( -
- {latest.worktreePath} -
- )} +
+ + Delivery evidence + +
+
Actor
+
{owner}
+
Invocation
+
{provenance?.invocationLabel ?? "unknown"}
+
Connector
+
{provenance?.connectorLabel ?? "none recorded"}
+
Runtime
+
{provenance?.runtimeLabel ?? "no dispatched run recorded"}
+
Base
+
{latest.baseBranch}
+
Activity
+
{relativeTime(latest.lastHeartbeatAt)}
+ {latest.worktreePath && ( + <> +
Worktree
+
+ {latest.worktreePath} +
+ + )} +
+
@@ -367,7 +388,6 @@ export function WorkCoordinationPanel({ branch, baseBranch, worktreePath: worktreePath || null, - source: "CODEX_DESKTOP", }); }} > @@ -466,24 +486,43 @@ function connectionKindLabel( } function deliveryProvenance(session: DeliverySession): { - connectionLabel: string; + invocationLabel: string; + connectorLabel: string | null; + runtimeLabel: string | null; statusLabel: string; tone: string; unconfirmedMcp: boolean; } { const connection = session.ownerConnection; + const invocationLabel = + session.source === "MCP" + ? "Forge MCP" + : session.source === "NATIVE_SESSION" + ? "Native session" + : session.source === "ISSUE_DISPATCH" + ? "Issue dispatch" + : session.source === "SCHEDULED" + ? "Scheduled" + : session.source === "MANUAL" + ? "Manual UI" + : session.source === "CONTRIBUTOR" + ? "Contributor" + : session.source === "CODEX_DESKTOP" + ? "Legacy desktop claim" + : "Legacy Forge agent"; + const run = session.observedImplementation?.run; + const runtimeLabel = + run?.externalRunId && run.connection?.kind === "MANAGED_RUNTIME" && run.connection.runtime + ? `${run.connection.runtime.name}${run.runEngine ? ` · ${run.runEngine.toLowerCase()}` : ""}` + : null; if (!connection) { - const source = - session.source === "CODEX_DESKTOP" - ? "MCP · Codex Desktop" - : session.source === "FORGE_AGENT" - ? "Legacy agent session" - : session.source.toLowerCase().replaceAll("_", " "); return { - connectionLabel: source, + invocationLabel, + connectorLabel: null, + runtimeLabel, statusLabel: "provenance not registered", tone: "text-muted-foreground", - unconfirmedMcp: session.source === "CODEX_DESKTOP", + unconfirmedMcp: session.source === "MCP", }; } const unconfirmedMcp = connection.kind === "MCP_CLIENT" && connection.confidence !== "CONFIRMED"; @@ -504,7 +543,9 @@ function deliveryProvenance(session: DeliverySession): { ? "text-success" : "text-muted-foreground"; return { - connectionLabel: connectionKindLabel(connection.kind, connection), + invocationLabel, + connectorLabel: connectionKindLabel(connection.kind, connection), + runtimeLabel, statusLabel, tone, unconfirmedMcp, diff --git a/src/server/routers/action-request.ts b/src/server/routers/action-request.ts index 80055b08..4cbad409 100644 --- a/src/server/routers/action-request.ts +++ b/src/server/routers/action-request.ts @@ -13,6 +13,7 @@ import { voteOnActionRequest, } from "@/server/services/action-request-service"; import { enqueueGitHubResourceReconciliation } from "@/server/services/github/reconciliation-queue"; +import { IMPLEMENTATION_LINK_KINDS } from "@/server/services/github/types"; /** * Zod-side mirror of `ActionRequestKind` so the router can accept the @@ -124,7 +125,7 @@ export const actionRequestRouter = router({ select: { externalLinks: { where: { - kind: "IMPLEMENTS", + kind: { in: [...IMPLEMENTATION_LINK_KINDS] }, externalResource: { provider: "GITHUB", resourceType: "PULL_REQUEST" }, }, select: { externalResourceId: true }, diff --git a/src/server/routers/work-session.ts b/src/server/routers/work-session.ts index a52d1407..16f78943 100644 --- a/src/server/routers/work-session.ts +++ b/src/server/routers/work-session.ts @@ -141,13 +141,13 @@ export const workSessionRouter = router({ branch: branchSchema, baseBranch: branchSchema.default("main"), worktreePath: z.string().max(1_000).nullable().optional(), - source: z.nativeEnum(WorkSessionSource).default(WorkSessionSource.CODEX_DESKTOP), }), ) .mutation(({ ctx, input }) => claimWorkSession(ctx.db, { workspaceId: ctx.workspaceId, ...input, + source: WorkSessionSource.MANUAL, actor: { userId: ctx.session.user.id, agentId: ctx.apiKey?.linkedAgentId ?? null }, }), ), diff --git a/src/server/services/__tests__/completion-candidate.test.ts b/src/server/services/__tests__/completion-candidate.test.ts index 3e33882d..f11b524a 100644 --- a/src/server/services/__tests__/completion-candidate.test.ts +++ b/src/server/services/__tests__/completion-candidate.test.ts @@ -49,6 +49,7 @@ async function linkedPullRequest( issueId: string, state: "open" | "closed" | "merged", conclusion: string | null = "success", + kind: "IMPLEMENTS" | "FIXES" = "IMPLEMENTS", ) { const prisma = getPrisma(); const resource = await prisma.externalResource.create({ @@ -71,7 +72,7 @@ async function linkedPullRequest( workspaceId: fixture.workspace.id, issueId, externalResourceId: resource.id, - kind: "IMPLEMENTS", + kind, createdById: fixture.user.id, }, }); @@ -79,6 +80,27 @@ async function linkedPullRequest( } describe("completion candidate policy", () => { + it("treats an explicit fixes/closes PR as implementation evidence", async () => { + const { fixture, prisma } = await setup(); + const issue = await createIssue(fixture, { statusCategory: "IN_PROGRESS" }); + const pullRequest = await linkedPullRequest(fixture, issue.id, "merged", "success", "FIXES"); + + const result = await evaluateIssueCompletionCandidate(prisma, { + workspaceId: fixture.workspace.id, + issueId: issue.id, + actorId: fixture.user.id, + sourceType: "github-pull-request", + sourceId: pullRequest.id, + sourceLabel: "Fixes AXI issue", + }); + + expect(result.outcome).toBe("RECOMMENDED"); + const request = await prisma.actionRequest.findUniqueOrThrow({ + where: { id: "requestId" in result ? result.requestId : "" }, + }); + expect(request.payload).toMatchObject({ assessment: { state: "READY" } }); + }); + it("refreshes one durable recommendation for repeated completion signals", async () => { const { fixture, prisma } = await setup(); const issue = await createIssue(fixture, { statusCategory: "IN_PROGRESS" }); diff --git a/src/server/services/__tests__/github-reconciliation.test.ts b/src/server/services/__tests__/github-reconciliation.test.ts index 14aab91a..a649be5e 100644 --- a/src/server/services/__tests__/github-reconciliation.test.ts +++ b/src/server/services/__tests__/github-reconciliation.test.ts @@ -190,7 +190,7 @@ describe("GitHub status reconciliation", () => { expect(calls).toBe(1); }); - it("backs off merged PRs with no checks and re-enables terminal rows when reopened", async () => { + it("does not back off merged PRs for stale checks and re-enables terminal rows when reopened", async () => { const { fixture, prisma, resource } = await setupResource(); const now = new Date("2026-07-14T12:00:00.000Z"); const syncResource = async () => @@ -211,8 +211,8 @@ describe("GitHub status reconciliation", () => { syncResource, }); const held = await prisma.externalResource.findUniqueOrThrow({ where: { id: resource.id } }); - expect(held.syncFailureCount).toBe(1); - expect(held.syncRetryAt).toEqual(new Date("2026-07-14T12:05:00.000Z")); + expect(held.syncFailureCount).toBe(0); + expect(held.syncRetryAt).toBeNull(); await prisma.externalResource.update({ where: { id: resource.id }, @@ -272,6 +272,33 @@ describe("GitHub status reconciliation", () => { expect(newHead.metadata).not.toHaveProperty("checks"); }); + it("normalizes merged PRs as terminal even when checks and mergeability are unknown", async () => { + const { fixture, prisma, resource } = await setupResource(); + const merged = await upsertExternalResource(prisma, { + workspaceId: fixture.workspace.id, + snapshot: { + provider: "GITHUB", + resourceType: "PULL_REQUEST", + repoFullName: "acme/forge", + number: 42, + url: resource.url, + title: resource.title, + state: "merged", + labels: [], + assignees: [], + metadata: { + mergeableState: "unknown", + checks: { status: "unknown", partial: true, diagnostic: "Checks unavailable" }, + }, + }, + }); + const metadata = merged.metadata as Record; + expect(merged.syncTerminalAt).toBeInstanceOf(Date); + expect(merged.syncRetryAt).toBeNull(); + expect(merged.syncLastError).toBeNull(); + expect(metadata.mergeableState).toBeNull(); + }); + it("does not inspect disabled workspaces, terminal PRs, or an active retry lease", async () => { const { fixture, prisma, resource } = await setupResource(); const now = new Date("2026-07-14T12:00:00.000Z"); diff --git a/src/server/services/__tests__/github-relation-persistence.test.ts b/src/server/services/__tests__/github-relation-persistence.test.ts new file mode 100644 index 00000000..ffefdb31 --- /dev/null +++ b/src/server/services/__tests__/github-relation-persistence.test.ts @@ -0,0 +1,107 @@ +import { afterAll, afterEach, describe, expect, it } from "vitest"; +import { linkExternalResourceToIssue } from "@/server/services/github/resource-sync"; +import { + createIssue, + createWorkspaceFixture, + disconnectPrisma, + getPrisma, + type TestFixture, +} from "@/server/routers/__tests__/helpers"; + +const fixtures: TestFixture[] = []; + +afterEach(async () => { + while (fixtures.length) await fixtures.pop()!.cleanup(); +}); + +afterAll(async () => { + await disconnectPrisma(); +}); + +describe("native GitHub relation persistence", () => { + it("reclassifies one issue/resource relation instead of duplicating cards", async () => { + const fixture = await createWorkspaceFixture({ keyPrefix: "GR" }); + fixtures.push(fixture); + const prisma = getPrisma(); + const issue = await createIssue(fixture); + const resource = await prisma.externalResource.create({ + data: { + workspaceId: fixture.workspace.id, + provider: "GITHUB", + resourceType: "PULL_REQUEST", + repoFullName: "acme/forge", + number: 117, + url: "https://github.com/acme/forge/pull/117", + title: "Release v0.28.0", + state: "merged", + }, + }); + + const actor = { actorId: fixture.user.id }; + await linkExternalResourceToIssue(prisma, { + workspaceId: fixture.workspace.id, + issueId: issue.id, + externalResourceId: resource.id, + kind: "IMPLEMENTS", + actor, + }); + await linkExternalResourceToIssue(prisma, { + workspaceId: fixture.workspace.id, + issueId: issue.id, + externalResourceId: resource.id, + kind: "RELEASES", + actor, + }); + const activityAfterReclassification = await prisma.activityEvent.count({ + where: { workspaceId: fixture.workspace.id, subjectId: issue.id }, + }); + await linkExternalResourceToIssue(prisma, { + workspaceId: fixture.workspace.id, + issueId: issue.id, + externalResourceId: resource.id, + kind: "RELEASES", + actor, + }); + + await expect( + prisma.externalResourceLink.findMany({ + where: { issueId: issue.id, externalResourceId: resource.id }, + }), + ).resolves.toMatchObject([{ kind: "RELEASES" }]); + expect( + await prisma.activityEvent.count({ + where: { workspaceId: fixture.workspace.id, subjectId: issue.id }, + }), + ).toBe(activityAfterReclassification); + }); + + it("rejects a cross-workspace relation even when both ids are valid", async () => { + const fixture = await createWorkspaceFixture({ keyPrefix: "GA" }); + const other = await createWorkspaceFixture({ keyPrefix: "GB" }); + fixtures.push(fixture, other); + const prisma = getPrisma(); + const issue = await createIssue(fixture); + const resource = await prisma.externalResource.create({ + data: { + workspaceId: other.workspace.id, + provider: "GITHUB", + resourceType: "PULL_REQUEST", + repoFullName: "acme/other", + number: 1, + url: "https://github.com/acme/other/pull/1", + title: "Other tenant", + state: "open", + }, + }); + + await expect( + linkExternalResourceToIssue(prisma, { + workspaceId: fixture.workspace.id, + issueId: issue.id, + externalResourceId: resource.id, + kind: "RELATES_TO", + actor: { actorId: fixture.user.id }, + }), + ).rejects.toThrow("External resource not found"); + }); +}); diff --git a/src/server/services/__tests__/github-webhook-hardening.test.ts b/src/server/services/__tests__/github-webhook-hardening.test.ts index d1f0d8c5..018c8d88 100644 --- a/src/server/services/__tests__/github-webhook-hardening.test.ts +++ b/src/server/services/__tests__/github-webhook-hardening.test.ts @@ -1573,14 +1573,13 @@ describe("GitHub webhook hardening", () => { await expect( prisma.externalResourceLink.findUnique({ where: { - issueId_externalResourceId_kind: { + issueId_externalResourceId: { issueId: issue.id, externalResourceId: resource.id, - kind: "RELATES_TO", }, }, }), - ).resolves.toBeTruthy(); + ).resolves.toMatchObject({ kind: "RELATES_TO" }); await expect( migrateGenericGitHubAttachments(prisma, { workspaceId: fixture.workspace.id, diff --git a/src/server/services/__tests__/work-session.test.ts b/src/server/services/__tests__/work-session.test.ts index 3fd986dc..cbdef2a0 100644 --- a/src/server/services/__tests__/work-session.test.ts +++ b/src/server/services/__tests__/work-session.test.ts @@ -6,6 +6,7 @@ import { advanceWorkSession, attachPullRequest, claimWorkSession, + listIssueWorkSessions, resolveMcpQuietRequestsForConnection, sweepStaleWorkSessions, touchWorkSession, @@ -36,6 +37,67 @@ async function setup() { } describe("work session coordination", () => { + it("reports implementation provenance only from a run that produced output", async () => { + const { fixture, prisma, issue } = await setup(); + const agent = await prisma.agent.create({ + data: { + workspaceId: fixture.workspace.id, + profileKey: `run-backed-${Date.now()}`, + name: "Run-backed agent", + }, + }); + const connection = await prisma.agentConnection.create({ + data: { + workspaceId: fixture.workspace.id, + agentId: agent.id, + kind: "MCP_CLIENT", + livenessModel: "LEASE", + instanceKey: `run-backed-${Date.now()}`, + displayName: "Codex CLI", + clientName: "codex-cli", + }, + }); + await claimWorkSession(prisma, { + workspaceId: fixture.workspace.id, + issueId: issue.id, + repoFullName: "acme/forge", + branch: "codex/run-backed", + source: WorkSessionSource.MCP, + actor: { userId: fixture.user.id, agentId: agent.id, connectionId: connection.id }, + }); + const run = await prisma.agentRun.create({ + data: { + workspaceId: fixture.workspace.id, + issueId: issue.id, + agentId: agent.id, + connectionId: connection.id, + engagementMode: "EXECUTE", + externalRunId: null, + }, + }); + + expect( + (await listIssueWorkSessions(prisma, fixture.workspace.id, issue.id))[0] + ?.observedImplementation, + ).toBeNull(); + await prisma.agentRun.update({ where: { id: run.id }, data: { outputStartedAt: new Date() } }); + await expect( + listIssueWorkSessions(prisma, fixture.workspace.id, issue.id), + ).resolves.toMatchObject([ + { + source: "MCP", + observedImplementation: { + agent: { id: agent.id }, + run: { + id: run.id, + externalRunId: null, + connection: { id: connection.id, kind: "MCP_CLIENT", clientName: "codex-cli" }, + }, + }, + }, + ]); + }); + it("returns the owned lease idempotently and rejects competing work", async () => { const { fixture, prisma, issue } = await setup(); const first = await claimWorkSession(prisma, { diff --git a/src/server/services/completion-candidate.ts b/src/server/services/completion-candidate.ts index 8e16f0e0..3823f405 100644 --- a/src/server/services/completion-candidate.ts +++ b/src/server/services/completion-candidate.ts @@ -12,6 +12,7 @@ import { createActionRequest, transitionActionRequest, } from "@/server/services/action-request-service"; +import { IMPLEMENTATION_LINK_KINDS } from "@/server/services/github/types"; const COMPLETION_SOURCE = "completion-candidate"; const RECOVERY_SOURCE = "github-pr-recovery"; @@ -157,7 +158,10 @@ async function completionContext(db: PrismaClient, workspaceId: string, issueId: executionPlans: { select: { id: true } }, executionSteps: { select: { id: true } }, externalLinks: { - where: { kind: "IMPLEMENTS", externalResource: { resourceType: "PULL_REQUEST" } }, + where: { + kind: { in: [...IMPLEMENTATION_LINK_KINDS] }, + externalResource: { resourceType: "PULL_REQUEST" }, + }, select: { externalResource: { select: { @@ -637,7 +641,7 @@ export async function reconcileGitHubPullRequestCompletion( url: true, metadata: true, links: { - where: { kind: "IMPLEMENTS" }, + where: { kind: { in: [...IMPLEMENTATION_LINK_KINDS] } }, select: { issueId: true, issue: { diff --git a/src/server/services/github/client.ts b/src/server/services/github/client.ts index 6be03d8e..ab42cf12 100644 --- a/src/server/services/github/client.ts +++ b/src/server/services/github/client.ts @@ -598,6 +598,7 @@ export function pullRequestSnapshot( pr: GitHubPullResponse, ): GitHubResourceSnapshot { const state = pr.merged ? "merged" : pr.draft ? "draft" : pr.state; + const terminal = state === "merged" || state === "closed"; return { provider: "GITHUB", resourceType: "PULL_REQUEST", @@ -618,7 +619,10 @@ export function pullRequestSnapshot( merged: pr.merged ?? false, mergedAt: pr.merged_at ?? null, closedAt: pr.closed_at ?? null, - mergeableState: pr.mergeable_state ?? null, + // GitHub mergeability is a pre-merge planning fact. Retaining an old + // "unknown"/"blocked" value after terminal state creates contradictory + // cards, so terminal snapshots deliberately clear it. + mergeableState: terminal ? null : (pr.mergeable_state ?? null), requestedReviewers: (pr.requested_reviewers ?? []) .map((reviewer) => reviewer.login ?? "") .filter(Boolean), diff --git a/src/server/services/github/reconciliation.ts b/src/server/services/github/reconciliation.ts index 481ef4d4..9e1c2963 100644 --- a/src/server/services/github/reconciliation.ts +++ b/src/server/services/github/reconciliation.ts @@ -3,6 +3,7 @@ import type { PrismaClient } from "@prisma/client"; import { logger } from "@/server/logger"; import { GitHubRequestError } from "@/server/services/github/client"; import { syncGitHubExternalResource } from "@/server/services/github/resource-sync"; +import { IMPLEMENTATION_LINK_KINDS } from "@/server/services/github/types"; const PROVIDER = "GITHUB"; const RESOURCE_TYPE = "PULL_REQUEST"; @@ -186,7 +187,7 @@ export async function sweepGitHubStatusReconciliation( links: { some: { workspaceId: workspace.id, - kind: "IMPLEMENTS", + kind: { in: [...IMPLEMENTATION_LINK_KINDS] }, issue: { workspaceId: workspace.id, deletedAt: null }, }, }, @@ -242,9 +243,9 @@ export async function sweepGitHubStatusReconciliation( checks.source !== "api-aggregate" || checks.status === "unknown" || checks.partial === true; - // Unknown/partial checks on any PR state use exponential/provider - // backoff. This includes open/draft resources with missing permissions. - if (untrustedChecks && resource.state !== "closed") { + // Unknown/partial checks only block actionable open/draft PRs. A merged + // or closed PR is terminal regardless of a stale checks aggregate. + if (untrustedChecks && resource.state !== "closed" && resource.state !== "merged") { const failureCount = candidate.syncFailureCount + 1; const exponential = retryAtForFailure({ error: new Error( diff --git a/src/server/services/github/relation.ts b/src/server/services/github/relation.ts new file mode 100644 index 00000000..ad981af2 --- /dev/null +++ b/src/server/services/github/relation.ts @@ -0,0 +1,56 @@ +import type { ExternalLinkKind } from "@/server/services/github/types"; + +const IMPLEMENTS_PATTERN = /\bimplements?\s*[:#-]?\s*$/i; +const FIXES_PATTERN = /\b(?:fix(?:e[sd])?|clos(?:e[sd])?|resolv(?:e[sd])?)\s*[:#-]?\s*$/i; +const RELEASE_TITLE_PATTERN = /^\s*release\s+v?\d/i; +const RELEASE_BRANCH_PATTERN = /(?:^|\/)release(?:[-/]|$)/i; + +function relationRank(kind: ExternalLinkKind): number { + if (kind === "FIXES") return 3; + if (kind === "IMPLEMENTS") return 2; + return 1; +} + +export function isReleasePullRequest(input: { title: string; headRef?: string | null }): boolean { + return ( + RELEASE_TITLE_PATTERN.test(input.title) || RELEASE_BRANCH_PATTERN.test(input.headRef ?? "") + ); +} + +/** + * Derive native issue-to-PR relations from explicit Forge issue references. + * Bare mentions are related work; implementation and closing keywords upgrade + * that relation, while release assembly PRs remain release containment. + */ +export function derivePullRequestIssueRelations(input: { + workspaceKey: string; + title: string; + body?: string | null; + headRef?: string | null; +}): Map { + const escaped = input.workspaceKey.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const issuePattern = new RegExp(`\\b${escaped}-(\\d+)\\b`, "gi"); + const release = isReleasePullRequest(input); + const relations = new Map(); + + for (const text of [input.title, input.body ?? ""]) { + let match: RegExpExecArray | null; + while ((match = issuePattern.exec(text))) { + const number = Number(match[1]); + if (!Number.isInteger(number) || number <= 0) continue; + const prefix = text.slice(Math.max(0, match.index - 48), match.index); + const kind: ExternalLinkKind = release + ? "RELEASES" + : FIXES_PATTERN.test(prefix) + ? "FIXES" + : IMPLEMENTS_PATTERN.test(prefix) + ? "IMPLEMENTS" + : "RELATES_TO"; + const current = relations.get(number); + if (!current || release || relationRank(kind) > relationRank(current)) { + relations.set(number, kind); + } + } + } + return relations; +} diff --git a/src/server/services/github/resource-sync.ts b/src/server/services/github/resource-sync.ts index ba071457..18c96808 100644 --- a/src/server/services/github/resource-sync.ts +++ b/src/server/services/github/resource-sync.ts @@ -541,16 +541,15 @@ export async function canonicalizeGitHubResourceIdentity( // Older builds could create one row per repository spelling. Preserve every // issue relation on the canonical row, keeping the original link id unless - // the same issue/kind relation already exists there. + // the same issue/resource relation already exists there. for (const duplicate of equivalent) { if (duplicate.id === canonical.id) continue; for (const link of duplicate.links) { const collision = await db.externalResourceLink.findUnique({ where: { - issueId_externalResourceId_kind: { + issueId_externalResourceId: { issueId: link.issueId, externalResourceId: canonical.id, - kind: link.kind, }, }, select: { id: true }, @@ -616,6 +615,10 @@ async function upsertExternalResourceInTransaction( ...existingMetadata, ...snapshotMetadata, }; + const terminal = + args.snapshot.resourceType === "PULL_REQUEST" && + (args.snapshot.state === "closed" || args.snapshot.state === "merged"); + if (terminal) metadata.mergeableState = null; const existingHead = existingMetadata.head && typeof existingMetadata.head === "object" && @@ -640,22 +643,14 @@ async function upsertExternalResourceInTransaction( metadata.checks && typeof metadata.checks === "object" && !Array.isArray(metadata.checks) ? (metadata.checks as Record) : {}; - const passingChecks = - checks.source === "api-aggregate" && - checks.status === "completed" && - typeof checks.conclusion === "string" && - ["success", "neutral", "skipped"].includes(checks.conclusion); const checksDiagnostic = - checks.partial === true && typeof checks.diagnostic === "string" + !terminal && checks.partial === true && typeof checks.diagnostic === "string" ? checks.diagnostic.slice(0, 2_000) : null; const checksRetryAt = typeof checks.retryAt === "string" && Number.isFinite(Date.parse(checks.retryAt)) ? new Date(checks.retryAt) : null; - const terminal = - args.snapshot.resourceType === "PULL_REQUEST" && - (args.snapshot.state === "closed" || (args.snapshot.state === "merged" && passingChecks)); const row = await db.externalResource.upsert({ where: { workspaceId_provider_repoFullName_resourceType_number: { @@ -766,21 +761,19 @@ export async function linkExternalResourceToIssue( const existingLink = await db.externalResourceLink.findUnique({ where: { - issueId_externalResourceId_kind: { + issueId_externalResourceId: { issueId: args.issueId, externalResourceId: args.externalResourceId, - kind: args.kind, }, }, }); - if (existingLink) return existingLink; + if (existingLink?.kind === args.kind) return existingLink; const link = await db.externalResourceLink.upsert({ where: { - issueId_externalResourceId_kind: { + issueId_externalResourceId: { issueId: args.issueId, externalResourceId: args.externalResourceId, - kind: args.kind, }, }, create: { @@ -790,7 +783,10 @@ export async function linkExternalResourceToIssue( kind: args.kind, createdById: args.actor.actorId ?? undefined, }, - update: {}, + update: { + kind: args.kind, + ...(args.actor.actorId ? { createdById: args.actor.actorId } : {}), + }, }); if (args.recordActivity ?? true) { @@ -800,14 +796,15 @@ export async function linkExternalResourceToIssue( actorAgentId: args.actor.actorAgentId ?? null, entity: "Issue", entityId: args.issueId, - action: "external-resource.link", + action: existingLink ? "external-resource.relate" : "external-resource.link", + before: existingLink ?? undefined, after: link, eventKind: EventKind.ISSUE_UPDATED, subjectType: "issue", subjectId: args.issueId, payload: { source: "github", - change: "external-resource-linked", + change: existingLink ? "external-resource-relation-updated" : "external-resource-linked", externalResourceId: args.externalResourceId, linkKind: args.kind, resourceType: resource.resourceType, diff --git a/src/server/services/github/types.ts b/src/server/services/github/types.ts index df5c817f..d4de954d 100644 --- a/src/server/services/github/types.ts +++ b/src/server/services/github/types.ts @@ -5,9 +5,21 @@ export const GITHUB_PROVIDER = "GITHUB" as const; export const GITHUB_RESOURCE_TYPES = ["ISSUE", "PULL_REQUEST"] as const; export type GitHubResourceType = (typeof GITHUB_RESOURCE_TYPES)[number]; -export const EXTERNAL_LINK_KINDS = ["SOURCE", "IMPLEMENTS", "REVIEWS", "RELATES_TO"] as const; +export const EXTERNAL_LINK_KINDS = [ + "SOURCE", + "IMPLEMENTS", + "FIXES", + "RELEASES", + "REVIEWS", + "RELATES_TO", +] as const; export type ExternalLinkKind = (typeof EXTERNAL_LINK_KINDS)[number]; +export const IMPLEMENTATION_LINK_KINDS = [ + "IMPLEMENTS", + "FIXES", +] as const satisfies readonly ExternalLinkKind[]; + export type GitHubRepoRef = { owner: string; repo: string; diff --git a/src/server/services/github/webhook.ts b/src/server/services/github/webhook.ts index 7b89671a..8a8c0a25 100644 --- a/src/server/services/github/webhook.ts +++ b/src/server/services/github/webhook.ts @@ -29,6 +29,7 @@ import { type ActorMeta, } from "@/server/services/github/resource-sync"; import { GITHUB_PROVIDER, type GitHubResourceSnapshot } from "@/server/services/github/types"; +import { derivePullRequestIssueRelations } from "@/server/services/github/relation"; type GitHubWebhookRepository = { full_name?: string; @@ -239,19 +240,6 @@ export async function finishGitHubWebhookDelivery(args: { return updated.count === 1; } -function forgeIssueKeys(text: string | null | undefined, workspaceKey: string): number[] { - if (!text) return []; - const escaped = workspaceKey.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); - const re = new RegExp(`\\b${escaped}-(\\d+)\\b`, "gi"); - const out = new Set(); - let match: RegExpExecArray | null; - while ((match = re.exec(text))) { - const n = Number(match[1]); - if (Number.isInteger(n) && n > 0) out.add(n); - } - return [...out]; -} - async function ensureSourceIssueForSnapshot(args: { db: PrismaClient; workspaceId: string; @@ -415,15 +403,18 @@ async function processPullRequestEvent(args: { if (!persisted.applied) return 0; const resource = persisted.resource; - const keyNumbers = [ - ...forgeIssueKeys(args.payload.pull_request.title, args.mapping.workspace.key), - ...forgeIssueKeys(args.payload.pull_request.body, args.mapping.workspace.key), - ]; + const relations = derivePullRequestIssueRelations({ + workspaceKey: args.mapping.workspace.key, + title: args.payload.pull_request.title, + body: args.payload.pull_request.body, + headRef: args.payload.pull_request.head?.ref, + }); + const keyNumbers = [...relations.keys()]; const issues = keyNumbers.length > 0 ? await args.db.issue.findMany({ where: { workspaceId, number: { in: keyNumbers }, deletedAt: null }, - select: { id: true }, + select: { id: true, number: true }, }) : []; @@ -450,7 +441,7 @@ async function processPullRequestEvent(args: { workspaceId, issueId: issue.id, externalResourceId: resource.id, - kind: "IMPLEMENTS", + kind: relations.get(issue.number) ?? "RELATES_TO", actor: args.actor, }); } diff --git a/src/server/services/mcp.ts b/src/server/services/mcp.ts index a67d9cff..6a594c4d 100644 --- a/src/server/services/mcp.ts +++ b/src/server/services/mcp.ts @@ -8399,7 +8399,7 @@ export const mcpTools = { return claimWorkSession(db, { workspaceId: ctx.workspaceId, ...input, - source: WorkSessionSource.FORGE_AGENT, + source: WorkSessionSource.MCP, actor: { userId: ctx.userId, agentId, connectionId: ctx.connectionId ?? null }, }); }, diff --git a/src/server/services/work-session.ts b/src/server/services/work-session.ts index 66cd32b8..446ecfae 100644 --- a/src/server/services/work-session.ts +++ b/src/server/services/work-session.ts @@ -15,6 +15,7 @@ import { recordChange } from "@/server/audit"; import { createActionRequest } from "@/server/services/action-request-service"; import { autoWatchActor } from "@/server/services/issue-watchers"; import { finishRun } from "@/server/services/agent-run"; +import { IMPLEMENTATION_LINK_KINDS } from "@/server/services/github/types"; type DbClient = PrismaClient | Prisma.TransactionClient; @@ -458,63 +459,55 @@ export async function listIssueWorkSessions( }, }, }); - const [latestImplementationRun, latestAgentComment] = await Promise.all([ - db.agentRun.findFirst({ - where: { workspaceId, issueId, engagementMode: "EXECUTE" }, - orderBy: [{ lastEventAt: "desc" }, { startedAt: "desc" }], - select: { - lastEventAt: true, - agent: { - select: { - id: true, - name: true, - profileKey: true, - avatar: true, - connections: { - where: { revokedAt: null }, - orderBy: [{ lastSeenAt: "desc" }, { createdAt: "desc" }], - take: 1, - select: { id: true, displayName: true, clientName: true, kind: true }, - }, - }, - }, - }, - }), - db.comment.findFirst({ - where: { workspaceId, issueId, authoringAgentId: { not: null } }, - orderBy: { updatedAt: "desc" }, - select: { - updatedAt: true, - authoringAgent: { - select: { - id: true, - name: true, - profileKey: true, - avatar: true, - connections: { - where: { revokedAt: null }, - orderBy: [{ lastSeenAt: "desc" }, { createdAt: "desc" }], - take: 1, - select: { id: true, displayName: true, clientName: true, kind: true }, - }, - }, + const latestImplementationRun = await db.agentRun.findFirst({ + where: { + workspaceId, + issueId, + engagementMode: "EXECUTE", + outputStartedAt: { not: null }, + }, + orderBy: [{ lastEventAt: "desc" }, { startedAt: "desc" }], + select: { + id: true, + status: true, + startedAt: true, + lastEventAt: true, + externalRunId: true, + triggerKind: true, + runEngine: true, + runEngineSource: true, + runtimePolicy: true, + agent: { select: { id: true, name: true, profileKey: true, avatar: true } }, + connection: { + select: { + id: true, + kind: true, + displayName: true, + clientName: true, + clientVersion: true, + runtime: { select: { id: true, name: true, adapterKey: true } }, }, }, - }), - ]); + }, + }); const observedImplementation = latestImplementationRun ? { agent: latestImplementationRun.agent, observedAt: latestImplementationRun.lastEventAt, source: "implementation-run" as const, + run: { + id: latestImplementationRun.id, + status: latestImplementationRun.status, + startedAt: latestImplementationRun.startedAt, + externalRunId: latestImplementationRun.externalRunId, + triggerKind: latestImplementationRun.triggerKind, + runEngine: latestImplementationRun.runEngine, + runEngineSource: latestImplementationRun.runEngineSource, + runtimePolicy: latestImplementationRun.runtimePolicy, + connection: latestImplementationRun.connection, + }, } - : latestAgentComment?.authoringAgent - ? { - agent: latestAgentComment.authoringAgent, - observedAt: latestAgentComment.updatedAt, - source: "agent-comment" as const, - } - : null; + : null; // Auto-bind an IMPLEMENTS PR whose head branch matches the session. This is // intentionally a repair path: explicit linking remains preferred. @@ -526,7 +519,7 @@ export async function listIssueWorkSessions( where: { workspaceId, issueId, - kind: "IMPLEMENTS", + kind: { in: [...IMPLEMENTATION_LINK_KINDS] }, externalResource: { resourceType: "PULL_REQUEST" }, }, include: { externalResource: true }, diff --git a/tests/e2e/issue-flow.spec.ts b/tests/e2e/issue-flow.spec.ts index a8b56cdb..770333b1 100644 --- a/tests/e2e/issue-flow.spec.ts +++ b/tests/e2e/issue-flow.spec.ts @@ -55,6 +55,20 @@ test.describe("Issue flow", () => { await page.goto(new URL(page.url()).pathname); await expect(activityTab).toHaveAttribute("aria-selected", "true"); + // A browser claim is a manual UI invocation. It must not be guessed as a + // Codex Desktop/MCP connection or as runtime execution. + const delivery = page.getByRole("region", { name: "Code work coordination" }); + await delivery.getByRole("button", { name: "Start isolated work" }).click(); + await delivery.getByLabel("Repository").fill("Codename-11/Forge"); + await delivery + .getByRole("textbox", { name: "Branch" }) + .fill(`codex/e2e-delivery-${Date.now()}`); + await delivery.getByRole("button", { name: "Claim work" }).click(); + await expect(delivery.locator("span").filter({ hasText: /^Manual UI$/ })).toBeVisible(); + await expect(delivery.getByText("MCP · Codex Desktop", { exact: true })).toHaveCount(0); + await delivery.getByText("Delivery evidence", { exact: true }).click(); + await expect(delivery.getByText("no dispatched run recorded", { exact: true })).toBeVisible(); + // Move status on the detail page and confirm it sticks. const status = page.getByRole("combobox", { name: "Status" }); await status.click(); diff --git a/tests/unit/github-relation.test.ts b/tests/unit/github-relation.test.ts new file mode 100644 index 00000000..15b10391 --- /dev/null +++ b/tests/unit/github-relation.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from "vitest"; +import { + derivePullRequestIssueRelations, + isReleasePullRequest, +} from "@/server/services/github/relation"; + +describe("GitHub native relation derivation", () => { + it("distinguishes implementation, closing, and bare references", () => { + const relations = derivePullRequestIssueRelations({ + workspaceKey: "AXI", + title: "Improve delivery cards (AXI-117)", + body: "Related context AXI-63. Implements AXI-117. Closes AXI-118.", + }); + expect(Object.fromEntries(relations)).toEqual({ + 63: "RELATES_TO", + 117: "IMPLEMENTS", + 118: "FIXES", + }); + }); + + it("treats every issue reference in release assembly as containment", () => { + const relations = derivePullRequestIssueRelations({ + workspaceKey: "AXI", + title: "Release v0.28.0 — delivery truth", + body: "Includes #72 for AXI-117 and fixes from AXI-118.", + headRef: "codex/release-v0.28.0", + }); + expect([...relations.entries()]).toEqual([ + [117, "RELEASES"], + [118, "RELEASES"], + ]); + }); + + it("recognizes release branches even when the title is customized", () => { + expect(isReleasePullRequest({ title: "July delivery", headRef: "release/v0.28.0" })).toBe(true); + }); +}); From b45834f374b7c3bcf061dd26c5468036aca44f18 Mon Sep 17 00:00:00 2001 From: Bailey Dixon Date: Thu, 16 Jul 2026 16:20:06 -0400 Subject: [PATCH 02/13] fix: preserve stronger GitHub relations --- DEVLOG.md | 4 +- .../migration.sql | 8 ++-- .../github-relation-persistence.test.ts | 47 +++++++++++++++++++ src/server/services/github/resource-sync.ts | 3 ++ 4 files changed, 57 insertions(+), 5 deletions(-) diff --git a/DEVLOG.md b/DEVLOG.md index 221a93d7..5a907c4c 100644 --- a/DEVLOG.md +++ b/DEVLOG.md @@ -15,7 +15,9 @@ guessing Codex Desktop or a runtime from agent identity. GitHub webhook matching now derives one native relation per issue/resource: `IMPLEMENTS`, `FIXES`, `RELEASES`, or `RELATES_TO`. The migration reclassifies release assembly PRs, removes legacy duplicate relations deterministically, and -enforces the singular relation invariant. Terminal PR synchronization clears +enforces the singular relation invariant. Generic URL recovery preserves a +stronger existing relation, and migration dedupe keeps implementation evidence +ahead of source/context rows. Terminal PR synchronization clears stale mergeability and suppresses pending-check contradictions, while merged implementation evidence still participates in completion readiness. Passing completion evidence and READY state now use the semantic success token. diff --git a/prisma/migrations/20260716200000_delivery_github_relation_truth/migration.sql b/prisma/migrations/20260716200000_delivery_github_relation_truth/migration.sql index cbca60a2..d380e0b2 100644 --- a/prisma/migrations/20260716200000_delivery_github_relation_truth/migration.sql +++ b/prisma/migrations/20260716200000_delivery_github_relation_truth/migration.sql @@ -26,10 +26,10 @@ WITH ranked AS ( PARTITION BY "issueId", "externalResourceId" ORDER BY CASE "kind" - WHEN 'SOURCE' THEN 0 - WHEN 'FIXES' THEN 1 - WHEN 'IMPLEMENTS' THEN 2 - WHEN 'RELEASES' THEN 3 + WHEN 'FIXES' THEN 0 + WHEN 'IMPLEMENTS' THEN 1 + WHEN 'RELEASES' THEN 2 + WHEN 'SOURCE' THEN 3 WHEN 'REVIEWS' THEN 4 ELSE 5 END, diff --git a/src/server/services/__tests__/github-relation-persistence.test.ts b/src/server/services/__tests__/github-relation-persistence.test.ts index ffefdb31..a988a71b 100644 --- a/src/server/services/__tests__/github-relation-persistence.test.ts +++ b/src/server/services/__tests__/github-relation-persistence.test.ts @@ -104,4 +104,51 @@ describe("native GitHub relation persistence", () => { }), ).rejects.toThrow("External resource not found"); }); + + it("does not let a generic related replay downgrade implementation evidence", async () => { + const fixture = await createWorkspaceFixture({ keyPrefix: "GP" }); + fixtures.push(fixture); + const prisma = getPrisma(); + const issue = await createIssue(fixture); + const resource = await prisma.externalResource.create({ + data: { + workspaceId: fixture.workspace.id, + provider: "GITHUB", + resourceType: "PULL_REQUEST", + repoFullName: "acme/forge", + number: 118, + url: "https://github.com/acme/forge/pull/118", + title: "Fixes GP-1", + state: "open", + }, + }); + const relation = { + workspaceId: fixture.workspace.id, + issueId: issue.id, + externalResourceId: resource.id, + actor: { actorId: fixture.user.id }, + }; + + await linkExternalResourceToIssue(prisma, { ...relation, kind: "FIXES" }); + const activityAfterFixes = await prisma.activityEvent.count({ + where: { workspaceId: fixture.workspace.id, subjectId: issue.id }, + }); + await linkExternalResourceToIssue(prisma, { ...relation, kind: "RELATES_TO" }); + + await expect( + prisma.externalResourceLink.findUniqueOrThrow({ + where: { + issueId_externalResourceId: { + issueId: issue.id, + externalResourceId: resource.id, + }, + }, + }), + ).resolves.toMatchObject({ kind: "FIXES" }); + expect( + await prisma.activityEvent.count({ + where: { workspaceId: fixture.workspace.id, subjectId: issue.id }, + }), + ).toBe(activityAfterFixes); + }); }); diff --git a/src/server/services/github/resource-sync.ts b/src/server/services/github/resource-sync.ts index 18c96808..e6e40735 100644 --- a/src/server/services/github/resource-sync.ts +++ b/src/server/services/github/resource-sync.ts @@ -768,6 +768,9 @@ export async function linkExternalResourceToIssue( }, }); if (existingLink?.kind === args.kind) return existingLink; + // Generic GitHub URL attachment and recovery paths use RELATES_TO. Replaying + // those paths must not erase a previously established native semantic link. + if (args.kind === "RELATES_TO" && existingLink) return existingLink; const link = await db.externalResourceLink.upsert({ where: { From 86f6b08874a16fb7366a14c4f624a14b6048a950 Mon Sep 17 00:00:00 2001 From: Bailey Dixon Date: Thu, 16 Jul 2026 16:29:30 -0400 Subject: [PATCH 03/13] fix: reconcile merged PR completion evidence --- DEVLOG.md | 3 +- .../__tests__/github-reconciliation.test.ts | 40 ++++++++++++++++--- src/server/services/github/reconciliation.ts | 7 ++-- src/server/services/github/relation.ts | 2 +- src/server/services/github/resource-sync.ts | 10 ++++- tests/unit/github-relation.test.ts | 4 +- 6 files changed, 52 insertions(+), 14 deletions(-) diff --git a/DEVLOG.md b/DEVLOG.md index 5a907c4c..32897223 100644 --- a/DEVLOG.md +++ b/DEVLOG.md @@ -19,7 +19,8 @@ enforces the singular relation invariant. Generic URL recovery preserves a stronger existing relation, and migration dedupe keeps implementation evidence ahead of source/context rows. Terminal PR synchronization clears stale mergeability and suppresses pending-check contradictions, while merged -implementation evidence still participates in completion readiness. Passing +implementation evidence continues reconciliation until its final checks +aggregate is trusted and still participates in completion readiness. Passing completion evidence and READY state now use the semantic success token. Verification passed lint (existing warnings only), typecheck, a production diff --git a/src/server/services/__tests__/github-reconciliation.test.ts b/src/server/services/__tests__/github-reconciliation.test.ts index a649be5e..abf04ddd 100644 --- a/src/server/services/__tests__/github-reconciliation.test.ts +++ b/src/server/services/__tests__/github-reconciliation.test.ts @@ -190,7 +190,7 @@ describe("GitHub status reconciliation", () => { expect(calls).toBe(1); }); - it("does not back off merged PRs for stale checks and re-enables terminal rows when reopened", async () => { + it("backs off merged PRs with stale checks and re-enables terminal rows when reopened", async () => { const { fixture, prisma, resource } = await setupResource(); const now = new Date("2026-07-14T12:00:00.000Z"); const syncResource = async () => @@ -211,8 +211,9 @@ describe("GitHub status reconciliation", () => { syncResource, }); const held = await prisma.externalResource.findUniqueOrThrow({ where: { id: resource.id } }); - expect(held.syncFailureCount).toBe(0); - expect(held.syncRetryAt).toBeNull(); + expect(held.syncFailureCount).toBe(1); + expect(held.syncRetryAt).toEqual(new Date("2026-07-14T12:05:00.000Z")); + expect(held.syncTerminalAt).toBeNull(); await prisma.externalResource.update({ where: { id: resource.id }, @@ -272,7 +273,7 @@ describe("GitHub status reconciliation", () => { expect(newHead.metadata).not.toHaveProperty("checks"); }); - it("normalizes merged PRs as terminal even when checks and mergeability are unknown", async () => { + it("normalizes merged facts while keeping unknown checks eligible for reconciliation", async () => { const { fixture, prisma, resource } = await setupResource(); const merged = await upsertExternalResource(prisma, { workspaceId: fixture.workspace.id, @@ -293,10 +294,37 @@ describe("GitHub status reconciliation", () => { }, }); const metadata = merged.metadata as Record; - expect(merged.syncTerminalAt).toBeInstanceOf(Date); + expect(merged.syncTerminalAt).toBeNull(); expect(merged.syncRetryAt).toBeNull(); - expect(merged.syncLastError).toBeNull(); + expect(merged.syncFailureCount).toBe(1); + expect(merged.syncLastError).toBe("Checks unavailable"); expect(metadata.mergeableState).toBeNull(); + + const trusted = await upsertExternalResource(prisma, { + workspaceId: fixture.workspace.id, + snapshot: { + provider: "GITHUB", + resourceType: "PULL_REQUEST", + repoFullName: "acme/forge", + number: 42, + url: resource.url, + title: resource.title, + state: "merged", + labels: [], + assignees: [], + metadata: { + checks: { + source: "api-aggregate", + status: "completed", + conclusion: "success", + partial: false, + }, + }, + }, + }); + expect(trusted.syncTerminalAt).toBeInstanceOf(Date); + expect(trusted.syncFailureCount).toBe(0); + expect(trusted.syncLastError).toBeNull(); }); it("does not inspect disabled workspaces, terminal PRs, or an active retry lease", async () => { diff --git a/src/server/services/github/reconciliation.ts b/src/server/services/github/reconciliation.ts index 9e1c2963..99904941 100644 --- a/src/server/services/github/reconciliation.ts +++ b/src/server/services/github/reconciliation.ts @@ -243,9 +243,10 @@ export async function sweepGitHubStatusReconciliation( checks.source !== "api-aggregate" || checks.status === "unknown" || checks.partial === true; - // Unknown/partial checks only block actionable open/draft PRs. A merged - // or closed PR is terminal regardless of a stale checks aggregate. - if (untrustedChecks && resource.state !== "closed" && resource.state !== "merged") { + // Merged implementation PRs still require trusted completion evidence; + // keep retrying partial/unknown aggregates even though merge state is a + // separate terminal GitHub fact. Closed, unmerged PRs do not. + if (untrustedChecks && resource.state !== "closed") { const failureCount = candidate.syncFailureCount + 1; const exponential = retryAtForFailure({ error: new Error( diff --git a/src/server/services/github/relation.ts b/src/server/services/github/relation.ts index ad981af2..d359882f 100644 --- a/src/server/services/github/relation.ts +++ b/src/server/services/github/relation.ts @@ -1,7 +1,7 @@ import type { ExternalLinkKind } from "@/server/services/github/types"; const IMPLEMENTS_PATTERN = /\bimplements?\s*[:#-]?\s*$/i; -const FIXES_PATTERN = /\b(?:fix(?:e[sd])?|clos(?:e[sd])?|resolv(?:e[sd])?)\s*[:#-]?\s*$/i; +const FIXES_PATTERN = /\b(?:fix(?:e[sd])?|close[sd]?|resolve[sd]?)\s*[:#-]?\s*$/i; const RELEASE_TITLE_PATTERN = /^\s*release\s+v?\d/i; const RELEASE_BRANCH_PATTERN = /(?:^|\/)release(?:[-/]|$)/i; diff --git a/src/server/services/github/resource-sync.ts b/src/server/services/github/resource-sync.ts index e6e40735..ab8d384f 100644 --- a/src/server/services/github/resource-sync.ts +++ b/src/server/services/github/resource-sync.ts @@ -615,10 +615,10 @@ async function upsertExternalResourceInTransaction( ...existingMetadata, ...snapshotMetadata, }; - const terminal = + const terminalState = args.snapshot.resourceType === "PULL_REQUEST" && (args.snapshot.state === "closed" || args.snapshot.state === "merged"); - if (terminal) metadata.mergeableState = null; + if (terminalState) metadata.mergeableState = null; const existingHead = existingMetadata.head && typeof existingMetadata.head === "object" && @@ -643,6 +643,12 @@ async function upsertExternalResourceInTransaction( metadata.checks && typeof metadata.checks === "object" && !Array.isArray(metadata.checks) ? (metadata.checks as Record) : {}; + const trustedCompletedChecks = + checks.source === "api-aggregate" && checks.status === "completed" && checks.partial !== true; + // A merged PR is a terminal GitHub fact, but its synchronization row must + // stay eligible for reconciliation until the final checks aggregate is + // trusted and complete. Closed, unmerged PRs need no completion evidence. + const terminal = terminalState && (args.snapshot.state === "closed" || trustedCompletedChecks); const checksDiagnostic = !terminal && checks.partial === true && typeof checks.diagnostic === "string" ? checks.diagnostic.slice(0, 2_000) diff --git a/tests/unit/github-relation.test.ts b/tests/unit/github-relation.test.ts index 15b10391..ed2bc58f 100644 --- a/tests/unit/github-relation.test.ts +++ b/tests/unit/github-relation.test.ts @@ -9,12 +9,14 @@ describe("GitHub native relation derivation", () => { const relations = derivePullRequestIssueRelations({ workspaceKey: "AXI", title: "Improve delivery cards (AXI-117)", - body: "Related context AXI-63. Implements AXI-117. Closes AXI-118.", + body: "Related context AXI-63. Implements AXI-117. Closes AXI-118. Close AXI-119. Resolve AXI-120.", }); expect(Object.fromEntries(relations)).toEqual({ 63: "RELATES_TO", 117: "IMPLEMENTS", 118: "FIXES", + 119: "FIXES", + 120: "FIXES", }); }); From 6d8d96ab4e1aa394a9e55a8378ee08310682e1d4 Mon Sep 17 00:00:00 2001 From: Bailey Dixon Date: Thu, 16 Jul 2026 16:39:56 -0400 Subject: [PATCH 04/13] fix: preserve explicit GitHub relation intent --- DEVLOG.md | 7 +- src/server/routers/attachment.ts | 1 + .../github-relation-persistence.test.ts | 18 ++++- .../github-webhook-hardening.test.ts | 79 +++++++++++++++++++ src/server/services/github/resource-sync.ts | 26 +++++- src/server/services/mcp.ts | 1 + 6 files changed, 126 insertions(+), 6 deletions(-) diff --git a/DEVLOG.md b/DEVLOG.md index 32897223..7cd1ba10 100644 --- a/DEVLOG.md +++ b/DEVLOG.md @@ -15,9 +15,10 @@ guessing Codex Desktop or a runtime from agent identity. GitHub webhook matching now derives one native relation per issue/resource: `IMPLEMENTS`, `FIXES`, `RELEASES`, or `RELATES_TO`. The migration reclassifies release assembly PRs, removes legacy duplicate relations deterministically, and -enforces the singular relation invariant. Generic URL recovery preserves a -stronger existing relation, and migration dedupe keeps implementation evidence -ahead of source/context rows. Terminal PR synchronization clears +enforces the singular relation invariant. Generic URL recovery and repository +identity canonicalization preserve a stronger existing relation, while explicit +relinks remain operator-controlled and migration dedupe keeps implementation +evidence ahead of source/context rows. Terminal PR synchronization clears stale mergeability and suppresses pending-check contradictions, while merged implementation evidence continues reconciliation until its final checks aggregate is trusted and still participates in completion readiness. Passing diff --git a/src/server/routers/attachment.ts b/src/server/routers/attachment.ts index 537a7275..e1342bdc 100644 --- a/src/server/routers/attachment.ts +++ b/src/server/routers/attachment.ts @@ -192,6 +192,7 @@ export const attachmentRouter = router({ issueId: input.targetId, url: safeUrl, kind: "RELATES_TO", + preserveExistingRelation: true, actor: { actorId: ctx.session.user.id, actorAgentId: ctx.apiKey?.linkedAgentId ?? null, diff --git a/src/server/services/__tests__/github-relation-persistence.test.ts b/src/server/services/__tests__/github-relation-persistence.test.ts index a988a71b..0aa4f261 100644 --- a/src/server/services/__tests__/github-relation-persistence.test.ts +++ b/src/server/services/__tests__/github-relation-persistence.test.ts @@ -133,7 +133,11 @@ describe("native GitHub relation persistence", () => { const activityAfterFixes = await prisma.activityEvent.count({ where: { workspaceId: fixture.workspace.id, subjectId: issue.id }, }); - await linkExternalResourceToIssue(prisma, { ...relation, kind: "RELATES_TO" }); + await linkExternalResourceToIssue(prisma, { + ...relation, + kind: "RELATES_TO", + preserveExistingRelation: true, + }); await expect( prisma.externalResourceLink.findUniqueOrThrow({ @@ -150,5 +154,17 @@ describe("native GitHub relation persistence", () => { where: { workspaceId: fixture.workspace.id, subjectId: issue.id }, }), ).toBe(activityAfterFixes); + + await linkExternalResourceToIssue(prisma, { ...relation, kind: "RELATES_TO" }); + await expect( + prisma.externalResourceLink.findUniqueOrThrow({ + where: { + issueId_externalResourceId: { + issueId: issue.id, + externalResourceId: resource.id, + }, + }, + }), + ).resolves.toMatchObject({ kind: "RELATES_TO" }); }); }); diff --git a/src/server/services/__tests__/github-webhook-hardening.test.ts b/src/server/services/__tests__/github-webhook-hardening.test.ts index 018c8d88..eb471503 100644 --- a/src/server/services/__tests__/github-webhook-hardening.test.ts +++ b/src/server/services/__tests__/github-webhook-hardening.test.ts @@ -239,6 +239,85 @@ describe("GitHub webhook hardening", () => { ).resolves.toBe(1); }); + it("preserves the stronger relation when canonical rows collide", async () => { + const { fixture, prisma, issue, mapping } = await setup(); + const canonical = await prisma.externalResource.create({ + data: { + workspaceId: fixture.workspace.id, + provider: "GITHUB", + connectionMappingId: mapping.id, + resourceType: "PULL_REQUEST", + repoFullName: "acme/forge", + number: 42, + url: "https://github.com/acme/forge/pull/42", + title: "Canonical casing", + state: "open", + externalUpdatedAt: new Date("2026-07-14T13:00:00Z"), + }, + }); + const duplicate = await prisma.externalResource.create({ + data: { + workspaceId: fixture.workspace.id, + provider: "GITHUB", + connectionMappingId: mapping.id, + resourceType: "PULL_REQUEST", + repoFullName: "Acme/Forge", + number: 42, + url: "https://github.com/Acme/Forge/pull/42", + title: "Legacy casing", + state: "open", + externalUpdatedAt: new Date("2026-07-14T12:00:00Z"), + }, + }); + await prisma.externalResourceLink.createMany({ + data: [ + { + workspaceId: fixture.workspace.id, + issueId: issue.id, + externalResourceId: canonical.id, + kind: "RELATES_TO", + }, + { + workspaceId: fixture.workspace.id, + issueId: issue.id, + externalResourceId: duplicate.id, + kind: "FIXES", + }, + ], + }); + + await upsertExternalResource(prisma, { + workspaceId: fixture.workspace.id, + connectionMappingId: mapping.id, + snapshot: { + provider: "GITHUB", + resourceType: "PULL_REQUEST", + repoFullName: "acme/forge", + number: 42, + url: "https://github.com/acme/forge/pull/42", + title: "Canonical casing", + state: "open", + externalUpdatedAt: new Date("2026-07-14T14:00:00Z"), + }, + }); + + await expect( + prisma.externalResourceLink.findMany({ + where: { workspaceId: fixture.workspace.id, issueId: issue.id }, + }), + ).resolves.toMatchObject([{ externalResourceId: canonical.id, kind: "FIXES" }]); + await expect( + prisma.externalResource.count({ + where: { + workspaceId: fixture.workspace.id, + provider: "GITHUB", + resourceType: "PULL_REQUEST", + number: 42, + }, + }), + ).resolves.toBe(1); + }); + it("applies webhook freshness guards across repository casing", async () => { const { fixture, prisma, issue, mapping } = await setup(); const merged = await prisma.externalResource.create({ diff --git a/src/server/services/github/resource-sync.ts b/src/server/services/github/resource-sync.ts index ab8d384f..6a43096a 100644 --- a/src/server/services/github/resource-sync.ts +++ b/src/server/services/github/resource-sync.ts @@ -65,6 +65,15 @@ function assertLinkKind(kind: string): asserts kind is ExternalLinkKind { } } +function externalLinkKindRank(kind: string): number { + if (kind === "FIXES") return 5; + if (kind === "IMPLEMENTS") return 4; + if (kind === "RELEASES") return 3; + if (kind === "SOURCE") return 2; + if (kind === "REVIEWS") return 1; + return 0; +} + function publicResource(row: ExternalResource) { return row; } @@ -552,9 +561,15 @@ export async function canonicalizeGitHubResourceIdentity( externalResourceId: canonical.id, }, }, - select: { id: true }, + select: { id: true, kind: true }, }); if (collision) { + if (externalLinkKindRank(link.kind) > externalLinkKindRank(collision.kind)) { + await db.externalResourceLink.update({ + where: { id: collision.id }, + data: { kind: link.kind, createdById: link.createdById }, + }); + } await db.externalResourceLink.delete({ where: { id: link.id } }); } else { await db.externalResourceLink.update({ @@ -741,6 +756,7 @@ export async function linkExternalResourceToIssue( kind: ExternalLinkKind; actor: ActorMeta; recordActivity?: boolean; + preserveExistingRelation?: boolean; }, ): Promise { const [issue, resource] = await Promise.all([ @@ -776,7 +792,9 @@ export async function linkExternalResourceToIssue( if (existingLink?.kind === args.kind) return existingLink; // Generic GitHub URL attachment and recovery paths use RELATES_TO. Replaying // those paths must not erase a previously established native semantic link. - if (args.kind === "RELATES_TO" && existingLink) return existingLink; + if (args.preserveExistingRelation && args.kind === "RELATES_TO" && existingLink) { + return existingLink; + } const link = await db.externalResourceLink.upsert({ where: { @@ -837,6 +855,7 @@ export async function linkGitHubUrlToIssue(args: { kind: string; actor: ActorMeta; mappingId?: string | null; + preserveExistingRelation?: boolean; }): Promise<{ resource: ExternalResource; link: ExternalResourceLink }> { const kind = args.kind; assertLinkKind(kind); @@ -866,6 +885,7 @@ export async function linkGitHubUrlToIssue(args: { externalResourceId: resource.id, kind, actor: args.actor, + preserveExistingRelation: args.preserveExistingRelation, }); return { resource, link }; }); @@ -964,6 +984,7 @@ export async function recoverGenericGitHubAttachments( externalResourceId: attachment.resourceId, kind: "RELATES_TO", actor: { actorId: null }, + preserveExistingRelation: true, }); await tx.attachment.deleteMany({ where: { id: attachment.id, workspaceId: attachment.workspaceId, kind: "LINK" }, @@ -1040,6 +1061,7 @@ export async function migrateGenericGitHubAttachments( url: row.url, kind: "RELATES_TO", actor: { actorId: null }, + preserveExistingRelation: true, }); const deleted = await db.attachment.deleteMany({ where: { diff --git a/src/server/services/mcp.ts b/src/server/services/mcp.ts index 6a594c4d..750fb311 100644 --- a/src/server/services/mcp.ts +++ b/src/server/services/mcp.ts @@ -4843,6 +4843,7 @@ export const mcpTools = { issueId: input.targetId, url: input.url, kind: "RELATES_TO", + preserveExistingRelation: true, actor: { actorId, actorAgentId: ctx.apiKey?.linkedAgentId ?? null, From 46a87ce308b74d9d7ceef18d36e22b96ad2f9200 Mon Sep 17 00:00:00 2001 From: Bailey Dixon Date: Thu, 16 Jul 2026 16:49:32 -0400 Subject: [PATCH 05/13] fix: scope GitHub status automation to implementation --- DEVLOG.md | 2 + .../github-webhook-hardening.test.ts | 74 +++++++++++++++++++ src/server/services/github/resource-sync.ts | 8 +- src/server/services/github/webhook.ts | 12 ++- 4 files changed, 93 insertions(+), 3 deletions(-) diff --git a/DEVLOG.md b/DEVLOG.md index 7cd1ba10..6361b699 100644 --- a/DEVLOG.md +++ b/DEVLOG.md @@ -23,6 +23,8 @@ stale mergeability and suppresses pending-check contradictions, while merged implementation evidence continues reconciliation until its final checks aggregate is trusted and still participates in completion readiness. Passing completion evidence and READY state now use the semantic success token. +PR lifecycle and failed-check status automation is limited to implementation +relations, so related mentions and release-containment links remain contextual. Verification passed lint (existing warnings only), typecheck, a production Next build, 1,399 Vitest tests with one intentional skip, focused relation and diff --git a/src/server/services/__tests__/github-webhook-hardening.test.ts b/src/server/services/__tests__/github-webhook-hardening.test.ts index eb471503..810a08af 100644 --- a/src/server/services/__tests__/github-webhook-hardening.test.ts +++ b/src/server/services/__tests__/github-webhook-hardening.test.ts @@ -791,6 +791,80 @@ describe("GitHub webhook hardening", () => { }); }); + it("does not apply PR lifecycle or failed-check status rules to a related mention", async () => { + const { fixture, prisma, issue, mapping } = await setup(); + const automatedStatus = await prisma.status.findFirstOrThrow({ + where: { workspaceId: fixture.workspace.id, category: "CANCELED" }, + }); + await prisma.connectionMapping.update({ + where: { id: mapping.id }, + data: { + config: { + github: { + statusRules: { + prOpenedStatusId: automatedStatus.id, + checksFailedStatusId: automatedStatus.id, + }, + }, + }, + }, + }); + + await processGitHubWebhook({ + db: prisma, + deliveryId: delivery("related-pr-opened"), + event: "pull_request", + payload: { + action: "opened", + installation: { id: 101 }, + repository: { full_name: "acme/forge" }, + pull_request: pullRequest({ + body: `Related ${fixture.workspace.key}-${issue.number}`, + }), + }, + }); + + const resource = await prisma.externalResource.findFirstOrThrow({ + where: { + workspaceId: fixture.workspace.id, + provider: "GITHUB", + resourceType: "PULL_REQUEST", + number: 42, + }, + }); + await expect( + prisma.externalResourceLink.findUniqueOrThrow({ + where: { + issueId_externalResourceId: { + issueId: issue.id, + externalResourceId: resource.id, + }, + }, + }), + ).resolves.toMatchObject({ kind: "RELATES_TO" }); + + await processGitHubWebhook({ + db: prisma, + deliveryId: delivery("related-pr-checks-failed"), + event: "check_run", + payload: { + action: "completed", + installation: { id: 101 }, + repository: { full_name: "acme/forge" }, + check_run: { + head_sha: "head-new", + status: "completed", + conclusion: "failure", + pull_requests: [{ number: 42 }], + }, + }, + }); + + await expect( + prisma.issue.findUniqueOrThrow({ where: { id: issue.id }, include: { status: true } }), + ).resolves.toMatchObject({ status: { category: "IN_PROGRESS" } }); + }); + it("ignores an older review hint instead of replacing a newer decision", async () => { const { fixture, prisma, mapping } = await setup(); const resource = await upsertExternalResource(prisma, { diff --git a/src/server/services/github/resource-sync.ts b/src/server/services/github/resource-sync.ts index 6a43096a..ac7a91ef 100644 --- a/src/server/services/github/resource-sync.ts +++ b/src/server/services/github/resource-sync.ts @@ -32,6 +32,7 @@ import { import { EXTERNAL_LINK_KINDS, GITHUB_PROVIDER, + IMPLEMENTATION_LINK_KINDS, type ExternalLinkKind, type GitHubResourceSnapshot, type GitHubResourceType, @@ -1264,12 +1265,17 @@ export async function applyGitHubSnapshotToLinkedIssues(args: { }); const changedIssueIds = new Set(); for (const link of links) { + const statusId = + args.snapshot.resourceType === "PULL_REQUEST" && + !IMPLEMENTATION_LINK_KINDS.some((kind) => kind === link.kind) + ? null + : (args.statusRuleId ?? null); const changed = await applyIssuePatchFromGitHub({ tx: args.tx, workspaceId: args.workspaceId, issueId: link.issueId, title: config.syncTitle && link.kind === "SOURCE" ? args.snapshot.title : undefined, - statusId: args.statusRuleId ?? null, + statusId, actor: args.actor, payload: { source: "github", diff --git a/src/server/services/github/webhook.ts b/src/server/services/github/webhook.ts index 8a8c0a25..fa0ca140 100644 --- a/src/server/services/github/webhook.ts +++ b/src/server/services/github/webhook.ts @@ -28,7 +28,11 @@ import { upsertExternalResourceFromWebhook, type ActorMeta, } from "@/server/services/github/resource-sync"; -import { GITHUB_PROVIDER, type GitHubResourceSnapshot } from "@/server/services/github/types"; +import { + GITHUB_PROVIDER, + IMPLEMENTATION_LINK_KINDS, + type GitHubResourceSnapshot, +} from "@/server/services/github/types"; import { derivePullRequestIssueRelations } from "@/server/services/github/relation"; type GitHubWebhookRepository = { @@ -793,7 +797,11 @@ async function processCheckEvent(args: { const changedIssueIds = new Set(); if (status) { const links = await tx.externalResourceLink.findMany({ - where: { workspaceId, externalResourceId: resource.id }, + where: { + workspaceId, + externalResourceId: resource.id, + kind: { in: [...IMPLEMENTATION_LINK_KINDS] }, + }, select: { issueId: true }, }); for (const link of links) { From b371462633ad12268a85ff517921317a544f6a5e Mon Sep 17 00:00:00 2001 From: Bailey Dixon Date: Thu, 16 Jul 2026 19:28:57 -0400 Subject: [PATCH 06/13] fix: clarify issue delivery and activity evidence --- DEVLOG.md | 30 ++++ docs/agents/providers-and-transports.md | 8 +- src/app/(app)/w/[slug]/issues/[id]/page.tsx | 51 ++++-- .../issue-detail/github-links-panel.tsx | 44 ++++- .../issue-detail/issue-activity-panel.tsx | 157 ++++++++++++------ .../issue-detail/work-coordination-panel.tsx | 81 ++++++--- .../markdown/attachment-renderer.tsx | 71 ++++++-- src/lib/activity-grouping.ts | 28 ++++ src/lib/delivery-identity.ts | 62 +++++++ tests/e2e/issue-flow.spec.ts | 7 + tests/unit/activity-grouping.test.ts | 26 +++ tests/unit/delivery-identity.test.ts | 69 ++++++++ tests/unit/markdown-url-safety.test.ts | 8 + 13 files changed, 537 insertions(+), 105 deletions(-) create mode 100644 src/lib/activity-grouping.ts create mode 100644 src/lib/delivery-identity.ts create mode 100644 tests/unit/activity-grouping.test.ts create mode 100644 tests/unit/delivery-identity.test.ts diff --git a/DEVLOG.md b/DEVLOG.md index 6361b699..f56da32c 100644 --- a/DEVLOG.md +++ b/DEVLOG.md @@ -13786,3 +13786,33 @@ activity provenance. The immutable tag will be cut only from the merged release commit, then deployed through the clean-checkout production path and verified live. + +--- + +## 2026-07-16 — Delivery agent and operator identity + +Refined the issue Delivery card so execution identity and human ownership are +not collapsed into one ambiguous Actor label. Registered agents now remain the +primary identity with their operator shown as supporting context. Manual work +shows the operator explicitly, while legacy Codex Desktop, MCP, dispatch, and +scheduled sessions without a registered agent connection are labeled as +unverified instead of being falsely attributed to the credential owner. + +Expanded Delivery evidence now lists Agent and Operator independently alongside +invocation, connector, and runtime facts. The agent transport documentation and +the issue-flow Playwright contract were updated to preserve the distinction. +Connection state now uses a semantic status badge with a hover/focus explanation, +and the evidence disclosure uses a bordered control and rotating chevron so its +interactivity is visible without adding density to the collapsed card. +GitHub PR state now follows the same semantic badge and explanatory tooltip +pattern, while GitHub evidence uses the same recognizable disclosure control. +The issue claim card now separates owner identity, active state, expiry, and the +release action into a clearer compact hierarchy. +The issue Activity rail now groups only adjacent equivalent events, preserving +audit order while reducing repeated metadata noise. It shows eight recent +groups by default and provides an explicit event-counted show-more/show-less +control for the remaining history. +The shared Markdown renderer now recognizes compact GitHub +`owner/repository#number` references before Forge issue keys, preventing +hyphenated owners from becoming partial links in issue comments, Command +Center decisions, and every other shared rich-text surface. diff --git a/docs/agents/providers-and-transports.md b/docs/agents/providers-and-transports.md index ac79f6c1..cc6d7601 100644 --- a/docs/agents/providers-and-transports.md +++ b/docs/agents/providers-and-transports.md @@ -13,8 +13,9 @@ There are two independent axes: runtime) or **Streaming/Completions** (Forge). Orthogonal to tier; see [Chat & Dispatch Engines](./engines.md). -There are also four deliberately separate identities in the execution record: +There are also five deliberately separate identities in the execution record: +- **Operator** — the human who owns or manages the delivery lease or credential. - **Agent profile / binding** — who is acting and what it may do in the workspace. - **Agent connection** — the concrete managed runtime, MCP client, webhook, or @@ -24,7 +25,10 @@ There are also four deliberately separate identities in the execution record: An API key authenticates a caller; it is not the execution identity. Forge snapshots the connection on runs and work sessions so key rotation or later -profile relinking cannot rewrite historical attribution. +profile relinking cannot rewrite historical attribution. Delivery surfaces +therefore show Agent and Operator separately; legacy clients without a +registered agent connection are labeled unverified instead of attributing the +agent's work to the credential owner. ## Connection-aware liveness diff --git a/src/app/(app)/w/[slug]/issues/[id]/page.tsx b/src/app/(app)/w/[slug]/issues/[id]/page.tsx index c1ccd268..39991f1b 100644 --- a/src/app/(app)/w/[slug]/issues/[id]/page.tsx +++ b/src/app/(app)/w/[slug]/issues/[id]/page.tsx @@ -3,7 +3,17 @@ import { use, useEffect, useState } from "react"; import { useRouter, useSearchParams } from "next/navigation"; import { toast } from "sonner"; import type { AgentStatus, WorkItemKind } from "@prisma/client"; -import { Archive, ArchiveRestore, Bot, Paperclip, Plus, Timer, Workflow } from "lucide-react"; +import { + Archive, + ArchiveRestore, + Bot, + Clock3, + LogOut, + Paperclip, + Plus, + Timer, + Workflow, +} from "lucide-react"; import { Topbar } from "@/components/topbar"; import { Badge } from "@/components/ui/badge"; import { Avatar } from "@/components/ui/avatar"; @@ -951,26 +961,45 @@ function ClaimHolderCard({ ? shortId : null; return ( -
-
+
+
{claimedByAgent ? ( ) : ( )}
-
{primary}
- {sub &&
{sub}
} +
+
{primary}
+ + Active + +
+
+ {sub && {sub}} + {claimedByAgent ? "Agent claim" : "Operator claim"} +
- {claimExpiresAt && ( - - expires {relativeTime(claimExpiresAt)} +
+
+ {claimExpiresAt ? ( + + + Expires {relativeTime(claimExpiresAt)} + ) : ( + No expiry recorded )} +
-
); } diff --git a/src/components/issue-detail/github-links-panel.tsx b/src/components/issue-detail/github-links-panel.tsx index 832f1a96..c89a9a6d 100644 --- a/src/components/issue-detail/github-links-panel.tsx +++ b/src/components/issue-detail/github-links-panel.tsx @@ -1,9 +1,18 @@ "use client"; import { useState } from "react"; -import { AlertTriangle, ExternalLink, GitPullRequest, Github, Plus, RefreshCw } from "lucide-react"; +import { + AlertTriangle, + ChevronDown, + ExternalLink, + GitPullRequest, + Github, + Plus, + RefreshCw, +} from "lucide-react"; import { toast } from "sonner"; import { Button } from "@/components/ui/button"; +import { Tooltip } from "@/components/ui/tooltip"; import { trpc } from "@/lib/trpc"; import { formatDate, relativeTime } from "@/lib/utils"; import { GitHubLinkModal } from "@/components/issue-detail/github-link-modal"; @@ -23,6 +32,21 @@ function stateLabel(state: string): string { return state || "unknown"; } +function stateDescription(state: string): string { + if (state === "merged") return "GitHub reports that this pull request was merged."; + if (state === "draft") return "This pull request is still marked as a draft in GitHub."; + if (state === "open") return "This pull request is open and has not been merged."; + if (state === "closed") return "This pull request was closed without being merged."; + return "Forge has not received a recognized GitHub state for this item."; +} + +function stateTone(state: string): string { + if (state === "merged") return "border-success/30 bg-success/10 text-success"; + if (state === "draft") return "border-warning/30 bg-warning/10 text-warning"; + if (state === "closed") return "border-danger/30 bg-danger/10 text-danger"; + return "border-border/70 bg-subtle/50 text-foreground"; +} + function metadataRecord(value: unknown): Record { return value && typeof value === "object" && !Array.isArray(value) ? (value as Record) @@ -119,17 +143,25 @@ export function GitHubLinksPanel({ issueId }: { issueId: string }) { {resource.repoFullName}#{resource.number} {kindLabel(link.kind)} - {stateLabel(resource.state)} + + + {stateLabel(resource.state)} + + {resource.lastSyncedAt && ( synced {relativeTime(resource.lastSyncedAt)} )}
{isPr && ( -
- - Evidence +
+ + GitHub evidence + -
+
{typeof head.ref === "string" && ( {head.ref} diff --git a/src/components/issue-detail/issue-activity-panel.tsx b/src/components/issue-detail/issue-activity-panel.tsx index 0542a223..5a95c98f 100644 --- a/src/components/issue-detail/issue-activity-panel.tsx +++ b/src/components/issue-detail/issue-activity-panel.tsx @@ -1,5 +1,6 @@ "use client"; -import { Activity as ActivityIcon } from "lucide-react"; +import { useState } from "react"; +import { Activity as ActivityIcon, ChevronDown, ChevronUp } from "lucide-react"; import { Avatar } from "@/components/ui/avatar"; import { Badge } from "@/components/ui/badge"; import { AgentAvatar } from "@/components/agents/agent-avatar"; @@ -9,9 +10,12 @@ import { activityActorOwnerTitle, } from "@/lib/activity-actor"; import { issueUpdateCopy } from "@/lib/activity-update-summary"; +import { groupConsecutive } from "@/lib/activity-grouping"; import { trpc } from "@/lib/trpc"; import { relativeTime } from "@/lib/utils"; +const COLLAPSED_GROUP_LIMIT = 8; + /** * Activity tab body — the audit-backed event stream for a single issue. * Queries `issue.activity` (workspace-scoped, not admin-gated) and renders @@ -268,6 +272,7 @@ function activityCopy( } export function IssueActivityPanel({ issueId }: { issueId: string }) { + const [showAll, setShowAll] = useState(false); const { data, isLoading } = trpc.issue.activity.useQuery( { issueId, limit: 50 }, // Activity changes in bursts as the issue is edited; no need to refetch @@ -290,6 +295,19 @@ export function IssueActivityPanel({ issueId }: { issueId: string }) { } const rows = data ?? []; + const preparedRows = rows.map((event) => { + const copy = activityCopy(event.kind, event.payload, event.actorAgent?.profileKey ?? null); + const actorKey = event.actorAgent?.id ?? event.actor?.id ?? "system"; + return { + event, + copy, + groupKey: JSON.stringify([actorKey, event.kind, copy.label, copy.detail, copy.phase]), + }; + }); + const groups = groupConsecutive(preparedRows, (row) => row.groupKey); + const visibleGroups = showAll ? groups : groups.slice(0, COLLAPSED_GROUP_LIMIT); + const hiddenGroups = groups.slice(COLLAPSED_GROUP_LIMIT); + const hiddenEventCount = hiddenGroups.reduce((total, group) => total + group.count, 0); return (
@@ -305,60 +323,97 @@ export function IssueActivityPanel({ issueId }: { issueId: string }) { No activity yet. Status changes, assignments, and comments will show up here.

) : ( -
    - {rows.map((e) => { - const agent = e.actorAgent; - const actorLabel = activityActorName(e); - const actorKind = activityActorKind(e); - const actorOwnerTitle = activityActorOwnerTitle(e); - const copy = activityCopy(e.kind, e.payload, agent?.profileKey ?? null); - return ( -
  • - {agent ? ( - - ) : ( - - )} -
    -
    - - {actorLabel} - - {actorKind !== "human" && ( - - {actorKind} - - )} - {copy.phase && ( - - {copy.phase} + <> +
      + {visibleGroups.map((group) => { + const { event: e, copy } = group.newest; + const newestTime = relativeTime(e.createdAt); + const oldestTime = relativeTime(group.oldest.event.createdAt); + const agent = e.actorAgent; + const actorLabel = activityActorName(e); + const actorKind = activityActorKind(e); + const actorOwnerTitle = activityActorOwnerTitle(e); + return ( +
    • + {agent ? ( + + ) : ( + + )} +
      +
      + + {actorLabel} + {actorKind !== "human" && ( + + {actorKind} + + )} + {copy.phase && ( + + {copy.phase} + + )} + {group.count > 1 && ( + + ×{group.count} + + )} + {copy.label} +
      + {copy.detail && ( +
      + {copy.detail} +
      )} - {copy.label} -
      - {copy.detail && ( -
      - {copy.detail} +
      + {group.count > 1 && oldestTime !== newestTime + ? `${oldestTime} – ${newestTime}` + : newestTime}
      - )} -
      - {relativeTime(e.createdAt)}
      -
      -
    • - ); - })} -
    +
  • + ); + })} +
+ {groups.length > COLLAPSED_GROUP_LIMIT && ( + + )} + )}
); diff --git a/src/components/issue-detail/work-coordination-panel.tsx b/src/components/issue-detail/work-coordination-panel.tsx index 881b7ff7..b698461c 100644 --- a/src/components/issue-detail/work-coordination-panel.tsx +++ b/src/components/issue-detail/work-coordination-panel.tsx @@ -3,6 +3,7 @@ import { useEffect, useMemo, useState } from "react"; import { AlertTriangle, + ChevronDown, GitBranch, CheckCircle2, CircleDot, @@ -17,8 +18,10 @@ import type { inferRouterOutputs } from "@trpc/server"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Combobox } from "@/components/ui/combobox"; +import { Tooltip } from "@/components/ui/tooltip"; import { trpc } from "@/lib/trpc"; import { cn, relativeTime } from "@/lib/utils"; +import { resolveDeliveryIdentity } from "@/lib/delivery-identity"; import { useWorkspace } from "@/hooks/use-workspace"; import type { AppRouter } from "@/server/routers/_app"; @@ -101,9 +104,7 @@ export function WorkCoordinationPanel({ onError: (error) => toast.error(error.message), }); - const owner = latest?.ownerAgent - ? `${latest.ownerAgent.name} · @${latest.ownerAgent.profileKey}` - : (latest?.ownerUser?.name ?? latest?.ownerUser?.email ?? "Unassigned"); + const identity = latest ? resolveDeliveryIdentity(latest) : null; const status = latest ? (STATUS_COPY[latest.status] ?? STATUS_COPY.CLAIMED) : null; const provenance = latest ? deliveryProvenance(latest) : null; const connectionMismatch = Boolean( @@ -150,7 +151,14 @@ export function WorkCoordinationPanel({ )}
-
{owner}
+
+ {identity?.primaryLabel ?? "Unassigned"} +
+ {identity?.summary && ( +
+ {identity.summary} +
+ )} {provenance && (
@@ -161,9 +169,17 @@ export function WorkCoordinationPanel({ {provenance.connectorLabel} )} - - {provenance.statusLabel} - + + + {provenance.statusLabel} + +
)}
{latest.repoFullName}:{latest.branch}
-
- - Delivery evidence +
+ + Delivery evidence +
-
Actor
-
{owner}
+
Agent
+
+ {identity?.agentLabel ?? "none recorded"} +
+
Operator
+
+ {identity?.operatorLabel ?? "none recorded"} +
Invocation
{provenance?.invocationLabel ?? "unknown"}
Connector
@@ -490,7 +513,8 @@ function deliveryProvenance(session: DeliverySession): { connectorLabel: string | null; runtimeLabel: string | null; statusLabel: string; - tone: string; + statusDescription: string; + badgeTone: string; unconfirmedMcp: boolean; } { const connection = session.ownerConnection; @@ -521,7 +545,9 @@ function deliveryProvenance(session: DeliverySession): { connectorLabel: null, runtimeLabel, statusLabel: "provenance not registered", - tone: "text-muted-foreground", + statusDescription: + "No concrete client or runtime connection is attached to this delivery session.", + badgeTone: "border-border bg-subtle/60 text-muted-foreground", unconfirmedMcp: session.source === "MCP", }; } @@ -534,20 +560,35 @@ function deliveryProvenance(session: DeliverySession): { : connection.status === "QUIET" && unconfirmedMcp ? "quiet · status unconfirmed" : connection.status.toLowerCase(); - const tone = + const badgeTone = connection.status === "DISCONNECTED" || connection.status === "REVOKED" - ? "text-danger" + ? "border-danger/30 bg-danger/10 text-danger" : connection.status === "QUIET" - ? "text-warning" + ? "border-warning/30 bg-warning/10 text-warning" : connection.status === "ACTIVE" && connection.confidence === "CONFIRMED" - ? "text-success" - : "text-muted-foreground"; + ? "border-success/30 bg-success/10 text-success" + : "border-border bg-subtle/60 text-muted-foreground"; + const statusDescription = + connection.status === "ACTIVE" && connection.confidence === "CONFIRMED" + ? "Forge has direct, current evidence that this delivery connection is active." + : connection.status === "ACTIVE" + ? "Recent activity was observed, but live connection presence is not fully confirmed." + : connection.status === "QUIET" && unconfirmedMcp + ? "The MCP client has not sent a recent signal. Silence does not prove that it is offline." + : connection.status === "QUIET" + ? "This connection has not produced a recent activity signal." + : connection.status === "DISCONNECTED" + ? "The client or runtime explicitly disconnected from Forge." + : connection.status === "REVOKED" + ? "This delivery connection was revoked and can no longer act." + : "Forge has recorded this connection state from the latest available evidence."; return { invocationLabel, connectorLabel: connectionKindLabel(connection.kind, connection), runtimeLabel, statusLabel, - tone, + statusDescription, + badgeTone, unconfirmedMcp, }; } diff --git a/src/components/markdown/attachment-renderer.tsx b/src/components/markdown/attachment-renderer.tsx index 7e181647..6a1c53f6 100644 --- a/src/components/markdown/attachment-renderer.tsx +++ b/src/components/markdown/attachment-renderer.tsx @@ -73,6 +73,10 @@ import { isSafeExternalUrl, toRenderableHref } from "@/lib/url-safety"; const FORGE_ATTACHMENT_RE = /(!?)\[([^\]]*)\]\(forge-attachment:([a-z0-9]{20,})\)/gi; const FORGE_LINK_RE = /\[([^\]]*)\]\(forge-link:(https?:\/\/[^\s)]+)\)/gi; +// GitHub's compact owner/repo#number form must be consumed before Forge issue +// keys. Otherwise an owner such as `CODENAME-11` is incorrectly linked as a +// local issue and the remainder starts at the slash. +const GITHUB_REF_RE = /\b([a-z0-9](?:[a-z0-9-]{0,38})\/[a-z0-9._-]+)#(\d+)\b/gi; // Issue refs (KEY-NN) and agent mentions (@profileKey). The lookbehind on // `@` keeps `email@host` from accidentally matching as a mention. const REF_RE = /(\b[A-Z][A-Z0-9]{1,9}-\d+\b)|(?<=^|[\s(,.;:!?])@([a-z0-9][a-z0-9_-]*)/gi; @@ -88,6 +92,7 @@ type InlineSegment = | { type: "image"; alt: string; attachmentId: string } | { type: "attachmentLink"; label: string; attachmentId: string } | { type: "linkChip"; label: string; url: string } + | { type: "githubRef"; repoFullName: string; number: number } | { type: "issueRef"; key: string } | { type: "mention"; profileKey: string } | { type: "mdLink"; label: string; url: string } @@ -177,7 +182,8 @@ function tokenizeInline(text: string): InlineSegment[] { if (lo < t.length) passC.push({ type: "text", value: t.slice(lo) }); } - // Pass D: issue refs + agent mentions. + // Pass D: compact GitHub owner/repo#number references. This runs before + // Forge issue refs so hyphenated organization names are not split at `/`. const passD: InlineSegment[] = []; for (const seg of passC) { if (seg.type !== "text") { @@ -186,18 +192,21 @@ function tokenizeInline(text: string): InlineSegment[] { } const t = seg.value; let lo = 0; - REF_RE.lastIndex = 0; - for (const m of t.matchAll(REF_RE)) { + GITHUB_REF_RE.lastIndex = 0; + for (const m of t.matchAll(GITHUB_REF_RE)) { const idx = m.index ?? 0; if (idx > lo) passD.push({ type: "text", value: t.slice(lo, idx) }); - if (m[1]) passD.push({ type: "issueRef", key: m[1].toUpperCase() }); - else if (m[2]) passD.push({ type: "mention", profileKey: m[2].toLowerCase() }); + passD.push({ + type: "githubRef", + repoFullName: m[1], + number: Number(m[2]), + }); lo = idx + m[0].length; } if (lo < t.length) passD.push({ type: "text", value: t.slice(lo) }); } - // Pass E: bare URLs on what's left. + // Pass E: issue refs + agent mentions. const passE: InlineSegment[] = []; for (const seg of passD) { if (seg.type !== "text") { @@ -206,22 +215,42 @@ function tokenizeInline(text: string): InlineSegment[] { } const t = seg.value; let lo = 0; - URL_RE.lastIndex = 0; - for (const m of t.matchAll(URL_RE)) { + REF_RE.lastIndex = 0; + for (const m of t.matchAll(REF_RE)) { const idx = m.index ?? 0; if (idx > lo) passE.push({ type: "text", value: t.slice(lo, idx) }); - passE.push({ type: "url", url: m[0] }); + if (m[1]) passE.push({ type: "issueRef", key: m[1].toUpperCase() }); + else if (m[2]) passE.push({ type: "mention", profileKey: m[2].toLowerCase() }); lo = idx + m[0].length; } if (lo < t.length) passE.push({ type: "text", value: t.slice(lo) }); } - // Pass F: inline emphasis (bold, italic, code, strikethrough) on + // Pass F: bare URLs on what's left. + const passF: InlineSegment[] = []; + for (const seg of passE) { + if (seg.type !== "text") { + passF.push(seg); + continue; + } + const t = seg.value; + let lo = 0; + URL_RE.lastIndex = 0; + for (const m of t.matchAll(URL_RE)) { + const idx = m.index ?? 0; + if (idx > lo) passF.push({ type: "text", value: t.slice(lo, idx) }); + passF.push({ type: "url", url: m[0] }); + lo = idx + m[0].length; + } + if (lo < t.length) passF.push({ type: "text", value: t.slice(lo) }); + } + + // Pass G: inline emphasis (bold, italic, code, strikethrough) on // remaining text spans. We do this last so emphasis can't "swallow" // the URL of an inline link (`*foo [bar](url)*` still highlights the // link), and so an attachment chip wrapped in `*…*` works naturally. const out: InlineSegment[] = []; - for (const seg of passE) { + for (const seg of passF) { if (seg.type !== "text") { out.push(seg); continue; @@ -727,10 +756,22 @@ function renderInlineSegs( } return ; } - if (s.type === "issueRef") - return ; - if (s.type === "mention") - return ; + if (s.type === "issueRef") return ; + if (s.type === "githubRef") { + const label = `${s.repoFullName}#${s.number}`; + return ( + + {label} + + ); + } + if (s.type === "mention") return ; if (s.type === "mdLink") { // Explicit allowlist: http(s) links open in a new tab, and internal // app paths (e.g. `/w/foo/issues/abc`) get an in-app Link. Everything diff --git a/src/lib/activity-grouping.ts b/src/lib/activity-grouping.ts new file mode 100644 index 00000000..b2020890 --- /dev/null +++ b/src/lib/activity-grouping.ts @@ -0,0 +1,28 @@ +export type ConsecutiveGroup = { + newest: T; + oldest: T; + count: number; +}; + +/** + * Collapse only adjacent equivalent rows. This keeps audit order intact and + * avoids merging distinct work that merely happens to share the same label. + */ +export function groupConsecutive( + items: readonly T[], + keyFor: (item: T) => string, +): Array> { + const groups: Array> = []; + + for (const item of items) { + const previous = groups.at(-1); + if (previous && keyFor(previous.newest) === keyFor(item)) { + previous.oldest = item; + previous.count += 1; + } else { + groups.push({ newest: item, oldest: item, count: 1 }); + } + } + + return groups; +} diff --git a/src/lib/delivery-identity.ts b/src/lib/delivery-identity.ts new file mode 100644 index 00000000..125c2a7e --- /dev/null +++ b/src/lib/delivery-identity.ts @@ -0,0 +1,62 @@ +import type { WorkSessionSource } from "@prisma/client"; + +type DeliveryPerson = { + name: string | null; + email?: string | null; + profileKey?: string | null; +}; + +export type DeliveryIdentityInput = { + source: WorkSessionSource; + ownerUser?: DeliveryPerson | null; + ownerAgent?: DeliveryPerson | null; + ownerConnection?: { agent?: DeliveryPerson | null } | null; +}; + +export type DeliveryIdentity = { + agentLabel: string | null; + operatorLabel: string | null; + primaryLabel: string; + summary: string | null; +}; + +export function resolveDeliveryIdentity(session: DeliveryIdentityInput): DeliveryIdentity { + const registeredAgent = session.ownerAgent ?? session.ownerConnection?.agent ?? null; + const registeredAgentLabel = + registeredAgent?.name && registeredAgent.profileKey + ? `${registeredAgent.name} · @${registeredAgent.profileKey}` + : null; + const unregisteredAgentLabel = + session.source === "CODEX_DESKTOP" + ? "Unregistered Codex Desktop client" + : session.source === "MCP" + ? "Unregistered MCP client" + : session.source === "FORGE_AGENT" + ? "Unregistered Forge agent" + : session.source === "ISSUE_DISPATCH" + ? "Unregistered dispatched agent" + : session.source === "SCHEDULED" + ? "Unregistered scheduled agent" + : session.source === "NATIVE_SESSION" + ? "Unregistered native session agent" + : null; + const agentLabel = registeredAgentLabel ?? unregisteredAgentLabel; + const operatorLabel = session.ownerUser?.name ?? session.ownerUser?.email ?? null; + + if (agentLabel) { + const agentState = registeredAgentLabel ? "Agent" : "Agent identity unverified"; + return { + agentLabel, + operatorLabel, + primaryLabel: agentLabel, + summary: operatorLabel ? `${agentState} · Operator ${operatorLabel}` : agentState, + }; + } + + return { + agentLabel: null, + operatorLabel, + primaryLabel: operatorLabel ?? "Unassigned", + summary: operatorLabel ? "Operator" : null, + }; +} diff --git a/tests/e2e/issue-flow.spec.ts b/tests/e2e/issue-flow.spec.ts index 770333b1..704bf4df 100644 --- a/tests/e2e/issue-flow.spec.ts +++ b/tests/e2e/issue-flow.spec.ts @@ -66,7 +66,14 @@ test.describe("Issue flow", () => { await delivery.getByRole("button", { name: "Claim work" }).click(); await expect(delivery.locator("span").filter({ hasText: /^Manual UI$/ })).toBeVisible(); await expect(delivery.getByText("MCP · Codex Desktop", { exact: true })).toHaveCount(0); + await expect(delivery.getByText("provenance not registered", { exact: true })).toHaveAttribute( + "title", + "No concrete client or runtime connection is attached to this delivery session.", + ); await delivery.getByText("Delivery evidence", { exact: true }).click(); + await expect(delivery.getByText("Agent", { exact: true })).toBeVisible(); + await expect(delivery.getByText("Operator", { exact: true })).toBeVisible(); + await expect(delivery.getByText("Actor", { exact: true })).toHaveCount(0); await expect(delivery.getByText("no dispatched run recorded", { exact: true })).toBeVisible(); // Move status on the detail page and confirm it sticks. diff --git a/tests/unit/activity-grouping.test.ts b/tests/unit/activity-grouping.test.ts new file mode 100644 index 00000000..90ce8ada --- /dev/null +++ b/tests/unit/activity-grouping.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from "vitest"; +import { groupConsecutive } from "@/lib/activity-grouping"; + +describe("groupConsecutive", () => { + it("collapses adjacent equivalent rows while retaining their bounds", () => { + const rows = [ + { id: "newest", key: "title" }, + { id: "middle", key: "title" }, + { id: "oldest", key: "title" }, + ]; + + expect(groupConsecutive(rows, (row) => row.key)).toEqual([ + { newest: rows[0], oldest: rows[2], count: 3 }, + ]); + }); + + it("does not merge matching rows separated by a different event", () => { + const rows = [ + { id: "one", key: "title" }, + { id: "two", key: "status" }, + { id: "three", key: "title" }, + ]; + + expect(groupConsecutive(rows, (row) => row.key).map((group) => group.count)).toEqual([1, 1, 1]); + }); +}); diff --git a/tests/unit/delivery-identity.test.ts b/tests/unit/delivery-identity.test.ts new file mode 100644 index 00000000..ecc2edd2 --- /dev/null +++ b/tests/unit/delivery-identity.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it } from "vitest"; +import { resolveDeliveryIdentity } from "@/lib/delivery-identity"; + +describe("resolveDeliveryIdentity", () => { + it("leads with the registered agent and keeps the operator as context", () => { + expect( + resolveDeliveryIdentity({ + source: "FORGE_AGENT", + ownerUser: { name: "Bailey" }, + ownerAgent: { name: "Codex", profileKey: "codex" }, + }), + ).toEqual({ + agentLabel: "Codex · @codex", + operatorLabel: "Bailey", + primaryLabel: "Codex · @codex", + summary: "Agent · Operator Bailey", + }); + }); + + it("uses a registered connection agent when the legacy owner field is absent", () => { + expect( + resolveDeliveryIdentity({ + source: "MCP", + ownerUser: { name: "Bailey" }, + ownerConnection: { agent: { name: "Codex", profileKey: "codex" } }, + }).agentLabel, + ).toBe("Codex · @codex"); + }); + + it("does not attribute an unregistered desktop client to its operator", () => { + expect( + resolveDeliveryIdentity({ + source: "CODEX_DESKTOP", + ownerUser: { name: "Bailey" }, + }), + ).toEqual({ + agentLabel: "Unregistered Codex Desktop client", + operatorLabel: "Bailey", + primaryLabel: "Unregistered Codex Desktop client", + summary: "Agent identity unverified · Operator Bailey", + }); + }); + + it("marks an unregistered MCP client without inventing a profile", () => { + expect(resolveDeliveryIdentity({ source: "MCP" })).toMatchObject({ + agentLabel: "Unregistered MCP client", + operatorLabel: null, + summary: "Agent identity unverified", + }); + }); + + it("shows a manual human claim as operator-owned work", () => { + expect(resolveDeliveryIdentity({ source: "MANUAL", ownerUser: { name: "Bailey" } })).toEqual({ + agentLabel: null, + operatorLabel: "Bailey", + primaryLabel: "Bailey", + summary: "Operator", + }); + }); + + it("keeps an unidentified contributor session unassigned", () => { + expect(resolveDeliveryIdentity({ source: "CONTRIBUTOR" })).toEqual({ + agentLabel: null, + operatorLabel: null, + primaryLabel: "Unassigned", + summary: null, + }); + }); +}); diff --git a/tests/unit/markdown-url-safety.test.ts b/tests/unit/markdown-url-safety.test.ts index b629dde3..960fb461 100644 --- a/tests/unit/markdown-url-safety.test.ts +++ b/tests/unit/markdown-url-safety.test.ts @@ -26,6 +26,14 @@ describe("MarkdownWithAttachments URL safety", () => { expect(html).not.toContain('target="_blank"'); }); + it("renders the complete compact GitHub reference instead of splitting at the slash", () => { + const html = renderMarkdown("Merged PR CODENAME-11/Forge#56 recommends completion."); + + expect(html).toContain('href="https://github.com/CODENAME-11/Forge/issues/56"'); + expect(html).toContain(">CODENAME-11/Forge#56"); + expect(html).not.toContain("/issues/CODENAME-11"); + }); + it("does not render javascript or data markdown links as clickable hrefs", () => { const jsHtml = renderMarkdown("[bad](javascript:alert(1))"); const dataHtml = renderMarkdown("[bad](data:text/html,

x

)"); From 7449591d0fbb099cc2af23591d9c995a70d663ee Mon Sep 17 00:00:00 2001 From: Bailey Dixon Date: Thu, 16 Jul 2026 19:43:39 -0400 Subject: [PATCH 07/13] chore: prepare v0.27.1 release --- CHANGELOG.md | 12 ++++++++++++ DEVLOG.md | 3 +++ package.json | 2 +- tests/e2e/issue-flow.spec.ts | 6 +++--- 4 files changed, 19 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 496ef8b1..53eb9204 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,18 @@ The dashboard's **What's New** rail reads this file at request time entries. Keep entries terse — one line per item under each version date, grouped by `Added` / `Changed` / `Fixed` / `Removed`. +## [2026-07-16] — v0.27.1 · Truthful delivery evidence + +### Changed + +- **Delivery identity separates the agent from its operator.** Compact and expanded cards distinguish execution identity, invocation, connector, runtime, and human ownership while explaining connection confidence on hover or keyboard focus. +- **Issue evidence and activity stay compact without hiding history.** Delivery and GitHub disclosures share a clear interaction pattern, repeated adjacent activity is grouped, and operators can expand capped history on demand. + +### Fixed + +- **GitHub and completion state remain internally consistent.** Native implementation, closing, related, and release-containment relations render distinctly; merged pull requests no longer retain pending or unknown merge metadata. +- **Compact GitHub references link as one unit.** Shared Markdown surfaces recognize `owner/repository#number` before Forge issue keys instead of splitting hyphenated owners at the slash. + ## [2026-07-16] — v0.27.0 · Personal work and canonical delivery ### Added diff --git a/DEVLOG.md b/DEVLOG.md index f56da32c..730fe530 100644 --- a/DEVLOG.md +++ b/DEVLOG.md @@ -13816,3 +13816,6 @@ The shared Markdown renderer now recognizes compact GitHub `owner/repository#number` references before Forge issue keys, preventing hyphenated owners from becoming partial links in issue comments, Command Center decisions, and every other shared rich-text surface. +Prepared the reviewed change as patch release `v0.27.1`; the immutable tag is +cut only after the release-ready commit lands on `main` with all required CI +checks green. diff --git a/package.json b/package.json index de7ffae7..9b479311 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "forge", - "version": "0.27.0", + "version": "0.27.1", "private": true, "description": "Forge — a fast, minimalist, keyboard-driven project management platform with pluggable agents.", "license": "MIT", diff --git a/tests/e2e/issue-flow.spec.ts b/tests/e2e/issue-flow.spec.ts index 704bf4df..0b7a3e7a 100644 --- a/tests/e2e/issue-flow.spec.ts +++ b/tests/e2e/issue-flow.spec.ts @@ -71,9 +71,9 @@ test.describe("Issue flow", () => { "No concrete client or runtime connection is attached to this delivery session.", ); await delivery.getByText("Delivery evidence", { exact: true }).click(); - await expect(delivery.getByText("Agent", { exact: true })).toBeVisible(); - await expect(delivery.getByText("Operator", { exact: true })).toBeVisible(); - await expect(delivery.getByText("Actor", { exact: true })).toHaveCount(0); + await expect(delivery.getByRole("term", { name: "Agent" })).toBeVisible(); + await expect(delivery.getByRole("term", { name: "Operator" })).toBeVisible(); + await expect(delivery.getByRole("term", { name: "Actor" })).toHaveCount(0); await expect(delivery.getByText("no dispatched run recorded", { exact: true })).toBeVisible(); // Move status on the detail page and confirm it sticks. From fa41fb9fbeaeb6be4b7626e24c7de44352f2c0b2 Mon Sep 17 00:00:00 2001 From: Bailey Dixon Date: Thu, 16 Jul 2026 19:52:24 -0400 Subject: [PATCH 08/13] test: scope delivery evidence assertions --- tests/e2e/issue-flow.spec.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/e2e/issue-flow.spec.ts b/tests/e2e/issue-flow.spec.ts index 0b7a3e7a..98acd711 100644 --- a/tests/e2e/issue-flow.spec.ts +++ b/tests/e2e/issue-flow.spec.ts @@ -71,9 +71,9 @@ test.describe("Issue flow", () => { "No concrete client or runtime connection is attached to this delivery session.", ); await delivery.getByText("Delivery evidence", { exact: true }).click(); - await expect(delivery.getByRole("term", { name: "Agent" })).toBeVisible(); - await expect(delivery.getByRole("term", { name: "Operator" })).toBeVisible(); - await expect(delivery.getByRole("term", { name: "Actor" })).toHaveCount(0); + await expect(delivery.locator("dt").filter({ hasText: /^Agent$/ })).toBeVisible(); + await expect(delivery.locator("dt").filter({ hasText: /^Operator$/ })).toBeVisible(); + await expect(delivery.locator("dt").filter({ hasText: /^Actor$/ })).toHaveCount(0); await expect(delivery.getByText("no dispatched run recorded", { exact: true })).toBeVisible(); // Move status on the detail page and confirm it sticks. From aa2d4eee635d2c3baac6fdef061e6d65b5426584 Mon Sep 17 00:00:00 2001 From: Bailey Dixon Date: Thu, 16 Jul 2026 20:14:15 -0400 Subject: [PATCH 09/13] fix: preserve github relation identity --- DEVLOG.md | 13 ++- .../migration.sql | 10 ++- .../github-relation-persistence.test.ts | 85 +++++++++++++++++++ src/server/services/action-request-service.ts | 11 ++- src/server/services/completion-candidate.ts | 15 +++- src/server/services/github/relation.ts | 22 +++-- src/server/services/github/resource-sync.ts | 32 ++++++- tests/unit/github-relation.test.ts | 18 ++++ 8 files changed, 190 insertions(+), 16 deletions(-) diff --git a/DEVLOG.md b/DEVLOG.md index 730fe530..e78ce5fc 100644 --- a/DEVLOG.md +++ b/DEVLOG.md @@ -17,8 +17,11 @@ GitHub webhook matching now derives one native relation per issue/resource: release assembly PRs, removes legacy duplicate relations deterministically, and enforces the singular relation invariant. Generic URL recovery and repository identity canonicalization preserve a stronger existing relation, while explicit -relinks remain operator-controlled and migration dedupe keeps implementation -evidence ahead of source/context rows. Terminal PR synchronization clears +relinks remain operator-controlled. Imported `SOURCE` identity is preserved +ahead of derived PR relations so later imports cannot duplicate the Forge issue; +explicit downgrades dismiss stale Ready-to-Close requests when no implementation +PR remains. Grouped references such as `Fixes AXI-1, AXI-2` retain the keyword +across the full clause. Terminal PR synchronization clears stale mergeability and suppresses pending-check contradictions, while merged implementation evidence continues reconciliation until its final checks aggregate is trusted and still participates in completion readiness. Passing @@ -33,6 +36,12 @@ single-worker browser run passed 51 journeys before Chromium exited while opening an unrelated Mission Control context; both affected Mission Control tests passed immediately in isolation, covering all 53 journeys. +Release review follow-up applied all 119 migrations to a clean isolated local +Postgres database and passed nine focused relation/persistence regressions, +including SOURCE preservation, grouped relation derivation, and audited stale +completion-request dismissal. The production-snapshot development database was +not used for migration or test execution. + ## 2026-07-16 — v0.26.1 released, deployed, and verified Merged release PR #60 and tagged exact `main` commit diff --git a/prisma/migrations/20260716200000_delivery_github_relation_truth/migration.sql b/prisma/migrations/20260716200000_delivery_github_relation_truth/migration.sql index d380e0b2..0cf30226 100644 --- a/prisma/migrations/20260716200000_delivery_github_relation_truth/migration.sql +++ b/prisma/migrations/20260716200000_delivery_github_relation_truth/migration.sql @@ -26,10 +26,12 @@ WITH ranked AS ( PARTITION BY "issueId", "externalResourceId" ORDER BY CASE "kind" - WHEN 'FIXES' THEN 0 - WHEN 'IMPLEMENTS' THEN 1 - WHEN 'RELEASES' THEN 2 - WHEN 'SOURCE' THEN 3 + -- SOURCE is the durable identity used to de-duplicate imported + -- GitHub issues/PRs. Derived relations must not erase it. + WHEN 'SOURCE' THEN 0 + WHEN 'FIXES' THEN 1 + WHEN 'IMPLEMENTS' THEN 2 + WHEN 'RELEASES' THEN 3 WHEN 'REVIEWS' THEN 4 ELSE 5 END, diff --git a/src/server/services/__tests__/github-relation-persistence.test.ts b/src/server/services/__tests__/github-relation-persistence.test.ts index 0aa4f261..b3520967 100644 --- a/src/server/services/__tests__/github-relation-persistence.test.ts +++ b/src/server/services/__tests__/github-relation-persistence.test.ts @@ -167,4 +167,89 @@ describe("native GitHub relation persistence", () => { }), ).resolves.toMatchObject({ kind: "RELATES_TO" }); }); + + it("preserves imported SOURCE identity when derived relations are replayed", async () => { + const fixture = await createWorkspaceFixture({ keyPrefix: "GS" }); + fixtures.push(fixture); + const prisma = getPrisma(); + const issue = await createIssue(fixture); + const resource = await prisma.externalResource.create({ + data: { + workspaceId: fixture.workspace.id, + provider: "GITHUB", + resourceType: "PULL_REQUEST", + repoFullName: "acme/forge", + number: 119, + url: "https://github.com/acme/forge/pull/119", + title: "Imported pull request", + state: "open", + }, + }); + const relation = { + workspaceId: fixture.workspace.id, + issueId: issue.id, + externalResourceId: resource.id, + actor: { actorId: fixture.user.id }, + }; + + await linkExternalResourceToIssue(prisma, { ...relation, kind: "SOURCE" }); + await linkExternalResourceToIssue(prisma, { ...relation, kind: "FIXES" }); + + await expect( + prisma.externalResourceLink.findUniqueOrThrow({ + where: { + issueId_externalResourceId: { + issueId: issue.id, + externalResourceId: resource.id, + }, + }, + }), + ).resolves.toMatchObject({ kind: "SOURCE" }); + }); + + it("dismisses stale completion requests when the last implementation PR is reclassified", async () => { + const fixture = await createWorkspaceFixture({ keyPrefix: "GD" }); + fixtures.push(fixture); + const prisma = getPrisma(); + const issue = await createIssue(fixture); + const resource = await prisma.externalResource.create({ + data: { + workspaceId: fixture.workspace.id, + provider: "GITHUB", + resourceType: "PULL_REQUEST", + repoFullName: "acme/forge", + number: 120, + url: "https://github.com/acme/forge/pull/120", + title: "Implementation pull request", + state: "merged", + }, + }); + const relation = { + workspaceId: fixture.workspace.id, + issueId: issue.id, + externalResourceId: resource.id, + actor: { actorId: fixture.user.id }, + }; + + await linkExternalResourceToIssue(prisma, { ...relation, kind: "IMPLEMENTS" }); + const request = await prisma.actionRequest.create({ + data: { + workspaceId: fixture.workspace.id, + issueId: issue.id, + title: "Ready to close", + sourceType: "completion-candidate", + sourceId: resource.id, + dedupeKey: `issue-completion:${issue.id}`, + }, + }); + + await linkExternalResourceToIssue(prisma, { ...relation, kind: "RELEASES" }); + + await expect( + prisma.actionRequest.findUniqueOrThrow({ where: { id: request.id } }), + ).resolves.toMatchObject({ + status: "DISMISSED", + resolution: "The linked pull request is no longer implementation evidence.", + }); + }); }); diff --git a/src/server/services/action-request-service.ts b/src/server/services/action-request-service.ts index 336d4ff4..84be5cd9 100644 --- a/src/server/services/action-request-service.ts +++ b/src/server/services/action-request-service.ts @@ -641,7 +641,7 @@ export async function createActionRequest( /** Resolve / dismiss / snooze an action request. */ export async function transitionActionRequest( - db: PrismaClient, + db: PrismaClient | Prisma.TransactionClient, params: { workspaceId: string; actorId: string | null; @@ -657,7 +657,7 @@ export async function transitionActionRequest( if (!row) { throw new TRPCError({ code: "NOT_FOUND", message: "Action request not found." }); } - await db.$transaction(async (tx) => { + const apply = async (tx: Prisma.TransactionClient) => { try { await tx.actionRequest.update({ where: { id: row.id }, @@ -693,7 +693,12 @@ export async function transitionActionRequest( subjectType: "action-request", subjectId: row.id, }); - }); + }; + if ("$transaction" in db) { + await db.$transaction(apply); + } else { + await apply(db); + } } /** diff --git a/src/server/services/completion-candidate.ts b/src/server/services/completion-candidate.ts index 3823f405..0c2a127a 100644 --- a/src/server/services/completion-candidate.ts +++ b/src/server/services/completion-candidate.ts @@ -6,6 +6,7 @@ import { EventKind, NotificationSeverity, type PrismaClient, + type Prisma, } from "@prisma/client"; import { recordChange } from "@/server/audit"; import { @@ -103,7 +104,7 @@ function canonicalJson(value: unknown): unknown { } async function dismissOpenRequests( - db: PrismaClient, + db: PrismaClient | Prisma.TransactionClient, params: { workspaceId: string; actorId: string | null; @@ -134,6 +135,18 @@ async function dismissOpenRequests( } } +export async function dismissIssueCompletionCandidate( + db: PrismaClient | Prisma.TransactionClient, + params: { workspaceId: string; issueId: string; actorId: string | null; resolution: string }, +): Promise { + await dismissOpenRequests(db, { + workspaceId: params.workspaceId, + actorId: params.actorId, + dedupeKey: completionDedupeKey(params.issueId), + resolution: params.resolution, + }); +} + async function completionContext(db: PrismaClient, workspaceId: string, issueId: string) { const issue = await db.issue.findFirst({ where: { id: issueId, workspaceId, deletedAt: null }, diff --git a/src/server/services/github/relation.ts b/src/server/services/github/relation.ts index d359882f..b3422803 100644 --- a/src/server/services/github/relation.ts +++ b/src/server/services/github/relation.ts @@ -4,6 +4,7 @@ const IMPLEMENTS_PATTERN = /\bimplements?\s*[:#-]?\s*$/i; const FIXES_PATTERN = /\b(?:fix(?:e[sd])?|close[sd]?|resolve[sd]?)\s*[:#-]?\s*$/i; const RELEASE_TITLE_PATTERN = /^\s*release\s+v?\d/i; const RELEASE_BRANCH_PATTERN = /(?:^|\/)release(?:[-/]|$)/i; +const GROUPED_REFERENCE_SEPARATOR = /^\s*(?:,\s*(?:(?:and|&)\s*)?|(?:and|&)\s*)$/i; function relationRank(kind: ExternalLinkKind): number { if (kind === "FIXES") return 3; @@ -35,21 +36,32 @@ export function derivePullRequestIssueRelations(input: { for (const text of [input.title, input.body ?? ""]) { let match: RegExpExecArray | null; + let previousMatchEnd: number | null = null; + let previousKind: ExternalLinkKind | null = null; while ((match = issuePattern.exec(text))) { const number = Number(match[1]); if (!Number.isInteger(number) || number <= 0) continue; const prefix = text.slice(Math.max(0, match.index - 48), match.index); + const directKind: ExternalLinkKind | null = FIXES_PATTERN.test(prefix) + ? "FIXES" + : IMPLEMENTS_PATTERN.test(prefix) + ? "IMPLEMENTS" + : null; + const groupedKind: ExternalLinkKind | null = + previousMatchEnd !== null && + previousKind !== null && + GROUPED_REFERENCE_SEPARATOR.test(text.slice(previousMatchEnd, match.index)) + ? previousKind + : null; const kind: ExternalLinkKind = release ? "RELEASES" - : FIXES_PATTERN.test(prefix) - ? "FIXES" - : IMPLEMENTS_PATTERN.test(prefix) - ? "IMPLEMENTS" - : "RELATES_TO"; + : (directKind ?? groupedKind ?? "RELATES_TO"); const current = relations.get(number); if (!current || release || relationRank(kind) > relationRank(current)) { relations.set(number, kind); } + previousMatchEnd = issuePattern.lastIndex; + previousKind = kind; } } return relations; diff --git a/src/server/services/github/resource-sync.ts b/src/server/services/github/resource-sync.ts index ac7a91ef..a96ad638 100644 --- a/src/server/services/github/resource-sync.ts +++ b/src/server/services/github/resource-sync.ts @@ -13,7 +13,10 @@ import { import { recordChange } from "@/server/audit"; import { syncWorkSessionsFromPullRequest } from "@/server/services/work-session"; import { createIssueWithSideEffects } from "@/server/services/issue-create"; -import { reconcileGitHubPullRequestCompletion } from "@/server/services/completion-candidate"; +import { + dismissIssueCompletionCandidate, + reconcileGitHubPullRequestCompletion, +} from "@/server/services/completion-candidate"; import { getGitHubIssue, getGitHubPullRequest, @@ -791,6 +794,10 @@ export async function linkExternalResourceToIssue( }, }); if (existingLink?.kind === args.kind) return existingLink; + // SOURCE is the durable identity for a Forge issue imported from GitHub. + // Derived PR mentions must not erase the link used by future import de-dupe + // and source-only title/status synchronization. + if (existingLink?.kind === "SOURCE" && args.kind !== "SOURCE") return existingLink; // Generic GitHub URL attachment and recovery paths use RELATES_TO. Replaying // those paths must not erase a previously established native semantic link. if (args.preserveExistingRelation && args.kind === "RELATES_TO" && existingLink) { @@ -817,6 +824,29 @@ export async function linkExternalResourceToIssue( }, }); + const reclassifiedAwayFromImplementation = + existingLink !== null && + IMPLEMENTATION_LINK_KINDS.some((kind) => kind === existingLink.kind) && + !IMPLEMENTATION_LINK_KINDS.some((kind) => kind === link.kind); + if (reclassifiedAwayFromImplementation) { + const remainingImplementationLinks = await db.externalResourceLink.count({ + where: { + workspaceId: args.workspaceId, + issueId: args.issueId, + kind: { in: [...IMPLEMENTATION_LINK_KINDS] }, + externalResource: { resourceType: "PULL_REQUEST" }, + }, + }); + if (remainingImplementationLinks === 0) { + await dismissIssueCompletionCandidate(db, { + workspaceId: args.workspaceId, + issueId: args.issueId, + actorId: args.actor.actorId, + resolution: "The linked pull request is no longer implementation evidence.", + }); + } + } + if (args.recordActivity ?? true) { await recordChange(db, { workspaceId: args.workspaceId, diff --git a/tests/unit/github-relation.test.ts b/tests/unit/github-relation.test.ts index ed2bc58f..5cb1afaf 100644 --- a/tests/unit/github-relation.test.ts +++ b/tests/unit/github-relation.test.ts @@ -33,6 +33,24 @@ describe("GitHub native relation derivation", () => { ]); }); + it("applies one relation keyword to grouped issue references", () => { + const relations = derivePullRequestIssueRelations({ + workspaceKey: "AXI", + title: "Fixes AXI-1, AXI-2 and AXI-3", + body: "Implements AXI-4, AXI-5 & AXI-6. Related AXI-7, context for AXI-8.", + }); + expect(Object.fromEntries(relations)).toEqual({ + 1: "FIXES", + 2: "FIXES", + 3: "FIXES", + 4: "IMPLEMENTS", + 5: "IMPLEMENTS", + 6: "IMPLEMENTS", + 7: "RELATES_TO", + 8: "RELATES_TO", + }); + }); + it("recognizes release branches even when the title is customized", () => { expect(isReleasePullRequest({ title: "July delivery", headRef: "release/v0.28.0" })).toBe(true); }); From d3088de44ca378ba2994ae8953850129ab3e752b Mon Sep 17 00:00:00 2001 From: Bailey Dixon Date: Thu, 16 Jul 2026 20:26:34 -0400 Subject: [PATCH 10/13] fix: preserve source sync semantics --- DEVLOG.md | 4 +- .../github-relation-persistence.test.ts | 132 +++++++++++++++++- src/server/services/github/resource-sync.ts | 3 +- 3 files changed, 136 insertions(+), 3 deletions(-) diff --git a/DEVLOG.md b/DEVLOG.md index e78ce5fc..70c984c1 100644 --- a/DEVLOG.md +++ b/DEVLOG.md @@ -21,7 +21,9 @@ relinks remain operator-controlled. Imported `SOURCE` identity is preserved ahead of derived PR relations so later imports cannot duplicate the Forge issue; explicit downgrades dismiss stale Ready-to-Close requests when no implementation PR remains. Grouped references such as `Fixes AXI-1, AXI-2` retain the keyword -across the full clause. Terminal PR synchronization clears +across the full clause. Duplicate-resource canonicalization also preserves +`SOURCE`, while imported source PRs retain their mapped status synchronization. +Terminal PR synchronization clears stale mergeability and suppresses pending-check contradictions, while merged implementation evidence continues reconciliation until its final checks aggregate is trusted and still participates in completion readiness. Passing diff --git a/src/server/services/__tests__/github-relation-persistence.test.ts b/src/server/services/__tests__/github-relation-persistence.test.ts index b3520967..21c68900 100644 --- a/src/server/services/__tests__/github-relation-persistence.test.ts +++ b/src/server/services/__tests__/github-relation-persistence.test.ts @@ -1,5 +1,9 @@ import { afterAll, afterEach, describe, expect, it } from "vitest"; -import { linkExternalResourceToIssue } from "@/server/services/github/resource-sync"; +import { + applyGitHubSnapshotToLinkedIssues, + canonicalizeGitHubResourceIdentity, + linkExternalResourceToIssue, +} from "@/server/services/github/resource-sync"; import { createIssue, createWorkspaceFixture, @@ -252,4 +256,130 @@ describe("native GitHub relation persistence", () => { resolution: "The linked pull request is no longer implementation evidence.", }); }); + + it("keeps SOURCE identity when duplicate GitHub resources are canonicalized", async () => { + const fixture = await createWorkspaceFixture({ keyPrefix: "GC" }); + fixtures.push(fixture); + const prisma = getPrisma(); + const issue = await createIssue(fixture); + const source = await prisma.externalResource.create({ + data: { + workspaceId: fixture.workspace.id, + provider: "GITHUB", + resourceType: "PULL_REQUEST", + repoFullName: "acme/forge", + number: 121, + url: "https://github.com/acme/forge/pull/121", + title: "Canonical source", + state: "open", + externalUpdatedAt: new Date("2026-07-16T12:00:00Z"), + }, + }); + const duplicate = await prisma.externalResource.create({ + data: { + workspaceId: fixture.workspace.id, + provider: "GITHUB", + resourceType: "PULL_REQUEST", + repoFullName: "ACME/FORGE", + number: 121, + url: "https://github.com/ACME/FORGE/pull/121", + title: "Duplicate relation", + state: "open", + externalUpdatedAt: new Date("2026-07-16T11:00:00Z"), + }, + }); + await prisma.externalResourceLink.createMany({ + data: [ + { + workspaceId: fixture.workspace.id, + issueId: issue.id, + externalResourceId: source.id, + kind: "SOURCE", + }, + { + workspaceId: fixture.workspace.id, + issueId: issue.id, + externalResourceId: duplicate.id, + kind: "FIXES", + }, + ], + }); + + await canonicalizeGitHubResourceIdentity(prisma, { + workspaceId: fixture.workspace.id, + snapshot: { + provider: "GITHUB", + resourceType: "PULL_REQUEST", + repoFullName: "acme/forge", + number: 121, + url: source.url, + title: source.title, + state: "open", + }, + }); + + await expect( + prisma.externalResourceLink.findUniqueOrThrow({ + where: { + issueId_externalResourceId: { issueId: issue.id, externalResourceId: source.id }, + }, + }), + ).resolves.toMatchObject({ kind: "SOURCE" }); + await expect( + prisma.externalResource.findUnique({ where: { id: duplicate.id } }), + ).resolves.toBeNull(); + }); + + it("keeps imported SOURCE pull requests eligible for mapped status synchronization", async () => { + const fixture = await createWorkspaceFixture({ keyPrefix: "GY" }); + fixtures.push(fixture); + const prisma = getPrisma(); + const issue = await createIssue(fixture); + const targetStatus = await prisma.status.findFirstOrThrow({ + where: { workspaceId: fixture.workspace.id, category: "IN_PROGRESS" }, + }); + const resource = await prisma.externalResource.create({ + data: { + workspaceId: fixture.workspace.id, + provider: "GITHUB", + resourceType: "PULL_REQUEST", + repoFullName: "acme/forge", + number: 122, + url: "https://github.com/acme/forge/pull/122", + title: "Imported source", + state: "open", + }, + }); + await linkExternalResourceToIssue(prisma, { + workspaceId: fixture.workspace.id, + issueId: issue.id, + externalResourceId: resource.id, + kind: "SOURCE", + actor: { actorId: fixture.user.id }, + }); + + await applyGitHubSnapshotToLinkedIssues({ + tx: prisma, + workspaceId: fixture.workspace.id, + resourceId: resource.id, + mapping: { config: null }, + snapshot: { + provider: "GITHUB", + resourceType: "PULL_REQUEST", + repoFullName: resource.repoFullName, + number: resource.number, + url: resource.url, + title: resource.title, + state: resource.state, + }, + actor: { actorId: fixture.user.id }, + statusRuleId: targetStatus.id, + }); + + await expect( + prisma.issue.findUniqueOrThrow({ where: { id: issue.id } }), + ).resolves.toMatchObject({ + statusId: targetStatus.id, + }); + }); }); diff --git a/src/server/services/github/resource-sync.ts b/src/server/services/github/resource-sync.ts index a96ad638..2c79f0de 100644 --- a/src/server/services/github/resource-sync.ts +++ b/src/server/services/github/resource-sync.ts @@ -70,10 +70,10 @@ function assertLinkKind(kind: string): asserts kind is ExternalLinkKind { } function externalLinkKindRank(kind: string): number { + if (kind === "SOURCE") return 6; if (kind === "FIXES") return 5; if (kind === "IMPLEMENTS") return 4; if (kind === "RELEASES") return 3; - if (kind === "SOURCE") return 2; if (kind === "REVIEWS") return 1; return 0; } @@ -1297,6 +1297,7 @@ export async function applyGitHubSnapshotToLinkedIssues(args: { for (const link of links) { const statusId = args.snapshot.resourceType === "PULL_REQUEST" && + link.kind !== "SOURCE" && !IMPLEMENTATION_LINK_KINDS.some((kind) => kind === link.kind) ? null : (args.statusRuleId ?? null); From b204d9b9a820c4dde31d0069b2677dda63306f4b Mon Sep 17 00:00:00 2001 From: Bailey Dixon Date: Thu, 16 Jul 2026 20:35:49 -0400 Subject: [PATCH 11/13] fix: dismiss stale completion evidence --- DEVLOG.md | 4 ++- .../migration.sql | 31 +++++++++++++++++++ .../github-relation-persistence.test.ts | 16 ++++++++++ src/server/services/github/resource-sync.ts | 31 +++++++++++++++++++ 4 files changed, 81 insertions(+), 1 deletion(-) diff --git a/DEVLOG.md b/DEVLOG.md index 70c984c1..b08528b1 100644 --- a/DEVLOG.md +++ b/DEVLOG.md @@ -23,7 +23,9 @@ explicit downgrades dismiss stale Ready-to-Close requests when no implementation PR remains. Grouped references such as `Fixes AXI-1, AXI-2` retain the keyword across the full clause. Duplicate-resource canonicalization also preserves `SOURCE`, while imported source PRs retain their mapped status synchronization. -Terminal PR synchronization clears +Both service canonicalization and migration-time release reclassification +dismiss stale completion requests when they remove the last implementation +relation. Terminal PR synchronization clears stale mergeability and suppresses pending-check contradictions, while merged implementation evidence continues reconciliation until its final checks aggregate is trusted and still participates in completion readiness. Passing diff --git a/prisma/migrations/20260716200000_delivery_github_relation_truth/migration.sql b/prisma/migrations/20260716200000_delivery_github_relation_truth/migration.sql index 0cf30226..ab7b6c01 100644 --- a/prisma/migrations/20260716200000_delivery_github_relation_truth/migration.sql +++ b/prisma/migrations/20260716200000_delivery_github_relation_truth/migration.sql @@ -18,6 +18,37 @@ WHERE link."externalResourceId" = resource."id" OR COALESCE(resource."metadata" #>> '{head,ref}', '') ~* '(^|/)release([-\/]|$)' ); +-- A release-only relation cannot support an older Ready-to-Close request. +-- Dismiss requests only when no implementation PR remains for the issue. +UPDATE "ActionRequest" request +SET + "status" = 'DISMISSED', + "resolvedAt" = NOW(), + "resolution" = 'The linked pull request is release containment, not implementation evidence.' +WHERE request."status" = 'OPEN' + AND request."sourceType" = 'completion-candidate' + AND request."issueId" IN ( + SELECT DISTINCT release_link."issueId" + FROM "ExternalResourceLink" release_link + JOIN "ExternalResource" release_resource + ON release_resource."id" = release_link."externalResourceId" + WHERE release_link."kind" = 'RELEASES' + AND release_resource."resourceType" = 'PULL_REQUEST' + AND ( + release_resource."title" ~* '^\s*release\s+v?[0-9]' + OR COALESCE(release_resource."metadata" #>> '{head,ref}', '') ~* '(^|/)release([-\/]|$)' + ) + ) + AND NOT EXISTS ( + SELECT 1 + FROM "ExternalResourceLink" implementation_link + JOIN "ExternalResource" implementation_resource + ON implementation_resource."id" = implementation_link."externalResourceId" + WHERE implementation_link."issueId" = request."issueId" + AND implementation_link."kind" IN ('IMPLEMENTS', 'FIXES') + AND implementation_resource."resourceType" = 'PULL_REQUEST' + ); + -- Older rows could carry multiple semantic kinds for the same issue/resource. -- Keep one deterministic relation before enforcing the native invariant. WITH ranked AS ( diff --git a/src/server/services/__tests__/github-relation-persistence.test.ts b/src/server/services/__tests__/github-relation-persistence.test.ts index 21c68900..a62c35ee 100644 --- a/src/server/services/__tests__/github-relation-persistence.test.ts +++ b/src/server/services/__tests__/github-relation-persistence.test.ts @@ -304,6 +304,16 @@ describe("native GitHub relation persistence", () => { }, ], }); + const request = await prisma.actionRequest.create({ + data: { + workspaceId: fixture.workspace.id, + issueId: issue.id, + title: "Stale canonicalization candidate", + sourceType: "completion-candidate", + sourceId: duplicate.id, + dedupeKey: `issue-completion:${issue.id}`, + }, + }); await canonicalizeGitHubResourceIdentity(prisma, { workspaceId: fixture.workspace.id, @@ -328,6 +338,12 @@ describe("native GitHub relation persistence", () => { await expect( prisma.externalResource.findUnique({ where: { id: duplicate.id } }), ).resolves.toBeNull(); + await expect( + prisma.actionRequest.findUniqueOrThrow({ where: { id: request.id } }), + ).resolves.toMatchObject({ + status: "DISMISSED", + resolution: "The canonical GitHub source is no longer implementation evidence.", + }); }); it("keeps imported SOURCE pull requests eligible for mapped status synchronization", async () => { diff --git a/src/server/services/github/resource-sync.ts b/src/server/services/github/resource-sync.ts index 2c79f0de..ce1fb584 100644 --- a/src/server/services/github/resource-sync.ts +++ b/src/server/services/github/resource-sync.ts @@ -555,6 +555,7 @@ export async function canonicalizeGitHubResourceIdentity( // Older builds could create one row per repository spelling. Preserve every // issue relation on the canonical row, keeping the original link id unless // the same issue/resource relation already exists there. + const downgradedIssueIds = new Set(); for (const duplicate of equivalent) { if (duplicate.id === canonical.id) continue; for (const link of duplicate.links) { @@ -568,6 +569,18 @@ export async function canonicalizeGitHubResourceIdentity( select: { id: true, kind: true }, }); if (collision) { + const winningKind = + externalLinkKindRank(link.kind) > externalLinkKindRank(collision.kind) + ? link.kind + : collision.kind; + if ( + [link.kind, collision.kind].some((candidate) => + IMPLEMENTATION_LINK_KINDS.some((kind) => kind === candidate), + ) && + !IMPLEMENTATION_LINK_KINDS.some((kind) => kind === winningKind) + ) { + downgradedIssueIds.add(link.issueId); + } if (externalLinkKindRank(link.kind) > externalLinkKindRank(collision.kind)) { await db.externalResourceLink.update({ where: { id: collision.id }, @@ -590,6 +603,24 @@ export async function canonicalizeGitHubResourceIdentity( data: { repoFullName: args.snapshot.repoFullName }, }); } + for (const issueId of downgradedIssueIds) { + const remainingImplementationLinks = await db.externalResourceLink.count({ + where: { + workspaceId: args.workspaceId, + issueId, + kind: { in: [...IMPLEMENTATION_LINK_KINDS] }, + externalResource: { resourceType: "PULL_REQUEST" }, + }, + }); + if (remainingImplementationLinks === 0) { + await dismissIssueCompletionCandidate(db, { + workspaceId: args.workspaceId, + issueId, + actorId: null, + resolution: "The canonical GitHub source is no longer implementation evidence.", + }); + } + } } async function upsertExternalResourceInTransaction( From e9db3f513723fab320487ca52628d5adbeba63cd Mon Sep 17 00:00:00 2001 From: Bailey Dixon Date: Thu, 16 Jul 2026 20:46:47 -0400 Subject: [PATCH 12/13] fix: preserve relation cleanup and bare urls --- DEVLOG.md | 4 +- .../migration.sql | 51 ++++++++----------- .../markdown/attachment-renderer.tsx | 19 +++---- tests/unit/markdown-url-safety.test.ts | 9 ++++ 4 files changed, 42 insertions(+), 41 deletions(-) diff --git a/DEVLOG.md b/DEVLOG.md index b08528b1..d85c6e21 100644 --- a/DEVLOG.md +++ b/DEVLOG.md @@ -25,7 +25,9 @@ across the full clause. Duplicate-resource canonicalization also preserves `SOURCE`, while imported source PRs retain their mapped status synchronization. Both service canonicalization and migration-time release reclassification dismiss stale completion requests when they remove the last implementation -relation. Terminal PR synchronization clears +relation, with migration cleanup ordered after legacy relation dedupe. Bare URLs +are consumed before issue-reference tokens so paths containing `AXI-123` remain +fully clickable. Terminal PR synchronization clears stale mergeability and suppresses pending-check contradictions, while merged implementation evidence continues reconciliation until its final checks aggregate is trusted and still participates in completion readiness. Passing diff --git a/prisma/migrations/20260716200000_delivery_github_relation_truth/migration.sql b/prisma/migrations/20260716200000_delivery_github_relation_truth/migration.sql index ab7b6c01..e93524d8 100644 --- a/prisma/migrations/20260716200000_delivery_github_relation_truth/migration.sql +++ b/prisma/migrations/20260716200000_delivery_github_relation_truth/migration.sql @@ -18,37 +18,6 @@ WHERE link."externalResourceId" = resource."id" OR COALESCE(resource."metadata" #>> '{head,ref}', '') ~* '(^|/)release([-\/]|$)' ); --- A release-only relation cannot support an older Ready-to-Close request. --- Dismiss requests only when no implementation PR remains for the issue. -UPDATE "ActionRequest" request -SET - "status" = 'DISMISSED', - "resolvedAt" = NOW(), - "resolution" = 'The linked pull request is release containment, not implementation evidence.' -WHERE request."status" = 'OPEN' - AND request."sourceType" = 'completion-candidate' - AND request."issueId" IN ( - SELECT DISTINCT release_link."issueId" - FROM "ExternalResourceLink" release_link - JOIN "ExternalResource" release_resource - ON release_resource."id" = release_link."externalResourceId" - WHERE release_link."kind" = 'RELEASES' - AND release_resource."resourceType" = 'PULL_REQUEST' - AND ( - release_resource."title" ~* '^\s*release\s+v?[0-9]' - OR COALESCE(release_resource."metadata" #>> '{head,ref}', '') ~* '(^|/)release([-\/]|$)' - ) - ) - AND NOT EXISTS ( - SELECT 1 - FROM "ExternalResourceLink" implementation_link - JOIN "ExternalResource" implementation_resource - ON implementation_resource."id" = implementation_link."externalResourceId" - WHERE implementation_link."issueId" = request."issueId" - AND implementation_link."kind" IN ('IMPLEMENTS', 'FIXES') - AND implementation_resource."resourceType" = 'PULL_REQUEST' - ); - -- Older rows could carry multiple semantic kinds for the same issue/resource. -- Keep one deterministic relation before enforcing the native invariant. WITH ranked AS ( @@ -75,6 +44,26 @@ DELETE FROM "ExternalResourceLink" link USING ranked WHERE link."id" = ranked."id" AND ranked.rn > 1; +-- Reclassification and dedupe can both remove the last implementation link. +-- Any completion request left without implementation PR evidence is stale. +UPDATE "ActionRequest" request +SET + "status" = 'DISMISSED', + "resolvedAt" = NOW(), + "resolution" = 'No linked pull request remains as implementation evidence.' +WHERE request."status" = 'OPEN' + AND request."sourceType" = 'completion-candidate' + AND request."issueId" IS NOT NULL + AND NOT EXISTS ( + SELECT 1 + FROM "ExternalResourceLink" implementation_link + JOIN "ExternalResource" implementation_resource + ON implementation_resource."id" = implementation_link."externalResourceId" + WHERE implementation_link."issueId" = request."issueId" + AND implementation_link."kind" IN ('IMPLEMENTS', 'FIXES') + AND implementation_resource."resourceType" = 'PULL_REQUEST' + ); + DROP INDEX "ExternalResourceLink_issueId_externalResourceId_kind_key"; CREATE UNIQUE INDEX "ExternalResourceLink_issueId_externalResourceId_key" ON "ExternalResourceLink"("issueId", "externalResourceId"); diff --git a/src/components/markdown/attachment-renderer.tsx b/src/components/markdown/attachment-renderer.tsx index 6a1c53f6..44b2f58b 100644 --- a/src/components/markdown/attachment-renderer.tsx +++ b/src/components/markdown/attachment-renderer.tsx @@ -206,7 +206,8 @@ function tokenizeInline(text: string): InlineSegment[] { if (lo < t.length) passD.push({ type: "text", value: t.slice(lo) }); } - // Pass E: issue refs + agent mentions. + // Pass E: bare URLs. Consume complete URLs before issue references so a + // path segment such as `/issues/AXI-123` remains one clickable URL. const passE: InlineSegment[] = []; for (const seg of passD) { if (seg.type !== "text") { @@ -215,18 +216,17 @@ function tokenizeInline(text: string): InlineSegment[] { } const t = seg.value; let lo = 0; - REF_RE.lastIndex = 0; - for (const m of t.matchAll(REF_RE)) { + URL_RE.lastIndex = 0; + for (const m of t.matchAll(URL_RE)) { const idx = m.index ?? 0; if (idx > lo) passE.push({ type: "text", value: t.slice(lo, idx) }); - if (m[1]) passE.push({ type: "issueRef", key: m[1].toUpperCase() }); - else if (m[2]) passE.push({ type: "mention", profileKey: m[2].toLowerCase() }); + passE.push({ type: "url", url: m[0] }); lo = idx + m[0].length; } if (lo < t.length) passE.push({ type: "text", value: t.slice(lo) }); } - // Pass F: bare URLs on what's left. + // Pass F: issue refs + agent mentions on what's left. const passF: InlineSegment[] = []; for (const seg of passE) { if (seg.type !== "text") { @@ -235,11 +235,12 @@ function tokenizeInline(text: string): InlineSegment[] { } const t = seg.value; let lo = 0; - URL_RE.lastIndex = 0; - for (const m of t.matchAll(URL_RE)) { + REF_RE.lastIndex = 0; + for (const m of t.matchAll(REF_RE)) { const idx = m.index ?? 0; if (idx > lo) passF.push({ type: "text", value: t.slice(lo, idx) }); - passF.push({ type: "url", url: m[0] }); + if (m[1]) passF.push({ type: "issueRef", key: m[1].toUpperCase() }); + else if (m[2]) passF.push({ type: "mention", profileKey: m[2].toLowerCase() }); lo = idx + m[0].length; } if (lo < t.length) passF.push({ type: "text", value: t.slice(lo) }); diff --git a/tests/unit/markdown-url-safety.test.ts b/tests/unit/markdown-url-safety.test.ts index 960fb461..f277af9a 100644 --- a/tests/unit/markdown-url-safety.test.ts +++ b/tests/unit/markdown-url-safety.test.ts @@ -34,6 +34,15 @@ describe("MarkdownWithAttachments URL safety", () => { expect(html).not.toContain("/issues/CODENAME-11"); }); + it("keeps issue-looking tokens inside bare URLs clickable as one URL", () => { + const url = "https://forge.example/w/acme/issues/AXI-123"; + const html = renderMarkdown(`Inspect ${url} before closing.`); + + expect(html).toContain(`href="${url}"`); + expect(html).toContain(`>${url}`); + expect(html).not.toContain('href="/issues/AXI-123"'); + }); + it("does not render javascript or data markdown links as clickable hrefs", () => { const jsHtml = renderMarkdown("[bad](javascript:alert(1))"); const dataHtml = renderMarkdown("[bad](data:text/html,

x

)"); From 389642ee9472a0c145567d79d17a7e075ee90b80 Mon Sep 17 00:00:00 2001 From: Bailey Dixon Date: Thu, 16 Jul 2026 20:56:28 -0400 Subject: [PATCH 13/13] fix: align source status and url tokenization --- DEVLOG.md | 6 +++-- .../markdown/attachment-renderer.tsx | 27 +++++++++---------- .../github-webhook-hardening.test.ts | 15 +++++++++++ src/server/services/github/webhook.ts | 2 +- tests/unit/markdown-url-safety.test.ts | 9 +++++++ 5 files changed, 42 insertions(+), 17 deletions(-) diff --git a/DEVLOG.md b/DEVLOG.md index d85c6e21..731fc58c 100644 --- a/DEVLOG.md +++ b/DEVLOG.md @@ -26,8 +26,10 @@ across the full clause. Duplicate-resource canonicalization also preserves Both service canonicalization and migration-time release reclassification dismiss stale completion requests when they remove the last implementation relation, with migration cleanup ordered after legacy relation dedupe. Bare URLs -are consumed before issue-reference tokens so paths containing `AXI-123` remain -fully clickable. Terminal PR synchronization clears +are consumed before compact GitHub and issue-reference tokens so paths containing +`CODENAME-11/Forge#56` or `AXI-123` remain fully clickable. Imported `SOURCE` +PRs participate in failed-check status rules while related/release links do not. +Terminal PR synchronization clears stale mergeability and suppresses pending-check contradictions, while merged implementation evidence continues reconciliation until its final checks aggregate is trusted and still participates in completion readiness. Passing diff --git a/src/components/markdown/attachment-renderer.tsx b/src/components/markdown/attachment-renderer.tsx index 44b2f58b..ea0154a8 100644 --- a/src/components/markdown/attachment-renderer.tsx +++ b/src/components/markdown/attachment-renderer.tsx @@ -182,8 +182,8 @@ function tokenizeInline(text: string): InlineSegment[] { if (lo < t.length) passC.push({ type: "text", value: t.slice(lo) }); } - // Pass D: compact GitHub owner/repo#number references. This runs before - // Forge issue refs so hyphenated organization names are not split at `/`. + // Pass D: bare URLs. Consume complete URLs before compact GitHub or Forge + // issue references so path and fragment tokens remain part of the URL. const passD: InlineSegment[] = []; for (const seg of passC) { if (seg.type !== "text") { @@ -192,22 +192,17 @@ function tokenizeInline(text: string): InlineSegment[] { } const t = seg.value; let lo = 0; - GITHUB_REF_RE.lastIndex = 0; - for (const m of t.matchAll(GITHUB_REF_RE)) { + URL_RE.lastIndex = 0; + for (const m of t.matchAll(URL_RE)) { const idx = m.index ?? 0; if (idx > lo) passD.push({ type: "text", value: t.slice(lo, idx) }); - passD.push({ - type: "githubRef", - repoFullName: m[1], - number: Number(m[2]), - }); + passD.push({ type: "url", url: m[0] }); lo = idx + m[0].length; } if (lo < t.length) passD.push({ type: "text", value: t.slice(lo) }); } - // Pass E: bare URLs. Consume complete URLs before issue references so a - // path segment such as `/issues/AXI-123` remains one clickable URL. + // Pass E: compact GitHub owner/repo#number references on remaining text. const passE: InlineSegment[] = []; for (const seg of passD) { if (seg.type !== "text") { @@ -216,11 +211,15 @@ function tokenizeInline(text: string): InlineSegment[] { } const t = seg.value; let lo = 0; - URL_RE.lastIndex = 0; - for (const m of t.matchAll(URL_RE)) { + GITHUB_REF_RE.lastIndex = 0; + for (const m of t.matchAll(GITHUB_REF_RE)) { const idx = m.index ?? 0; if (idx > lo) passE.push({ type: "text", value: t.slice(lo, idx) }); - passE.push({ type: "url", url: m[0] }); + passE.push({ + type: "githubRef", + repoFullName: m[1], + number: Number(m[2]), + }); lo = idx + m[0].length; } if (lo < t.length) passE.push({ type: "text", value: t.slice(lo) }); diff --git a/src/server/services/__tests__/github-webhook-hardening.test.ts b/src/server/services/__tests__/github-webhook-hardening.test.ts index 810a08af..700c71e4 100644 --- a/src/server/services/__tests__/github-webhook-hardening.test.ts +++ b/src/server/services/__tests__/github-webhook-hardening.test.ts @@ -842,6 +842,15 @@ describe("GitHub webhook hardening", () => { }, }), ).resolves.toMatchObject({ kind: "RELATES_TO" }); + const sourceIssue = await createIssue(fixture, { statusCategory: "IN_PROGRESS" }); + await prisma.externalResourceLink.create({ + data: { + workspaceId: fixture.workspace.id, + issueId: sourceIssue.id, + externalResourceId: resource.id, + kind: "SOURCE", + }, + }); await processGitHubWebhook({ db: prisma, @@ -863,6 +872,12 @@ describe("GitHub webhook hardening", () => { await expect( prisma.issue.findUniqueOrThrow({ where: { id: issue.id }, include: { status: true } }), ).resolves.toMatchObject({ status: { category: "IN_PROGRESS" } }); + await expect( + prisma.issue.findUniqueOrThrow({ + where: { id: sourceIssue.id }, + include: { status: true }, + }), + ).resolves.toMatchObject({ status: { category: "CANCELED" } }); }); it("ignores an older review hint instead of replacing a newer decision", async () => { diff --git a/src/server/services/github/webhook.ts b/src/server/services/github/webhook.ts index fa0ca140..0c3e95fe 100644 --- a/src/server/services/github/webhook.ts +++ b/src/server/services/github/webhook.ts @@ -800,7 +800,7 @@ async function processCheckEvent(args: { where: { workspaceId, externalResourceId: resource.id, - kind: { in: [...IMPLEMENTATION_LINK_KINDS] }, + kind: { in: ["SOURCE", ...IMPLEMENTATION_LINK_KINDS] }, }, select: { issueId: true }, }); diff --git a/tests/unit/markdown-url-safety.test.ts b/tests/unit/markdown-url-safety.test.ts index f277af9a..608d8888 100644 --- a/tests/unit/markdown-url-safety.test.ts +++ b/tests/unit/markdown-url-safety.test.ts @@ -43,6 +43,15 @@ describe("MarkdownWithAttachments URL safety", () => { expect(html).not.toContain('href="/issues/AXI-123"'); }); + it("keeps compact GitHub-looking fragments inside bare URLs as one URL", () => { + const url = "https://example.com/CODENAME-11/Forge#56"; + const html = renderMarkdown(`Inspect ${url} before closing.`); + + expect(html).toContain(`href="${url}"`); + expect(html).toContain(`>${url}`); + expect(html).not.toContain('href="https://github.com/CODENAME-11/Forge/issues/56"'); + }); + it("does not render javascript or data markdown links as clickable hrefs", () => { const jsHtml = renderMarkdown("[bad](javascript:alert(1))"); const dataHtml = renderMarkdown("[bad](data:text/html,

x

)");