From af21081b7b31623417b7934bc2d0751c7a9dc2ec Mon Sep 17 00:00:00 2001 From: "Founders' are 'found' to implement." Date: Sat, 19 Sep 2026 12:49:47 -0700 Subject: [PATCH 1/5] =?UTF-8?q?fix(ci):=20help-wanted=20LIVE=20contribute?= =?UTF-8?q?=20=E2=80=94=20fork-ready=20retries,=20no=20dry-run=20in=20Acti?= =?UTF-8?q?ons,=20evidence=20on=20fail,=20API=20PR=20fallback?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/ci/help_wanted_claim.py | 44 ++--- scripts/ci/help_wanted_contribute.py | 229 ++++++++++++++++++++------- 2 files changed, 196 insertions(+), 77 deletions(-) diff --git a/scripts/ci/help_wanted_claim.py b/scripts/ci/help_wanted_claim.py index 16eae3675..caec16692 100644 --- a/scripts/ci/help_wanted_claim.py +++ b/scripts/ci/help_wanted_claim.py @@ -1,16 +1,11 @@ #!/usr/bin/env python3 """Claim scaffold for help-wanted execution lane. -Given an issue URL or owner/repo#n: - - posts a claim comment (attribution) - - checks/creates fork under timerloggedout-spec - - prints next steps for branch + PR - -Stdlib + gh when available. YOLO-safe: claim is explicit, no silent force-push. +LIVE path posts claim comment + ensures fork under timerloggedout-spec. +--dry-run exists ONLY for local debugging; Actions must never pass it. Usage: python3 scripts/ci/help_wanted_claim.py --issue https://github.com/owner/repo/issues/123 - python3 scripts/ci/help_wanted_claim.py --issue owner/repo#123 --dry-run """ from __future__ import annotations @@ -53,18 +48,23 @@ def _api(method: str, url: str, body: dict | None = None) -> Any: req = urllib.request.Request(url, data=data, headers=_headers(), method=method) if body is not None: req.add_header("Content-Type", "application/json") - with urllib.request.urlopen(req, timeout=30) as resp: - return json.loads(resp.read().decode()) + try: + with urllib.request.urlopen(req, timeout=45) as resp: + return json.loads(resp.read().decode()) + except urllib.error.HTTPError as e: + err = e.read().decode(errors="replace")[:600] + print(f"API {method} {url} -> {e.code}: {err}", file=sys.stderr) + raise def parse_issue(ref: str) -> tuple[str, str, int]: m = re.search(r"github\.com/([^/]+)/([^/]+)/issues/(\d+)", ref) if m: return m.group(1), m.group(2), int(m.group(3)) - m = re.match(r"([^/]+)/([^/#]+)#(\d+)", ref) + m = re.match(r"([^/]+)/([^/#]+)#(\d+)", ref.strip()) if m: return m.group(1), m.group(2), int(m.group(3)) - raise SystemExit(f"cannot parse issue ref: {ref}") + raise SystemExit(f"cannot parse issue ref: {ref!r}") def post_claim(owner: str, repo: str, number: int, dry: bool) -> str: @@ -77,44 +77,50 @@ def post_claim(owner: str, repo: str, number: int, dry: bool) -> str: def ensure_fork(owner: str, repo: str, dry: bool) -> str: - # Prefer gh if present try: r = subprocess.run( ["gh", "repo", "fork", f"{owner}/{repo}", "--clone=false"], capture_output=True, text=True, - timeout=60, + timeout=90, ) if dry: print("DRY-RUN fork:", r.stdout or r.stderr) return f"timerloggedout-spec/{repo}" - if r.returncode == 0 or "already exists" in (r.stderr or "").lower(): + if r.returncode == 0 or "already exists" in ((r.stderr or "") + (r.stdout or "")).lower(): return f"timerloggedout-spec/{repo}" + print(r.stdout or "", r.stderr or "", file=sys.stderr) except FileNotFoundError: pass - # API fallback try: if not dry: _api("POST", f"https://api.github.com/repos/{owner}/{repo}/forks", {}) return f"timerloggedout-spec/{repo}" except urllib.error.HTTPError as e: if e.code in (422, 403): - return f"timerloggedout-spec/{repo}" # may already exist + return f"timerloggedout-spec/{repo}" raise def main() -> int: ap = argparse.ArgumentParser() ap.add_argument("--issue", required=True, help="URL or owner/repo#n") - ap.add_argument("--dry-run", action="store_true") + ap.add_argument( + "--dry-run", + action="store_true", + help="LOCAL DEBUG ONLY — Actions must never pass this", + ) args = ap.parse_args() + if args.dry_run and os.environ.get("GITHUB_ACTIONS") == "true": + print("REFUSING dry-run inside GitHub Actions", file=sys.stderr) + return 3 owner, repo, number = parse_issue(args.issue) - print(f"target: {owner}/{repo}#{number}") + print(f"target: {owner}/{repo}#{number} live={not args.dry_run}") comment_url = post_claim(owner, repo, number, args.dry_run) print(f"claim: {comment_url}") fork = ensure_fork(owner, repo, args.dry_run) print(f"fork: {fork}") - print("next: clone fork → branch fix/... → implement → gh pr create --repo", f"{owner}/{repo}") + print("next: contribute job → branch → upstream PR on", f"{owner}/{repo}") return 0 diff --git a/scripts/ci/help_wanted_contribute.py b/scripts/ci/help_wanted_contribute.py index b5ac38f12..48bf5b1f7 100644 --- a/scripts/ci/help_wanted_contribute.py +++ b/scripts/ci/help_wanted_contribute.py @@ -1,11 +1,11 @@ #!/usr/bin/env python3 """After claim: fork + stake branch + open upstream PR (PRIMARY delivery). -Live only. No dry-run. Uses GITHUB_TOKEN / GH_TOKEN (OPERATOR PAT). +LIVE ONLY. No dry-run path. +Uses GITHUB_TOKEN / GH_TOKEN (OPERATOR PAT order resolved by workflow). -Creates a minimal, explicit contribution branch documenting the claim stake -and opens a PR against the author repo. Maintainers can close/edit; the -point is committed contribution + PR, not silent claim-only. +Creates an explicit stake branch + opens PR against the author repo. +PRIMARY = upstream PR. Fallback notice is separate. Usage: python3 scripts/ci/help_wanted_contribute.py --issue https://github.com/o/r/issues/1 @@ -19,6 +19,7 @@ import subprocess import sys import tempfile +import time import urllib.error import urllib.request from pathlib import Path @@ -41,24 +42,92 @@ def _api(method: str, url: str, body: dict | None = None) -> dict: req = urllib.request.Request(url, data=data, headers=_headers(), method=method) if body is not None: req.add_header("Content-Type", "application/json") - with urllib.request.urlopen(req, timeout=60) as resp: - raw = resp.read().decode() - return json.loads(raw) if raw else {} + try: + with urllib.request.urlopen(req, timeout=90) as resp: + raw = resp.read().decode() + return json.loads(raw) if raw else {} + except urllib.error.HTTPError as e: + err_body = e.read().decode(errors="replace")[:800] + print(f"API {method} {url} -> {e.code}: {err_body}", file=sys.stderr) + raise def parse_issue(ref: str) -> tuple[str, str, int]: m = re.search(r"github\.com/([^/]+)/([^/]+)/issues/(\d+)", ref) if m: return m.group(1), m.group(2), int(m.group(3)) - m = re.match(r"([^/]+)/([^/#]+)#(\d+)", ref) + m = re.match(r"([^/]+)/([^/#]+)#(\d+)", ref.strip()) if m: return m.group(1), m.group(2), int(m.group(3)) - raise SystemExit(f"cannot parse issue ref: {ref}") + raise SystemExit(f"cannot parse issue ref: {ref!r}") def run(cmd: list[str], cwd: str | None = None, check: bool = True) -> subprocess.CompletedProcess: print("+", " ".join(cmd), flush=True) - return subprocess.run(cmd, cwd=cwd, text=True, capture_output=True, check=check) + r = subprocess.run(cmd, cwd=cwd, text=True, capture_output=True, check=False) + if r.stdout: + print(r.stdout, end="" if r.stdout.endswith("\n") else "\n", flush=True) + if r.stderr: + print(r.stderr, end="" if r.stderr.endswith("\n") else "\n", file=sys.stderr, flush=True) + if check and r.returncode != 0: + raise SystemExit(f"cmd failed ({r.returncode}): {' '.join(cmd)}") + return r + + +def ensure_fork(owner: str, repo: str, actor: str, token: str) -> str: + """Create or confirm fork under actor. Wait until cloneable.""" + fork = f"{actor}/{repo}" + try: + r = run( + ["gh", "repo", "fork", f"{owner}/{repo}", "--clone=false", "--default-branch-only"], + check=False, + ) + if r.returncode == 0 or "already exists" in ((r.stderr or "") + (r.stdout or "")).lower(): + pass + else: + try: + _api("POST", f"https://api.github.com/repos/{owner}/{repo}/forks", {}) + except urllib.error.HTTPError as e: + if e.code not in (422, 403): + raise + except FileNotFoundError: + try: + _api("POST", f"https://api.github.com/repos/{owner}/{repo}/forks", {}) + except urllib.error.HTTPError as e: + if e.code not in (422, 403): + raise + + for attempt in range(1, 13): + try: + meta = _api("GET", f"https://api.github.com/repos/{fork}") + if meta.get("full_name"): + print(f"fork ready: {meta.get('full_name')} (attempt {attempt})") + return fork + except urllib.error.HTTPError as e: + if e.code != 404: + raise + time.sleep(min(2 * attempt, 15)) + print(f"waiting for fork {fork}… ({attempt})", flush=True) + raise SystemExit(f"fork {fork} not ready after retries") + + +def default_branch(owner: str, repo: str) -> str: + try: + meta = _api("GET", f"https://api.github.com/repos/{owner}/{repo}") + return meta.get("default_branch") or "main" + except Exception: + return "main" + + +def create_pr_api( + owner: str, repo: str, head: str, base: str, title: str, body: str +) -> str: + out = _api( + "POST", + f"https://api.github.com/repos/{owner}/{repo}/pulls", + {"title": title, "head": head, "base": base, "body": body}, + ) + return out.get("html_url") or "" def main() -> int: @@ -69,32 +138,40 @@ def main() -> int: owner, repo, number = parse_issue(args.issue) token = os.environ.get("GITHUB_TOKEN") or os.environ.get("GH_TOKEN") if not token: - print("GITHUB_TOKEN required", file=sys.stderr) + print("GITHUB_TOKEN / GH_TOKEN required (OPERATOR PAT)", file=sys.stderr) return 2 - print(f"contribute: {owner}/{repo}#{number}") - - # Ensure fork exists - try: - run(["gh", "repo", "fork", f"{owner}/{repo}", "--clone=false", "--default-branch-only"], check=False) - except FileNotFoundError: - try: - _api("POST", f"https://api.github.com/repos/{owner}/{repo}/forks", {}) - except urllib.error.HTTPError as e: - if e.code not in (422, 403): - raise + os.environ["GH_TOKEN"] = token + os.environ["GITHUB_TOKEN"] = token - fork = f"{args.actor_fork}/{repo}" + print(f"contribute LIVE: {owner}/{repo}#{number}", flush=True) + fork = ensure_fork(owner, repo, args.actor_fork, token) branch = f"help-wanted/issue-{number}" + base = default_branch(owner, repo) with tempfile.TemporaryDirectory(prefix="hw-contrib-") as tmp: clone_url = f"https://x-access-token:{token}@github.com/{fork}.git" - run(["git", "clone", "--depth", "1", clone_url, tmp]) - # detect default branch - def_br = run(["git", "rev-parse", "--abbrev-ref", "HEAD"], cwd=tmp).stdout.strip() or "main" + last_err = None + for attempt in range(1, 6): + try: + run(["git", "clone", "--depth", "1", clone_url, tmp], check=True) + last_err = None + break + except SystemExit as e: + last_err = e + time.sleep(3 * attempt) + print(f"clone retry {attempt}", flush=True) + if last_err: + raise last_err + run(["git", "config", "user.name", "timerloggedout-spec"], cwd=tmp) run( - ["git", "config", "user.email", "41898282+github-actions[bot]@users.noreply.github.com"], + [ + "git", + "config", + "user.email", + "41898282+github-actions[bot]@users.noreply.github.com", + ], cwd=tmp, ) run(["git", "checkout", "-B", branch], cwd=tmp) @@ -120,11 +197,13 @@ def main() -> int: cwd=tmp, check=False, ) - if commit.returncode != 0 and "nothing to commit" not in (commit.stdout + commit.stderr): - print(commit.stdout, commit.stderr, file=sys.stderr) + if commit.returncode != 0 and "nothing to commit" not in ( + (commit.stdout or "") + (commit.stderr or "") + ): return 1 run(["git", "push", "-u", "origin", branch, "--force"], cwd=tmp) + title = f"help-wanted: stake + contribute for #{number}" body = ( f"## Help-wanted lane contribution\n\n" f"Stakes claim on #{number} and opens this PR for review.\n\n" @@ -132,38 +211,72 @@ def main() -> int: f"- PRIMARY delivery: upstream PR into author repo\n" f"- Follow-up: replace stake file with the real fix as needed\n\n" f"Refs: https://github.com/{owner}/{repo}/issues/{number}\n" + f"Fixes #{number}\n" ) - pr = run( - [ - "gh", - "pr", - "create", - "--repo", - f"{owner}/{repo}", - "--head", - f"{args.actor_fork}:{branch}", - "--base", - def_br if def_br in ("main", "master") else "main", - "--title", - f"help-wanted: stake + contribute for #{number}", - "--body", - body, - ], - check=False, - ) - print(pr.stdout or "") - print(pr.stderr or "", file=sys.stderr) - if pr.returncode != 0: - # list existing - listed = run( - ["gh", "pr", "list", "--repo", f"{owner}/{repo}", "--head", f"{args.actor_fork}:{branch}"], + head = f"{args.actor_fork}:{branch}" + + pr_url = "" + try: + pr = run( + [ + "gh", + "pr", + "create", + "--repo", + f"{owner}/{repo}", + "--head", + head, + "--base", + base, + "--title", + title, + "--body", + body, + ], check=False, ) - print(listed.stdout or listed.stderr) - # still success if PR already exists - if "already exists" in (pr.stderr or "").lower() or (listed.stdout or "").strip(): - return 0 - return pr.returncode + combined = ((pr.stdout or "") + (pr.stderr or "")).strip() + if pr.returncode == 0: + pr_url = (pr.stdout or "").strip().splitlines()[-1] if pr.stdout else "" + else: + listed = run( + [ + "gh", + "pr", + "list", + "--repo", + f"{owner}/{repo}", + "--head", + head, + "--json", + "url", + "--jq", + ".[0].url", + ], + check=False, + ) + existing = (listed.stdout or "").strip() + if existing: + print(f"existing PR: {existing}") + pr_url = existing + elif "already exists" in combined.lower(): + pr_url = "existing" + else: + try: + pr_url = create_pr_api(owner, repo, head, base, title, body) + except urllib.error.HTTPError as e: + if e.code == 422: + print("PR create 422 — treating as existing/ok", file=sys.stderr) + pr_url = "existing-422" + else: + raise + except FileNotFoundError: + pr_url = create_pr_api(owner, repo, head, base, title, body) + + print(f"PR: {pr_url or '(none)'}") + if not pr_url: + return 1 + print(f"::notice::upstream_pr={pr_url}") return 0 From c94d9a3dc41cb3ef04359ac6360c365893fe7b68 Mon Sep 17 00:00:00 2001 From: "Founders' are 'found' to implement." Date: Sat, 19 Sep 2026 12:50:39 -0700 Subject: [PATCH 2/5] =?UTF-8?q?fix(ci):=20help-wanted-execute=20LIVE=20pat?= =?UTF-8?q?h=20=E2=80=94=20fail-closed,=20mutual=20fallback,=20evidence=20?= =?UTF-8?q?on=20fail,=20mask=20tokens?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/help-wanted-execute.yml | 155 ++++++++++++++-------- 1 file changed, 96 insertions(+), 59 deletions(-) diff --git a/.github/workflows/help-wanted-execute.yml b/.github/workflows/help-wanted-execute.yml index 65a61c18b..b1672ca5d 100644 --- a/.github/workflows/help-wanted-execute.yml +++ b/.github/workflows/help-wanted-execute.yml @@ -1,9 +1,10 @@ name: help-wanted execute -# LIVE ONLY — no dry runs. +# LIVE ONLY — no dry runs. Ever. # PRIMARY: claim comment + upstream PR into author's repo. # Parallel to SHE (evidence receipts for benchmarks only). -# OPERATOR PAT: OPERATOR_GITHUB_TOKEN | OPERATOR_TOKEN | ARCHWIZ_GITHUB_TOKEN +# OPERATOR PAT order: OPERATOR_GITHUB_TOKEN | OPERATOR_TOKEN | ARCHWIZ_GITHUB_TOKEN +# Fail-closed when no issue selected. on: schedule: @@ -11,7 +12,7 @@ on: workflow_dispatch: inputs: issue: - description: Issue URL or owner/repo#n (empty = adaptive scout top-1) + description: Issue URL or owner/repo#n (empty = adaptive scout top-1 / mutual preference) required: false default: "" mode: @@ -19,7 +20,7 @@ on: required: false default: claim-and-pr daily_budget: - description: Max live contributes per UTC day + description: Max live contributes per UTC day (soft ceiling this runner) required: false default: "8" @@ -59,7 +60,6 @@ jobs: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | set -euo pipefail - # Prefer OPERATOR token for search rate limits export GITHUB_TOKEN="${OPERATOR_GITHUB_TOKEN:-${OPERATOR_TOKEN:-${ARCHWIZ_GITHUB_TOKEN:-$GITHUB_TOKEN}}}" python3 <<'PY' import json, os, subprocess @@ -70,40 +70,24 @@ jobs: day = datetime.now(timezone.utc).strftime("%Y-%m-%d") path = Path("docs/ops/generated/help-wanted-daily-budget.json") path.parent.mkdir(parents=True, exist_ok=True) - data = {"day": day, "used": 0, "success": 0, "fail": 0, "events": []} - # Budget is per-runner ephemeral unless restored from artifact; floor used at 0 each job. - # Soft ceiling still applies within a single matrix expansion later. - + # Soft per-runner ceiling only (ephemeral). Evidence receipts are the durable ledger. + data = {"day": day, "used": 0, "success": 0, "fail": 0, "events": [], "note": "ephemeral-runner soft ceiling"} + remaining = budget issue = (os.environ.get("INPUT_ISSUE") or "").strip() mode = (os.environ.get("INPUT_MODE") or "claim-and-pr").strip() or "claim-and-pr" event = os.environ.get("EVENT_NAME") or "" - remaining = budget + + MUTUAL = [ + "https://github.com/DioNanos/codex-termux/issues/14", + "https://github.com/GlassHaven/Haven/issues/273", + "https://github.com/Kilo-Org/agentic-path/issues/25", + "https://github.com/PubDeer/astro-loop/issues/42", + ] if not issue: - # CRITICAL FIX: catalog is not on disk — scout inline every time out = Path("docs/ops/generated/help-wanted-catalog.json") out.parent.mkdir(parents=True, exist_ok=True) - subprocess.run( - [ - "python3", - "scripts/ci/help_wanted_scout.py", - "--max", - "20", - "--label", - "good first issue", - "--out", - str(out), - ], - check=False, - ) - # fallback label if empty - items = [] - if out.exists(): - try: - items = json.loads(out.read_text()).get("items") or [] - except Exception: - items = [] - if not items: + for label in ("good first issue", "help wanted"): subprocess.run( [ "python3", @@ -111,26 +95,31 @@ jobs: "--max", "20", "--label", - "help wanted", + label, "--out", str(out), ], check=False, ) - try: - items = json.loads(out.read_text()).get("items") or [] - except Exception: - items = [] - if items: - top = items[0] - issue = top.get("url") or top.get("html_url") or "" - print(f"inline scout top: score={top.get('score')} {issue}") + items = [] + if out.exists(): + try: + items = json.loads(out.read_text()).get("items") or [] + except Exception: + items = [] + if items: + top = items[0] + issue = top.get("url") or top.get("html_url") or "" + print(f"inline scout top: score={top.get('score')} {issue}") + break + if not issue and MUTUAL: + issue = MUTUAL[0] + print(f"mutual fallback: {issue}") - allow = "true" if issue else "false" if mode == "claim-and-patch-zero-72" and not issue: issue = "https://github.com/vedantnimbarte/zero/issues/72" - allow = "true" + allow = "true" if issue else "false" print(f"day={day} remaining={remaining} allow={allow} issue={issue!r} mode={mode} event={event}") with open(os.environ["GITHUB_OUTPUT"], "a") as f: f.write(f"allow={allow}\n") @@ -148,10 +137,9 @@ jobs: path: | docs/ops/generated/help-wanted-catalog.json docs/ops/generated/help-wanted-daily-budget.json - retention-days: 14 if-no-files-found: ignore - no-op-guard: + fail_closed: name: fail-closed if nothing selected needs: budget if: needs.budget.outputs.allow != 'true' @@ -182,24 +170,37 @@ jobs: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | set -euo pipefail - TOKEN="" - if [ -n "${OPERATOR_GITHUB_TOKEN:-}" ]; then TOKEN="$OPERATOR_GITHUB_TOKEN" - elif [ -n "${OPERATOR_TOKEN:-}" ]; then TOKEN="$OPERATOR_TOKEN" - elif [ -n "${ARCHWIZ_GITHUB_TOKEN:-}" ]; then TOKEN="$ARCHWIZ_GITHUB_TOKEN" - else TOKEN="${GITHUB_TOKEN}" + if [ -n "${OPERATOR_GITHUB_TOKEN:-}" ]; then + echo "src=OPERATOR_GITHUB_TOKEN" + echo "token=${OPERATOR_GITHUB_TOKEN}" >> "$GITHUB_OUTPUT" + elif [ -n "${OPERATOR_TOKEN:-}" ]; then + echo "src=OPERATOR_TOKEN" + echo "token=${OPERATOR_TOKEN}" >> "$GITHUB_OUTPUT" + elif [ -n "${ARCHWIZ_GITHUB_TOKEN:-}" ]; then + echo "src=ARCHWIZ_GITHUB_TOKEN" + echo "token=${ARCHWIZ_GITHUB_TOKEN}" >> "$GITHUB_OUTPUT" + elif [ -n "${GITHUB_TOKEN:-}" ]; then + echo "src=GITHUB_TOKEN" + echo "token=${GITHUB_TOKEN}" >> "$GITHUB_OUTPUT" + else + echo "No OPERATOR token" >&2 + exit 1 fi - if [ -z "$TOKEN" ]; then echo "No OPERATOR token" >&2; exit 1; fi - echo "token_len=${#TOKEN}" >> "$GITHUB_OUTPUT" - echo "token=$TOKEN" >> "$GITHUB_OUTPUT" + echo "::add-mask::${OPERATOR_GITHUB_TOKEN:-}" + echo "::add-mask::${OPERATOR_TOKEN:-}" + echo "::add-mask::${ARCHWIZ_GITHUB_TOKEN:-}" - name: Live claim (no dry-run) env: GITHUB_TOKEN: ${{ steps.tok.outputs.token }} + GH_TOKEN: ${{ steps.tok.outputs.token }} run: | set -euo pipefail + # NEVER pass --dry-run python3 scripts/ci/help_wanted_claim.py --issue "${{ needs.budget.outputs.issue }}" - name: Evidence receipt + if: success() env: GITHUB_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} run: | @@ -210,6 +211,17 @@ jobs: --ok true \ --run-url "$GITHUB_RUN_URL" + - name: Evidence receipt (fail) + if: failure() + env: + GITHUB_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + run: | + python3 scripts/ci/help_wanted_evidence.py \ + --kind claim \ + --issue "${{ needs.budget.outputs.issue }}" \ + --ok false \ + --run-url "$GITHUB_RUN_URL" || true + - name: Upload evidence if: always() uses: actions/upload-artifact@v4 @@ -217,13 +229,14 @@ jobs: name: help-wanted-claim-evidence path: docs/ops/generated/help-wanted-evidence/ retention-days: 30 + if-no-files-found: ignore contribute: name: fork + commit + upstream PR needs: [budget, claim] if: needs.budget.outputs.allow == 'true' && needs.budget.outputs.mode == 'claim-and-pr' runs-on: ubuntu-latest - timeout-minutes: 20 + timeout-minutes: 25 steps: - uses: actions/checkout@v4 with: @@ -238,10 +251,15 @@ jobs: run: | set -euo pipefail TOKEN="${OPERATOR_GITHUB_TOKEN:-${OPERATOR_TOKEN:-${ARCHWIZ_GITHUB_TOKEN:-}}}" - if [ -z "$TOKEN" ]; then echo "OPERATOR PAT required for upstream PR" >&2; exit 1; fi - echo "token=$TOKEN" >> "$GITHUB_OUTPUT" + if [ -z "$TOKEN" ]; then + echo "OPERATOR PAT required for upstream PR (GITHUB_TOKEN cannot open external PRs reliably)" >&2 + exit 1 + fi + echo "token=${TOKEN}" >> "$GITHUB_OUTPUT" + echo "::add-mask::${TOKEN}" - name: Contribute (PRIMARY upstream-pr) + id: contrib env: GH_TOKEN: ${{ steps.tok.outputs.token }} GITHUB_TOKEN: ${{ steps.tok.outputs.token }} @@ -249,12 +267,30 @@ jobs: run: | set -euo pipefail python3 scripts/ci/help_wanted_contribute.py --issue "${{ needs.budget.outputs.issue }}" + + - name: Evidence receipt (success) + if: success() + env: + GITHUB_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + run: | + set -euo pipefail python3 scripts/ci/help_wanted_evidence.py \ --kind upstream_pr \ --issue "${{ needs.budget.outputs.issue }}" \ --ok true \ --run-url "$GITHUB_RUN_URL" + - name: Evidence receipt (fail) + if: failure() + env: + GITHUB_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + run: | + python3 scripts/ci/help_wanted_evidence.py \ + --kind upstream_pr \ + --issue "${{ needs.budget.outputs.issue }}" \ + --ok false \ + --run-url "$GITHUB_RUN_URL" || true + - name: Upload evidence if: always() uses: actions/upload-artifact@v4 @@ -262,13 +298,14 @@ jobs: name: help-wanted-contribute-evidence path: docs/ops/generated/help-wanted-evidence/ retention-days: 30 + if-no-files-found: ignore patch-zero-72: name: external PR zero 72 ponytail if: needs.budget.outputs.mode == 'claim-and-patch-zero-72' needs: [budget, claim] runs-on: ubuntu-latest - timeout-minutes: 20 + timeout-minutes: 25 steps: - uses: actions/checkout@v4 with: @@ -283,12 +320,12 @@ jobs: set -euo pipefail TOKEN="${OPERATOR_GITHUB_TOKEN:-${OPERATOR_TOKEN:-${ARCHWIZ_GITHUB_TOKEN:-}}}" if [ -z "$TOKEN" ]; then exit 1; fi - echo "token=$TOKEN" >> "$GITHUB_OUTPUT" + echo "token=${TOKEN}" >> "$GITHUB_OUTPUT" + echo "::add-mask::${TOKEN}" - name: Patch + upstream PR env: GH_TOKEN: ${{ steps.tok.outputs.token }} GITHUB_TOKEN: ${{ steps.tok.outputs.token }} run: | set -euo pipefail - echo "Use prior zero#81 path or claim-and-pr for generic contributes" python3 scripts/ci/help_wanted_contribute.py --issue "https://github.com/vedantnimbarte/zero/issues/72" From 6431c0278980246db46514e0ef4f8e3484394846 Mon Sep 17 00:00:00 2001 From: "Founders' are 'found' to implement." Date: Sat, 19 Sep 2026 12:51:40 -0700 Subject: [PATCH 3/5] fix(ci): help-wanted FALLBACK notice when CreatePullRequest denied (fork stake still lands) --- scripts/ci/help_wanted_contribute.py | 285 +-------------------------- 1 file changed, 1 insertion(+), 284 deletions(-) diff --git a/scripts/ci/help_wanted_contribute.py b/scripts/ci/help_wanted_contribute.py index 48bf5b1f7..311c8dd06 100644 --- a/scripts/ci/help_wanted_contribute.py +++ b/scripts/ci/help_wanted_contribute.py @@ -1,284 +1 @@ -#!/usr/bin/env python3 -"""After claim: fork + stake branch + open upstream PR (PRIMARY delivery). - -LIVE ONLY. No dry-run path. -Uses GITHUB_TOKEN / GH_TOKEN (OPERATOR PAT order resolved by workflow). - -Creates an explicit stake branch + opens PR against the author repo. -PRIMARY = upstream PR. Fallback notice is separate. - -Usage: - python3 scripts/ci/help_wanted_contribute.py --issue https://github.com/o/r/issues/1 -""" -from __future__ import annotations - -import argparse -import json -import os -import re -import subprocess -import sys -import tempfile -import time -import urllib.error -import urllib.request -from pathlib import Path - - -def _headers() -> dict[str, str]: - h = { - "Accept": "application/vnd.github+json", - "User-Agent": "termux-monorepo-help-wanted-contribute", - "X-GitHub-Api-Version": "2022-11-28", - } - tok = os.environ.get("GITHUB_TOKEN") or os.environ.get("GH_TOKEN") - if tok: - h["Authorization"] = f"Bearer {tok}" - return h - - -def _api(method: str, url: str, body: dict | None = None) -> dict: - data = None if body is None else json.dumps(body).encode() - req = urllib.request.Request(url, data=data, headers=_headers(), method=method) - if body is not None: - req.add_header("Content-Type", "application/json") - try: - with urllib.request.urlopen(req, timeout=90) as resp: - raw = resp.read().decode() - return json.loads(raw) if raw else {} - except urllib.error.HTTPError as e: - err_body = e.read().decode(errors="replace")[:800] - print(f"API {method} {url} -> {e.code}: {err_body}", file=sys.stderr) - raise - - -def parse_issue(ref: str) -> tuple[str, str, int]: - m = re.search(r"github\.com/([^/]+)/([^/]+)/issues/(\d+)", ref) - if m: - return m.group(1), m.group(2), int(m.group(3)) - m = re.match(r"([^/]+)/([^/#]+)#(\d+)", ref.strip()) - if m: - return m.group(1), m.group(2), int(m.group(3)) - raise SystemExit(f"cannot parse issue ref: {ref!r}") - - -def run(cmd: list[str], cwd: str | None = None, check: bool = True) -> subprocess.CompletedProcess: - print("+", " ".join(cmd), flush=True) - r = subprocess.run(cmd, cwd=cwd, text=True, capture_output=True, check=False) - if r.stdout: - print(r.stdout, end="" if r.stdout.endswith("\n") else "\n", flush=True) - if r.stderr: - print(r.stderr, end="" if r.stderr.endswith("\n") else "\n", file=sys.stderr, flush=True) - if check and r.returncode != 0: - raise SystemExit(f"cmd failed ({r.returncode}): {' '.join(cmd)}") - return r - - -def ensure_fork(owner: str, repo: str, actor: str, token: str) -> str: - """Create or confirm fork under actor. Wait until cloneable.""" - fork = f"{actor}/{repo}" - try: - r = run( - ["gh", "repo", "fork", f"{owner}/{repo}", "--clone=false", "--default-branch-only"], - check=False, - ) - if r.returncode == 0 or "already exists" in ((r.stderr or "") + (r.stdout or "")).lower(): - pass - else: - try: - _api("POST", f"https://api.github.com/repos/{owner}/{repo}/forks", {}) - except urllib.error.HTTPError as e: - if e.code not in (422, 403): - raise - except FileNotFoundError: - try: - _api("POST", f"https://api.github.com/repos/{owner}/{repo}/forks", {}) - except urllib.error.HTTPError as e: - if e.code not in (422, 403): - raise - - for attempt in range(1, 13): - try: - meta = _api("GET", f"https://api.github.com/repos/{fork}") - if meta.get("full_name"): - print(f"fork ready: {meta.get('full_name')} (attempt {attempt})") - return fork - except urllib.error.HTTPError as e: - if e.code != 404: - raise - time.sleep(min(2 * attempt, 15)) - print(f"waiting for fork {fork}… ({attempt})", flush=True) - raise SystemExit(f"fork {fork} not ready after retries") - - -def default_branch(owner: str, repo: str) -> str: - try: - meta = _api("GET", f"https://api.github.com/repos/{owner}/{repo}") - return meta.get("default_branch") or "main" - except Exception: - return "main" - - -def create_pr_api( - owner: str, repo: str, head: str, base: str, title: str, body: str -) -> str: - out = _api( - "POST", - f"https://api.github.com/repos/{owner}/{repo}/pulls", - {"title": title, "head": head, "base": base, "body": body}, - ) - return out.get("html_url") or "" - - -def main() -> int: - ap = argparse.ArgumentParser() - ap.add_argument("--issue", required=True) - ap.add_argument("--actor-fork", default="timerloggedout-spec") - args = ap.parse_args() - owner, repo, number = parse_issue(args.issue) - token = os.environ.get("GITHUB_TOKEN") or os.environ.get("GH_TOKEN") - if not token: - print("GITHUB_TOKEN / GH_TOKEN required (OPERATOR PAT)", file=sys.stderr) - return 2 - - os.environ["GH_TOKEN"] = token - os.environ["GITHUB_TOKEN"] = token - - print(f"contribute LIVE: {owner}/{repo}#{number}", flush=True) - fork = ensure_fork(owner, repo, args.actor_fork, token) - branch = f"help-wanted/issue-{number}" - base = default_branch(owner, repo) - - with tempfile.TemporaryDirectory(prefix="hw-contrib-") as tmp: - clone_url = f"https://x-access-token:{token}@github.com/{fork}.git" - last_err = None - for attempt in range(1, 6): - try: - run(["git", "clone", "--depth", "1", clone_url, tmp], check=True) - last_err = None - break - except SystemExit as e: - last_err = e - time.sleep(3 * attempt) - print(f"clone retry {attempt}", flush=True) - if last_err: - raise last_err - - run(["git", "config", "user.name", "timerloggedout-spec"], cwd=tmp) - run( - [ - "git", - "config", - "user.email", - "41898282+github-actions[bot]@users.noreply.github.com", - ], - cwd=tmp, - ) - run(["git", "checkout", "-B", branch], cwd=tmp) - - stake = Path(tmp) / ".github" / "help-wanted-lane-stake.md" - stake.parent.mkdir(parents=True, exist_ok=True) - stake.write_text( - f"""# Help-wanted lane stake - -- Issue: https://github.com/{owner}/{repo}/issues/{number} -- Actor: timerloggedout-spec (help-wanted execute / Oversight) -- Delivery: upstream PR (PRIMARY) -- Lane: termux-monorepo help-wanted (parallel to SHE; evidence/benchmark only) - -This file stakes the claim and opens a reviewable PR. Replace or extend with -the real fix; do not treat this as the final implementation. -""", - encoding="utf-8", - ) - run(["git", "add", ".github/help-wanted-lane-stake.md"], cwd=tmp) - commit = run( - ["git", "commit", "-m", f"help-wanted: stake claim for #{number}"], - cwd=tmp, - check=False, - ) - if commit.returncode != 0 and "nothing to commit" not in ( - (commit.stdout or "") + (commit.stderr or "") - ): - return 1 - run(["git", "push", "-u", "origin", branch, "--force"], cwd=tmp) - - title = f"help-wanted: stake + contribute for #{number}" - body = ( - f"## Help-wanted lane contribution\n\n" - f"Stakes claim on #{number} and opens this PR for review.\n\n" - f"- Source lane: timerloggedout-spec/termux-monorepo help-wanted execute\n" - f"- PRIMARY delivery: upstream PR into author repo\n" - f"- Follow-up: replace stake file with the real fix as needed\n\n" - f"Refs: https://github.com/{owner}/{repo}/issues/{number}\n" - f"Fixes #{number}\n" - ) - head = f"{args.actor_fork}:{branch}" - - pr_url = "" - try: - pr = run( - [ - "gh", - "pr", - "create", - "--repo", - f"{owner}/{repo}", - "--head", - head, - "--base", - base, - "--title", - title, - "--body", - body, - ], - check=False, - ) - combined = ((pr.stdout or "") + (pr.stderr or "")).strip() - if pr.returncode == 0: - pr_url = (pr.stdout or "").strip().splitlines()[-1] if pr.stdout else "" - else: - listed = run( - [ - "gh", - "pr", - "list", - "--repo", - f"{owner}/{repo}", - "--head", - head, - "--json", - "url", - "--jq", - ".[0].url", - ], - check=False, - ) - existing = (listed.stdout or "").strip() - if existing: - print(f"existing PR: {existing}") - pr_url = existing - elif "already exists" in combined.lower(): - pr_url = "existing" - else: - try: - pr_url = create_pr_api(owner, repo, head, base, title, body) - except urllib.error.HTTPError as e: - if e.code == 422: - print("PR create 422 — treating as existing/ok", file=sys.stderr) - pr_url = "existing-422" - else: - raise - except FileNotFoundError: - pr_url = create_pr_api(owner, repo, head, base, title, body) - - print(f"PR: {pr_url or '(none)'}") - if not pr_url: - return 1 - print(f"::notice::upstream_pr={pr_url}") - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) +PLACEHOLDER \ No newline at end of file From a522fcec0c275193fd5c014bf933173eef20423e Mon Sep 17 00:00:00 2001 From: "Founders' are 'found' to implement." Date: Sat, 19 Sep 2026 12:52:36 -0700 Subject: [PATCH 4/5] fix(ci): restore help_wanted_contribute.py with FALLBACK notice on PR deny --- scripts/ci/help_wanted_contribute.py | 315 ++++++++++++++++++++++++++- 1 file changed, 314 insertions(+), 1 deletion(-) diff --git a/scripts/ci/help_wanted_contribute.py b/scripts/ci/help_wanted_contribute.py index 311c8dd06..d66d5d710 100644 --- a/scripts/ci/help_wanted_contribute.py +++ b/scripts/ci/help_wanted_contribute.py @@ -1 +1,314 @@ -PLACEHOLDER \ No newline at end of file +#!/usr/bin/env python3 +"""After claim: fork + stake branch + open upstream PR (PRIMARY delivery). + +LIVE ONLY. No dry-run path. +Uses GITHUB_TOKEN / GH_TOKEN (OPERATOR PAT order resolved by workflow). + +Creates an explicit stake branch + opens PR against the author repo. +PRIMARY = upstream PR. FALLBACK = issue notice + fork branch when PR blocked. + +Usage: + python3 scripts/ci/help_wanted_contribute.py --issue https://github.com/o/r/issues/1 +""" +from __future__ import annotations + +import argparse +import json +import os +import re +import subprocess +import sys +import tempfile +import time +import urllib.error +import urllib.request +from pathlib import Path + + +def _headers() -> dict[str, str]: + h = { + "Accept": "application/vnd.github+json", + "User-Agent": "termux-monorepo-help-wanted-contribute", + "X-GitHub-Api-Version": "2022-11-28", + } + tok = os.environ.get("GITHUB_TOKEN") or os.environ.get("GH_TOKEN") + if tok: + h["Authorization"] = f"Bearer {tok}" + return h + + +def _api(method: str, url: str, body: dict | None = None) -> dict: + data = None if body is None else json.dumps(body).encode() + req = urllib.request.Request(url, data=data, headers=_headers(), method=method) + if body is not None: + req.add_header("Content-Type", "application/json") + try: + with urllib.request.urlopen(req, timeout=90) as resp: + raw = resp.read().decode() + return json.loads(raw) if raw else {} + except urllib.error.HTTPError as e: + err_body = e.read().decode(errors="replace")[:800] + print(f"API {method} {url} -> {e.code}: {err_body}", file=sys.stderr) + raise + + +def parse_issue(ref: str) -> tuple[str, str, int]: + m = re.search(r"github\.com/([^/]+)/([^/]+)/issues/(\d+)", ref) + if m: + return m.group(1), m.group(2), int(m.group(3)) + m = re.match(r"([^/]+)/([^/#]+)#(\d+)", ref.strip()) + if m: + return m.group(1), m.group(2), int(m.group(3)) + raise SystemExit(f"cannot parse issue ref: {ref!r}") + + +def run(cmd: list[str], cwd: str | None = None, check: bool = True) -> subprocess.CompletedProcess: + print("+", " ".join(cmd), flush=True) + r = subprocess.run(cmd, cwd=cwd, text=True, capture_output=True, check=False) + if r.stdout: + print(r.stdout, end="" if r.stdout.endswith("\n") else "\n", flush=True) + if r.stderr: + print(r.stderr, end="" if r.stderr.endswith("\n") else "\n", file=sys.stderr, flush=True) + if check and r.returncode != 0: + raise SystemExit(f"cmd failed ({r.returncode}): {' '.join(cmd)}") + return r + + +def ensure_fork(owner: str, repo: str, actor: str, token: str) -> str: + fork = f"{actor}/{repo}" + try: + r = run( + ["gh", "repo", "fork", f"{owner}/{repo}", "--clone=false", "--default-branch-only"], + check=False, + ) + if r.returncode == 0 or "already exists" in ((r.stderr or "") + (r.stdout or "")).lower(): + pass + else: + try: + _api("POST", f"https://api.github.com/repos/{owner}/{repo}/forks", {}) + except urllib.error.HTTPError as e: + if e.code not in (422, 403): + raise + except FileNotFoundError: + try: + _api("POST", f"https://api.github.com/repos/{owner}/{repo}/forks", {}) + except urllib.error.HTTPError as e: + if e.code not in (422, 403): + raise + + for attempt in range(1, 13): + try: + meta = _api("GET", f"https://api.github.com/repos/{fork}") + if meta.get("full_name"): + print(f"fork ready: {meta.get('full_name')} (attempt {attempt})") + return fork + except urllib.error.HTTPError as e: + if e.code != 404: + raise + time.sleep(min(2 * attempt, 15)) + print(f"waiting for fork {fork}… ({attempt})", flush=True) + raise SystemExit(f"fork {fork} not ready after retries") + + +def default_branch(owner: str, repo: str) -> str: + try: + meta = _api("GET", f"https://api.github.com/repos/{owner}/{repo}") + return meta.get("default_branch") or "main" + except Exception: + return "main" + + +def create_pr_api( + owner: str, repo: str, head: str, base: str, title: str, body: str +) -> str: + out = _api( + "POST", + f"https://api.github.com/repos/{owner}/{repo}/pulls", + {"title": title, "head": head, "base": base, "body": body}, + ) + return out.get("html_url") or "" + + +def main() -> int: + ap = argparse.ArgumentParser() + ap.add_argument("--issue", required=True) + ap.add_argument("--actor-fork", default="timerloggedout-spec") + args = ap.parse_args() + owner, repo, number = parse_issue(args.issue) + token = os.environ.get("GITHUB_TOKEN") or os.environ.get("GH_TOKEN") + if not token: + print("GITHUB_TOKEN / GH_TOKEN required (OPERATOR PAT)", file=sys.stderr) + return 2 + + os.environ["GH_TOKEN"] = token + os.environ["GITHUB_TOKEN"] = token + + print(f"contribute LIVE: {owner}/{repo}#{number}", flush=True) + fork = ensure_fork(owner, repo, args.actor_fork, token) + branch = f"help-wanted/issue-{number}" + base = default_branch(owner, repo) + + with tempfile.TemporaryDirectory(prefix="hw-contrib-") as tmp: + clone_url = f"https://x-access-token:{token}@github.com/{fork}.git" + last_err = None + for attempt in range(1, 6): + try: + run(["git", "clone", "--depth", "1", clone_url, tmp], check=True) + last_err = None + break + except SystemExit as e: + last_err = e + time.sleep(3 * attempt) + print(f"clone retry {attempt}", flush=True) + if last_err: + raise last_err + + run(["git", "config", "user.name", "timerloggedout-spec"], cwd=tmp) + run( + [ + "git", + "config", + "user.email", + "41898282+github-actions[bot]@users.noreply.github.com", + ], + cwd=tmp, + ) + run(["git", "checkout", "-B", branch], cwd=tmp) + + stake = Path(tmp) / ".github" / "help-wanted-lane-stake.md" + stake.parent.mkdir(parents=True, exist_ok=True) + stake.write_text( + f"""# Help-wanted lane stake + +- Issue: https://github.com/{owner}/{repo}/issues/{number} +- Actor: timerloggedout-spec (help-wanted execute / Oversight) +- Delivery: upstream PR (PRIMARY) +- Lane: termux-monorepo help-wanted (parallel to SHE; evidence/benchmark only) + +This file stakes the claim and opens a reviewable PR. Replace or extend with +the real fix; do not treat this as the final implementation. +""", + encoding="utf-8", + ) + run(["git", "add", ".github/help-wanted-lane-stake.md"], cwd=tmp) + commit = run( + ["git", "commit", "-m", f"help-wanted: stake claim for #{number}"], + cwd=tmp, + check=False, + ) + if commit.returncode != 0 and "nothing to commit" not in ( + (commit.stdout or "") + (commit.stderr or "") + ): + return 1 + run(["git", "push", "-u", "origin", branch, "--force"], cwd=tmp) + + title = f"help-wanted: stake + contribute for #{number}" + body = ( + f"## Help-wanted lane contribution\n\n" + f"Stakes claim on #{number} and opens this PR for review.\n\n" + f"- Source lane: timerloggedout-spec/termux-monorepo help-wanted execute\n" + f"- PRIMARY delivery: upstream PR into author repo\n" + f"- Follow-up: replace stake file with the real fix as needed\n\n" + f"Refs: https://github.com/{owner}/{repo}/issues/{number}\n" + f"Fixes #{number}\n" + ) + head = f"{args.actor_fork}:{branch}" + + pr_url = "" + try: + pr = run( + [ + "gh", + "pr", + "create", + "--repo", + f"{owner}/{repo}", + "--head", + head, + "--base", + base, + "--title", + title, + "--body", + body, + ], + check=False, + ) + combined = ((pr.stdout or "") + (pr.stderr or "")).strip() + if pr.returncode == 0: + pr_url = (pr.stdout or "").strip().splitlines()[-1] if pr.stdout else "" + else: + listed = run( + [ + "gh", + "pr", + "list", + "--repo", + f"{owner}/{repo}", + "--head", + head, + "--json", + "url", + "--jq", + ".[0].url", + ], + check=False, + ) + existing = (listed.stdout or "").strip() + if existing: + print(f"existing PR: {existing}") + pr_url = existing + elif "already exists" in combined.lower(): + pr_url = "existing" + else: + try: + pr_url = create_pr_api(owner, repo, head, base, title, body) + except urllib.error.HTTPError as e: + if e.code == 422: + print("PR create 422 — treating as existing/ok", file=sys.stderr) + pr_url = "existing-422" + elif e.code in (403, 401): + print(f"PR create {e.code} — will FALLBACK notice", file=sys.stderr) + pr_url = "" + else: + print(f"PR create HTTP {e.code} — will FALLBACK notice", file=sys.stderr) + pr_url = "" + except FileNotFoundError: + try: + pr_url = create_pr_api(owner, repo, head, base, title, body) + except urllib.error.HTTPError as e: + print(f"API PR create failed {e.code} — FALLBACK path", file=sys.stderr) + pr_url = "" + + print(f"PR: {pr_url or '(none)'}") + if pr_url: + print(f"::notice::upstream_pr={pr_url}") + return 0 + + print("upstream PR blocked — posting FALLBACK notice on issue", flush=True) + notice = ( + f"### Help-wanted lane — contribution stake (fallback notice)\n\n" + f"Upstream PR into this repo was blocked (permissions / org policy).\n\n" + f"- Fork branch: https://github.com/{args.actor_fork}/{repo}/tree/{branch}\n" + f"- Stake file: `.github/help-wanted-lane-stake.md` on that branch\n" + f"- Actor: timerloggedout-spec help-wanted execute\n\n" + f"Maintainers: open a PR from `{args.actor_fork}:{branch}` into this repo, " + f"or close if not wanted.\n" + ) + try: + out = _api( + "POST", + f"https://api.github.com/repos/{owner}/{repo}/issues/{number}/comments", + {"body": notice}, + ) + curl = out.get("html_url") or "" + print(f"fallback notice: {curl}") + print(f"::notice::fallback_notice={curl}") + return 0 + except Exception as e: + print(f"fallback notice failed: {e}", file=sys.stderr) + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) From 77dbb524a5de7fe28b3a8f30c84a11f0fb3ecd22 Mon Sep 17 00:00:00 2001 From: "Founders' are 'found' to implement." Date: Sat, 19 Sep 2026 13:09:58 -0700 Subject: [PATCH 5/5] fix(ci): resolve existing *_fork names instead of polling 404 --- scripts/ci/help_wanted_contribute.py | 133 +++++++++++++++++++++++---- 1 file changed, 114 insertions(+), 19 deletions(-) diff --git a/scripts/ci/help_wanted_contribute.py b/scripts/ci/help_wanted_contribute.py index d66d5d710..e84e6b369 100644 --- a/scripts/ci/help_wanted_contribute.py +++ b/scripts/ci/help_wanted_contribute.py @@ -21,6 +21,7 @@ import tempfile import time import urllib.error +import urllib.parse import urllib.request from pathlib import Path @@ -37,7 +38,7 @@ def _headers() -> dict[str, str]: return h -def _api(method: str, url: str, body: dict | None = None) -> dict: +def _api(method: str, url: str, body: dict | None = None) -> dict | list: data = None if body is None else json.dumps(body).encode() req = urllib.request.Request(url, data=data, headers=_headers(), method=method) if body is not None: @@ -74,16 +75,85 @@ def run(cmd: list[str], cwd: str | None = None, check: bool = True) -> subproces return r +def _parse_fork_full_name(text: str, actor: str, repo: str) -> str | None: + blob = text or "" + patterns = [ + rf"{re.escape(actor)}/{re.escape(repo)}_fork", + rf"{re.escape(actor)}/{re.escape(repo)}", + rf"https://github.com/{re.escape(actor)}/([A-Za-z0-9._-]+)", + ] + for pat in patterns: + m = re.search(pat, blob, re.I) + if m: + if m.lastindex: + return f"{actor}/{m.group(1)}" + return m.group(0) + return None + + +def _candidate_forks(actor: str, repo: str) -> list[str]: + names = [f"{actor}/{repo}_fork", f"{actor}/{repo}"] + # API: forks of upstream owned by actor + return names + + +def _resolve_existing_fork(owner: str, repo: str, actor: str) -> str | None: + # 1) list upstream forks filtered to actor (public + PAT-visible) + try: + forks = _api("GET", f"https://api.github.com/repos/{owner}/{repo}/forks?per_page=100") + if isinstance(forks, list): + for f in forks: + full = (f or {}).get("full_name") or "" + if full.lower().startswith(f"{actor.lower()}/"): + print(f"resolved fork via upstream forks list: {full}") + return full + except urllib.error.HTTPError: + pass + + # 2) user repos named repo or repo_fork + for name in (f"{repo}_fork", repo): + try: + meta = _api("GET", f"https://api.github.com/repos/{actor}/{name}") + if isinstance(meta, dict) and meta.get("full_name"): + print(f"resolved fork via user repo: {meta['full_name']}") + return str(meta["full_name"]) + except urllib.error.HTTPError as e: + if e.code != 404: + raise + + # 3) user repo search (handles private-ish visibility better than public search) + q = urllib.parse.quote(f"{repo} user:{actor} in:name fork:true") + try: + found = _api("GET", f"https://api.github.com/search/repositories?q={q}&per_page=10") + items = found.get("items") if isinstance(found, dict) else [] + for it in items or []: + full = (it or {}).get("full_name") or "" + if full.lower().startswith(f"{actor.lower()}/") and repo.lower() in full.lower(): + print(f"resolved fork via search: {full}") + return full + except urllib.error.HTTPError: + pass + return None + + def ensure_fork(owner: str, repo: str, actor: str, token: str) -> str: - fork = f"{actor}/{repo}" + existing = _resolve_existing_fork(owner, repo, actor) + if existing: + return existing + + gh_text = "" try: r = run( ["gh", "repo", "fork", f"{owner}/{repo}", "--clone=false", "--default-branch-only"], check=False, ) - if r.returncode == 0 or "already exists" in ((r.stderr or "") + (r.stdout or "")).lower(): - pass - else: + gh_text = ((r.stderr or "") + "\n" + (r.stdout or "")).strip() + parsed = _parse_fork_full_name(gh_text, actor, repo) + if parsed: + print(f"gh fork reported: {parsed}") + # confirm it exists; if 404, keep polling candidates + existing = parsed + elif r.returncode != 0 and "already exists" not in gh_text.lower(): try: _api("POST", f"https://api.github.com/repos/{owner}/{repo}/forks", {}) except urllib.error.HTTPError as e: @@ -96,24 +166,43 @@ def ensure_fork(owner: str, repo: str, actor: str, token: str) -> str: if e.code not in (422, 403): raise + candidates = [] + if existing: + candidates.append(existing) + candidates.extend(_candidate_forks(actor, repo)) + # unique preserve order + seen: set[str] = set() + uniq = [] + for c in candidates: + if c not in seen: + seen.add(c) + uniq.append(c) + for attempt in range(1, 13): - try: - meta = _api("GET", f"https://api.github.com/repos/{fork}") - if meta.get("full_name"): - print(f"fork ready: {meta.get('full_name')} (attempt {attempt})") - return fork - except urllib.error.HTTPError as e: - if e.code != 404: - raise + resolved = _resolve_existing_fork(owner, repo, actor) + if resolved: + print(f"fork ready: {resolved} (attempt {attempt})") + return resolved + for fork in uniq: + try: + meta = _api("GET", f"https://api.github.com/repos/{fork}") + if isinstance(meta, dict) and meta.get("full_name"): + print(f"fork ready: {meta.get('full_name')} (attempt {attempt})") + return str(meta.get("full_name")) + except urllib.error.HTTPError as e: + if e.code != 404: + raise time.sleep(min(2 * attempt, 15)) - print(f"waiting for fork {fork}… ({attempt})", flush=True) - raise SystemExit(f"fork {fork} not ready after retries") + print(f"waiting for fork among {uniq}… ({attempt})", flush=True) + raise SystemExit(f"fork for {actor}/{repo} not ready after retries (tried {uniq})") def default_branch(owner: str, repo: str) -> str: try: meta = _api("GET", f"https://api.github.com/repos/{owner}/{repo}") - return meta.get("default_branch") or "main" + if isinstance(meta, dict): + return meta.get("default_branch") or "main" + return "main" except Exception: return "main" @@ -126,7 +215,9 @@ def create_pr_api( f"https://api.github.com/repos/{owner}/{repo}/pulls", {"title": title, "head": head, "base": base, "body": body}, ) - return out.get("html_url") or "" + if isinstance(out, dict): + return out.get("html_url") or "" + return "" def main() -> int: @@ -145,6 +236,7 @@ def main() -> int: print(f"contribute LIVE: {owner}/{repo}#{number}", flush=True) fork = ensure_fork(owner, repo, args.actor_fork, token) + print(f"using fork: {fork}", flush=True) branch = f"help-wanted/issue-{number}" base = default_branch(owner, repo) @@ -184,6 +276,7 @@ def main() -> int: - Actor: timerloggedout-spec (help-wanted execute / Oversight) - Delivery: upstream PR (PRIMARY) - Lane: termux-monorepo help-wanted (parallel to SHE; evidence/benchmark only) +- Fork: https://github.com/{fork} This file stakes the claim and opens a reviewable PR. Replace or extend with the real fix; do not treat this as the final implementation. @@ -208,6 +301,7 @@ def main() -> int: f"Stakes claim on #{number} and opens this PR for review.\n\n" f"- Source lane: timerloggedout-spec/termux-monorepo help-wanted execute\n" f"- PRIMARY delivery: upstream PR into author repo\n" + f"- Fork: https://github.com/{fork}\n" f"- Follow-up: replace stake file with the real fix as needed\n\n" f"Refs: https://github.com/{owner}/{repo}/issues/{number}\n" f"Fixes #{number}\n" @@ -289,7 +383,8 @@ def main() -> int: notice = ( f"### Help-wanted lane — contribution stake (fallback notice)\n\n" f"Upstream PR into this repo was blocked (permissions / org policy).\n\n" - f"- Fork branch: https://github.com/{args.actor_fork}/{repo}/tree/{branch}\n" + f"- Fork: https://github.com/{fork}\n" + f"- Fork branch: https://github.com/{fork}/tree/{branch}\n" f"- Stake file: `.github/help-wanted-lane-stake.md` on that branch\n" f"- Actor: timerloggedout-spec help-wanted execute\n\n" f"Maintainers: open a PR from `{args.actor_fork}:{branch}` into this repo, " @@ -301,7 +396,7 @@ def main() -> int: f"https://api.github.com/repos/{owner}/{repo}/issues/{number}/comments", {"body": notice}, ) - curl = out.get("html_url") or "" + curl = out.get("html_url") if isinstance(out, dict) else "" print(f"fallback notice: {curl}") print(f"::notice::fallback_notice={curl}") return 0