Skip to content

Commit f52a87f

Browse files
authored
fix(shell): ensure PR exists after git push (#378)
* fix(shell): ensure PR exists after git push * refactor(shell): extract post-push PR template * fix(shell): backfill codex plans before sync
1 parent b4426a3 commit f52a87f

7 files changed

Lines changed: 515 additions & 15 deletions

File tree

packages/app/src/lib/core/templates-entrypoint/git-hooks.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { renderEntrypointGitPostPushWrapperInstall } from "./git-post-push-wrapper.js"
2+
import { renderPostPushPrEnsure } from "./post-push-pr.js"
23

34
const entrypointGitHooksTemplate = String
45
.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
145146
fi
146147
cd "$REPO_ROOT"
147148
148-
# CHANGE: sync captured Codex plans to the current branch PR after push.
149-
# WHY: issue #369 requires the agent plan to be uploaded to PR discussion.
150-
# REF: issue-369
149+
${renderPostPushPrEnsure()}
150+
151+
# CHANGE: backfill Codex session plans before syncing the current branch PR.
152+
# WHY: live Codex hooks can be unavailable in already-running sessions; session logs are the durable fallback.
153+
# REF: issue-375
151154
if [ "${"${"}DOCKER_GIT_SKIP_PLAN_TO_GIT:-}" != "1" ]; then
152155
if ! command -v plan-to-git >/dev/null 2>&1; then
153156
echo "[plan-to-git] Error: plan-to-git not found" >&2
154157
exit 1
155158
fi
159+
plan-to-git import-codex --no-sync
156160
plan-to-git sync
157161
fi
158162
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
/* jscpd:ignore-start */
2+
// Mirror of packages/lib/src/core/templates-entrypoint/post-push-pr.ts.
3+
// The lib template test asserts rendered output equality to prevent drift.
4+
const postPushPrEnsureTemplate = String
5+
.raw`# CHANGE: ensure an open GitHub PR exists for the pushed branch before PR-bound post-push tools run.
6+
# 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.
7+
# REF: issue-375
8+
docker_git_github_repo_from_remote_url() {
9+
local remote_url="$1"
10+
local repo_path=""
11+
local owner=""
12+
local repo=""
13+
14+
case "$remote_url" in
15+
https://github.com/*)
16+
repo_path="${"${"}remote_url#https://github.com/}"
17+
;;
18+
http://github.com/*)
19+
repo_path="${"${"}remote_url#http://github.com/}"
20+
;;
21+
https://*@github.com/*)
22+
repo_path="${"${"}remote_url#https://*@github.com/}"
23+
;;
24+
http://*@github.com/*)
25+
repo_path="${"${"}remote_url#http://*@github.com/}"
26+
;;
27+
ssh://git@github.com/*)
28+
repo_path="${"${"}remote_url#ssh://git@github.com/}"
29+
;;
30+
git@github.com:*)
31+
repo_path="${"${"}remote_url#git@github.com:}"
32+
;;
33+
*)
34+
return 1
35+
;;
36+
esac
37+
38+
repo_path="${"${"}repo_path%%\?*}"
39+
repo_path="${"${"}repo_path%%#*}"
40+
repo_path="${"${"}repo_path%/}"
41+
repo_path="${"${"}repo_path%.git}"
42+
owner="${"${"}repo_path%%/*}"
43+
repo="${"${"}repo_path#*/}"
44+
repo="${"${"}repo%%/*}"
45+
repo="${"${"}repo%.git}"
46+
47+
if [[ -z "$owner" || -z "$repo" || "$owner" == "$repo_path" ]]; then
48+
return 1
49+
fi
50+
51+
printf "%s/%s\n" "$owner" "$repo"
52+
}
53+
54+
docker_git_github_repo_from_remote() {
55+
local remote="$1"
56+
local remote_url=""
57+
58+
remote_url="$(git remote get-url "$remote" 2>/dev/null || true)"
59+
if [[ -z "$remote_url" ]]; then
60+
return 1
61+
fi
62+
63+
docker_git_github_repo_from_remote_url "$remote_url"
64+
}
65+
66+
docker_git_ensure_open_pr() {
67+
local branch=""
68+
local base_repo=""
69+
local head_repo=""
70+
local head_owner=""
71+
local head_arg=""
72+
local base_branch=""
73+
local pr_url=""
74+
75+
if ! command -v gh >/dev/null 2>&1; then
76+
echo "[post-push-pr] Error: gh CLI not found" >&2
77+
return 1
78+
fi
79+
80+
branch="$(git rev-parse --abbrev-ref HEAD 2>/dev/null || true)"
81+
if [[ -z "$branch" || "$branch" == "HEAD" ]]; then
82+
echo "[post-push-pr] Error: cannot create PR from detached HEAD" >&2
83+
return 1
84+
fi
85+
86+
if ! base_repo="$(docker_git_github_repo_from_remote upstream)"; then
87+
if ! base_repo="$(docker_git_github_repo_from_remote origin)"; then
88+
echo "[post-push-pr] Skipped: no GitHub remote found"
89+
return 0
90+
fi
91+
fi
92+
93+
if ! head_repo="$(docker_git_github_repo_from_remote origin)"; then
94+
head_repo="$base_repo"
95+
fi
96+
97+
base_branch="$(gh repo view "$base_repo" --json defaultBranchRef --jq '.defaultBranchRef.name' 2>/dev/null || true)"
98+
if [[ -z "$base_branch" ]]; then
99+
echo "[post-push-pr] Error: failed to resolve default branch for $base_repo" >&2
100+
return 1
101+
fi
102+
103+
if [[ "$head_repo" == "$base_repo" ]]; then
104+
head_arg="$branch"
105+
else
106+
head_owner="${"${"}head_repo%%/*}"
107+
head_arg="${"${"}head_owner}:${"${"}branch}"
108+
fi
109+
110+
if ! pr_url="$(gh pr list --repo "$base_repo" --state open --head "$head_arg" --json url --jq '.[0].url // ""' 2>/dev/null)"; then
111+
echo "[post-push-pr] Error: failed to list open PRs for $head_arg in $base_repo" >&2
112+
return 1
113+
fi
114+
if [[ -z "$pr_url" && "$head_arg" != "$branch" ]]; then
115+
if ! pr_url="$(gh pr list --repo "$base_repo" --state open --head "$branch" --json url --jq '.[0].url // ""' 2>/dev/null)"; then
116+
echo "[post-push-pr] Error: failed to list open PRs for $branch in $base_repo" >&2
117+
return 1
118+
fi
119+
fi
120+
121+
if [[ -n "$pr_url" ]]; then
122+
echo "[post-push-pr] Open PR: $pr_url"
123+
return 0
124+
fi
125+
126+
echo "[post-push-pr] Creating PR for $head_arg into $base_repo:$base_branch"
127+
gh pr create --repo "$base_repo" --base "$base_branch" --head "$head_arg" --fill
128+
}
129+
130+
docker_git_ensure_open_pr`
131+
132+
export const renderPostPushPrEnsure = (): string => postPushPrEnsureTemplate
133+
/* jscpd:ignore-end */

packages/app/tests/docker-git/core-templates.test.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,12 +110,34 @@ describe("app planFiles", () => {
110110
"args = [\"--project\", \"$DOCKER_GIT_BROWSER_PROJECT\", \"--network\", \"$DOCKER_GIT_BROWSER_NETWORK\"]"
111111
)
112112
expect(entrypoint.contents).toContain("plan-to-git sync")
113+
expect(entrypoint.contents).toContain("docker_git_ensure_open_pr")
114+
expect(entrypoint.contents).toContain("gh pr list --repo \"$base_repo\" --state open --head \"$head_arg\"")
115+
expect(entrypoint.contents).toContain(
116+
"gh pr create --repo \"$base_repo\" --base \"$base_branch\" --head \"$head_arg\" --fill"
117+
)
113118
expect(entrypoint.contents).toContain("plan-to-git hook --source codex")
119+
expect(entrypoint.contents).toContain("plan-to-git import-codex --no-sync")
114120
expect(entrypoint.contents).toContain("CODEX_REQUIREMENTS_FILE=\"/etc/codex/requirements.toml\"")
115121
expect(entrypoint.contents).toContain("managed_dir = \"/opt/docker-git/hooks\"")
116122
expect(entrypoint.contents).toContain("[[hooks.UserPromptSubmit]]")
117123
expect(entrypoint.contents).toContain("[[hooks.Stop]]")
118124
expect(entrypoint.contents).toContain("command = \"/opt/docker-git/hooks/plan-to-git-codex-hook\"")
125+
126+
const cdIndex = entrypoint.contents.indexOf("cd \"$REPO_ROOT\"")
127+
const ensurePrIndex = entrypoint.contents.indexOf(
128+
"docker_git_ensure_open_pr\n\n# CHANGE: backfill Codex session plans"
129+
)
130+
const planImportIndex = entrypoint.contents.indexOf("plan-to-git import-codex --no-sync")
131+
const planSyncIndex = entrypoint.contents.indexOf("plan-to-git sync")
132+
const sessionBackupIndex = entrypoint.contents.indexOf(
133+
"docker-git-session-sync backup --verbose --background --require-comment"
134+
)
135+
136+
expect(cdIndex).toBeGreaterThanOrEqual(0)
137+
expect(ensurePrIndex).toBeGreaterThan(cdIndex)
138+
expect(planImportIndex).toBeGreaterThan(ensurePrIndex)
139+
expect(planSyncIndex).toBeGreaterThan(planImportIndex)
140+
expect(sessionBackupIndex).toBeGreaterThan(planSyncIndex)
119141
})
120142
)
121143
})

