diff --git a/packages/app/src/lib/core/templates-entrypoint/git-hooks.ts b/packages/app/src/lib/core/templates-entrypoint/git-hooks.ts index 197751dc..a862cd48 100644 --- a/packages/app/src/lib/core/templates-entrypoint/git-hooks.ts +++ b/packages/app/src/lib/core/templates-entrypoint/git-hooks.ts @@ -1,4 +1,5 @@ import { renderEntrypointGitPostPushWrapperInstall } from "./git-post-push-wrapper.js" +import { renderPostPushPrEnsure } from "./post-push-pr.js" const entrypointGitHooksTemplate = String .raw`# 3) Install global git hooks to protect main/master + managed AGENTS context @@ -145,14 +146,17 @@ if [[ -z "$REPO_ROOT" || ! -d "$REPO_ROOT" ]]; then fi cd "$REPO_ROOT" -# CHANGE: sync captured Codex plans to the current branch PR after push. -# WHY: issue #369 requires the agent plan to be uploaded to PR discussion. -# REF: issue-369 +${renderPostPushPrEnsure()} + +# CHANGE: backfill Codex session plans before syncing the current branch PR. +# WHY: live Codex hooks can be unavailable in already-running sessions; session logs are the durable fallback. +# REF: issue-375 if [ "${"${"}DOCKER_GIT_SKIP_PLAN_TO_GIT:-}" != "1" ]; then if ! command -v plan-to-git >/dev/null 2>&1; then echo "[plan-to-git] Error: plan-to-git not found" >&2 exit 1 fi + plan-to-git import-codex --no-sync plan-to-git sync fi diff --git a/packages/app/src/lib/core/templates-entrypoint/post-push-pr.ts b/packages/app/src/lib/core/templates-entrypoint/post-push-pr.ts new file mode 100644 index 00000000..8ef23598 --- /dev/null +++ b/packages/app/src/lib/core/templates-entrypoint/post-push-pr.ts @@ -0,0 +1,133 @@ +/* jscpd:ignore-start */ +// Mirror of packages/lib/src/core/templates-entrypoint/post-push-pr.ts. +// The lib template test asserts rendered output equality to prevent drift. +const postPushPrEnsureTemplate = String + .raw`# CHANGE: ensure an open GitHub PR exists for the pushed branch before PR-bound post-push tools run. +# WHY: issue #375 requires every successful git push to leave the branch with an open PR; plan sync and session backup both target PR discussion. +# REF: issue-375 +docker_git_github_repo_from_remote_url() { + local remote_url="$1" + local repo_path="" + local owner="" + local repo="" + + case "$remote_url" in + https://github.com/*) + repo_path="${"${"}remote_url#https://github.com/}" + ;; + http://github.com/*) + repo_path="${"${"}remote_url#http://github.com/}" + ;; + https://*@github.com/*) + repo_path="${"${"}remote_url#https://*@github.com/}" + ;; + http://*@github.com/*) + repo_path="${"${"}remote_url#http://*@github.com/}" + ;; + ssh://git@github.com/*) + repo_path="${"${"}remote_url#ssh://git@github.com/}" + ;; + git@github.com:*) + repo_path="${"${"}remote_url#git@github.com:}" + ;; + *) + return 1 + ;; + esac + + repo_path="${"${"}repo_path%%\?*}" + repo_path="${"${"}repo_path%%#*}" + repo_path="${"${"}repo_path%/}" + repo_path="${"${"}repo_path%.git}" + owner="${"${"}repo_path%%/*}" + repo="${"${"}repo_path#*/}" + repo="${"${"}repo%%/*}" + repo="${"${"}repo%.git}" + + if [[ -z "$owner" || -z "$repo" || "$owner" == "$repo_path" ]]; then + return 1 + fi + + printf "%s/%s\n" "$owner" "$repo" +} + +docker_git_github_repo_from_remote() { + local remote="$1" + local remote_url="" + + remote_url="$(git remote get-url "$remote" 2>/dev/null || true)" + if [[ -z "$remote_url" ]]; then + return 1 + fi + + docker_git_github_repo_from_remote_url "$remote_url" +} + +docker_git_ensure_open_pr() { + local branch="" + local base_repo="" + local head_repo="" + local head_owner="" + local head_arg="" + local base_branch="" + local pr_url="" + + if ! command -v gh >/dev/null 2>&1; then + echo "[post-push-pr] Error: gh CLI not found" >&2 + return 1 + fi + + branch="$(git rev-parse --abbrev-ref HEAD 2>/dev/null || true)" + if [[ -z "$branch" || "$branch" == "HEAD" ]]; then + echo "[post-push-pr] Error: cannot create PR from detached HEAD" >&2 + return 1 + fi + + if ! base_repo="$(docker_git_github_repo_from_remote upstream)"; then + if ! base_repo="$(docker_git_github_repo_from_remote origin)"; then + echo "[post-push-pr] Skipped: no GitHub remote found" + return 0 + fi + fi + + if ! head_repo="$(docker_git_github_repo_from_remote origin)"; then + head_repo="$base_repo" + fi + + base_branch="$(gh repo view "$base_repo" --json defaultBranchRef --jq '.defaultBranchRef.name' 2>/dev/null || true)" + if [[ -z "$base_branch" ]]; then + echo "[post-push-pr] Error: failed to resolve default branch for $base_repo" >&2 + return 1 + fi + + if [[ "$head_repo" == "$base_repo" ]]; then + head_arg="$branch" + else + head_owner="${"${"}head_repo%%/*}" + head_arg="${"${"}head_owner}:${"${"}branch}" + fi + + if ! pr_url="$(gh pr list --repo "$base_repo" --state open --head "$head_arg" --json url --jq '.[0].url // ""' 2>/dev/null)"; then + echo "[post-push-pr] Error: failed to list open PRs for $head_arg in $base_repo" >&2 + return 1 + fi + if [[ -z "$pr_url" && "$head_arg" != "$branch" ]]; then + if ! pr_url="$(gh pr list --repo "$base_repo" --state open --head "$branch" --json url --jq '.[0].url // ""' 2>/dev/null)"; then + echo "[post-push-pr] Error: failed to list open PRs for $branch in $base_repo" >&2 + return 1 + fi + fi + + if [[ -n "$pr_url" ]]; then + echo "[post-push-pr] Open PR: $pr_url" + return 0 + fi + + echo "[post-push-pr] Creating PR for $head_arg into $base_repo:$base_branch" + gh pr create --repo "$base_repo" --base "$base_branch" --head "$head_arg" --fill +} + +docker_git_ensure_open_pr` + +export const renderPostPushPrEnsure = (): string => postPushPrEnsureTemplate +/* jscpd:ignore-end */ diff --git a/packages/app/tests/docker-git/core-templates.test.ts b/packages/app/tests/docker-git/core-templates.test.ts index 9d9ab295..95fcf98c 100644 --- a/packages/app/tests/docker-git/core-templates.test.ts +++ b/packages/app/tests/docker-git/core-templates.test.ts @@ -110,12 +110,34 @@ describe("app planFiles", () => { "args = [\"--project\", \"$DOCKER_GIT_BROWSER_PROJECT\", \"--network\", \"$DOCKER_GIT_BROWSER_NETWORK\"]" ) expect(entrypoint.contents).toContain("plan-to-git sync") + expect(entrypoint.contents).toContain("docker_git_ensure_open_pr") + expect(entrypoint.contents).toContain("gh pr list --repo \"$base_repo\" --state open --head \"$head_arg\"") + expect(entrypoint.contents).toContain( + "gh pr create --repo \"$base_repo\" --base \"$base_branch\" --head \"$head_arg\" --fill" + ) expect(entrypoint.contents).toContain("plan-to-git hook --source codex") + expect(entrypoint.contents).toContain("plan-to-git import-codex --no-sync") expect(entrypoint.contents).toContain("CODEX_REQUIREMENTS_FILE=\"/etc/codex/requirements.toml\"") expect(entrypoint.contents).toContain("managed_dir = \"/opt/docker-git/hooks\"") expect(entrypoint.contents).toContain("[[hooks.UserPromptSubmit]]") expect(entrypoint.contents).toContain("[[hooks.Stop]]") expect(entrypoint.contents).toContain("command = \"/opt/docker-git/hooks/plan-to-git-codex-hook\"") + + const cdIndex = entrypoint.contents.indexOf("cd \"$REPO_ROOT\"") + const ensurePrIndex = entrypoint.contents.indexOf( + "docker_git_ensure_open_pr\n\n# CHANGE: backfill Codex session plans" + ) + const planImportIndex = entrypoint.contents.indexOf("plan-to-git import-codex --no-sync") + const planSyncIndex = entrypoint.contents.indexOf("plan-to-git sync") + const sessionBackupIndex = entrypoint.contents.indexOf( + "docker-git-session-sync backup --verbose --background --require-comment" + ) + + expect(cdIndex).toBeGreaterThanOrEqual(0) + expect(ensurePrIndex).toBeGreaterThan(cdIndex) + expect(planImportIndex).toBeGreaterThan(ensurePrIndex) + expect(planSyncIndex).toBeGreaterThan(planImportIndex) + expect(sessionBackupIndex).toBeGreaterThan(planSyncIndex) }) ) }) diff --git a/packages/lib/src/core/templates-entrypoint/git-hooks.ts b/packages/lib/src/core/templates-entrypoint/git-hooks.ts index 197751dc..a862cd48 100644 --- a/packages/lib/src/core/templates-entrypoint/git-hooks.ts +++ b/packages/lib/src/core/templates-entrypoint/git-hooks.ts @@ -1,4 +1,5 @@ import { renderEntrypointGitPostPushWrapperInstall } from "./git-post-push-wrapper.js" +import { renderPostPushPrEnsure } from "./post-push-pr.js" const entrypointGitHooksTemplate = String .raw`# 3) Install global git hooks to protect main/master + managed AGENTS context @@ -145,14 +146,17 @@ if [[ -z "$REPO_ROOT" || ! -d "$REPO_ROOT" ]]; then fi cd "$REPO_ROOT" -# CHANGE: sync captured Codex plans to the current branch PR after push. -# WHY: issue #369 requires the agent plan to be uploaded to PR discussion. -# REF: issue-369 +${renderPostPushPrEnsure()} + +# CHANGE: backfill Codex session plans before syncing the current branch PR. +# WHY: live Codex hooks can be unavailable in already-running sessions; session logs are the durable fallback. +# REF: issue-375 if [ "${"${"}DOCKER_GIT_SKIP_PLAN_TO_GIT:-}" != "1" ]; then if ! command -v plan-to-git >/dev/null 2>&1; then echo "[plan-to-git] Error: plan-to-git not found" >&2 exit 1 fi + plan-to-git import-codex --no-sync plan-to-git sync fi diff --git a/packages/lib/src/core/templates-entrypoint/post-push-pr.ts b/packages/lib/src/core/templates-entrypoint/post-push-pr.ts new file mode 100644 index 00000000..73d4924a --- /dev/null +++ b/packages/lib/src/core/templates-entrypoint/post-push-pr.ts @@ -0,0 +1,129 @@ +const postPushPrEnsureTemplate = String + .raw`# CHANGE: ensure an open GitHub PR exists for the pushed branch before PR-bound post-push tools run. +# WHY: issue #375 requires every successful git push to leave the branch with an open PR; plan sync and session backup both target PR discussion. +# REF: issue-375 +docker_git_github_repo_from_remote_url() { + local remote_url="$1" + local repo_path="" + local owner="" + local repo="" + + case "$remote_url" in + https://github.com/*) + repo_path="${"${"}remote_url#https://github.com/}" + ;; + http://github.com/*) + repo_path="${"${"}remote_url#http://github.com/}" + ;; + https://*@github.com/*) + repo_path="${"${"}remote_url#https://*@github.com/}" + ;; + http://*@github.com/*) + repo_path="${"${"}remote_url#http://*@github.com/}" + ;; + ssh://git@github.com/*) + repo_path="${"${"}remote_url#ssh://git@github.com/}" + ;; + git@github.com:*) + repo_path="${"${"}remote_url#git@github.com:}" + ;; + *) + return 1 + ;; + esac + + repo_path="${"${"}repo_path%%\?*}" + repo_path="${"${"}repo_path%%#*}" + repo_path="${"${"}repo_path%/}" + repo_path="${"${"}repo_path%.git}" + owner="${"${"}repo_path%%/*}" + repo="${"${"}repo_path#*/}" + repo="${"${"}repo%%/*}" + repo="${"${"}repo%.git}" + + if [[ -z "$owner" || -z "$repo" || "$owner" == "$repo_path" ]]; then + return 1 + fi + + printf "%s/%s\n" "$owner" "$repo" +} + +docker_git_github_repo_from_remote() { + local remote="$1" + local remote_url="" + + remote_url="$(git remote get-url "$remote" 2>/dev/null || true)" + if [[ -z "$remote_url" ]]; then + return 1 + fi + + docker_git_github_repo_from_remote_url "$remote_url" +} + +docker_git_ensure_open_pr() { + local branch="" + local base_repo="" + local head_repo="" + local head_owner="" + local head_arg="" + local base_branch="" + local pr_url="" + + if ! command -v gh >/dev/null 2>&1; then + echo "[post-push-pr] Error: gh CLI not found" >&2 + return 1 + fi + + branch="$(git rev-parse --abbrev-ref HEAD 2>/dev/null || true)" + if [[ -z "$branch" || "$branch" == "HEAD" ]]; then + echo "[post-push-pr] Error: cannot create PR from detached HEAD" >&2 + return 1 + fi + + if ! base_repo="$(docker_git_github_repo_from_remote upstream)"; then + if ! base_repo="$(docker_git_github_repo_from_remote origin)"; then + echo "[post-push-pr] Skipped: no GitHub remote found" + return 0 + fi + fi + + if ! head_repo="$(docker_git_github_repo_from_remote origin)"; then + head_repo="$base_repo" + fi + + base_branch="$(gh repo view "$base_repo" --json defaultBranchRef --jq '.defaultBranchRef.name' 2>/dev/null || true)" + if [[ -z "$base_branch" ]]; then + echo "[post-push-pr] Error: failed to resolve default branch for $base_repo" >&2 + return 1 + fi + + if [[ "$head_repo" == "$base_repo" ]]; then + head_arg="$branch" + else + head_owner="${"${"}head_repo%%/*}" + head_arg="${"${"}head_owner}:${"${"}branch}" + fi + + if ! pr_url="$(gh pr list --repo "$base_repo" --state open --head "$head_arg" --json url --jq '.[0].url // ""' 2>/dev/null)"; then + echo "[post-push-pr] Error: failed to list open PRs for $head_arg in $base_repo" >&2 + return 1 + fi + if [[ -z "$pr_url" && "$head_arg" != "$branch" ]]; then + if ! pr_url="$(gh pr list --repo "$base_repo" --state open --head "$branch" --json url --jq '.[0].url // ""' 2>/dev/null)"; then + echo "[post-push-pr] Error: failed to list open PRs for $branch in $base_repo" >&2 + return 1 + fi + fi + + if [[ -n "$pr_url" ]]; then + echo "[post-push-pr] Open PR: $pr_url" + return 0 + fi + + echo "[post-push-pr] Creating PR for $head_arg into $base_repo:$base_branch" + gh pr create --repo "$base_repo" --base "$base_branch" --head "$head_arg" --fill +} + +docker_git_ensure_open_pr` + +export const renderPostPushPrEnsure = (): string => postPushPrEnsureTemplate diff --git a/packages/lib/tests/core/git-post-push-wrapper.test.ts b/packages/lib/tests/core/git-post-push-wrapper.test.ts index 487e5fd5..f3dcba41 100644 --- a/packages/lib/tests/core/git-post-push-wrapper.test.ts +++ b/packages/lib/tests/core/git-post-push-wrapper.test.ts @@ -26,6 +26,7 @@ type WrapperHarness = { readonly nodeRepoRootLogPath: string readonly nodeScriptLogPath: string readonly planToGitLogPath: string + readonly ghLogPath: string } const fakeGitScript = `#!/usr/bin/env bash @@ -76,6 +77,28 @@ if [[ "$subcommand" == "rev-parse" ]]; then fi exit 128 fi + next_next_index=$((index + 2)) + if [[ "$next_index" -lt "$#" && "$next_next_index" -lt "$#" && "\${args[$next_index]}" == "--abbrev-ref" && "\${args[$next_next_index]}" == "HEAD" ]]; then + printf '%s\\n' "\${FAKE_GIT_BRANCH:-issue-375}" + exit 0 + fi +fi + +if [[ "$subcommand" == "remote" ]]; then + next_index=$((index + 1)) + next_next_index=$((index + 2)) + if [[ "$next_index" -lt "$#" && "$next_next_index" -lt "$#" && "\${args[$next_index]}" == "get-url" ]]; then + remote_name="\${args[$next_next_index]}" + if [[ "$remote_name" == "upstream" && -n "\${FAKE_GIT_UPSTREAM_URL:-}" ]]; then + printf '%s\\n' "$FAKE_GIT_UPSTREAM_URL" + exit 0 + fi + if [[ "$remote_name" == "origin" ]]; then + printf '%s\\n' "\${FAKE_GIT_ORIGIN_URL:-https://github.com/org/repo.git}" + exit 0 + fi + exit 2 + fi fi if [[ "$subcommand" == "push" && -n "\${FAKE_GIT_PUSH_EXIT_CODE:-}" ]]; then @@ -107,6 +130,37 @@ exit 0 const fakeGhScript = `#!/usr/bin/env bash set -euo pipefail + +if [[ -n "\${FAKE_GH_LOG_PATH:-}" ]]; then + printf '%s\\t%s\\n' "$PWD" "$*" >> "$FAKE_GH_LOG_PATH" +fi + +if [[ "\${1:-}" == "repo" && "\${2:-}" == "view" ]]; then + if [[ -n "\${FAKE_GH_REPO_VIEW_EXIT_CODE:-}" ]]; then + exit "$FAKE_GH_REPO_VIEW_EXIT_CODE" + fi + printf '%s\\n' "\${FAKE_GH_DEFAULT_BRANCH:-main}" + exit 0 +fi + +if [[ "\${1:-}" == "pr" && "\${2:-}" == "list" ]]; then + if [[ -n "\${FAKE_GH_PR_LIST_EXIT_CODE:-}" ]]; then + exit "$FAKE_GH_PR_LIST_EXIT_CODE" + fi + if [[ -n "\${FAKE_GH_OPEN_PR_URL:-}" ]]; then + printf '%s\\n' "$FAKE_GH_OPEN_PR_URL" + fi + exit 0 +fi + +if [[ "\${1:-}" == "pr" && "\${2:-}" == "create" ]]; then + if [[ -n "\${FAKE_GH_PR_CREATE_EXIT_CODE:-}" ]]; then + exit "$FAKE_GH_PR_CREATE_EXIT_CODE" + fi + printf '%s\\n' "\${FAKE_GH_CREATED_PR_URL:-https://github.com/org/repo/pull/375}" + exit 0 +fi + exit 0 ` @@ -117,11 +171,11 @@ if [[ -n "\${FAKE_PLAN_TO_GIT_LOG_PATH:-}" ]]; then printf '%s\\t%s\\n' "$PWD" "$*" >> "$FAKE_PLAN_TO_GIT_LOG_PATH" fi -if [[ "\${1:-}" != "sync" ]]; then +if [[ "\${1:-}" != "import-codex" && "\${1:-}" != "sync" ]]; then if [[ -n "\${FAKE_PLAN_TO_GIT_LOG_PATH:-}" ]]; then printf '%s\\tunexpected-command:%s\\n' "$PWD" "\${1:-}" >> "$FAKE_PLAN_TO_GIT_LOG_PATH" fi - echo "fakePlanToGit: expected sync command, got: \${1:-}" >&2 + echo "fakePlanToGit: expected import-codex or sync command, got: \${1:-}" >&2 exit 127 fi @@ -227,6 +281,7 @@ const makeHarnessEnv = ( FAKE_NODE_REPO_ROOT_LOG_PATH: harness.nodeRepoRootLogPath, FAKE_NODE_SCRIPT_LOG_PATH: harness.nodeScriptLogPath, FAKE_PLAN_TO_GIT_LOG_PATH: harness.planToGitLogPath, + FAKE_GH_LOG_PATH: harness.ghLogPath, ...overrides }) @@ -269,6 +324,7 @@ const withHarness = ( const nodeRepoRootLogPath = path.join(rootDir, "node-repo-root.log") const nodeScriptLogPath = path.join(rootDir, "node-script.log") const planToGitLogPath = path.join(rootDir, "plan-to-git.log") + const ghLogPath = path.join(rootDir, "gh.log") yield* _(fs.makeDirectory(path.join(repoDir, ".git"), { recursive: true })) yield* _(fs.makeDirectory(externalDir, { recursive: true })) @@ -305,7 +361,8 @@ const withHarness = ( nodeCwdLogPath, nodeRepoRootLogPath, nodeScriptLogPath, - planToGitLogPath + planToGitLogPath, + ghLogPath }) ) }) @@ -321,11 +378,16 @@ describe("git post-push wrapper", () => { const nodeRepoRoot = yield* _(readLogLines(harness.nodeRepoRootLogPath)) const nodeScript = yield* _(readLogLines(harness.nodeScriptLogPath)) const planToGit = yield* _(readLogLines(harness.planToGitLogPath)) + const gh = yield* _(readLogLines(harness.ghLogPath)) expect(nodeCwd).toEqual([harness.repoDir]) expect(nodeRepoRoot).toEqual([harness.repoDir]) expect(nodeScript).toEqual(["backup --verbose --background --require-comment"]) - expect(planToGit).toEqual([`${harness.repoDir}\tsync`]) + expect(planToGit).toEqual([ + `${harness.repoDir}\timport-codex --no-sync`, + `${harness.repoDir}\tsync` + ]) + expect(gh).toContain(`${harness.repoDir}\tpr create --repo org/repo --base main --head issue-375 --fill`) }) ).pipe(Effect.provide(NodeContext.layer))) @@ -339,11 +401,16 @@ describe("git post-push wrapper", () => { const nodeScript = yield* _(readLogLines(harness.nodeScriptLogPath)) const gitLog = yield* _(readLogLines(harness.gitLogPath)) const planToGit = yield* _(readLogLines(harness.planToGitLogPath)) + const gh = yield* _(readLogLines(harness.ghLogPath)) expect(nodeCwd).toEqual([harness.repoDir]) expect(nodeRepoRoot).toEqual([harness.repoDir]) expect(nodeScript).toEqual(["backup --verbose --background --require-comment"]) - expect(planToGit).toEqual([`${harness.repoDir}\tsync`]) + expect(planToGit).toEqual([ + `${harness.repoDir}\timport-codex --no-sync`, + `${harness.repoDir}\tsync` + ]) + expect(gh).toContain(`${harness.repoDir}\tpr create --repo org/repo --base main --head issue-375 --fill`) expect(gitLog.some((line) => line.startsWith(`${harness.externalDir}\t-C ${harness.repoDir} push`))).toBe(true) }) ).pipe(Effect.provide(NodeContext.layer))) @@ -358,11 +425,13 @@ describe("git post-push wrapper", () => { const nodeRepoRoot = yield* _(readLogLines(harness.nodeRepoRootLogPath)) const nodeScript = yield* _(readLogLines(harness.nodeScriptLogPath)) const planToGit = yield* _(readLogLines(harness.planToGitLogPath)) + const gh = yield* _(readLogLines(harness.ghLogPath)) expect(nodeCwd).toEqual([]) expect(nodeRepoRoot).toEqual([]) expect(nodeScript).toEqual([]) expect(planToGit).toEqual([]) + expect(gh).toEqual([]) }) ).pipe(Effect.provide(NodeContext.layer))) @@ -385,15 +454,17 @@ describe("git post-push wrapper", () => { const nodeRepoRoot = yield* _(readLogLines(harness.nodeRepoRootLogPath)) const nodeScript = yield* _(readLogLines(harness.nodeScriptLogPath)) const planToGit = yield* _(readLogLines(harness.planToGitLogPath)) + const gh = yield* _(readLogLines(harness.ghLogPath)) expect(nodeCwd).toEqual([]) expect(nodeRepoRoot).toEqual([]) expect(nodeScript).toEqual([]) expect(planToGit).toEqual([]) + expect(gh).toEqual([]) }) ).pipe(Effect.provide(NodeContext.layer))) - it.effect("skips plan sync when disabled but still runs session backup", () => + it.effect("skips plan sync when disabled but still ensures a PR and runs session backup", () => withHarness((harness) => Effect.gen(function*(_) { yield* _( @@ -404,13 +475,15 @@ describe("git post-push wrapper", () => { const nodeScript = yield* _(readLogLines(harness.nodeScriptLogPath)) const planToGit = yield* _(readLogLines(harness.planToGitLogPath)) + const gh = yield* _(readLogLines(harness.ghLogPath)) expect(nodeScript).toEqual(["backup --verbose --background --require-comment"]) expect(planToGit).toEqual([]) + expect(gh).toContain(`${harness.repoDir}\tpr create --repo org/repo --base main --head issue-375 --fill`) }) ).pipe(Effect.provide(NodeContext.layer))) - it.effect("propagates plan sync failures before session backup", () => + it.effect("propagates plan import failures after ensuring a PR and before session backup", () => withHarness((harness) => Effect.gen(function*(_) { yield* _( @@ -422,9 +495,11 @@ describe("git post-push wrapper", () => { const nodeScript = yield* _(readLogLines(harness.nodeScriptLogPath)) const planToGit = yield* _(readLogLines(harness.planToGitLogPath)) + const gh = yield* _(readLogLines(harness.ghLogPath)) expect(nodeScript).toEqual([]) - expect(planToGit).toEqual([`${harness.repoDir}\tsync`]) + expect(planToGit).toEqual([`${harness.repoDir}\timport-codex --no-sync`]) + expect(gh).toContain(`${harness.repoDir}\tpr create --repo org/repo --base main --head issue-375 --fill`) }) ).pipe(Effect.provide(NodeContext.layer))) @@ -442,7 +517,115 @@ describe("git post-push wrapper", () => { const planToGit = yield* _(readLogLines(harness.planToGitLogPath)) expect(nodeScript).toEqual(["backup --verbose --background --require-comment"]) - expect(planToGit).toEqual([`${harness.repoDir}\tsync`]) + expect(planToGit).toEqual([ + `${harness.repoDir}\timport-codex --no-sync`, + `${harness.repoDir}\tsync` + ]) + }) + ).pipe(Effect.provide(NodeContext.layer))) + + it.effect("reuses an existing open PR instead of creating a duplicate", () => + withHarness((harness) => + Effect.gen(function*(_) { + yield* _( + runWrapper(harness, harness.repoDir, ["push", "origin", "HEAD"], { + env: { FAKE_GH_OPEN_PR_URL: "https://github.com/org/repo/pull/375" } + }) + ) + + const nodeScript = yield* _(readLogLines(harness.nodeScriptLogPath)) + const planToGit = yield* _(readLogLines(harness.planToGitLogPath)) + const gh = yield* _(readLogLines(harness.ghLogPath)) + + expect(nodeScript).toEqual(["backup --verbose --background --require-comment"]) + expect(planToGit).toEqual([ + `${harness.repoDir}\timport-codex --no-sync`, + `${harness.repoDir}\tsync` + ]) + expect(gh).toContain(`${harness.repoDir}\tpr list --repo org/repo --state open --head issue-375 --json url --jq .[0].url // ""`) + expect(gh.some((line) => line.includes("pr create"))).toBe(false) + }) + ).pipe(Effect.provide(NodeContext.layer))) + + it.effect("creates fork PRs against upstream with an owner-qualified head branch", () => + withHarness((harness) => + Effect.gen(function*(_) { + yield* _( + runWrapper(harness, harness.repoDir, ["push", "origin", "HEAD"], { + env: { + FAKE_GIT_ORIGIN_URL: "https://github.com/me/repo.git", + FAKE_GIT_UPSTREAM_URL: "https://github.com/org/repo.git" + } + }) + ) + + const gh = yield* _(readLogLines(harness.ghLogPath)) + + expect(gh).toContain(`${harness.repoDir}\trepo view org/repo --json defaultBranchRef --jq .defaultBranchRef.name`) + expect(gh).toContain(`${harness.repoDir}\tpr list --repo org/repo --state open --head me:issue-375 --json url --jq .[0].url // ""`) + expect(gh).toContain(`${harness.repoDir}\tpr list --repo org/repo --state open --head issue-375 --json url --jq .[0].url // ""`) + expect(gh).toContain(`${harness.repoDir}\tpr create --repo org/repo --base main --head me:issue-375 --fill`) + }) + ).pipe(Effect.provide(NodeContext.layer))) + + it.effect("propagates PR creation failures before plan sync and session backup", () => + withHarness((harness) => + Effect.gen(function*(_) { + yield* _( + runWrapper(harness, harness.repoDir, ["push", "origin", "HEAD"], { + env: { FAKE_GH_PR_CREATE_EXIT_CODE: "41" }, + okExitCodes: [41] + }) + ) + + const nodeScript = yield* _(readLogLines(harness.nodeScriptLogPath)) + const planToGit = yield* _(readLogLines(harness.planToGitLogPath)) + const gh = yield* _(readLogLines(harness.ghLogPath)) + + expect(nodeScript).toEqual([]) + expect(planToGit).toEqual([]) + expect(gh).toContain(`${harness.repoDir}\tpr create --repo org/repo --base main --head issue-375 --fill`) + }) + ).pipe(Effect.provide(NodeContext.layer))) + + it.effect("propagates PR list failures without creating a duplicate PR", () => + withHarness((harness) => + Effect.gen(function*(_) { + yield* _( + runWrapper(harness, harness.repoDir, ["push", "origin", "HEAD"], { + env: { FAKE_GH_PR_LIST_EXIT_CODE: "42" }, + okExitCodes: [1] + }) + ) + + const nodeScript = yield* _(readLogLines(harness.nodeScriptLogPath)) + const planToGit = yield* _(readLogLines(harness.planToGitLogPath)) + const gh = yield* _(readLogLines(harness.ghLogPath)) + + expect(nodeScript).toEqual([]) + expect(planToGit).toEqual([]) + expect(gh).toContain(`${harness.repoDir}\tpr list --repo org/repo --state open --head issue-375 --json url --jq .[0].url // ""`) + expect(gh.some((line) => line.includes("pr create"))).toBe(false) + }) + ).pipe(Effect.provide(NodeContext.layer))) + + it.effect("fails on detached HEAD before listing or creating PRs", () => + withHarness((harness) => + Effect.gen(function*(_) { + yield* _( + runWrapper(harness, harness.repoDir, ["push", "origin", "HEAD"], { + env: { FAKE_GIT_BRANCH: "HEAD" }, + okExitCodes: [1] + }) + ) + + const nodeScript = yield* _(readLogLines(harness.nodeScriptLogPath)) + const planToGit = yield* _(readLogLines(harness.planToGitLogPath)) + const gh = yield* _(readLogLines(harness.ghLogPath)) + + expect(nodeScript).toEqual([]) + expect(planToGit).toEqual([]) + expect(gh).toEqual([]) }) ).pipe(Effect.provide(NodeContext.layer))) }) diff --git a/packages/lib/tests/core/templates.test.ts b/packages/lib/tests/core/templates.test.ts index a167f97a..ce980124 100644 --- a/packages/lib/tests/core/templates.test.ts +++ b/packages/lib/tests/core/templates.test.ts @@ -11,7 +11,9 @@ import { renderDockerfile } from "../../src/core/templates/dockerfile.js" import { renderEntrypoint } from "../../src/core/templates-entrypoint.js" import { renderEntrypointDnsRepair } from "../../src/core/templates-entrypoint/dns-repair.js" import { renderEntrypointGitHooks } from "../../src/core/templates-entrypoint/git.js" +import { renderPostPushPrEnsure as renderLibPostPushPrEnsure } from "../../src/core/templates-entrypoint/post-push-pr.js" import { renderPromptScript } from "../../src/core/templates-prompt.js" +import { renderPostPushPrEnsure as renderAppPostPushPrEnsure } from "../../../app/src/lib/core/templates-entrypoint/post-push-pr.js" const makeTemplateConfig = (overrides: Partial = {}): TemplateConfig => ({ ...defaultTemplateConfig, @@ -464,6 +466,10 @@ describe("renderEntrypoint clone cache", () => { }) describe("renderEntrypointGitHooks", () => { + it("keeps the app mirror of the post-push PR fragment in sync with lib", () => { + expect(renderAppPostPushPrEnsure()).toBe(renderLibPostPushPrEnsure()) + }) + it("installs pre-push protection checks, plan sync, and a global git post-push runtime", () => { const hooks = renderEntrypointGitHooks() @@ -480,7 +486,14 @@ describe("renderEntrypointGitHooks", () => { expect(hooks).toContain("cat <<'EOF' > \"$GIT_WRAPPER_BIN\"") expect(hooks).toContain("check_issue_managed_block_range") expect(hooks).toContain("Run plan sync and session backup after successful push") + expect(hooks).toContain("docker_git_ensure_open_pr") + expect(hooks).toContain("docker_git_github_repo_from_remote_url") + expect(hooks).toContain("gh pr list --repo \"$base_repo\" --state open --head \"$head_arg\"") + expect(hooks).toContain("gh pr create --repo \"$base_repo\" --base \"$base_branch\" --head \"$head_arg\" --fill") + expect(hooks).toContain("[post-push-pr] Error: cannot create PR from detached HEAD") + expect(hooks).toContain("[post-push-pr] Error: failed to list open PRs") expect(hooks).toContain("DOCKER_GIT_SKIP_PLAN_TO_GIT") + expect(hooks).toContain("plan-to-git import-codex --no-sync") expect(hooks).toContain("plan-to-git sync") expect(hooks).toContain("plan-to-git hook --source codex") expect(hooks).toContain("[features]") @@ -507,6 +520,18 @@ describe("renderEntrypointGitHooks", () => { expect(hooks).not.toContain("node \"$BACKUP_SCRIPT\"") expect(hooks).not.toContain("session-backup-gist.js") expect(hooks).toContain("[session-backup] Error: gh CLI not found") + + const cdIndex = hooks.indexOf('cd "$REPO_ROOT"') + const ensurePrIndex = hooks.indexOf("docker_git_ensure_open_pr\n\n# CHANGE: backfill Codex session plans") + const planImportIndex = hooks.indexOf("plan-to-git import-codex --no-sync") + const planSyncIndex = hooks.indexOf("plan-to-git sync") + const sessionBackupIndex = hooks.indexOf("docker-git-session-sync backup --verbose --background --require-comment") + + expect(cdIndex).toBeGreaterThanOrEqual(0) + expect(ensurePrIndex).toBeGreaterThan(cdIndex) + expect(planImportIndex).toBeGreaterThan(ensurePrIndex) + expect(planSyncIndex).toBeGreaterThan(planImportIndex) + expect(sessionBackupIndex).toBeGreaterThan(planSyncIndex) }) })