From 5f814307ad2f040d4f8a10bc7f5fc14c59f06a2f Mon Sep 17 00:00:00 2001 From: autonomic-bot Date: Tue, 2 Jun 2026 01:54:58 +0000 Subject: [PATCH] fix(recipe-upgrade): default to extending an existing open upgrade PR, not a parallel one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When an open upgrade PR already exists for a recipe (branch upgrade-*), push the new work onto ITS branch and update+re-test that PR — one evolving upgrade PR per recipe instead of spawning a second parallel PR. Only open a fresh upgrade- PR when none exists. Unrelated open PRs (e.g. backup fixes) are still never touched; merged-upstream PRs still close. Co-Authored-By: Claude Opus 4.8 --- .claude/skills/recipe-upgrade/SKILL.md | 11 ++- .../skills/recipe-upgrade/open-recipe-pr.sh | 98 +++++++++++-------- 2 files changed, 63 insertions(+), 46 deletions(-) diff --git a/.claude/skills/recipe-upgrade/SKILL.md b/.claude/skills/recipe-upgrade/SKILL.md index d4aced1..4cb8861 100644 --- a/.claude/skills/recipe-upgrade/SKILL.md +++ b/.claude/skills/recipe-upgrade/SKILL.md @@ -76,9 +76,11 @@ ssh cc-ci "GITEA_USERNAME='$GITEA_USERNAME' GITEA_PASSWORD='$GITEA_PASSWORD' GIT < /srv/cc-ci/.claude/skills/recipe-upgrade/open-recipe-pr.sh ``` This (a) re-syncs mirror `main` to true upstream main, (b) **closes any open PR already merged -upstream** (merging it would be a no-op), then pushes the upgrade branch and opens the new PR -**alongside** any other still-open PRs — it does **NOT** close superseded/unrelated open PRs; the -operator decides which to merge/close. The new PR's diff is still exactly this upgrade against the +upstream** (merging it would be a no-op), then handles the upgrade PR: **if an open upgrade PR already +exists for this recipe (branch `upgrade-*`), the new work is pushed onto THAT PR's branch and the PR is +updated + re-tested** — one evolving upgrade PR per recipe, not a second parallel one. Only if none +exists is a fresh `upgrade-` PR opened. Unrelated open PRs (e.g. a backup fix) are **NOT** +closed or touched — the operator decides on those. The PR's diff is exactly this upgrade against the real current upstream main. Capture the `PR_URL`. Optionally export `RECIPE_PR_BODY` first (image-tag table + the planned operator-action notes). Re-running with the same target version updates the existing same-branch PR rather than duplicating it. @@ -152,7 +154,8 @@ Always state explicitly that **nothing was merged** — the PR(s) await operator `/upgrade-all` cron always runs the **default** — it never auto-updates tests. - **Mirror reflects reality.** Each run force-syncs the `recipe-maintainers/` mirror `main` to true upstream main and closes open PRs already merged upstream. It does NOT close superseded/unrelated - open PRs — a new upgrade PR is opened alongside them and the operator decides which to merge/close. + open PRs. **An existing open upgrade PR is extended (new work pushed onto its branch + re-tested) + rather than spawning a second upgrade PR** — one evolving upgrade PR per recipe. - **Prefer a recipe-only PR.** Only open a cc-ci test PR (under `--with-tests`) when the upgrade is correct but a test is genuinely stale — never to paper over a real upgrade regression. - **Never weaken a test**; **bounded** changes (the upgrade + minimal test update, not rewrites). diff --git a/.claude/skills/recipe-upgrade/open-recipe-pr.sh b/.claude/skills/recipe-upgrade/open-recipe-pr.sh index 7e637f0..397880a 100755 --- a/.claude/skills/recipe-upgrade/open-recipe-pr.sh +++ b/.claude/skills/recipe-upgrade/open-recipe-pr.sh @@ -19,10 +19,11 @@ # never reflected it. # # Default mode (open a PR — preconditions: HEAD has the upgrade commit(s)): -# - Pushes the upgrade branch and opens the new PR ALONGSIDE any other still-open -# PRs for this recipe. It does NOT close superseded/unrelated open PRs — stacking -# a fresh PR on top of the open ones is intentional; the operator decides which to -# merge/close. (Only PRs already merged into upstream main are closed — see above.) +# - If an open UPGRADE PR already exists for this recipe (branch 'upgrade-*'), the new work is +# pushed onto THAT PR's branch and the PR is updated + re-tested — ONE evolving upgrade PR per +# recipe, NOT a second parallel PR. Otherwise a fresh 'upgrade-' branch + PR is opened. +# - Unrelated open PRs (e.g. a backup fix) are NEVER closed or touched — left for the operator. +# - Only PRs already merged into upstream main are closed (no-op merges) — see above. # # --reconcile-only: just sync main + close merged-upstream PRs; no push, no new # PR (used when a recipe is up to date but its mirror may be stale). @@ -79,18 +80,11 @@ echo "→ Force-syncing gitea/main to upstream main (${NEW_MAIN_SHA:0:8})..." git push --force gitea "${NEW_MAIN_SHA}:refs/heads/main" git fetch --quiet gitea '+refs/heads/*:refs/remotes/gitea/*' || true -# --- Determine the branch we're about to (re)open, if in open mode --- -BRANCH="" +# --- Preconditions for open mode --- if [ "${MODE}" != "--reconcile-only" ]; then DIVERGED=$(git log --oneline origin/main..HEAD 2>/dev/null || true) [ -n "${DIVERGED}" ] || { echo "ERROR: HEAD has no commits beyond origin/main. Nothing to PR."; exit 1; } LATEST_MSG=$(git log -1 --pretty=%s HEAD) - if echo "${LATEST_MSG}" | grep -qiE "upgrade to [0-9]"; then - VERSION=$(echo "${LATEST_MSG}" | grep -oiE "upgrade to [0-9][^[:space:]]+" | awk '{print $NF}') - BRANCH="upgrade-${VERSION}" - else - BRANCH="upgrade-$(git rev-parse --short HEAD)" - fi fi # --- Reconcile open PRs against the freshly-synced upstream main --- @@ -104,28 +98,27 @@ close_pr() { # echo " ✗ closed PR #${idx} — ${reason}" } +merged_upstream() { # -> 0 if the branch's tree already equals upstream main (no-op merge) + local ref="$1" mt + git rev-parse --verify --quiet "refs/remotes/gitea/${ref}" >/dev/null || return 1 + mt=$(git merge-tree --write-tree "${NEW_MAIN_SHA}" "refs/remotes/gitea/${ref}" 2>/dev/null) || return 1 + [ -n "${mt}" ] && [ "${mt}" = "${MAIN_TREE}" ] +} + echo "→ Reconciling open PRs on ${NAMESPACE}/${RECIPE}..." OPEN_PRS=$(curl -s "${AUTH[@]}" "${API}/repos/${NAMESPACE}/${RECIPE}/pulls?state=open&base=main&limit=50") -# Emit "indexhead_ref" per open PR. +# Close merged-upstream PRs (no-op merges); leave everything else open. Also pick an existing open +# UPGRADE PR (branch 'upgrade-*' — this tool's convention) to EXTEND, if one exists. +EXTEND_PR=""; EXTEND_BRANCH="" while IFS=$'\t' read -r IDX HEAD_REF; do [ -n "${IDX:-}" ] || continue - # Same branch we're re-opening? leave it — the force-push below updates it. - if [ -n "${BRANCH}" ] && [ "${HEAD_REF}" = "${BRANCH}" ]; then - echo " • PR #${IDX} is on ${BRANCH} (the branch being updated) — keeping" + if merged_upstream "${HEAD_REF}"; then + close_pr "${IDX}" "its changes are already in upstream main (merged upstream); mirror main re-synced" continue fi - # Is this PR already a no-op against upstream main (merged upstream)? - merged=0 - if git rev-parse --verify --quiet "refs/remotes/gitea/${HEAD_REF}" >/dev/null; then - mt=$(git merge-tree --write-tree "${NEW_MAIN_SHA}" "refs/remotes/gitea/${HEAD_REF}" 2>/dev/null) || mt="" - [ -n "${mt}" ] && [ "${mt}" = "${MAIN_TREE}" ] && merged=1 - fi - if [ "${merged}" = "1" ]; then - close_pr "${IDX}" "its changes are already in upstream main (merged upstream); mirror main re-synced" - else - # Do NOT auto-close superseded/unrelated open PRs. Open the new PR alongside them and - # let the operator decide which to merge/close. Only merged-upstream PRs are closed above. - echo " • PR #${IDX} (${HEAD_REF}) still open vs upstream main — leaving open (operator decides)" + echo " • PR #${IDX} (${HEAD_REF}) still open vs upstream main — leaving open" + if [ "${MODE}" != "--reconcile-only" ] && [ -z "${EXTEND_PR}" ] && printf '%s' "${HEAD_REF}" | grep -q '^upgrade-'; then + EXTEND_PR="${IDX}"; EXTEND_BRANCH="${HEAD_REF}" fi done < <(echo "${OPEN_PRS}" | jq -r '.[] | [(.number | tostring), .head.ref] | @tsv' 2>/dev/null || true) @@ -134,7 +127,19 @@ if [ "${MODE}" = "--reconcile-only" ]; then exit 0 fi -# --- Push the upgrade branch and open the new PR in place of the closed ones --- +# --- DEFAULT: if an open upgrade PR exists, push the new work onto ITS branch and re-test it, so +# there's ONE evolving upgrade PR per recipe rather than parallel ones. Only if none exists do we +# open a fresh upgrade- PR. Unrelated open PRs are never touched. --- +if [ -n "${EXTEND_PR}" ]; then + BRANCH="${EXTEND_BRANCH}" + echo "→ Extending existing open upgrade PR #${EXTEND_PR} (branch ${BRANCH}) with the new work — it will be re-tested; NOT opening a separate PR." +elif echo "${LATEST_MSG}" | grep -qiE "upgrade to [0-9]"; then + VERSION=$(echo "${LATEST_MSG}" | grep -oiE "upgrade to [0-9][^[:space:]]+" | awk '{print $NF}') + BRANCH="upgrade-${VERSION}" +else + BRANCH="upgrade-$(git rev-parse --short HEAD)" +fi + echo "→ Pushing branch '${BRANCH}'..." git push --force gitea "HEAD:refs/heads/${BRANCH}" @@ -144,19 +149,28 @@ PR_BODY="${PR_BODY} Tested green on the cc-ci recipe CI server (full suite, cold, against this PR head). NOT merged — for operator review. cc @trav @notplants" -PAYLOAD=$(jq -n \ - --arg title "${LATEST_MSG}" \ - --arg body "${PR_BODY}" \ - --arg head "${BRANCH}" \ - '{title:$title,body:$body,head:$head,base:"main",reviewers:["trav","notplants"]}') -RESP=$(mktemp) -PS=$(curl -s -o "${RESP}" -w "%{http_code}" "${AUTH[@]}" -H "Content-Type: application/json" -X POST "${API}/repos/${NAMESPACE}/${RECIPE}/pulls" -d "${PAYLOAD}") -if [ "${PS}" = "201" ]; then - echo "PR_URL=$(jq -r '.html_url' "${RESP}")" -elif [ "${PS}" = "409" ] || grep -q "pull request already exists" "${RESP}" 2>/dev/null; then - echo "PR_URL=https://${GITEA_URL}/${NAMESPACE}/${RECIPE}/pulls (branch ${BRANCH} already has a PR — updated by force-push)" +if [ -n "${EXTEND_PR}" ]; then + # The existing PR's branch now points at the new work, so it re-tests on the next !testme. + # Refresh its title + body to match the new work. + curl -s -o /dev/null "${AUTH[@]}" -H "Content-Type: application/json" -X PATCH \ + "${API}/repos/${NAMESPACE}/${RECIPE}/pulls/${EXTEND_PR}" \ + -d "$(jq -n --arg t "${LATEST_MSG}" --arg b "${PR_BODY}" '{title:$t,body:$b}')" || true + echo "PR_URL=https://${GITEA_URL}/${NAMESPACE}/${RECIPE}/pulls/${EXTEND_PR} (extended existing PR — re-test it)" else - echo "ERROR: PR creation failed (HTTP ${PS}):"; cat "${RESP}"; rm -f "${RESP}"; exit 1 + PAYLOAD=$(jq -n \ + --arg title "${LATEST_MSG}" \ + --arg body "${PR_BODY}" \ + --arg head "${BRANCH}" \ + '{title:$title,body:$body,head:$head,base:"main",reviewers:["trav","notplants"]}') + RESP=$(mktemp) + PS=$(curl -s -o "${RESP}" -w "%{http_code}" "${AUTH[@]}" -H "Content-Type: application/json" -X POST "${API}/repos/${NAMESPACE}/${RECIPE}/pulls" -d "${PAYLOAD}") + if [ "${PS}" = "201" ]; then + echo "PR_URL=$(jq -r '.html_url' "${RESP}")" + elif [ "${PS}" = "409" ] || grep -q "pull request already exists" "${RESP}" 2>/dev/null; then + echo "PR_URL=https://${GITEA_URL}/${NAMESPACE}/${RECIPE}/pulls (branch ${BRANCH} already has a PR — updated by force-push)" + else + echo "ERROR: PR creation failed (HTTP ${PS}):"; cat "${RESP}"; rm -f "${RESP}"; exit 1 + fi + rm -f "${RESP}" fi -rm -f "${RESP}"