Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 7 additions & 3 deletions packages/app/src/lib/core/templates-entrypoint/git-hooks.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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

Expand Down
133 changes: 133 additions & 0 deletions packages/app/src/lib/core/templates-entrypoint/post-push-pr.ts
Original file line number Diff line number Diff line change
@@ -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 */
22 changes: 22 additions & 0 deletions packages/app/tests/docker-git/core-templates.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
})
)
})
Expand Down
10 changes: 7 additions & 3 deletions packages/lib/src/core/templates-entrypoint/git-hooks.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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

Expand Down
129 changes: 129 additions & 0 deletions packages/lib/src/core/templates-entrypoint/post-push-pr.ts
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading