diff --git a/.changeset/auto-delete-closed-source.md b/.changeset/auto-delete-closed-source.md new file mode 100644 index 00000000..a5fb95cc --- /dev/null +++ b/.changeset/auto-delete-closed-source.md @@ -0,0 +1,5 @@ +--- +"@prover-coder-ai/docker-git": minor +--- + +Add opt-in automatic deletion of containers whose originating GitHub issue or pull request has been closed. Enable with `DOCKER_GIT_AUTO_DELETE_CLOSED=1` (scan interval configurable via `DOCKER_GIT_AUTO_DELETE_SCAN_INTERVAL_SECONDS`, default 300s). Deletion is conservative: it never runs for open/unknown source states, nor while an agent or live interactive session is active. diff --git a/packages/api/src/program.ts b/packages/api/src/program.ts index 02e95585..307318ea 100644 --- a/packages/api/src/program.ts +++ b/packages/api/src/program.ts @@ -7,6 +7,7 @@ import { makeRouter } from "./http.js" import { initializeAgentState } from "./services/agents.js" import { attachAuthTerminalWebSocketServer } from "./services/auth-terminal-sessions.js" import { initializeFederationState, startOutboxPolling } from "./services/federation.js" +import { resolveProjectAutoDeleteConfig, startProjectAutoDeleteLoop } from "./services/project-auto-delete.js" import { resolveProjectAutoSuspendConfig, startProjectAutoSuspendLoop } from "./services/project-auto-suspend.js" import { attachProjectBrowserWebSocketServer } from "./services/project-browser.js" import { attachProjectDatabaseWebSocketServer } from "./services/project-databases.js" @@ -63,6 +64,7 @@ export const program = (() => { const pollingInterval = parseInt(process.env["DOCKER_GIT_OUTBOX_POLLING_INTERVAL_MS"] ?? "5000", 10) const autoSuspendConfig = resolveProjectAutoSuspendConfig() + const autoDeleteConfig = resolveProjectAutoDeleteConfig() return Effect.scoped( Console.log(`docker-git api boot port=${port}`).pipe( @@ -82,6 +84,14 @@ export const program = (() => { Effect.zipRight( Effect.fork(startProjectAutoSuspendLoop(autoSuspendConfig).pipe(Effect.provide(NodeContext.layer))) ), + Effect.zipRight( + Console.log( + `docker-git auto-delete (closed issue/PR) enabled=${autoDeleteConfig.enabled} scanMs=${autoDeleteConfig.scanIntervalMs}` + ) + ), + Effect.zipRight( + Effect.fork(startProjectAutoDeleteLoop(autoDeleteConfig).pipe(Effect.provide(NodeContext.layer))) + ), Effect.zipRight(Layer.launch(Layer.provide(app, serverLayer))) ) ) diff --git a/packages/api/src/services/project-activity.ts b/packages/api/src/services/project-activity.ts new file mode 100644 index 00000000..7e732676 --- /dev/null +++ b/packages/api/src/services/project-activity.ts @@ -0,0 +1,47 @@ +import type { ProjectItem } from "@effect-template/lib/usecases/projects" +import { Effect } from "effect" + +import { activeAgents } from "./container-tasks-core.js" +import { readContainerTaskSnapshot } from "./container-tasks.js" +import { hasLiveProjectBrowserSession } from "./project-browser.js" +import { hasLiveProjectSkillerSession } from "./skiller.js" +import { hasLiveProjectTerminalSession } from "./terminal-sessions.js" + +// CHANGE: share project activity predicates across auto-suspend and auto-delete loops +// WHY: both loops must agree on what "work in progress" means; avoid duplicate logic +// REF: issue-117 +// PURITY: SHELL +// INVARIANT: failures resolve to "no active agent" so callers stay conservative + +/** + * Whether an agent is currently working inside the project's container. + * + * @pure false + * @effect FileSystem (task snapshot) + * @invariant snapshot read failures resolve to `false` + * @complexity O(tasks) + */ +export const projectHasActiveAgent = ( + project: ProjectItem +) => + readContainerTaskSnapshot(project.projectDir, false).pipe( + Effect.map((snapshot) => + activeAgents(snapshot.agents).length > 0 || snapshot.tasks.some((task) => task.kind === "agent") + ), + Effect.catchAll(() => Effect.succeed(false)) + ) + +/** + * Whether a live interactive session (ssh/terminal/browser/skiller) is attached. + * + * @pure false (reads in-memory session registries) + * @complexity O(1) + */ +export const projectHasLiveInteractiveSession = ( + project: ProjectItem, + sshSessions: number +): boolean => + sshSessions > 0 || + hasLiveProjectTerminalSession(project.projectDir) || + hasLiveProjectBrowserSession(project.projectDir) || + hasLiveProjectSkillerSession(project.projectDir) diff --git a/packages/api/src/services/project-auto-delete.ts b/packages/api/src/services/project-auto-delete.ts new file mode 100644 index 00000000..9eb37f2f --- /dev/null +++ b/packages/api/src/services/project-auto-delete.ts @@ -0,0 +1,169 @@ +import { deleteDockerGitProject, listProjectItems, parseProjectSourceRef } from "@effect-template/lib" +import type { ProjectItem } from "@effect-template/lib/usecases/projects" +import { fetchProjectSourceState } from "@effect-template/lib/usecases/project-source-state" +import { ensureGhAuthImage, ghAuthRoot } from "@effect-template/lib/usecases/github-auth-image" +import { defaultProjectsRoot, resolvePathFromCwd } from "@effect-template/lib/usecases/path-helpers" +import { withFsPathContext } from "@effect-template/lib/usecases/runtime" +import { resolveGithubToken } from "@effect-template/lib/usecases/state-repo/github-auth" +import { Duration, Effect, Schedule } from "effect" + +import { decideProjectClosedSourceAction } from "./project-closed-source-policy.js" +import { projectHasActiveAgent, projectHasLiveInteractiveSession } from "./project-activity.js" +import { loadProjectRuntimeByProject, runtimeForProject } from "./project-runtime.js" + +// CHANGE: automatically delete containers whose originating issue or PR has been closed +// WHY: closed issues/PRs leave behind unused environments that pile up over time +// QUOTE(ТЗ): "Сделать возможность автоматического удаления контейнера issues или PR которого уже закрылся" +// REF: issue-117 +// SOURCE: https://github.com/ProverCoderAI/docker-git/issues/117 +// PURITY: SHELL +// INVARIANT: disabled by default; only ever deletes projects with a closed source and no live work + +export type ProjectAutoDeleteConfig = { + readonly enabled: boolean + readonly scanIntervalMs: number +} + +const secondMs = 1_000 +const defaultScanIntervalSeconds = 300 + +const parsePositiveIntegerEnv = ( + key: string, + defaultValue: number +): number => { + const parsed = Number.parseInt(process.env[key] ?? "", 10) + return Number.isFinite(parsed) && parsed > 0 ? parsed : defaultValue +} + +const parseEnabledEnv = ( + key: string, + defaultValue: boolean +): boolean => { + const raw = process.env[key]?.trim().toLowerCase() + if (raw === undefined || raw.length === 0) { + return defaultValue + } + return raw === "1" || raw === "true" || raw === "on" || raw === "yes" +} + +// CHANGE: opt-in by default — deletion is irreversible, so require explicit enablement +// WHY: an unexpected auto-delete must never surprise a user; they must turn it on knowingly +// REF: issue-117 +export const resolveProjectAutoDeleteConfig = (): ProjectAutoDeleteConfig => ({ + enabled: parseEnabledEnv("DOCKER_GIT_AUTO_DELETE_CLOSED", false), + scanIntervalMs: + parsePositiveIntegerEnv("DOCKER_GIT_AUTO_DELETE_SCAN_INTERVAL_SECONDS", defaultScanIntervalSeconds) * secondMs +}) + +type AutoDeleteRuntime = { + readonly cwd: string + readonly ghAuthHostPath: string + readonly token: string +} + +const evaluateProject = ( + runtime: AutoDeleteRuntime, + project: ProjectItem, + sshSessions: number +) => + Effect.gen(function*(_) { + const ref = parseProjectSourceRef(project.repoUrl, project.repoRef) + if (ref === null) { + return + } + + const sourceState = yield* _( + fetchProjectSourceState(runtime.cwd, runtime.ghAuthHostPath, runtime.token, ref).pipe( + Effect.catchAll(() => Effect.succeed("unknown" as const)) + ) + ) + + const hasActiveAgent = yield* _(projectHasActiveAgent(project)) + const hasLiveInteractiveSession = projectHasLiveInteractiveSession(project, sshSessions) + + const decision = decideProjectClosedSourceAction({ sourceState, hasActiveAgent, hasLiveInteractiveSession }) + if (decision._tag === "Keep") { + return + } + + yield* _( + Effect.log( + `[auto-delete] Removing ${project.containerName}: ${ref.provider} ${ref.kind} #${ref.number} is closed` + ) + ) + yield* _( + deleteDockerGitProject({ + projectDir: project.projectDir, + repoUrl: project.repoUrl, + containerName: project.containerName, + serviceName: project.serviceName + }) + ) + }).pipe( + Effect.catchAll((error) => + Effect.logWarning( + `[auto-delete] Failed to evaluate ${project.containerName}: ${ + error instanceof Error ? error.message : String(error) + }` + ) + ) + ) + +export const scanProjectAutoDelete = ( + config: ProjectAutoDeleteConfig +) => + Effect.gen(function*(_) { + if (!config.enabled) { + return + } + + const projects = yield* _(listProjectItems) + const sourceProjects = projects.filter((project) => parseProjectSourceRef(project.repoUrl, project.repoRef) !== null) + if (sourceProjects.length === 0) { + return + } + + const runtime = yield* _( + withFsPathContext(({ cwd, fs, path }) => + Effect.gen(function*(_) { + const root = path.resolve(defaultProjectsRoot(cwd)) + const token = yield* _(resolveGithubToken(fs, path, root)) + if (token === null) { + return null + } + const ghAuthHostPath = resolvePathFromCwd(path, cwd, ghAuthRoot) + yield* _(ensureGhAuthImage(fs, path, cwd, "gh api")) + return { cwd, ghAuthHostPath, token } satisfies AutoDeleteRuntime + }) + ) + ) + + if (runtime === null) { + yield* _(Effect.logWarning("[auto-delete] No GitHub token available; skipping closed-source cleanup scan")) + return + } + + const runtimeByProject = yield* _(loadProjectRuntimeByProject(sourceProjects)) + yield* _( + Effect.forEach( + sourceProjects, + (project) => evaluateProject(runtime, project, runtimeForProject(runtimeByProject, project).sshSessions), + { concurrency: 2, discard: true } + ) + ) + }).pipe( + Effect.catchAll((error) => + Effect.logWarning( + `[auto-delete] Scan failed: ${error instanceof Error ? error.message : String(error)}` + ) + ) + ) + +export const startProjectAutoDeleteLoop = ( + config: ProjectAutoDeleteConfig +) => + config.enabled + ? scanProjectAutoDelete(config).pipe( + Effect.repeat(Schedule.addDelay(Schedule.forever, () => Duration.millis(config.scanIntervalMs))) + ) + : Effect.log("docker-git auto-delete (closed issue/PR) disabled.") diff --git a/packages/api/src/services/project-auto-suspend.ts b/packages/api/src/services/project-auto-suspend.ts index 4b50ccf6..f3344f4f 100644 --- a/packages/api/src/services/project-auto-suspend.ts +++ b/packages/api/src/services/project-auto-suspend.ts @@ -2,14 +2,10 @@ import { listProjectItems, readProjectRuntimeState, recordProjectRuntimeActivity import type { ProjectItem } from "@effect-template/lib/usecases/projects" import { Duration, Effect, Match, Schedule } from "effect" -import { activeAgents } from "./container-tasks-core.js" -import { readContainerTaskSnapshot } from "./container-tasks.js" -import { hasLiveProjectBrowserSession } from "./project-browser.js" +import { projectHasActiveAgent, projectHasLiveInteractiveSession } from "./project-activity.js" import { decideProjectIdleAction } from "./project-idle-policy.js" import { applyProjectResourceProfile, suspendProjectRuntime } from "./project-lifecycle-resources.js" import { loadProjectRuntimeByProject, runtimeForProject } from "./project-runtime.js" -import { hasLiveProjectSkillerSession } from "./skiller.js" -import { hasLiveProjectTerminalSession } from "./terminal-sessions.js" export type ProjectAutoSuspendConfig = { readonly enabled: boolean @@ -63,30 +59,6 @@ export const resolveProjectAutoSuspendConfig = (): ProjectAutoSuspendConfig => ( ) }) -const snapshotHasAgentTask = ( - project: ProjectItem -) => - readContainerTaskSnapshot(project.projectDir, false).pipe( - Effect.map((snapshot) => - activeAgents(snapshot.agents).length > 0 || snapshot.tasks.some((task) => task.kind === "agent") - ), - Effect.catchAll(() => Effect.succeed(false)) - ) - -const projectHasActiveAgent = ( - project: ProjectItem -) => - snapshotHasAgentTask(project) - -const projectHasLiveInteractiveSession = ( - project: ProjectItem, - sshSessions: number -): boolean => - sshSessions > 0 || - hasLiveProjectTerminalSession(project.projectDir) || - hasLiveProjectBrowserSession(project.projectDir) || - hasLiveProjectSkillerSession(project.projectDir) - const runProjectIdleDecision = ( project: ProjectItem, config: ProjectAutoSuspendConfig, diff --git a/packages/api/src/services/project-closed-source-policy.ts b/packages/api/src/services/project-closed-source-policy.ts new file mode 100644 index 00000000..eb74008b --- /dev/null +++ b/packages/api/src/services/project-closed-source-policy.ts @@ -0,0 +1,50 @@ +import { type ProjectSourceState, shouldDeleteForSourceState } from "@effect-template/lib" + +// CHANGE: decide whether a project whose issue/PR is closed may be auto-deleted +// WHY: deletion is destructive, so it must never race with active work +// QUOTE(ТЗ): "Сделать возможность автоматического удаления контейнера issues или PR которого уже закрылся" +// REF: issue-117 +// SOURCE: https://github.com/ProverCoderAI/docker-git/issues/117 +// PURITY: CORE +// EFFECT: n/a +// INVARIANT: Delete is returned only when the source is closed and no work is in progress +// COMPLEXITY: O(1) + +export type ProjectClosedSourcePolicyInput = { + readonly sourceState: ProjectSourceState + readonly hasActiveAgent: boolean + readonly hasLiveInteractiveSession: boolean +} + +export type ProjectClosedSourceDecision = + | { readonly _tag: "Keep"; readonly reason: "source-open-or-unknown" | "active-agent" | "live-interactive-session" } + | { readonly _tag: "Delete" } + +/** + * Decide whether a project should be deleted because its issue/PR is closed. + * + * A project is deleted only when all of the following hold: + * - its originating issue or pull request is definitively `closed`, + * - no agent is currently working inside it, and + * - no live interactive session (terminal/browser/skiller/ssh) is attached. + * + * Anything else keeps the project, with a reason explaining why. + * + * @pure true + * @invariant result._tag = "Delete" → input.sourceState = "closed" ∧ ¬hasActiveAgent ∧ ¬hasLiveInteractiveSession + * @complexity O(1) + */ +export const decideProjectClosedSourceAction = ( + input: ProjectClosedSourcePolicyInput +): ProjectClosedSourceDecision => { + if (!shouldDeleteForSourceState(input.sourceState)) { + return { _tag: "Keep", reason: "source-open-or-unknown" } + } + if (input.hasActiveAgent) { + return { _tag: "Keep", reason: "active-agent" } + } + if (input.hasLiveInteractiveSession) { + return { _tag: "Keep", reason: "live-interactive-session" } + } + return { _tag: "Delete" } +} diff --git a/packages/api/tests/project-closed-source-policy.test.ts b/packages/api/tests/project-closed-source-policy.test.ts new file mode 100644 index 00000000..483c9003 --- /dev/null +++ b/packages/api/tests/project-closed-source-policy.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from "vitest" + +import { + decideProjectClosedSourceAction, + type ProjectClosedSourcePolicyInput +} from "../src/services/project-closed-source-policy.js" + +const makeInput = (overrides: Partial = {}): ProjectClosedSourcePolicyInput => ({ + sourceState: "closed", + hasActiveAgent: false, + hasLiveInteractiveSession: false, + ...overrides +}) + +describe("project closed-source policy", () => { + it("deletes a closed-source project with no active work", () => { + expect(decideProjectClosedSourceAction(makeInput())).toEqual({ _tag: "Delete" }) + }) + + it("keeps an open-source project", () => { + expect(decideProjectClosedSourceAction(makeInput({ sourceState: "open" }))).toEqual({ + _tag: "Keep", + reason: "source-open-or-unknown" + }) + }) + + it("keeps a project whose source state is unknown", () => { + expect(decideProjectClosedSourceAction(makeInput({ sourceState: "unknown" }))).toEqual({ + _tag: "Keep", + reason: "source-open-or-unknown" + }) + }) + + it("keeps a closed-source project while an agent is active", () => { + expect(decideProjectClosedSourceAction(makeInput({ hasActiveAgent: true }))).toEqual({ + _tag: "Keep", + reason: "active-agent" + }) + }) + + it("keeps a closed-source project while an interactive session is live", () => { + expect(decideProjectClosedSourceAction(makeInput({ hasLiveInteractiveSession: true }))).toEqual({ + _tag: "Keep", + reason: "live-interactive-session" + }) + }) + + it("prefers the active-agent reason over a live interactive session", () => { + expect( + decideProjectClosedSourceAction(makeInput({ hasActiveAgent: true, hasLiveInteractiveSession: true })) + ).toEqual({ _tag: "Keep", reason: "active-agent" }) + }) + + it("never deletes an open project even with no active work", () => { + expect(decideProjectClosedSourceAction(makeInput({ sourceState: "open" }))._tag).toBe("Keep") + }) +}) diff --git a/packages/lib/src/core/domain.ts b/packages/lib/src/core/domain.ts index 484da3d0..a55f995c 100644 --- a/packages/lib/src/core/domain.ts +++ b/packages/lib/src/core/domain.ts @@ -26,6 +26,17 @@ export type { } from "./auth-domain.js" export type { MenuAction, ParseError } from "./menu.js" export { parseMenuSelection } from "./menu.js" +export type { + ProjectSourceKind, + ProjectSourceProvider, + ProjectSourceRef, + ProjectSourceState +} from "./project-source-ref.js" +export { + normalizeProjectSourceState, + parseProjectSourceRef, + shouldDeleteForSourceState +} from "./project-source-ref.js" export { deriveRepoPathParts, deriveRepoSlug, resolveRepoInput } from "./repo.js" export type { SessionsCommand, diff --git a/packages/lib/src/core/project-source-ref.ts b/packages/lib/src/core/project-source-ref.ts new file mode 100644 index 00000000..16df9fad --- /dev/null +++ b/packages/lib/src/core/project-source-ref.ts @@ -0,0 +1,150 @@ +import { parseGithubRepoUrl, parseGitlabRepoUrl } from "./repo.js" + +// CHANGE: derive the originating issue/PR identity of a docker-git project +// WHY: enable automatic cleanup of containers whose issue or PR has been closed +// QUOTE(ТЗ): "Сделать возможность автоматического удаления контейнера issues или PR которого уже закрылся" +// REF: issue-117 +// SOURCE: https://github.com/ProverCoderAI/docker-git/issues/117 +// PURITY: CORE +// EFFECT: n/a +// INVARIANT: returns null unless the project clearly originates from an issue or PR/MR +// COMPLEXITY: O(n) where n = |repoUrl| + |repoRef| + +export type ProjectSourceProvider = "github" | "gitlab" + +export type ProjectSourceKind = "issue" | "pull" + +/** + * Identity of the GitHub/GitLab issue or pull/merge request a project was cloned from. + * + * For GitLab projects `owner` carries the full namespace path (e.g. `group/subgroup`). + */ +export type ProjectSourceRef = { + readonly provider: ProjectSourceProvider + readonly owner: string + readonly repo: string + readonly kind: ProjectSourceKind + readonly number: string +} + +// A project cloned from an issue stores `repoRef = issue-` for both providers. +const issueRefRe = /^issue-(\d+)$/u +// A project cloned from a GitHub PR stores `repoRef = refs/pull//head`. +const githubPullRefRe = /^refs\/pull\/(\d+)\/head$/u +// A project cloned from a GitLab MR stores `repoRef = refs/merge-requests//head`. +const gitlabMergeRequestRefRe = /^refs\/merge-requests\/(\d+)\/head$/u + +type RefIdentity = { + readonly kind: ProjectSourceKind + readonly number: string +} + +const parseGithubRefIdentity = (repoRef: string): RefIdentity | null => { + const issue = issueRefRe.exec(repoRef) + if (issue?.[1]) { + return { kind: "issue", number: issue[1] } + } + const pull = githubPullRefRe.exec(repoRef) + if (pull?.[1]) { + return { kind: "pull", number: pull[1] } + } + return null +} + +const parseGitlabRefIdentity = (repoRef: string): RefIdentity | null => { + const issue = issueRefRe.exec(repoRef) + if (issue?.[1]) { + return { kind: "issue", number: issue[1] } + } + const mr = gitlabMergeRequestRefRe.exec(repoRef) + if (mr?.[1]) { + return { kind: "pull", number: mr[1] } + } + return null +} + +/** + * Parse the issue/PR identity that a project was cloned from. + * + * Mirrors the inverse of {@link resolveRepoInput}: PR/issue/MR URLs are normalized + * into a `repoUrl` + `repoRef` pair at clone time, and this recovers the original + * issue or pull/merge request number so its open/closed state can be queried. + * + * @param repoUrl - The normalized `.git` repository URL stored in the project config. + * @param repoRef - The ref stored in the project config (`issue-`, `refs/pull//head`, …). + * @returns The source ref identity, or `null` if the project is not tied to an issue or PR/MR. + * + * @pure true + * @invariant ∀(url, ref): result ≠ null → result.number matches /^\d+$/ + * @complexity O(n) + */ +export const parseProjectSourceRef = ( + repoUrl: string, + repoRef: string +): ProjectSourceRef | null => { + const ref = repoRef.trim() + if (ref.length === 0) { + return null + } + + const github = parseGithubRepoUrl(repoUrl) + if (github !== null) { + const identity = parseGithubRefIdentity(ref) + return identity === null + ? null + : { provider: "github", owner: github.owner, repo: github.repo, kind: identity.kind, number: identity.number } + } + + const gitlab = parseGitlabRepoUrl(repoUrl) + if (gitlab !== null) { + const identity = parseGitlabRefIdentity(ref) + return identity === null + ? null + : { + provider: "gitlab", + owner: gitlab.projectPath, + repo: gitlab.repo, + kind: identity.kind, + number: identity.number + } + } + + return null +} + +export type ProjectSourceState = "open" | "closed" | "unknown" + +/** + * Normalize a raw provider issue/PR state string into a {@link ProjectSourceState}. + * + * GitHub returns `open` | `closed`; GitLab returns `opened` | `closed` | `merged` | `locked`. + * Anything we cannot confidently classify as open or closed becomes `unknown` so that + * the cautious default (never delete) applies. + * + * @pure true + * @complexity O(1) + */ +export const normalizeProjectSourceState = ( + raw: string | null | undefined +): ProjectSourceState => { + const value = raw?.trim().toLowerCase() ?? "" + if (value === "open" || value === "opened") { + return "open" + } + if (value === "closed" || value === "merged" || value === "locked") { + return "closed" + } + return "unknown" +} + +/** + * Decide whether a project should be auto-deleted given its source state. + * + * Only a definitively `closed` issue/PR triggers deletion; `open` and `unknown` + * states are preserved so transient API failures never destroy live work. + * + * @pure true + * @invariant deleteForSourceState(s) → s = "closed" + * @complexity O(1) + */ +export const shouldDeleteForSourceState = (state: ProjectSourceState): boolean => state === "closed" diff --git a/packages/lib/src/usecases/project-source-state.ts b/packages/lib/src/usecases/project-source-state.ts new file mode 100644 index 00000000..5a06ceaa --- /dev/null +++ b/packages/lib/src/usecases/project-source-state.ts @@ -0,0 +1,65 @@ +import type * as CommandExecutor from "@effect/platform/CommandExecutor" +import type { PlatformError } from "@effect/platform/Error" +import { Effect } from "effect" + +import { + normalizeProjectSourceState, + type ProjectSourceRef, + type ProjectSourceState +} from "../core/project-source-ref.js" +import { runGhApiNullable } from "./github-api-helpers.js" + +// CHANGE: query the open/closed state of the issue or PR a project was cloned from +// WHY: drive automatic deletion of containers whose issue or PR has already been closed +// QUOTE(ТЗ): "Сделать возможность автоматического удаления контейнера issues или PR которого уже закрылся" +// REF: issue-117 +// SOURCE: https://github.com/ProverCoderAI/docker-git/issues/117 +// PURITY: SHELL +// EFFECT: Effect +// INVARIANT: never throws on API/HTTP failure — unknown is returned so deletion is skipped +// COMPLEXITY: O(1) API call + +/** + * Fetch the open/closed state of the GitHub issue or pull request behind a project. + * + * GitHub's `/repos/{owner}/{repo}/issues/{n}` endpoint resolves both issues and pull + * requests (a PR is an issue), so a single call covers either kind. Any failure or + * unrecognised response collapses to `unknown`, which the caller treats as "keep". + * + * @pure false + * @effect CommandExecutor (Docker gh CLI) + * @invariant ∀ref: result ∈ {open, closed, unknown} + * @complexity O(1) API call + */ +const fetchGithubSourceState = ( + cwd: string, + hostPath: string, + token: string, + ref: ProjectSourceRef +): Effect.Effect => + runGhApiNullable(cwd, hostPath, token, [ + `/repos/${ref.owner}/${ref.repo}/issues/${ref.number}`, + "--jq", + ".state" + ]).pipe(Effect.map((raw) => normalizeProjectSourceState(raw))) + +/** + * Resolve the open/closed state of the issue or PR a project originates from. + * + * Only GitHub is supported today; GitLab refs resolve to `unknown` so they are never + * auto-deleted until first-class GitLab support is added. + * + * @pure false + * @effect CommandExecutor (Docker gh CLI) + * @invariant GitLab refs → unknown + * @complexity O(1) API call + */ +export const fetchProjectSourceState = ( + cwd: string, + hostPath: string, + token: string, + ref: ProjectSourceRef +): Effect.Effect => + ref.provider === "github" + ? fetchGithubSourceState(cwd, hostPath, token, ref) + : Effect.succeed("unknown") diff --git a/packages/lib/tests/core/project-source-ref.test.ts b/packages/lib/tests/core/project-source-ref.test.ts new file mode 100644 index 00000000..cf62ccaf --- /dev/null +++ b/packages/lib/tests/core/project-source-ref.test.ts @@ -0,0 +1,115 @@ +import { describe, expect, it } from "vitest" + +import { + normalizeProjectSourceState, + parseProjectSourceRef, + shouldDeleteForSourceState +} from "../../src/core/project-source-ref.js" + +describe("parseProjectSourceRef", () => { + it("recovers a GitHub issue identity from issue-", () => { + expect(parseProjectSourceRef("https://github.com/owner/repo.git", "issue-42")).toEqual({ + provider: "github", + owner: "owner", + repo: "repo", + kind: "issue", + number: "42" + }) + }) + + it("recovers a GitHub pull-request identity from refs/pull//head", () => { + expect(parseProjectSourceRef("https://github.com/owner/repo.git", "refs/pull/7/head")).toEqual({ + provider: "github", + owner: "owner", + repo: "repo", + kind: "pull", + number: "7" + }) + }) + + it("recovers a GitLab merge-request identity from refs/merge-requests//head", () => { + expect(parseProjectSourceRef("https://gitlab.com/group/repo.git", "refs/merge-requests/9/head")).toEqual({ + provider: "gitlab", + owner: "group/repo", + repo: "repo", + kind: "pull", + number: "9" + }) + }) + + it("recovers a GitLab issue identity from issue-", () => { + expect(parseProjectSourceRef("https://gitlab.com/group/sub/repo.git", "issue-3")).toEqual({ + provider: "gitlab", + owner: "group/sub/repo", + repo: "repo", + kind: "issue", + number: "3" + }) + }) + + it("trims surrounding whitespace from the ref", () => { + expect(parseProjectSourceRef("https://github.com/owner/repo.git", " issue-1 ")?.number).toBe("1") + }) + + it("returns null for a plain branch ref", () => { + expect(parseProjectSourceRef("https://github.com/owner/repo.git", "main")).toBeNull() + }) + + it("returns null for an empty ref", () => { + expect(parseProjectSourceRef("https://github.com/owner/repo.git", " ")).toBeNull() + }) + + it("returns null when the repo URL is not a known provider", () => { + expect(parseProjectSourceRef("https://example.com/owner/repo.git", "issue-1")).toBeNull() + }) + + it("does not treat a GitHub merge-request style ref as a pull request", () => { + expect(parseProjectSourceRef("https://github.com/owner/repo.git", "refs/merge-requests/9/head")).toBeNull() + }) +}) + +describe("normalizeProjectSourceState", () => { + it("maps GitHub open to open", () => { + expect(normalizeProjectSourceState("open")).toBe("open") + }) + + it("maps GitLab opened to open", () => { + expect(normalizeProjectSourceState("opened")).toBe("open") + }) + + it("maps closed to closed", () => { + expect(normalizeProjectSourceState("closed")).toBe("closed") + }) + + it("maps GitLab merged to closed", () => { + expect(normalizeProjectSourceState("merged")).toBe("closed") + }) + + it("maps GitLab locked to closed", () => { + expect(normalizeProjectSourceState("locked")).toBe("closed") + }) + + it("is case- and whitespace-insensitive", () => { + expect(normalizeProjectSourceState(" CLOSED ")).toBe("closed") + }) + + it("maps unrecognized values to unknown", () => { + expect(normalizeProjectSourceState("draft")).toBe("unknown") + }) + + it("maps null and undefined to unknown", () => { + expect(normalizeProjectSourceState(null)).toBe("unknown") + expect(normalizeProjectSourceState(undefined)).toBe("unknown") + }) +}) + +describe("shouldDeleteForSourceState", () => { + it("deletes only when the source is closed", () => { + expect(shouldDeleteForSourceState("closed")).toBe(true) + }) + + it("never deletes for open or unknown", () => { + expect(shouldDeleteForSourceState("open")).toBe(false) + expect(shouldDeleteForSourceState("unknown")).toBe(false) + }) +})