Skip to content

Commit 8a62c12

Browse files
committed
fix(shell): ensure PR exists after git push
1 parent b4426a3 commit 8a62c12

5 files changed

Lines changed: 463 additions & 3 deletions

File tree

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

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,133 @@ if [[ -z "$REPO_ROOT" || ! -d "$REPO_ROOT" ]]; then
145145
fi
146146
cd "$REPO_ROOT"
147147
148+
# CHANGE: ensure an open GitHub PR exists for the pushed branch before PR-bound post-push tools run.
149+
# 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.
150+
# REF: issue-375
151+
docker_git_github_repo_from_remote_url() {
152+
local remote_url="$1"
153+
local repo_path=""
154+
local owner=""
155+
local repo=""
156+
157+
case "$remote_url" in
158+
https://github.com/*)
159+
repo_path="${"${"}remote_url#https://github.com/}"
160+
;;
161+
http://github.com/*)
162+
repo_path="${"${"}remote_url#http://github.com/}"
163+
;;
164+
https://*@github.com/*)
165+
repo_path="${"${"}remote_url#https://*@github.com/}"
166+
;;
167+
http://*@github.com/*)
168+
repo_path="${"${"}remote_url#http://*@github.com/}"
169+
;;
170+
ssh://git@github.com/*)
171+
repo_path="${"${"}remote_url#ssh://git@github.com/}"
172+
;;
173+
git@github.com:*)
174+
repo_path="${"${"}remote_url#git@github.com:}"
175+
;;
176+
*)
177+
return 1
178+
;;
179+
esac
180+
181+
repo_path="${"${"}repo_path%%\?*}"
182+
repo_path="${"${"}repo_path%%#*}"
183+
repo_path="${"${"}repo_path%/}"
184+
repo_path="${"${"}repo_path%.git}"
185+
owner="${"${"}repo_path%%/*}"
186+
repo="${"${"}repo_path#*/}"
187+
repo="${"${"}repo%%/*}"
188+
repo="${"${"}repo%.git}"
189+
190+
if [[ -z "$owner" || -z "$repo" || "$owner" == "$repo_path" ]]; then
191+
return 1
192+
fi
193+
194+
printf "%s/%s\n" "$owner" "$repo"
195+
}
196+
197+
docker_git_github_repo_from_remote() {
198+
local remote="$1"
199+
local remote_url=""
200+
201+
remote_url="$(git remote get-url "$remote" 2>/dev/null || true)"
202+
if [[ -z "$remote_url" ]]; then
203+
return 1
204+
fi
205+
206+
docker_git_github_repo_from_remote_url "$remote_url"
207+
}
208+
209+
docker_git_ensure_open_pr() {
210+
local branch=""
211+
local base_repo=""
212+
local head_repo=""
213+
local head_owner=""
214+
local head_arg=""
215+
local base_branch=""
216+
local pr_url=""
217+
218+
if ! command -v gh >/dev/null 2>&1; then
219+
echo "[post-push-pr] Error: gh CLI not found" >&2
220+
return 1
221+
fi
222+
223+
branch="$(git rev-parse --abbrev-ref HEAD 2>/dev/null || true)"
224+
if [[ -z "$branch" || "$branch" == "HEAD" ]]; then
225+
echo "[post-push-pr] Error: cannot create PR from detached HEAD" >&2
226+
return 1
227+
fi
228+
229+
if ! base_repo="$(docker_git_github_repo_from_remote upstream)"; then
230+
if ! base_repo="$(docker_git_github_repo_from_remote origin)"; then
231+
echo "[post-push-pr] Skipped: no GitHub remote found"
232+
return 0
233+
fi
234+
fi
235+
236+
if ! head_repo="$(docker_git_github_repo_from_remote origin)"; then
237+
head_repo="$base_repo"
238+
fi
239+
240+
base_branch="$(gh repo view "$base_repo" --json defaultBranchRef --jq '.defaultBranchRef.name' 2>/dev/null || true)"
241+
if [[ -z "$base_branch" ]]; then
242+
echo "[post-push-pr] Error: failed to resolve default branch for $base_repo" >&2
243+
return 1
244+
fi
245+
246+
if [[ "$head_repo" == "$base_repo" ]]; then
247+
head_arg="$branch"
248+
else
249+
head_owner="${"${"}head_repo%%/*}"
250+
head_arg="${"${"}head_owner}:${"${"}branch}"
251+
fi
252+
253+
if ! pr_url="$(gh pr list --repo "$base_repo" --state open --head "$head_arg" --json url --jq '.[0].url // ""' 2>/dev/null)"; then
254+
echo "[post-push-pr] Error: failed to list open PRs for $head_arg in $base_repo" >&2
255+
return 1
256+
fi
257+
if [[ -z "$pr_url" && "$head_arg" != "$branch" ]]; then
258+
if ! pr_url="$(gh pr list --repo "$base_repo" --state open --head "$branch" --json url --jq '.[0].url // ""' 2>/dev/null)"; then
259+
echo "[post-push-pr] Error: failed to list open PRs for $branch in $base_repo" >&2
260+
return 1
261+
fi
262+
fi
263+
264+
if [[ -n "$pr_url" ]]; then
265+
echo "[post-push-pr] Open PR: $pr_url"
266+
return 0
267+
fi
268+
269+
echo "[post-push-pr] Creating PR for $head_arg into $base_repo:$base_branch"
270+
gh pr create --repo "$base_repo" --base "$base_branch" --head "$head_arg" --fill
271+
}
272+
273+
docker_git_ensure_open_pr
274+
148275
# CHANGE: sync captured Codex plans to the current branch PR after push.
149276
# WHY: issue #369 requires the agent plan to be uploaded to PR discussion.
150277
# REF: issue-369

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

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,12 +110,31 @@ 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")
114119
expect(entrypoint.contents).toContain("CODEX_REQUIREMENTS_FILE=\"/etc/codex/requirements.toml\"")
115120
expect(entrypoint.contents).toContain("managed_dir = \"/opt/docker-git/hooks\"")
116121
expect(entrypoint.contents).toContain("[[hooks.UserPromptSubmit]]")
117122
expect(entrypoint.contents).toContain("[[hooks.Stop]]")
118123
expect(entrypoint.contents).toContain("command = \"/opt/docker-git/hooks/plan-to-git-codex-hook\"")
124+
125+
const cdIndex = entrypoint.contents.indexOf("cd \"$REPO_ROOT\"")
126+
const ensurePrIndex = entrypoint.contents.indexOf(
127+
"docker_git_ensure_open_pr\n\n# CHANGE: sync captured Codex plans"
128+
)
129+
const planSyncIndex = entrypoint.contents.indexOf("plan-to-git sync")
130+
const sessionBackupIndex = entrypoint.contents.indexOf(
131+
"docker-git-session-sync backup --verbose --background --require-comment"
132+
)
133+
134+
expect(cdIndex).toBeGreaterThanOrEqual(0)
135+
expect(ensurePrIndex).toBeGreaterThan(cdIndex)
136+
expect(planSyncIndex).toBeGreaterThan(ensurePrIndex)
137+
expect(sessionBackupIndex).toBeGreaterThan(planSyncIndex)
119138
})
120139
)
121140
})

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

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,133 @@ if [[ -z "$REPO_ROOT" || ! -d "$REPO_ROOT" ]]; then
145145
fi
146146
cd "$REPO_ROOT"
147147
148+
# CHANGE: ensure an open GitHub PR exists for the pushed branch before PR-bound post-push tools run.
149+
# 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.
150+
# REF: issue-375
151+
docker_git_github_repo_from_remote_url() {
152+
local remote_url="$1"
153+
local repo_path=""
154+
local owner=""
155+
local repo=""
156+
157+
case "$remote_url" in
158+
https://github.com/*)
159+
repo_path="${"${"}remote_url#https://github.com/}"
160+
;;
161+
http://github.com/*)
162+
repo_path="${"${"}remote_url#http://github.com/}"
163+
;;
164+
https://*@github.com/*)
165+
repo_path="${"${"}remote_url#https://*@github.com/}"
166+
;;
167+
http://*@github.com/*)
168+
repo_path="${"${"}remote_url#http://*@github.com/}"
169+
;;
170+
ssh://git@github.com/*)
171+
repo_path="${"${"}remote_url#ssh://git@github.com/}"
172+
;;
173+
git@github.com:*)
174+
repo_path="${"${"}remote_url#git@github.com:}"
175+
;;
176+
*)
177+
return 1
178+
;;
179+
esac
180+
181+
repo_path="${"${"}repo_path%%\?*}"
182+
repo_path="${"${"}repo_path%%#*}"
183+
repo_path="${"${"}repo_path%/}"
184+
repo_path="${"${"}repo_path%.git}"
185+
owner="${"${"}repo_path%%/*}"
186+
repo="${"${"}repo_path#*/}"
187+
repo="${"${"}repo%%/*}"
188+
repo="${"${"}repo%.git}"
189+
190+
if [[ -z "$owner" || -z "$repo" || "$owner" == "$repo_path" ]]; then
191+
return 1
192+
fi
193+
194+
printf "%s/%s\n" "$owner" "$repo"
195+
}
196+
197+
docker_git_github_repo_from_remote() {
198+
local remote="$1"
199+
local remote_url=""
200+
201+
remote_url="$(git remote get-url "$remote" 2>/dev/null || true)"
202+
if [[ -z "$remote_url" ]]; then
203+
return 1
204+
fi
205+
206+
docker_git_github_repo_from_remote_url "$remote_url"
207+
}
208+
209+
docker_git_ensure_open_pr() {
210+
local branch=""
211+
local base_repo=""
212+
local head_repo=""
213+
local head_owner=""
214+
local head_arg=""
215+
local base_branch=""
216+
local pr_url=""
217+
218+
if ! command -v gh >/dev/null 2>&1; then
219+
echo "[post-push-pr] Error: gh CLI not found" >&2
220+
return 1
221+
fi
222+
223+
branch="$(git rev-parse --abbrev-ref HEAD 2>/dev/null || true)"
224+
if [[ -z "$branch" || "$branch" == "HEAD" ]]; then
225+
echo "[post-push-pr] Error: cannot create PR from detached HEAD" >&2
226+
return 1
227+
fi
228+
229+
if ! base_repo="$(docker_git_github_repo_from_remote upstream)"; then
230+
if ! base_repo="$(docker_git_github_repo_from_remote origin)"; then
231+
echo "[post-push-pr] Skipped: no GitHub remote found"
232+
return 0
233+
fi
234+
fi
235+
236+
if ! head_repo="$(docker_git_github_repo_from_remote origin)"; then
237+
head_repo="$base_repo"
238+
fi
239+
240+
base_branch="$(gh repo view "$base_repo" --json defaultBranchRef --jq '.defaultBranchRef.name' 2>/dev/null || true)"
241+
if [[ -z "$base_branch" ]]; then
242+
echo "[post-push-pr] Error: failed to resolve default branch for $base_repo" >&2
243+
return 1
244+
fi
245+
246+
if [[ "$head_repo" == "$base_repo" ]]; then
247+
head_arg="$branch"
248+
else
249+
head_owner="${"${"}head_repo%%/*}"
250+
head_arg="${"${"}head_owner}:${"${"}branch}"
251+
fi
252+
253+
if ! pr_url="$(gh pr list --repo "$base_repo" --state open --head "$head_arg" --json url --jq '.[0].url // ""' 2>/dev/null)"; then
254+
echo "[post-push-pr] Error: failed to list open PRs for $head_arg in $base_repo" >&2
255+
return 1
256+
fi
257+
if [[ -z "$pr_url" && "$head_arg" != "$branch" ]]; then
258+
if ! pr_url="$(gh pr list --repo "$base_repo" --state open --head "$branch" --json url --jq '.[0].url // ""' 2>/dev/null)"; then
259+
echo "[post-push-pr] Error: failed to list open PRs for $branch in $base_repo" >&2
260+
return 1
261+
fi
262+
fi
263+
264+
if [[ -n "$pr_url" ]]; then
265+
echo "[post-push-pr] Open PR: $pr_url"
266+
return 0
267+
fi
268+
269+
echo "[post-push-pr] Creating PR for $head_arg into $base_repo:$base_branch"
270+
gh pr create --repo "$base_repo" --base "$base_branch" --head "$head_arg" --fill
271+
}
272+
273+
docker_git_ensure_open_pr
274+
148275
# CHANGE: sync captured Codex plans to the current branch PR after push.
149276
# WHY: issue #369 requires the agent plan to be uploaded to PR discussion.
150277
# REF: issue-369

0 commit comments

Comments
 (0)