diff --git a/DEVLOG.md b/DEVLOG.md index 7ae2ddf8..1af6c583 100644 --- a/DEVLOG.md +++ b/DEVLOG.md @@ -13227,3 +13227,30 @@ fresh production E2E build succeeded; and both new Playwright journeys passed. The unconfigured full Vitest command could not run integration tests because this shell did not provide `DATABASE_URL` or `AUTH_SECRET`; its focused, dependency-free coverage passed separately. + +--- + +## 2026-07-15 — MCP delivery timeline contract + +Closed the gap between dispatched AgentRun guidance and ordinary MCP code-work +sessions. The native Streamable HTTP handshake now teaches agents to claim and +heartbeat delivery sessions, keep mechanical traces out of comments, use +rolling status comments for meaningful phase changes, and leave one durable +implementation/PR/validation handoff on the issue. + +Added a settings-driven `DeliveryTimelinePolicy` with Off, Recommend, Require +on PR, and Auto on PR modes. `workSessions.attachPullRequest` accepts an +optional `timelineUpdate`; strict workspaces reject missing updates, automatic +workspaces synthesize a concise agent-authored PR handoff, and recommended +workspaces return an explicit `comments.create` next action plus a suggested +template. PR attachment, comment creation, watcher enrollment, audit/event +fan-out, and delivery-state advancement occur atomically. Reattaching the same +PR is idempotent and cannot duplicate the automatic comment. The settings UI +explains each policy, and MCP tool descriptions put the human-facing contract +at the relevant decision points. + +Verification: Prisma schema validation, typecheck, and lint passed (existing +repository warnings only); the focused work-session suite passed 8 tests; the +full Vitest gate passed 1,316 tests with one intentional live skip; the fresh +production build succeeded; and the focused settings Playwright journey +passed. diff --git a/prisma/migrations/20260715193000_delivery_timeline_policy/migration.sql b/prisma/migrations/20260715193000_delivery_timeline_policy/migration.sql new file mode 100644 index 00000000..bb87b46e --- /dev/null +++ b/prisma/migrations/20260715193000_delivery_timeline_policy/migration.sql @@ -0,0 +1,4 @@ +CREATE TYPE "DeliveryTimelinePolicy" AS ENUM ('OFF', 'RECOMMEND', 'REQUIRE_ON_PR', 'AUTO_ON_PR'); + +ALTER TABLE "Workspace" +ADD COLUMN "deliveryTimelinePolicy" "DeliveryTimelinePolicy" NOT NULL DEFAULT 'RECOMMEND'; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index bc4e413e..132fe514 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -217,6 +217,15 @@ enum CompletionAutomation { AUTO_WHEN_SAFE } +/// Controls whether code-delivery sessions publish a human-readable issue +/// update when an implementation pull request is attached. +enum DeliveryTimelinePolicy { + OFF + RECOMMEND + REQUIRE_ON_PR + AUTO_ON_PR +} + enum ScheduledTaskAction { CREATE_ISSUE } @@ -951,6 +960,9 @@ model Workspace { /// verified completion evidence. RECOMMEND opens one shared actionable /// card; AUTO_WHEN_SAFE transitions only when the central safety gate passes. completionAutomation CompletionAutomation @default(RECOMMEND) + /// Human-readable issue timeline behavior when a work session attaches an + /// implementation PR. Mechanical heartbeats remain audit-only. + deliveryTimelinePolicy DeliveryTimelinePolicy @default(RECOMMEND) /// DONE-category status used by completion recommendations/automation. /// Null keeps completion candidates disabled until an admin chooses one. completionStatusId String? diff --git a/src/app/(app)/w/[slug]/settings/workspace/page.tsx b/src/app/(app)/w/[slug]/settings/workspace/page.tsx index 42cacf3e..e5c7d6a6 100644 --- a/src/app/(app)/w/[slug]/settings/workspace/page.tsx +++ b/src/app/(app)/w/[slug]/settings/workspace/page.tsx @@ -17,6 +17,7 @@ import { workspaceColor } from "@/lib/workspace-color"; type DefaultIssueAssigneeMode = "NONE" | "CREATOR" | "USER"; type CompletionAutomation = "OFF" | "RECOMMEND" | "AUTO_WHEN_SAFE"; +type DeliveryTimelinePolicy = "OFF" | "RECOMMEND" | "REQUIRE_ON_PR" | "AUTO_ON_PR"; export default function WorkspaceSettingsPage() { const router = useRouter(); @@ -63,6 +64,8 @@ export default function WorkspaceSettingsPage() { const [completionAutomation, setCompletionAutomation] = useState("RECOMMEND"); const [completionStatusId, setCompletionStatusId] = useState(null); + const [deliveryTimelinePolicy, setDeliveryTimelinePolicy] = + useState("RECOMMEND"); const [defaultIssueAssigneeMode, setDefaultIssueAssigneeMode] = useState("NONE"); const [defaultIssueAssigneeUserId, setDefaultIssueAssigneeUserId] = useState(null); @@ -111,6 +114,7 @@ export default function WorkspaceSettingsPage() { setReviewStatusId(current.reviewStatusId ?? null); setCompletionAutomation(current.completionAutomation ?? "RECOMMEND"); setCompletionStatusId(current.completionStatusId ?? null); + setDeliveryTimelinePolicy(current.deliveryTimelinePolicy ?? "RECOMMEND"); setDefaultIssueAssigneeMode( (current.defaultIssueAssigneeMode ?? "NONE") as DefaultIssueAssigneeMode, ); @@ -208,6 +212,10 @@ export default function WorkspaceSettingsPage() { completionAutomation !== (current.completionAutomation ?? "RECOMMEND"), ], ["completionStatusId", (completionStatusId ?? null) !== (current.completionStatusId ?? null)], + [ + "deliveryTimelinePolicy", + deliveryTimelinePolicy !== (current.deliveryTimelinePolicy ?? "RECOMMEND"), + ], [ "defaultIssueAssigneeMode", defaultIssueAssigneeMode !== @@ -255,6 +263,7 @@ export default function WorkspaceSettingsPage() { reviewStatusId, completionAutomation, completionStatusId, + deliveryTimelinePolicy, defaultIssueAssigneeMode, normalizedDefaultIssueAssigneeUserId, ]); @@ -302,6 +311,7 @@ export default function WorkspaceSettingsPage() { reviewStatusId, completionAutomation, completionStatusId, + deliveryTimelinePolicy, defaultIssueAssigneeMode, defaultIssueAssigneeUserId: normalizedDefaultIssueAssigneeUserId, }); @@ -340,6 +350,7 @@ export default function WorkspaceSettingsPage() { reviewStatusId, completionAutomation, completionStatusId, + deliveryTimelinePolicy, defaultIssueAssigneeMode, defaultIssueAssigneeUserId, normalizedDefaultIssueAssigneeUserId, @@ -864,6 +875,43 @@ export default function WorkspaceSettingsPage() { +
+ + + + setDeliveryTimelinePolicy((value ?? "RECOMMEND") as DeliveryTimelinePolicy) + } + disabled={!canEdit} + options={[ + { value: "OFF", label: "Off — no delivery prompt" }, + { value: "RECOMMEND", label: "Recommend a handoff comment" }, + { value: "REQUIRE_ON_PR", label: "Require a comment when attaching a PR" }, + { value: "AUTO_ON_PR", label: "Automatically post a concise PR comment" }, + ]} + /> + +
+ {deliveryTimelinePolicy === "OFF" && + "Forge records the PR and delivery state without prompting for an issue comment."} + {deliveryTimelinePolicy === "RECOMMEND" && + "Forge returns a suggested next action and comment template after the PR is attached."} + {deliveryTimelinePolicy === "REQUIRE_ON_PR" && + "PR attachment is rejected unless the caller supplies a human-readable timeline update in the same request."} + {deliveryTimelinePolicy === "AUTO_ON_PR" && + "Forge posts one agent-authored PR handoff automatically when the caller does not provide a richer update."} +
+
+
+
> | null): McpToolName[] { +function allowedMcpToolNames( + auth: Awaited> | null, +): McpToolName[] { return selectMcpToolNames({ profile: "full", scopes: auth?.scopes ?? null, @@ -242,7 +250,8 @@ async function handleCatalogTool( .filter((toolName) => { if (namespace && mcpToolNamespace(toolName) !== namespace) return false; if (!query) return true; - const haystack = `${toolName} ${compactDescription(toolName)} ${mcpTools[toolName].scopes.join(" ")}`.toLowerCase(); + const haystack = + `${toolName} ${compactDescription(toolName)} ${mcpTools[toolName].scopes.join(" ")}`.toLowerCase(); return haystack.includes(query); }) .slice(0, input.limit) @@ -308,8 +317,7 @@ async function handleRpc( protocolVersion: PROTOCOL_VERSION, capabilities: { tools: { listChanged: false } }, serverInfo: await mcpServerInfo(), - instructions: - "Forge - project management. Tools cover issues, projects, comments, analytics, and the agent queue.", + instructions: FORGE_MCP_INSTRUCTIONS, }); case "notifications/initialized": @@ -445,7 +453,10 @@ export async function POST(req: NextRequest) { const listOptions: ListOptions = { profile: profileParam, namespaces: toolsParam - ? toolsParam.split(",").map((s) => s.trim()).filter(Boolean) + ? toolsParam + .split(",") + .map((s) => s.trim()) + .filter(Boolean) : null, }; diff --git a/src/server/routers/work-session.ts b/src/server/routers/work-session.ts index 9421df8d..c36e85c3 100644 --- a/src/server/routers/work-session.ts +++ b/src/server/routers/work-session.ts @@ -124,7 +124,13 @@ export const workSessionRouter = router({ }), attachPullRequest: workspaceProcedure - .input(z.object({ sessionId: z.string().cuid(), externalResourceId: z.string().cuid() })) + .input( + z.object({ + sessionId: z.string().cuid(), + externalResourceId: z.string().cuid(), + timelineUpdate: z.object({ body: z.string().trim().min(1).max(50_000) }).optional(), + }), + ) .mutation(async ({ ctx, input }) => { await assertSessionManager(ctx, input.sessionId); return attachPullRequest(ctx.db, { diff --git a/src/server/routers/workspace.ts b/src/server/routers/workspace.ts index 30f46b02..7128e3f1 100644 --- a/src/server/routers/workspace.ts +++ b/src/server/routers/workspace.ts @@ -6,6 +6,7 @@ import { CompletionAutomation, CycleStatus, DefaultIssueAssigneeMode, + DeliveryTimelinePolicy, EngagementMode, EventKind, MentionEngagementPolicy, @@ -138,6 +139,7 @@ export const workspaceRouter = router({ startedStatusId: true, reviewStatusId: true, completionAutomation: true, + deliveryTimelinePolicy: true, completionStatusId: true, githubSyncEnabled: true, githubSyncStaleMinutes: true, @@ -319,6 +321,7 @@ export const workspaceRouter = router({ startedStatusId: z.string().nullable().optional(), reviewStatusId: z.string().nullable().optional(), completionAutomation: z.nativeEnum(CompletionAutomation).optional(), + deliveryTimelinePolicy: z.nativeEnum(DeliveryTimelinePolicy).optional(), completionStatusId: z.string().nullable().optional(), githubSyncEnabled: z.boolean().optional(), githubSyncStaleMinutes: z.number().int().min(0).max(10080).optional(), diff --git a/src/server/services/__tests__/work-session.test.ts b/src/server/services/__tests__/work-session.test.ts index 53e77ca3..7576704a 100644 --- a/src/server/services/__tests__/work-session.test.ts +++ b/src/server/services/__tests__/work-session.test.ts @@ -1,5 +1,6 @@ import { afterAll, afterEach, describe, expect, it } from "vitest"; -import { WorkSessionSource } from "@prisma/client"; +import { DeliveryTimelinePolicy, WorkSessionSource } from "@prisma/client"; +import { FORGE_MCP_INSTRUCTIONS } from "@/server/services/mcp-instructions"; import { mcpTools, type McpContext } from "@/server/services/mcp"; import { advanceWorkSession, @@ -98,6 +99,12 @@ describe("work session coordination", () => { actor: { userId: fixture.user.id }, }); expect(merged.status).toBe("MERGED"); + expect(merged.timeline).toMatchObject({ + policy: DeliveryTimelinePolicy.RECOMMEND, + recommended: true, + nextAction: "comments.create", + commentId: null, + }); await expect( advanceWorkSession(prisma, { workspaceId: fixture.workspace.id, @@ -148,6 +155,128 @@ describe("work session coordination", () => { expect(verified.endedAt).not.toBeNull(); }); + it("automatically posts one human-readable PR handoff when configured", async () => { + const { fixture, prisma, issue } = await setup(); + await prisma.workspace.update({ + where: { id: fixture.workspace.id }, + data: { deliveryTimelinePolicy: DeliveryTimelinePolicy.AUTO_ON_PR }, + }); + const session = await claimWorkSession(prisma, { + workspaceId: fixture.workspace.id, + issueId: issue.id, + repoFullName: "acme/forge", + branch: "codex/ws-auto-comment", + source: WorkSessionSource.CODEX_DESKTOP, + actor: { userId: fixture.user.id }, + }); + const pr = await prisma.externalResource.create({ + data: { + workspaceId: fixture.workspace.id, + provider: "GITHUB", + resourceType: "PULL_REQUEST", + repoFullName: "acme/forge", + number: 73, + url: "https://github.com/acme/forge/pull/73", + title: "Keep delivery visible", + state: "draft", + metadata: { draft: true, base: { ref: "main" }, head: { ref: session.branch } }, + }, + }); + + const attached = await attachPullRequest(prisma, { + workspaceId: fixture.workspace.id, + sessionId: session.id, + externalResourceId: pr.id, + actor: { userId: fixture.user.id }, + }); + expect(attached.timeline).toMatchObject({ + policy: DeliveryTimelinePolicy.AUTO_ON_PR, + recommended: false, + nextAction: null, + }); + expect(attached.timeline.commentId).toBeTruthy(); + expect( + await prisma.comment.findUniqueOrThrow({ where: { id: attached.timeline.commentId! } }), + ).toMatchObject({ + issueId: issue.id, + authorId: fixture.user.id, + body: expect.stringContaining("https://github.com/acme/forge/pull/73"), + }); + + await attachPullRequest(prisma, { + workspaceId: fixture.workspace.id, + sessionId: session.id, + externalResourceId: pr.id, + actor: { userId: fixture.user.id }, + }); + expect(await prisma.comment.count({ where: { issueId: issue.id } })).toBe(1); + }); + + it("requires an atomic timeline update under the strict PR policy", async () => { + const { fixture, prisma, issue } = await setup(); + await prisma.workspace.update({ + where: { id: fixture.workspace.id }, + data: { deliveryTimelinePolicy: DeliveryTimelinePolicy.REQUIRE_ON_PR }, + }); + const session = await claimWorkSession(prisma, { + workspaceId: fixture.workspace.id, + issueId: issue.id, + repoFullName: "acme/forge", + branch: "codex/ws-required-comment", + source: WorkSessionSource.CODEX_DESKTOP, + actor: { userId: fixture.user.id }, + }); + const pr = await prisma.externalResource.create({ + data: { + workspaceId: fixture.workspace.id, + provider: "GITHUB", + resourceType: "PULL_REQUEST", + repoFullName: "acme/forge", + number: 74, + url: "https://github.com/acme/forge/pull/74", + title: "Require the handoff", + state: "open", + metadata: { head: { ref: session.branch } }, + }, + }); + + await expect( + attachPullRequest(prisma, { + workspaceId: fixture.workspace.id, + sessionId: session.id, + externalResourceId: pr.id, + actor: { userId: fixture.user.id }, + }), + ).rejects.toMatchObject({ code: "PRECONDITION_FAILED" }); + + const attached = await attachPullRequest(prisma, { + workspaceId: fixture.workspace.id, + sessionId: session.id, + externalResourceId: pr.id, + actor: { userId: fixture.user.id }, + timelineUpdate: { body: "Implemented the delivery contract. Validation: focused tests." }, + }); + expect(attached.timeline.commentId).toBeTruthy(); + expect( + await prisma.comment.findUniqueOrThrow({ where: { id: attached.timeline.commentId! } }), + ).toMatchObject({ body: "Implemented the delivery contract. Validation: focused tests." }); + }); + + it("advertises the human-readable delivery contract in MCP instructions", () => { + expect(FORGE_MCP_INSTRUCTIONS).toContain("comments.upsertStatus"); + expect(FORGE_MCP_INSTRUCTIONS).toContain("comments.create"); + expect(FORGE_MCP_INSTRUCTIONS).toContain("workSessions.attachPullRequest"); + const attachTool = mcpTools["workSessions.attachPullRequest"]; + expect(attachTool.description).toContain("human-readable issue handoff"); + expect( + attachTool.input.safeParse({ + sessionId: "cmrmh4rzz030cmm07rjd2nxe6", + externalResourceId: "cmrmg1qee01lzmm07dyzrutbq", + timelineUpdate: { body: "Implemented and verified the change." }, + }).success, + ).toBe(true); + }); + it("marks quiet leases stale and creates one shared action request", async () => { const { fixture, prisma, issue } = await setup(); await prisma.workspace.update({ diff --git a/src/server/services/mcp-instructions.ts b/src/server/services/mcp-instructions.ts new file mode 100644 index 00000000..c876f340 --- /dev/null +++ b/src/server/services/mcp-instructions.ts @@ -0,0 +1,2 @@ +export const FORGE_MCP_INSTRUCTIONS = + "Forge is the delivery source of truth for project work. For code changes, inspect and claim the issue's work session before editing, heartbeat at meaningful phase changes and after commits, link implementation pull requests with github.link(kind=IMPLEMENTS), then attach them with workSessions.attachPullRequest. Keep the issue timeline useful to humans: use comments.upsertStatus for meaningful interim checkpoints, not mechanical logs, and publish one durable comments.create handoff with the implementation outcome, pull request, validation, and caveats. The workspace delivery timeline policy returned by attachPullRequest may recommend, require, or automatically create that handoff."; diff --git a/src/server/services/mcp.ts b/src/server/services/mcp.ts index 2e7a2ef0..e1dc4153 100644 --- a/src/server/services/mcp.ts +++ b/src/server/services/mcp.ts @@ -7884,6 +7884,8 @@ export const mcpTools = { }, "workSessions.claim": { + description: + "Claim the one active code-delivery session for an issue before editing. After claiming, post meaningful human-facing phase changes with comments.upsertStatus and heartbeat the lease after commits.", scopes: ["WRITE_ISSUES"] as const, input: z.object({ issueId: z.string().cuid(), @@ -7925,6 +7927,8 @@ export const mcpTools = { }, "workSessions.heartbeat": { + description: + "Refresh an owned code-delivery lease and optionally record its current commit. Heartbeats are mechanical coordination; use comments.upsertStatus for semantic human-facing progress.", scopes: ["WRITE_ISSUES"] as const, input: z.object({ sessionId: z.string().cuid(), @@ -7955,9 +7959,22 @@ export const mcpTools = { }, "workSessions.attachPullRequest": { - scopes: ["WRITE_ISSUES"] as const, - input: z.object({ sessionId: z.string().cuid(), externalResourceId: z.string().cuid() }), - async run(input: { sessionId: string; externalResourceId: string }, ctx: McpContext) { + description: + "Attach a native implementation pull request to an owned delivery session. Include timelineUpdate for an atomic human-readable issue handoff; the workspace policy may recommend, require, or automatically create one.", + scopes: ["WRITE_ISSUES", "WRITE_COMMENTS"] as const, + input: z.object({ + sessionId: z.string().cuid(), + externalResourceId: z.string().cuid(), + timelineUpdate: z.object({ body: z.string().trim().min(1).max(50_000) }).optional(), + }), + async run( + input: { + sessionId: string; + externalResourceId: string; + timelineUpdate?: { body: string }; + }, + ctx: McpContext, + ) { const agentId = ctx.apiKey?.linkedAgentId; if (!agentId) throw new Error("workSessions.attachPullRequest requires a linked agent key."); const owned = await db.workSession.findFirst({ diff --git a/src/server/services/work-session.ts b/src/server/services/work-session.ts index 8fe01c99..3780f753 100644 --- a/src/server/services/work-session.ts +++ b/src/server/services/work-session.ts @@ -3,6 +3,8 @@ import "server-only"; import { ActionRequestKind, ActionRequestStatus, + CommentKind, + DeliveryTimelinePolicy, EventKind, NotificationSeverity, WorkSessionStatus, @@ -11,6 +13,7 @@ import type { Prisma, PrismaClient, WorkSessionSource } from "@prisma/client"; import { TRPCError } from "@trpc/server"; import { recordChange } from "@/server/audit"; import { createActionRequest } from "@/server/services/action-request-service"; +import { autoWatchActor } from "@/server/services/issue-watchers"; type DbClient = PrismaClient | Prisma.TransactionClient; @@ -46,6 +49,10 @@ export type WorkSessionActor = { agentId?: string | null; }; +export type DeliveryTimelineUpdate = { + body: string; +}; + async function resolveStaleRequest( db: PrismaClient, workspaceId: string, @@ -328,6 +335,7 @@ export async function attachPullRequest( sessionId: string; externalResourceId: string; actor: WorkSessionActor; + timelineUpdate?: DeliveryTimelineUpdate; }, ) { const result = await db.$transaction(async (tx) => { @@ -343,6 +351,10 @@ export async function attachPullRequest( }, }); if (!resource) throw new TRPCError({ code: "NOT_FOUND", message: "Pull request not found." }); + const workspace = await tx.workspace.findUniqueOrThrow({ + where: { id: input.workspaceId }, + select: { deliveryTimelinePolicy: true }, + }); const head = record(record(resource.metadata).head); if (typeof head.ref === "string" && head.ref !== session.branch) { throw new TRPCError({ @@ -350,6 +362,30 @@ export async function attachPullRequest( message: `PR branch ${head.ref} does not match work session branch ${session.branch}.`, }); } + const firstAttachment = session.pullRequestId !== resource.id; + let timelineBody = input.timelineUpdate?.body.trim() || null; + if ( + firstAttachment && + workspace.deliveryTimelinePolicy === DeliveryTimelinePolicy.REQUIRE_ON_PR && + !timelineBody + ) { + throw new TRPCError({ + code: "PRECONDITION_FAILED", + message: + "This workspace requires a human-readable timelineUpdate when attaching a pull request.", + }); + } + if ( + firstAttachment && + workspace.deliveryTimelinePolicy === DeliveryTimelinePolicy.AUTO_ON_PR && + !timelineBody + ) { + const base = record(record(resource.metadata).base); + const draft = record(resource.metadata).draft === true || resource.state === "draft"; + timelineBody = + `Opened [${draft ? "draft " : ""}PR #${resource.number ?? "—"}: ${resource.title ?? "Implementation"}](${resource.url}) for this work session.\n\n` + + `Branch \`${session.branch}\` targets \`${typeof base.ref === "string" ? base.ref : session.baseBranch}\`. CI and review remain authoritative in GitHub.`; + } const derived = deriveWorkSessionPrStatus(resource); const now = new Date(); const updated = await tx.workSession.update({ @@ -369,8 +405,73 @@ export async function attachPullRequest( : {}), }, }); + let timelineCommentId: string | null = null; + if (firstAttachment && timelineBody) { + const issue = await tx.issue.findFirstOrThrow({ + where: { id: session.issueId, workspaceId: input.workspaceId, deletedAt: null }, + select: { id: true, number: true, title: true, workspace: { select: { key: true } } }, + }); + const comment = await tx.comment.create({ + data: { + workspaceId: input.workspaceId, + issueId: issue.id, + authorId: input.actor.userId, + authoringAgentId: input.actor.agentId ?? null, + body: timelineBody, + kind: CommentKind.BODY, + }, + }); + timelineCommentId = comment.id; + await autoWatchActor(tx, { + workspaceId: input.workspaceId, + issueId: issue.id, + userId: input.actor.userId, + callerAgentId: input.actor.agentId ?? null, + }); + await recordChange(tx, { + workspaceId: input.workspaceId, + actorId: input.actor.userId, + actorAgentId: input.actor.agentId ?? null, + entity: "Comment", + entityId: comment.id, + action: "create-delivery-update", + after: comment, + eventKind: EventKind.COMMENT_CREATED, + subjectType: "issue", + subjectId: issue.id, + payload: { + commentId: comment.id, + issueId: issue.id, + issuePrefix: `${issue.workspace.key}-${issue.number}`, + number: issue.number, + title: issue.title, + preview: timelineBody.slice(0, 120), + workSessionId: session.id, + externalResourceId: resource.id, + mentions: { agentIds: [], userIds: [], agents: [] }, + mentionsCount: 0, + agentRequests: [], + }, + }); + } await auditSession(tx, session, input.actor, "work-session-pr-linked", session, updated); - return updated; + const shouldRecommend = + firstAttachment && + !timelineCommentId && + workspace.deliveryTimelinePolicy === DeliveryTimelinePolicy.RECOMMEND; + return { + ...updated, + timeline: { + policy: workspace.deliveryTimelinePolicy, + commentId: timelineCommentId, + recommended: shouldRecommend, + nextAction: shouldRecommend ? "comments.create" : null, + issueId: session.issueId, + suggestedComment: shouldRecommend + ? `Implemented work for this issue and opened [PR #${resource.number ?? "—"}: ${resource.title ?? "Implementation"}](${resource.url}).\n\nValidation: _add checks run and any caveats_.` + : null, + }, + }; }); return result; } @@ -465,12 +566,8 @@ export async function advanceWorkSession( data: { status: next, lastHeartbeatAt: now, - ...(next === WorkSessionStatus.RELEASED - ? { releasedAt: now, releasedVersion } - : {}), - ...(next === WorkSessionStatus.DEPLOYED - ? { deployedAt: now, deployedSha } - : {}), + ...(next === WorkSessionStatus.RELEASED ? { releasedAt: now, releasedVersion } : {}), + ...(next === WorkSessionStatus.DEPLOYED ? { deployedAt: now, deployedSha } : {}), ...(next === WorkSessionStatus.VERIFIED ? { verifiedAt: now, endedAt: now } : {}), ...(next === WorkSessionStatus.ABANDONED ? { endedAt: now } : {}), }, diff --git a/tests/e2e/mcp-delivery-settings.spec.ts b/tests/e2e/mcp-delivery-settings.spec.ts new file mode 100644 index 00000000..a6b843ac --- /dev/null +++ b/tests/e2e/mcp-delivery-settings.spec.ts @@ -0,0 +1,17 @@ +import { expect, test } from "@playwright/test"; + +test("workspace admins can configure PR handoff timeline comments", async ({ page }) => { + await page.goto("/w/forge/settings/workspace"); + + const policy = page.getByRole("combobox", { name: "PR handoff comment policy" }); + await expect(page.getByText("Code delivery timeline", { exact: true })).toBeVisible(); + await policy.click(); + await expect(page.getByRole("option", { name: "Off — no delivery prompt" })).toBeVisible(); + await expect(page.getByRole("option", { name: "Recommend a handoff comment" })).toBeVisible(); + await expect( + page.getByRole("option", { name: "Require a comment when attaching a PR" }), + ).toBeVisible(); + await expect( + page.getByRole("option", { name: "Automatically post a concise PR comment" }), + ).toBeVisible(); +});