From 8a62c12f1efa9bec423224cb42fd2d0f7b27f86d Mon Sep 17 00:00:00 2001 From: skulidropek <66840575+skulidropek@users.noreply.github.com> Date: Sat, 6 Jun 2026 10:01:48 +0000 Subject: [PATCH 1/3] fix(shell): ensure PR exists after git push --- .../core/templates-entrypoint/git-hooks.ts | 127 +++++++++++++ .../tests/docker-git/core-templates.test.ts | 19 ++ .../core/templates-entrypoint/git-hooks.ts | 127 +++++++++++++ .../tests/core/git-post-push-wrapper.test.ts | 177 +++++++++++++++++- packages/lib/tests/core/templates.test.ts | 16 ++ 5 files changed, 463 insertions(+), 3 deletions(-) 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..59bb5f0b 100644 --- a/packages/app/src/lib/core/templates-entrypoint/git-hooks.ts +++ b/packages/app/src/lib/core/templates-entrypoint/git-hooks.ts @@ -145,6 +145,133 @@ if [[ -z "$REPO_ROOT" || ! -d "$REPO_ROOT" ]]; then fi cd "$REPO_ROOT" +# 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 + # 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 diff --git a/packages/app/tests/docker-git/core-templates.test.ts b/packages/app/tests/docker-git/core-templates.test.ts index 9d9ab295..87911034 100644 --- a/packages/app/tests/docker-git/core-templates.test.ts +++ b/packages/app/tests/docker-git/core-templates.test.ts @@ -110,12 +110,31 @@ 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("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: sync captured Codex plans" + ) + 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(planSyncIndex).toBeGreaterThan(ensurePrIndex) + 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..59bb5f0b 100644 --- a/packages/lib/src/core/templates-entrypoint/git-hooks.ts +++ b/packages/lib/src/core/templates-entrypoint/git-hooks.ts @@ -145,6 +145,133 @@ if [[ -z "$REPO_ROOT" || ! -d "$REPO_ROOT" ]]; then fi cd "$REPO_ROOT" +# 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 + # 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 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..c57b7c86 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 ` @@ -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,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([harness.repoDir]) expect(nodeRepoRoot).toEqual([harness.repoDir]) expect(nodeScript).toEqual(["backup --verbose --background --require-comment"]) expect(planToGit).toEqual([`${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 +398,13 @@ 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(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 +419,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 +448,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 +469,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 sync failures after ensuring a PR and before session backup", () => withHarness((harness) => Effect.gen(function*(_) { yield* _( @@ -422,9 +489,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(gh).toContain(`${harness.repoDir}\tpr create --repo org/repo --base main --head issue-375 --fill`) }) ).pipe(Effect.provide(NodeContext.layer))) @@ -445,4 +514,106 @@ describe("git post-push wrapper", () => { expect(planToGit).toEqual([`${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}\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..6ad8a755 100644 --- a/packages/lib/tests/core/templates.test.ts +++ b/packages/lib/tests/core/templates.test.ts @@ -480,6 +480,12 @@ 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 sync") expect(hooks).toContain("plan-to-git hook --source codex") @@ -507,6 +513,16 @@ 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: sync captured Codex plans") + 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(planSyncIndex).toBeGreaterThan(ensurePrIndex) + expect(sessionBackupIndex).toBeGreaterThan(planSyncIndex) }) }) From ed459e5fa4a6063240329bbb9db54cbdb59954bd Mon Sep 17 00:00:00 2001 From: skulidropek <66840575+skulidropek@users.noreply.github.com> Date: Sat, 6 Jun 2026 10:28:48 +0000 Subject: [PATCH 2/3] refactor(shell): extract post-push PR template --- .../core/templates-entrypoint/git-hooks.ts | 128 +---------------- .../core/templates-entrypoint/post-push-pr.ts | 133 ++++++++++++++++++ .../core/templates-entrypoint/git-hooks.ts | 128 +---------------- .../core/templates-entrypoint/post-push-pr.ts | 129 +++++++++++++++++ packages/lib/tests/core/templates.test.ts | 6 + 5 files changed, 272 insertions(+), 252 deletions(-) create mode 100644 packages/app/src/lib/core/templates-entrypoint/post-push-pr.ts create mode 100644 packages/lib/src/core/templates-entrypoint/post-push-pr.ts 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 59bb5f0b..b000f492 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,132 +146,7 @@ if [[ -z "$REPO_ROOT" || ! -d "$REPO_ROOT" ]]; then fi cd "$REPO_ROOT" -# 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 +${renderPostPushPrEnsure()} # 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. 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/lib/src/core/templates-entrypoint/git-hooks.ts b/packages/lib/src/core/templates-entrypoint/git-hooks.ts index 59bb5f0b..b000f492 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,132 +146,7 @@ if [[ -z "$REPO_ROOT" || ! -d "$REPO_ROOT" ]]; then fi cd "$REPO_ROOT" -# 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 +${renderPostPushPrEnsure()} # 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. 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/templates.test.ts b/packages/lib/tests/core/templates.test.ts index 6ad8a755..ff77756c 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() From cc36eda409b597606e7b4c25c99919d8cb482e67 Mon Sep 17 00:00:00 2001 From: skulidropek <66840575+skulidropek@users.noreply.github.com> Date: Sat, 6 Jun 2026 10:57:48 +0000 Subject: [PATCH 3/3] fix(shell): backfill codex plans before sync --- .../core/templates-entrypoint/git-hooks.ts | 7 +++-- .../tests/docker-git/core-templates.test.ts | 7 +++-- .../core/templates-entrypoint/git-hooks.ts | 7 +++-- .../tests/core/git-post-push-wrapper.test.ts | 28 +++++++++++++------ packages/lib/tests/core/templates.test.ts | 7 +++-- 5 files changed, 38 insertions(+), 18 deletions(-) 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 b000f492..a862cd48 100644 --- a/packages/app/src/lib/core/templates-entrypoint/git-hooks.ts +++ b/packages/app/src/lib/core/templates-entrypoint/git-hooks.ts @@ -148,14 +148,15 @@ cd "$REPO_ROOT" ${renderPostPushPrEnsure()} -# 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 +# 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/tests/docker-git/core-templates.test.ts b/packages/app/tests/docker-git/core-templates.test.ts index 87911034..95fcf98c 100644 --- a/packages/app/tests/docker-git/core-templates.test.ts +++ b/packages/app/tests/docker-git/core-templates.test.ts @@ -116,6 +116,7 @@ describe("app planFiles", () => { "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]]") @@ -124,8 +125,9 @@ describe("app planFiles", () => { const cdIndex = entrypoint.contents.indexOf("cd \"$REPO_ROOT\"") const ensurePrIndex = entrypoint.contents.indexOf( - "docker_git_ensure_open_pr\n\n# CHANGE: sync captured Codex plans" + "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" @@ -133,7 +135,8 @@ describe("app planFiles", () => { expect(cdIndex).toBeGreaterThanOrEqual(0) expect(ensurePrIndex).toBeGreaterThan(cdIndex) - expect(planSyncIndex).toBeGreaterThan(ensurePrIndex) + 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 b000f492..a862cd48 100644 --- a/packages/lib/src/core/templates-entrypoint/git-hooks.ts +++ b/packages/lib/src/core/templates-entrypoint/git-hooks.ts @@ -148,14 +148,15 @@ cd "$REPO_ROOT" ${renderPostPushPrEnsure()} -# 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 +# 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/tests/core/git-post-push-wrapper.test.ts b/packages/lib/tests/core/git-post-push-wrapper.test.ts index c57b7c86..f3dcba41 100644 --- a/packages/lib/tests/core/git-post-push-wrapper.test.ts +++ b/packages/lib/tests/core/git-post-push-wrapper.test.ts @@ -171,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 @@ -383,7 +383,10 @@ describe("git post-push wrapper", () => { 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))) @@ -403,7 +406,10 @@ describe("git post-push wrapper", () => { 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) }) @@ -477,7 +483,7 @@ describe("git post-push wrapper", () => { }) ).pipe(Effect.provide(NodeContext.layer))) - it.effect("propagates plan sync failures after ensuring a PR and before session backup", () => + it.effect("propagates plan import failures after ensuring a PR and before session backup", () => withHarness((harness) => Effect.gen(function*(_) { yield* _( @@ -492,7 +498,7 @@ describe("git post-push wrapper", () => { 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))) @@ -511,7 +517,10 @@ 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))) @@ -529,7 +538,10 @@ describe("git post-push wrapper", () => { const gh = yield* _(readLogLines(harness.ghLogPath)) 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 list --repo org/repo --state open --head issue-375 --json url --jq .[0].url // ""`) expect(gh.some((line) => line.includes("pr create"))).toBe(false) }) diff --git a/packages/lib/tests/core/templates.test.ts b/packages/lib/tests/core/templates.test.ts index ff77756c..ce980124 100644 --- a/packages/lib/tests/core/templates.test.ts +++ b/packages/lib/tests/core/templates.test.ts @@ -493,6 +493,7 @@ describe("renderEntrypointGitHooks", () => { 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]") @@ -521,13 +522,15 @@ describe("renderEntrypointGitHooks", () => { 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: sync captured Codex plans") + 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(planSyncIndex).toBeGreaterThan(ensurePrIndex) + expect(planImportIndex).toBeGreaterThan(ensurePrIndex) + expect(planSyncIndex).toBeGreaterThan(planImportIndex) expect(sessionBackupIndex).toBeGreaterThan(planSyncIndex) }) })