diff --git a/packages/core/src/__tests__/session-manager/restore.test.ts b/packages/core/src/__tests__/session-manager/restore.test.ts index bf9691b4a..b70a50a32 100644 --- a/packages/core/src/__tests__/session-manager/restore.test.ts +++ b/packages/core/src/__tests__/session-manager/restore.test.ts @@ -226,6 +226,36 @@ describe("restore", () => { await expect(sm.restore("app-1")).rejects.toThrow(/is merged/); }); + it("refuses to restore sessions whose PR is closed (unmerged terminal state)", async () => { + const ws = "/tmp/mock-ws/app-1-closed"; + writeMetadata(sessionsDir, "app-1", { + worktree: ws, + branch: "feat/abandoned", + status: "killed", + pr: "https://github.com/acme/repo/pull/99", + project: "my-app", + runtimeHandle: makeHandle("rt-old"), + }); + // No legacy `status` maps to lifecycle.pr.state="closed", so write the + // canonical lifecycle directly to exercise the closed branch of step 3a. + updateMetadata(sessionsDir, "app-1", { + lifecycle: JSON.stringify({ + version: 2, + session: { state: "terminated", terminatedAt: new Date().toISOString() }, + pr: { + state: "closed", + reason: "closed_unmerged", + number: 99, + url: "https://github.com/acme/repo/pull/99", + lastObservedAt: new Date().toISOString(), + }, + }), + }); + + const sm = createSessionManager({ config, registry: mockRegistry }); + await expect(sm.restore("app-1")).rejects.toThrow(/is closed/); + }); + it("throws SessionNotRestorableError for working sessions", async () => { writeMetadata(sessionsDir, "app-1", { worktree: "/tmp", diff --git a/packages/plugins/scm-github/src/index.ts b/packages/plugins/scm-github/src/index.ts index 4683d7221..7322b1646 100644 --- a/packages/plugins/scm-github/src/index.ts +++ b/packages/plugins/scm-github/src/index.ts @@ -650,10 +650,13 @@ function createGitHubSCM(): SCM { }> = JSON.parse(raw); // Only same-repo PRs auto-associate. Fork PRs (different head owner) - // are someone else's work — never the session's. + // are someone else's work — never the session's. Strict-by-default: + // a missing/null/empty headOwner is rejected so a future gh API + // change or deleted-account edge case can't quietly let a fork PR + // through. const sameRepoPrs = prs.filter((pr) => { const headOwner = pr.headRepositoryOwner?.login; - return !headOwner || headOwner === owner; + return typeof headOwner === "string" && headOwner === owner; }); if (sameRepoPrs.length === 0) return null; diff --git a/packages/plugins/scm-github/test/index.test.ts b/packages/plugins/scm-github/test/index.test.ts index d0d7e4d5a..e59926438 100644 --- a/packages/plugins/scm-github/test/index.test.ts +++ b/packages/plugins/scm-github/test/index.test.ts @@ -400,6 +400,7 @@ describe("scm-github plugin", () => { headRefName: "feat/my-feature", baseRefName: "main", isDraft: false, + headRepositoryOwner: { login: "acme" }, }, ]); @@ -459,12 +460,37 @@ describe("scm-github plugin", () => { headRefName: "feat/my-feature", baseRefName: "main", isDraft: true, + headRepositoryOwner: { login: "acme" }, }, ]); const result = await scm.detectPR(makeSession(), project); expect(result?.isDraft).toBe(true); }); + it("rejects PRs with missing/null headRepositoryOwner (strict-by-default)", async () => { + mockGh([ + { + number: 50, + url: "https://github.com/acme/repo/pull/50", + title: "owner missing", + headRefName: "feat/my-feature", + baseRefName: "main", + isDraft: false, + headRepositoryOwner: null, + }, + { + number: 51, + url: "https://github.com/acme/repo/pull/51", + title: "owner field absent", + headRefName: "feat/my-feature", + baseRefName: "main", + isDraft: false, + }, + ]); + const result = await scm.detectPR(makeSession(), project); + expect(result).toBeNull(); + }); + it("returns null when session.branch === project.defaultBranch", async () => { const result = await scm.detectPR( makeSession({ branch: "main" }), @@ -1334,6 +1360,7 @@ describe("scm-github plugin", () => { headRefName: "feat/my-feature", baseRefName: "main", isDraft: false, + headRepositoryOwner: { login: "acme" }, }, ]); const a = await scm.detectPR(makeSession(), project); @@ -1366,6 +1393,7 @@ describe("scm-github plugin", () => { headRefName: "feat/my-feature", baseRefName: "main", isDraft: false, + headRepositoryOwner: { login: "acme" }, }, ]); const after = await scm.detectPR(makeSession(), project); @@ -1382,6 +1410,7 @@ describe("scm-github plugin", () => { headRefName: "feat/a", baseRefName: "main", isDraft: false, + headRepositoryOwner: { login: "acme" }, }, ]); mockGh([ @@ -1392,6 +1421,7 @@ describe("scm-github plugin", () => { headRefName: "feat/b", baseRefName: "main", isDraft: false, + headRepositoryOwner: { login: "acme" }, }, ]); const a = await scm.detectPR(makeSession({ branch: "feat/a" }), project); @@ -1410,6 +1440,7 @@ describe("scm-github plugin", () => { headRefName: pr.branch, baseRefName: "main", isDraft: false, + headRepositoryOwner: { login: "acme" }, }, ]); await scm.detectPR(makeSession({ branch: pr.branch }), project);