Make PR babysitter launcher fork-safe (#35575)
This commit is contained in:
@@ -16,7 +16,12 @@
|
|||||||
#
|
#
|
||||||
# Each babysitter monitors exactly lint.yml and pr-test.yml. The script creates
|
# Each babysitter monitors exactly lint.yml and pr-test.yml. The script creates
|
||||||
# or reuses a clean tracking worktree for every PR, starts the yolo2 Bash alias
|
# or reuses a clean tracking worktree for every PR, starts the yolo2 Bash alias
|
||||||
# in a PR-numbered tmux window, and leaves the session detached. Attach with:
|
# in a PR-numbered tmux window, and leaves the session detached. Here yolo2 is:
|
||||||
|
#
|
||||||
|
# with-proxy codex --dangerously-bypass-approvals-and-sandbox \
|
||||||
|
# --dangerously-enable-internet-mode
|
||||||
|
#
|
||||||
|
# Attach with:
|
||||||
#
|
#
|
||||||
# tmux attach -t sglang-prs
|
# tmux attach -t sglang-prs
|
||||||
|
|
||||||
@@ -33,6 +38,11 @@ PRS=("$@")
|
|||||||
|
|
||||||
declare -A WORKTREE_BY_PR=()
|
declare -A WORKTREE_BY_PR=()
|
||||||
declare -A WINDOW_BY_PR=()
|
declare -A WINDOW_BY_PR=()
|
||||||
|
declare -A LOCAL_BRANCH_BY_PR=()
|
||||||
|
declare -A HEAD_REF_BY_PR=()
|
||||||
|
declare -A HEAD_REPO_BY_PR=()
|
||||||
|
declare -A HEAD_SHA_BY_PR=()
|
||||||
|
declare -A REMOTE_BY_PR=()
|
||||||
|
|
||||||
usage() {
|
usage() {
|
||||||
sed -n '2,/^$/s/^# \{0,1\}//p' "${BASH_SOURCE[0]}"
|
sed -n '2,/^$/s/^# \{0,1\}//p' "${BASH_SOURCE[0]}"
|
||||||
@@ -96,15 +106,20 @@ ensure_remote() {
|
|||||||
local remote_name="$1"
|
local remote_name="$1"
|
||||||
local remote_repo="$2"
|
local remote_repo="$2"
|
||||||
local existing_url
|
local existing_url
|
||||||
|
local existing_push_url
|
||||||
|
|
||||||
if git -C "${REPO_ROOT}" config --get "remote.${remote_name}.url" >/dev/null 2>&1; then
|
if git -C "${REPO_ROOT}" config --get "remote.${remote_name}.url" >/dev/null 2>&1; then
|
||||||
existing_url="$(git -C "${REPO_ROOT}" config --get "remote.${remote_name}.url")"
|
existing_url="$(git -C "${REPO_ROOT}" remote get-url "${remote_name}")"
|
||||||
[[ "$(normalize_github_repo "${existing_url}")" == "${remote_repo}" ]] || \
|
[[ "$(normalize_github_repo "${existing_url}")" == "${remote_repo}" ]] || \
|
||||||
die "remote ${remote_name} points to ${existing_url}, expected ${remote_repo}"
|
die "remote ${remote_name} points to ${existing_url}, expected ${remote_repo}"
|
||||||
else
|
else
|
||||||
git -C "${REPO_ROOT}" remote add \
|
git -C "${REPO_ROOT}" remote add \
|
||||||
"${remote_name}" "https://github.com/${remote_repo}.git"
|
"${remote_name}" "https://github.com/${remote_repo}.git"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
existing_push_url="$(git -C "${REPO_ROOT}" remote get-url --push "${remote_name}")"
|
||||||
|
[[ "$(normalize_github_repo "${existing_push_url}")" == "${remote_repo}" ]] || \
|
||||||
|
die "remote ${remote_name} pushes to ${existing_push_url}, expected ${remote_repo}"
|
||||||
}
|
}
|
||||||
|
|
||||||
fetch_pr_head() {
|
fetch_pr_head() {
|
||||||
@@ -112,6 +127,7 @@ fetch_pr_head() {
|
|||||||
local pr_data
|
local pr_data
|
||||||
local state
|
local state
|
||||||
local is_draft
|
local is_draft
|
||||||
|
local maintainer_can_modify
|
||||||
local fetched_sha
|
local fetched_sha
|
||||||
local attempt
|
local attempt
|
||||||
|
|
||||||
@@ -119,12 +135,12 @@ fetch_pr_head() {
|
|||||||
pr_data="$(
|
pr_data="$(
|
||||||
gh pr view "${pr}" \
|
gh pr view "${pr}" \
|
||||||
--repo "${SOURCE_REPO}" \
|
--repo "${SOURCE_REPO}" \
|
||||||
--json state,isDraft,headRefOid,headRefName,headRepositoryOwner,headRepository \
|
--json state,isDraft,headRefOid,headRefName,headRepositoryOwner,headRepository,maintainerCanModify \
|
||||||
--jq '[.state, (.isDraft | tostring), .headRefOid, .headRefName, .headRepositoryOwner.login, .headRepository.name] | @tsv'
|
--jq '[.state, (.isDraft | tostring), .headRefOid, .headRefName, .headRepositoryOwner.login, .headRepository.name, (.maintainerCanModify | tostring)] | @tsv'
|
||||||
)"
|
)"
|
||||||
|
|
||||||
IFS=$'\t' read -r state is_draft PR_HEAD_SHA PR_HEAD_REF \
|
IFS=$'\t' read -r state is_draft PR_HEAD_SHA PR_HEAD_REF \
|
||||||
PR_HEAD_OWNER PR_HEAD_REPO_NAME <<<"${pr_data}"
|
PR_HEAD_OWNER PR_HEAD_REPO_NAME maintainer_can_modify <<<"${pr_data}"
|
||||||
[[ "${state}" == "OPEN" ]] || die "PR ${pr} is not open"
|
[[ "${state}" == "OPEN" ]] || die "PR ${pr} is not open"
|
||||||
[[ "${is_draft}" == "false" ]] || die "PR ${pr} is still a draft"
|
[[ "${is_draft}" == "false" ]] || die "PR ${pr} is still a draft"
|
||||||
[[ -n "${PR_HEAD_OWNER}" && -n "${PR_HEAD_REPO_NAME}" ]] || \
|
[[ -n "${PR_HEAD_OWNER}" && -n "${PR_HEAD_REPO_NAME}" ]] || \
|
||||||
@@ -134,18 +150,15 @@ fetch_pr_head() {
|
|||||||
if [[ "${PR_HEAD_REPO}" == "${SOURCE_REPO}" ]]; then
|
if [[ "${PR_HEAD_REPO}" == "${SOURCE_REPO}" ]]; then
|
||||||
PR_REMOTE="origin"
|
PR_REMOTE="origin"
|
||||||
else
|
else
|
||||||
|
[[ "${maintainer_can_modify}" == "true" ]] || \
|
||||||
|
die "PR ${pr} is from ${PR_HEAD_REPO}, but maintainer edits are disabled"
|
||||||
PR_REMOTE="pr-${pr}"
|
PR_REMOTE="pr-${pr}"
|
||||||
ensure_remote "${PR_REMOTE}" "${PR_HEAD_REPO}"
|
ensure_remote "${PR_REMOTE}" "${PR_HEAD_REPO}"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
PR_REMOTE_REF="refs/remotes/${PR_REMOTE}/${PR_HEAD_REF}"
|
PR_REMOTE_REF="refs/remotes/${PR_REMOTE}/${PR_HEAD_REF}"
|
||||||
if [[ "${PR_HEAD_REPO}" == "${SOURCE_REPO}" ]]; then
|
|
||||||
git -C "${REPO_ROOT}" fetch --no-tags "${PR_REMOTE}" \
|
git -C "${REPO_ROOT}" fetch --no-tags "${PR_REMOTE}" \
|
||||||
"+refs/heads/${PR_HEAD_REF}:${PR_REMOTE_REF}"
|
"+refs/heads/${PR_HEAD_REF}:${PR_REMOTE_REF}"
|
||||||
else
|
|
||||||
git -C "${REPO_ROOT}" fetch --no-tags origin \
|
|
||||||
"+refs/pull/${pr}/head:${PR_REMOTE_REF}"
|
|
||||||
fi
|
|
||||||
fetched_sha="$(git -C "${REPO_ROOT}" rev-parse "${PR_REMOTE_REF}")"
|
fetched_sha="$(git -C "${REPO_ROOT}" rev-parse "${PR_REMOTE_REF}")"
|
||||||
|
|
||||||
if [[ "${fetched_sha}" == "${PR_HEAD_SHA}" ]]; then
|
if [[ "${fetched_sha}" == "${PR_HEAD_SHA}" ]]; then
|
||||||
@@ -161,42 +174,43 @@ fetch_pr_head() {
|
|||||||
prepare_worktree() {
|
prepare_worktree() {
|
||||||
local pr="$1"
|
local pr="$1"
|
||||||
local worktree="${WORKTREE_ROOT}/pr-${pr}"
|
local worktree="${WORKTREE_ROOT}/pr-${pr}"
|
||||||
|
local local_branch="babysit/pr-${pr}"
|
||||||
local existing_worktree
|
local existing_worktree
|
||||||
local current_branch
|
local current_branch
|
||||||
local current_sha
|
local current_sha
|
||||||
local upstream
|
local upstream
|
||||||
|
|
||||||
fetch_pr_head "${pr}"
|
fetch_pr_head "${pr}"
|
||||||
existing_worktree="$(worktree_for_branch "${PR_HEAD_REF}")"
|
existing_worktree="$(worktree_for_branch "${local_branch}")"
|
||||||
|
|
||||||
if [[ -n "${existing_worktree}" && "${existing_worktree}" != "${worktree}" ]]; then
|
if [[ -n "${existing_worktree}" && "${existing_worktree}" != "${worktree}" ]]; then
|
||||||
die "branch ${PR_HEAD_REF} is already checked out at ${existing_worktree}"
|
die "branch ${local_branch} is already checked out at ${existing_worktree}"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [[ -n "${existing_worktree}" ]]; then
|
if [[ -n "${existing_worktree}" ]]; then
|
||||||
[[ -d "${worktree}" ]] || die "registered worktree is missing: ${worktree}"
|
[[ -d "${worktree}" ]] || die "registered worktree is missing: ${worktree}"
|
||||||
elif [[ -e "${worktree}" ]]; then
|
elif [[ -e "${worktree}" ]]; then
|
||||||
die "path exists but is not a registered worktree: ${worktree}"
|
die "path exists but is not a registered worktree: ${worktree}"
|
||||||
elif git -C "${REPO_ROOT}" show-ref --verify --quiet "refs/heads/${PR_HEAD_REF}"; then
|
elif git -C "${REPO_ROOT}" show-ref --verify --quiet "refs/heads/${local_branch}"; then
|
||||||
git -C "${REPO_ROOT}" worktree add "${worktree}" "${PR_HEAD_REF}"
|
git -C "${REPO_ROOT}" worktree add "${worktree}" "${local_branch}"
|
||||||
else
|
else
|
||||||
git -C "${REPO_ROOT}" worktree add --track -b "${PR_HEAD_REF}" \
|
git -C "${REPO_ROOT}" worktree add -b "${local_branch}" \
|
||||||
"${worktree}" "${PR_REMOTE}/${PR_HEAD_REF}"
|
"${worktree}" "${PR_REMOTE}/${PR_HEAD_REF}"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
current_branch="$(git -C "${worktree}" branch --show-current)"
|
current_branch="$(git -C "${worktree}" branch --show-current)"
|
||||||
[[ "${current_branch}" == "${PR_HEAD_REF}" ]] || \
|
[[ "${current_branch}" == "${local_branch}" ]] || \
|
||||||
die "${worktree} is on ${current_branch}, expected ${PR_HEAD_REF}"
|
die "${worktree} is on ${current_branch}, expected ${local_branch}"
|
||||||
[[ -z "$(git -C "${worktree}" status --porcelain)" ]] || \
|
[[ -z "$(git -C "${worktree}" status --porcelain)" ]] || \
|
||||||
die "worktree has uncommitted changes: ${worktree}"
|
die "worktree has uncommitted changes: ${worktree}"
|
||||||
|
|
||||||
upstream="$(git -C "${worktree}" for-each-ref \
|
upstream="$(git -C "${worktree}" for-each-ref \
|
||||||
--format='%(upstream:short)' "refs/heads/${PR_HEAD_REF}")"
|
--format='%(upstream:short)' "refs/heads/${local_branch}")"
|
||||||
if [[ -z "${upstream}" ]]; then
|
if [[ -z "${upstream}" ]]; then
|
||||||
git -C "${worktree}" branch \
|
git -C "${worktree}" branch \
|
||||||
--set-upstream-to="${PR_REMOTE}/${PR_HEAD_REF}" "${PR_HEAD_REF}"
|
--set-upstream-to="${PR_REMOTE}/${PR_HEAD_REF}" "${local_branch}"
|
||||||
elif [[ "${upstream}" != "${PR_REMOTE}/${PR_HEAD_REF}" ]]; then
|
elif [[ "${upstream}" != "${PR_REMOTE}/${PR_HEAD_REF}" ]]; then
|
||||||
die "branch ${PR_HEAD_REF} tracks ${upstream}, expected ${PR_REMOTE}/${PR_HEAD_REF}"
|
die "branch ${local_branch} tracks ${upstream}, expected ${PR_REMOTE}/${PR_HEAD_REF}"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
git -C "${worktree}" merge --ff-only "${PR_REMOTE}/${PR_HEAD_REF}"
|
git -C "${worktree}" merge --ff-only "${PR_REMOTE}/${PR_HEAD_REF}"
|
||||||
@@ -205,19 +219,33 @@ prepare_worktree() {
|
|||||||
die "${worktree} is not exactly at PR ${pr} head ${PR_HEAD_SHA}"
|
die "${worktree} is not exactly at PR ${pr} head ${PR_HEAD_SHA}"
|
||||||
|
|
||||||
WORKTREE_BY_PR["${pr}"]="${worktree}"
|
WORKTREE_BY_PR["${pr}"]="${worktree}"
|
||||||
echo "prepared PR ${pr}: ${PR_HEAD_REF} -> ${worktree}"
|
LOCAL_BRANCH_BY_PR["${pr}"]="${local_branch}"
|
||||||
|
HEAD_REF_BY_PR["${pr}"]="${PR_HEAD_REF}"
|
||||||
|
HEAD_REPO_BY_PR["${pr}"]="${PR_HEAD_REPO}"
|
||||||
|
HEAD_SHA_BY_PR["${pr}"]="${PR_HEAD_SHA}"
|
||||||
|
REMOTE_BY_PR["${pr}"]="${PR_REMOTE}"
|
||||||
|
echo "prepared PR ${pr}: ${PR_HEAD_REPO}:${PR_HEAD_REF} -> ${worktree}"
|
||||||
}
|
}
|
||||||
|
|
||||||
prompt_for_pr() {
|
prompt_for_pr() {
|
||||||
local pr="$1"
|
local pr="$1"
|
||||||
local worktree="$2"
|
local worktree="$2"
|
||||||
|
local head_ref="${HEAD_REF_BY_PR[${pr}]}"
|
||||||
|
local head_repo="${HEAD_REPO_BY_PR[${pr}]}"
|
||||||
|
local head_sha="${HEAD_SHA_BY_PR[${pr}]}"
|
||||||
|
local local_branch="${LOCAL_BRANCH_BY_PR[${pr}]}"
|
||||||
|
local remote="${REMOTE_BY_PR[${pr}]}"
|
||||||
|
|
||||||
printf '%s\n' \
|
printf '%s\n' \
|
||||||
"Before taking any action, read ${SKILL_PATH} completely and follow it as the babysit-pr-to-pass-ci skill for this task." \
|
"Before taking any action, read ${SKILL_PATH} completely and follow it as the babysit-pr-to-pass-ci skill for this task." \
|
||||||
"" \
|
"" \
|
||||||
"\$babysit-pr-to-pass-ci https://github.com/${SOURCE_REPO}/pull/${pr} --only lint.yml pr-test.yml" \
|
"\$babysit-pr-to-pass-ci https://github.com/${SOURCE_REPO}/pull/${pr} --only lint.yml pr-test.yml" \
|
||||||
"" \
|
"" \
|
||||||
"Use the dedicated worktree ${worktree}. Follow the skill exactly and pursue its durable goal until both selected workflows pass on the latest PR head. Do not merge the PR. Do not touch any other worktree. If the skill requires user review, stop and clearly explain what is needed."
|
"Use the dedicated worktree ${worktree} on local branch ${local_branch}. The current PR head is ${head_repo}:${head_ref} at ${head_sha}." \
|
||||||
|
"" \
|
||||||
|
"For any fix authorized by the skill, commit only in this worktree and push non-forcibly to the actual PR head with: git push ${remote} HEAD:refs/heads/${head_ref}" \
|
||||||
|
"" \
|
||||||
|
"The launcher also gives this process push.default=upstream, so a plain git push targets that same PR head branch. After every push, re-read the PR head SHA before monitoring new runs. Do not merge the PR. Do not touch any other worktree. If the skill requires user review, stop and clearly explain what is needed."
|
||||||
}
|
}
|
||||||
|
|
||||||
launch_window() {
|
launch_window() {
|
||||||
@@ -228,9 +256,14 @@ launch_window() {
|
|||||||
local window_id
|
local window_id
|
||||||
|
|
||||||
prompt="$(prompt_for_pr "${pr}" "${worktree}")"
|
prompt="$(prompt_for_pr "${pr}" "${worktree}")"
|
||||||
|
# yolo2 expands to:
|
||||||
|
# with-proxy codex --dangerously-bypass-approvals-and-sandbox
|
||||||
|
# --dangerously-enable-internet-mode
|
||||||
|
# The GIT_CONFIG_* variables make a plain `git push` update the differently
|
||||||
|
# named upstream PR branch without changing repository or user config.
|
||||||
# $1 is intentionally expanded by the inner interactive Bash process.
|
# $1 is intentionally expanded by the inner interactive Bash process.
|
||||||
# shellcheck disable=SC2016
|
# shellcheck disable=SC2016
|
||||||
printf -v launch_command 'bash -ic %q bash %q' \
|
printf -v launch_command 'env GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=push.default GIT_CONFIG_VALUE_0=upstream bash -ic %q bash %q' \
|
||||||
'yolo2 "$1"; exec bash -i' "${prompt}"
|
'yolo2 "$1"; exec bash -i' "${prompt}"
|
||||||
|
|
||||||
if [[ -z "${TMUX_CREATED:-}" ]]; then
|
if [[ -z "${TMUX_CREATED:-}" ]]; then
|
||||||
@@ -289,13 +322,14 @@ main() {
|
|||||||
done
|
done
|
||||||
|
|
||||||
bash -ic 'type yolo2 >/dev/null 2>&1' >/dev/null 2>&1 || \
|
bash -ic 'type yolo2 >/dev/null 2>&1' >/dev/null 2>&1 || \
|
||||||
die "the yolo2 Bash alias is not available"
|
die "the yolo2 Bash alias is not available (expected: with-proxy codex --dangerously-bypass-approvals-and-sandbox --dangerously-enable-internet-mode)"
|
||||||
gh auth status -h github.com >/dev/null 2>&1 || die "GitHub CLI authentication failed"
|
gh auth status -h github.com >/dev/null 2>&1 || die "GitHub CLI authentication failed"
|
||||||
[[ -f "${SKILL_PATH}" ]] || die "babysitting skill not found: ${SKILL_PATH}"
|
[[ -f "${SKILL_PATH}" ]] || die "babysitting skill not found: ${SKILL_PATH}"
|
||||||
|
|
||||||
origin_url="$(git -C "${REPO_ROOT}" config --get remote.origin.url || true)"
|
origin_url="$(git -C "${REPO_ROOT}" config --get remote.origin.url || true)"
|
||||||
[[ "$(normalize_github_repo "${origin_url}")" == "${SOURCE_REPO}" ]] || \
|
[[ "$(normalize_github_repo "${origin_url}")" == "${SOURCE_REPO}" ]] || \
|
||||||
die "origin points to ${origin_url:-nothing}, not ${SOURCE_REPO}"
|
die "origin points to ${origin_url:-nothing}, not ${SOURCE_REPO}"
|
||||||
|
ensure_remote origin "${SOURCE_REPO}"
|
||||||
|
|
||||||
mkdir -p "${WORKTREE_ROOT}"
|
mkdir -p "${WORKTREE_ROOT}"
|
||||||
WORKTREE_ROOT="$(cd "${WORKTREE_ROOT}" && pwd -P)"
|
WORKTREE_ROOT="$(cd "${WORKTREE_ROOT}" && pwd -P)"
|
||||||
|
|||||||
Reference in New Issue
Block a user