packages/lib/src/core/templates-entrypoint/git-hooks.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { renderEntrypointGitPostPushWrapperInstall } from "./git-post-push-wrapper.js"
2+
import { renderPostPushPrEnsure } from "./post-push-pr.js"
23

34
const entrypointGitHooksTemplate = String
45
.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
145146
fi
146147
cd "$REPO_ROOT"
147148
148-
# CHANGE: sync captured Codex plans to the current branch PR after push.
149-
# WHY: issue #369 requires the agent plan to be uploaded to PR discussion.
150-
# REF: issue-369
149+
${renderPostPushPrEnsure()}
150+
151+
# CHANGE: backfill Codex session plans before syncing the current branch PR.
152+
# WHY: live Codex hooks can be unavailable in already-running sessions; session logs are the durable fallback.
153+
# REF: issue-375
151154
if [ "${"${"}DOCKER_GIT_SKIP_PLAN_TO_GIT:-}" != "1" ]; then
152155
if ! command -v plan-to-git >/dev/null 2>&1; then
153156
echo "[plan-to-git] Error: plan-to-git not found" >&2
154157
exit 1
155158
fi
159+
plan-to-git import-codex --no-sync
156160
plan-to-git sync
157161
fi
158162
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
const postPushPrEnsureTemplate = String
2+
.raw`# CHANGE: ensure an open GitHub PR exists for the pushed branch before PR-bound post-push tools run.
3+
# 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.
4+
# REF: issue-375
5+
docker_git_github_repo_from_remote_url() {
6+
local remote_url="$1"
7+
local repo_path=""
8+
local owner=""
9+
local repo=""
10+
11+
case "$remote_url" in
12+
https://github.com/*)
13+
repo_path="${"${"}remote_url#https://github.com/}"
14+
;;
15+
http://github.com/*)
16+
repo_path="${"${"}remote_url#http://github.com/}"
17+
;;
18+
https://*@github.com/*)
19+
repo_path="${"${"}remote_url#https://*@github.com/}"
20+
;;
21+
http://*@github.com/*)
22+
repo_path="${"${"}remote_url#http://*@github.com/}"
23+
;;
24+
ssh://git@github.com/*)
25+
repo_path="${"${"}remote_url#ssh://git@github.com/}"
26+
;;
27+
git@github.com:*)
28+
repo_path="${"${"}remote_url#git@github.com:}"
29+
;;
30+
*)
31+
return 1
32+
;;
33+
esac
34+
35+
repo_path="${"${"}repo_path%%\?*}"
36+
repo_path="${"${"}repo_path%%#*}"
37+
repo_path="${"${"}repo_path%/}"
38+
repo_path="${"${"}repo_path%.git}"
39+
owner="${"${"}repo_path%%/*}"
40+
repo="${"${"}repo_path#*/}"
41+
repo="${"${"}repo%%/*}"
42+
repo="${"${"}repo%.git}"
43+
44+
if [[ -z "$owner" || -z "$repo" || "$owner" == "$repo_path" ]]; then
45+
return 1
46+
fi
47+
48+
printf "%s/%s\n" "$owner" "$repo"
49+
}
50+
51+
docker_git_github_repo_from_remote() {
52+
local remote="$1"
53+
local remote_url=""
54+
55+
remote_url="$(git remote get-url "$remote" 2>/dev/null || true)"
56+
if [[ -z "$remote_url" ]]; then
57+
return 1
58+
fi
59+
60+
docker_git_github_repo_from_remote_url "$remote_url"
61+
}
62+
63+
docker_git_ensure_open_pr() {
64+
local branch=""
65+
local base_repo=""
66+
local head_repo=""
67+
local head_owner=""
68+
local head_arg=""
69+
local base_branch=""
70+
local pr_url=""
71+
72+
if ! command -v gh >/dev/null 2>&1; then
73+
echo "[post-push-pr] Error: gh CLI not found" >&2
74+
return 1
75+
fi
76+
77+
branch="$(git rev-parse --abbrev-ref HEAD 2>/dev/null || true)"
78+
if [[ -z "$branch" || "$branch" == "HEAD" ]]; then
79+
echo "[post-push-pr] Error: cannot create PR from detached HEAD" >&2
80+
return 1
81+
fi
82+
83+
if ! base_repo="$(docker_git_github_repo_from_remote upstream)"; then
84+
if ! base_repo="$(docker_git_github_repo_from_remote origin)"; then
85+
echo "[post-push-pr] Skipped: no GitHub remote found"
86+
return 0
87+
fi
88+
fi
89+
90+
if ! head_repo="$(docker_git_github_repo_from_remote origin)"; then
91+
head_repo="$base_repo"
92+
fi
93+
94+
base_branch="$(gh repo view "$base_repo" --json defaultBranchRef --jq '.defaultBranchRef.name' 2>/dev/null || true)"
95+
if [[ -z "$base_branch" ]]; then
96+
echo "[post-push-pr] Error: failed to resolve default branch for $base_repo" >&2
97+
return 1
98+
fi
99+
100+
if [[ "$head_repo" == "$base_repo" ]]; then
101+
head_arg="$branch"
102+
else
103+
head_owner="${"${"}head_repo%%/*}"
104+
head_arg="${"${"}head_owner}:${"${"}branch}"
105+
fi
106+
107+
if ! pr_url="$(gh pr list --repo "$base_repo" --state open --head "$head_arg" --json url --jq '.[0].url // ""' 2>/dev/null)"; then
108+
echo "[post-push-pr] Error: failed to list open PRs for $head_arg in $base_repo" >&2
109+
return 1
110+
fi
111+
if [[ -z "$pr_url" && "$head_arg" != "$branch" ]]; then
112+
if ! pr_url="$(gh pr list --repo "$base_repo" --state open --head "$branch" --json url --jq '.[0].url // ""' 2>/dev/null)"; then
113+
echo "[post-push-pr] Error: failed to list open PRs for $branch in $base_repo" >&2
114+
return 1
115+
fi
116+
fi
117+
118+
if [[ -n "$pr_url" ]]; then
119+
echo "[post-push-pr] Open PR: $pr_url"
120+
return 0
121+
fi
122+
123+
echo "[post-push-pr] Creating PR for $head_arg into $base_repo:$base_branch"
124+
gh pr create --repo "$base_repo" --base "$base_branch" --head "$head_arg" --fill
125+
}
126+
127+
docker_git_ensure_open_pr`
128+
129+
export const renderPostPushPrEnsure = (): string => postPushPrEnsureTemplate

0 commit comments

Comments
 (0)