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 af2b8881..731fc58c 100644 --- a/DEVLOG.md +++ b/DEVLOG.md @@ -2,6 +2,54 @@ > 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. Generic URL recovery and repository +identity canonicalization preserve a stronger existing relation, while explicit +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. 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, with migration cleanup ordered after legacy relation dedupe. Bare URLs +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 +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 +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. + +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 @@ -13755,3 +13803,36 @@ 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. +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/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/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/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/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..e93524d8 --- /dev/null +++ b/prisma/migrations/20260716200000_delivery_github_relation_truth/migration.sql @@ -0,0 +1,69 @@ +-- 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" + -- 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, + "createdAt" ASC, + "id" ASC + ) AS rn + FROM "ExternalResourceLink" +) +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/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/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/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..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"; @@ -11,6 +20,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"; } @@ -21,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) @@ -73,6 +99,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 +111,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 +142,46 @@ export function GitHubLinksPanel({ issueId }: { issueId: string }) { {resource.repoFullName}#{resource.number} - {kindLabel(link.kind)} - {stateLabel(resource.state)} + {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} - )} -
+
+ + GitHub 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 && (
{ + 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 cb757767..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( @@ -117,7 +118,7 @@ export function WorkCoordinationPanel({ latest.observedImplementation.agent.id !== latest.ownerAgent.id, ); const ownershipMismatch = connectionMismatch || observedMismatch; - const observedConnection = latest?.observedImplementation?.agent.connections[0] ?? null; + const observedConnection = latest?.observedImplementation?.run.connection ?? null; return (
)}
-
{owner}
+
+ {identity?.primaryLabel ?? "Unassigned"} +
+ {identity?.summary && ( +
+ {identity.summary} +
+ )} {provenance && (
- {provenance.connectionLabel} - - - {provenance.statusLabel} + {provenance.invocationLabel} + {provenance.connectorLabel && ( + + {provenance.connectorLabel} + + )} + + + {provenance.statusLabel} + +
)}
{latest.repoFullName}:{latest.branch}
-
- base {latest.baseBranch} · activity {relativeTime(latest.lastHeartbeatAt)} -
- {latest.worktreePath && ( -
- {latest.worktreePath} -
- )} +
+ + Delivery evidence + + +
+
Agent
+
+ {identity?.agentLabel ?? "none recorded"} +
+
Operator
+
+ {identity?.operatorLabel ?? "none recorded"} +
+
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 +411,6 @@ export function WorkCoordinationPanel({ branch, baseBranch, worktreePath: worktreePath || null, - source: "CODEX_DESKTOP", }); }} > @@ -466,24 +509,46 @@ function connectionKindLabel( } function deliveryProvenance(session: DeliverySession): { - connectionLabel: string; + invocationLabel: string; + connectorLabel: string | null; + runtimeLabel: string | null; statusLabel: string; - tone: string; + statusDescription: string; + badgeTone: 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", + 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", }; } const unconfirmedMcp = connection.kind === "MCP_CLIENT" && connection.confidence !== "CONFIRMED"; @@ -495,18 +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 { - connectionLabel: connectionKindLabel(connection.kind, connection), + 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..ea0154a8 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: 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") { @@ -186,18 +192,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) 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: "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 on what's left. + // Pass E: compact GitHub owner/repo#number references on remaining text. const passE: InlineSegment[] = []; for (const seg of passD) { if (seg.type !== "text") { @@ -206,22 +211,46 @@ 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) }); } - // Pass F: inline emphasis (bold, italic, code, strikethrough) on + // Pass F: issue refs + agent mentions 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; + 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) }); + 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) }); + } + + // 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/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/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/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..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("backs off merged PRs with no 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 () => @@ -213,6 +213,7 @@ describe("GitHub status reconciliation", () => { 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.syncTerminalAt).toBeNull(); await prisma.externalResource.update({ where: { id: resource.id }, @@ -272,6 +273,60 @@ describe("GitHub status reconciliation", () => { expect(newHead.metadata).not.toHaveProperty("checks"); }); + 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, + 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).toBeNull(); + expect(merged.syncRetryAt).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 () => { 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..a62c35ee --- /dev/null +++ b/src/server/services/__tests__/github-relation-persistence.test.ts @@ -0,0 +1,401 @@ +import { afterAll, afterEach, describe, expect, it } from "vitest"; +import { + applyGitHubSnapshotToLinkedIssues, + canonicalizeGitHubResourceIdentity, + 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"); + }); + + 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", + preserveExistingRelation: true, + }); + + 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); + + 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" }); + }); + + 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.", + }); + }); + + 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", + }, + ], + }); + 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, + 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(); + 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 () => { + 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/__tests__/github-webhook-hardening.test.ts b/src/server/services/__tests__/github-webhook-hardening.test.ts index d1f0d8c5..700c71e4 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({ @@ -712,6 +791,95 @@ 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" }); + 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, + 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" } }); + 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 () => { const { fixture, prisma, mapping } = await setup(); const resource = await upsertExternalResource(prisma, { @@ -1573,14 +1741,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/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 8e16f0e0..0c2a127a 100644 --- a/src/server/services/completion-candidate.ts +++ b/src/server/services/completion-candidate.ts @@ -6,12 +6,14 @@ import { EventKind, NotificationSeverity, type PrismaClient, + type Prisma, } from "@prisma/client"; import { recordChange } from "@/server/audit"; 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"; @@ -102,7 +104,7 @@ function canonicalJson(value: unknown): unknown { } async function dismissOpenRequests( - db: PrismaClient, + db: PrismaClient | Prisma.TransactionClient, params: { workspaceId: string; actorId: string | null; @@ -133,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 }, @@ -157,7 +171,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 +654,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..99904941 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,8 +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. + // 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({ diff --git a/src/server/services/github/relation.ts b/src/server/services/github/relation.ts new file mode 100644 index 00000000..b3422803 --- /dev/null +++ b/src/server/services/github/relation.ts @@ -0,0 +1,68 @@ +import type { ExternalLinkKind } from "@/server/services/github/types"; + +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; + 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; + 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" + : (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 ba071457..ce1fb584 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, @@ -32,6 +35,7 @@ import { import { EXTERNAL_LINK_KINDS, GITHUB_PROVIDER, + IMPLEMENTATION_LINK_KINDS, type ExternalLinkKind, type GitHubResourceSnapshot, type GitHubResourceType, @@ -65,6 +69,15 @@ 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 === "REVIEWS") return 1; + return 0; +} + function publicResource(row: ExternalResource) { return row; } @@ -541,21 +554,39 @@ 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. + const downgradedIssueIds = new Set(); 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 }, + 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 }, + data: { kind: link.kind, createdById: link.createdById }, + }); + } await db.externalResourceLink.delete({ where: { id: link.id } }); } else { await db.externalResourceLink.update({ @@ -572,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( @@ -616,6 +665,10 @@ async function upsertExternalResourceInTransaction( ...existingMetadata, ...snapshotMetadata, }; + const terminalState = + args.snapshot.resourceType === "PULL_REQUEST" && + (args.snapshot.state === "closed" || args.snapshot.state === "merged"); + if (terminalState) metadata.mergeableState = null; const existingHead = existingMetadata.head && typeof existingMetadata.head === "object" && @@ -640,22 +693,20 @@ 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 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 = - 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: { @@ -740,6 +791,7 @@ export async function linkExternalResourceToIssue( kind: ExternalLinkKind; actor: ActorMeta; recordActivity?: boolean; + preserveExistingRelation?: boolean; }, ): Promise { const [issue, resource] = await Promise.all([ @@ -766,21 +818,28 @@ 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; + // 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) { + 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,9 +849,35 @@ export async function linkExternalResourceToIssue( kind: args.kind, createdById: args.actor.actorId ?? undefined, }, - update: {}, + update: { + kind: args.kind, + ...(args.actor.actorId ? { createdById: args.actor.actorId } : {}), + }, }); + 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, @@ -800,14 +885,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, @@ -831,6 +917,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); @@ -860,6 +947,7 @@ export async function linkGitHubUrlToIssue(args: { externalResourceId: resource.id, kind, actor: args.actor, + preserveExistingRelation: args.preserveExistingRelation, }); return { resource, link }; }); @@ -958,6 +1046,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" }, @@ -1034,6 +1123,7 @@ export async function migrateGenericGitHubAttachments( url: row.url, kind: "RELATES_TO", actor: { actorId: null }, + preserveExistingRelation: true, }); const deleted = await db.attachment.deleteMany({ where: { @@ -1236,12 +1326,18 @@ export async function applyGitHubSnapshotToLinkedIssues(args: { }); const changedIssueIds = new Set(); 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); 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/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..0c3e95fe 100644 --- a/src/server/services/github/webhook.ts +++ b/src/server/services/github/webhook.ts @@ -28,7 +28,12 @@ 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 = { full_name?: string; @@ -239,19 +244,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 +407,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 +445,7 @@ async function processPullRequestEvent(args: { workspaceId, issueId: issue.id, externalResourceId: resource.id, - kind: "IMPLEMENTS", + kind: relations.get(issue.number) ?? "RELATES_TO", actor: args.actor, }); } @@ -802,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: ["SOURCE", ...IMPLEMENTATION_LINK_KINDS] }, + }, select: { issueId: true }, }); for (const link of links) { diff --git a/src/server/services/mcp.ts b/src/server/services/mcp.ts index a67d9cff..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, @@ -8399,7 +8400,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..98acd711 100644 --- a/tests/e2e/issue-flow.spec.ts +++ b/tests/e2e/issue-flow.spec.ts @@ -55,6 +55,27 @@ 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 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.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. const status = page.getByRole("combobox", { name: "Status" }); await status.click(); 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/github-relation.test.ts b/tests/unit/github-relation.test.ts new file mode 100644 index 00000000..5cb1afaf --- /dev/null +++ b/tests/unit/github-relation.test.ts @@ -0,0 +1,57 @@ +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. Close AXI-119. Resolve AXI-120.", + }); + expect(Object.fromEntries(relations)).toEqual({ + 63: "RELATES_TO", + 117: "IMPLEMENTS", + 118: "FIXES", + 119: "FIXES", + 120: "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("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); + }); +}); diff --git a/tests/unit/markdown-url-safety.test.ts b/tests/unit/markdown-url-safety.test.ts index b629dde3..608d8888 100644 --- a/tests/unit/markdown-url-safety.test.ts +++ b/tests/unit/markdown-url-safety.test.ts @@ -26,6 +26,32 @@ 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("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("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

)");