guide for the X mcp server #51
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: Guide draft | |
| on: | |
| issues: | |
| types: [labeled] | |
| jobs: | |
| draft: | |
| if: github.event.label.name == 'guide:draft' | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 180 | |
| concurrency: | |
| group: guide-draft-issue-${{ github.event.issue.number }} | |
| cancel-in-progress: false | |
| permissions: | |
| contents: write | |
| issues: write | |
| pull-requests: write | |
| env: | |
| ISSUE_NUMBER: ${{ github.event.issue.number }} | |
| ISSUE_TITLE: ${{ github.event.issue.title }} | |
| ISSUE_BODY: ${{ github.event.issue.body }} | |
| GH_TOKEN: ${{ secrets.AGENT_PAT || secrets.GITHUB_TOKEN }} | |
| GH_REPO: ${{ github.repository }} | |
| steps: | |
| - name: Ensure factory labels | |
| run: | | |
| set -euo pipefail | |
| # Repo labels must exist before gh issue edit --add-label (missing label = hard fail). | |
| ensure_label() { | |
| local name="$1" color="$2" desc="$3" | |
| if gh label list --limit 100 --json name --jq '.[].name' | grep -Fxq "$name"; then | |
| return 0 | |
| fi | |
| gh label create "$name" --color "$color" --description "$desc" | |
| } | |
| ensure_label "guide:draft" "1D76DB" "Trigger guide draft factory" | |
| ensure_label "guide:in-progress" "FBCA04" "Guide draft factory running" | |
| ensure_label "guide:blocked" "D73A4A" "Guide draft factory blocked" | |
| - name: Preflight existing factory PR | |
| id: preflight | |
| run: | | |
| set -euo pipefail | |
| # Resume from an open factory PR for this issue (guide/issue-N-*) instead | |
| # of refusing — clarifications should iterate on prior research/setup. | |
| # If push succeeded but PR create flaked, also resume from an orphan | |
| # remote factory branch (no open PR yet). | |
| existing=$(gh pr list \ | |
| --state open \ | |
| --search "in:body \"#${ISSUE_NUMBER}\"" \ | |
| --json number,url,body,author,headRefName \ | |
| --jq "[.[] | select(.body | test(\"(?i)(closes|fixes|resolves)\\\\s+#${ISSUE_NUMBER}\\\\b\"))]") | |
| count=$(echo "$existing" | jq 'length') | |
| resume=false | |
| resume_pr_url="" | |
| resume_pr_number="" | |
| resume_branch="" | |
| refused=false | |
| refused_pr_url="" | |
| for i in $(seq 0 $((count - 1))); do | |
| author=$(echo "$existing" | jq -r ".[$i].author.login") | |
| head=$(echo "$existing" | jq -r ".[$i].headRefName") | |
| url=$(echo "$existing" | jq -r ".[$i].url") | |
| num=$(echo "$existing" | jq -r ".[$i].number") | |
| if ! gh api "repos/${GH_REPO}/collaborators/${author}" --silent 2>/dev/null; then | |
| continue | |
| fi | |
| if [[ "$head" == guide/issue-${ISSUE_NUMBER}-* ]]; then | |
| resume=true | |
| resume_pr_url="$url" | |
| resume_pr_number="$num" | |
| resume_branch="$head" | |
| break | |
| fi | |
| # Non-factory collaborator PR still blocks (avoid stomping human work). | |
| refused=true | |
| refused_pr_url="$url" | |
| break | |
| done | |
| if [ "$resume" != "true" ] && [ "$refused" != "true" ]; then | |
| # matching-refs: prefix search without paginating all branches | |
| refs_json=$(gh api "repos/${GH_REPO}/git/matching-refs/heads/guide/issue-${ISSUE_NUMBER}-" 2>/dev/null || echo "[]") | |
| bcount=$(echo "$refs_json" | jq 'length') | |
| if [ "$bcount" -eq 1 ]; then | |
| resume=true | |
| resume_branch=$(echo "$refs_json" | jq -r '.[0].ref | sub("^refs/heads/"; "")') | |
| echo "Resuming from orphan factory branch ${resume_branch} (no open PR)" | |
| elif [ "$bcount" -gt 1 ]; then | |
| # Prefer the branch with the newest committer date. | |
| best="" | |
| best_date="" | |
| while IFS= read -r row; do | |
| b=$(echo "$row" | jq -r '.ref | sub("^refs/heads/"; "")') | |
| sha=$(echo "$row" | jq -r '.object.sha') | |
| [ -z "$b" ] || [ -z "$sha" ] && continue | |
| date=$(gh api "repos/${GH_REPO}/git/commits/${sha}" --jq '.committer.date' 2>/dev/null || echo "") | |
| if [ -n "$date" ] && { [ -z "$best_date" ] || [[ "$date" > "$best_date" ]]; }; then | |
| best="$b" | |
| best_date="$date" | |
| fi | |
| done < <(echo "$refs_json" | jq -c '.[]') | |
| if [ -n "$best" ]; then | |
| resume=true | |
| resume_branch="$best" | |
| echo "Resuming from newest orphan factory branch ${resume_branch} (${bcount} matches)" | |
| fi | |
| fi | |
| fi | |
| echo "refused=$refused" >> "$GITHUB_OUTPUT" | |
| echo "refused_pr_url=$refused_pr_url" >> "$GITHUB_OUTPUT" | |
| echo "resume=$resume" >> "$GITHUB_OUTPUT" | |
| echo "resume_pr_url=$resume_pr_url" >> "$GITHUB_OUTPUT" | |
| echo "resume_pr_number=$resume_pr_number" >> "$GITHUB_OUTPUT" | |
| echo "resume_branch=$resume_branch" >> "$GITHUB_OUTPUT" | |
| - name: Refuse non-factory PR | |
| if: steps.preflight.outputs.refused == 'true' | |
| env: | |
| REFUSED_PR_URL: ${{ steps.preflight.outputs.refused_pr_url }} | |
| run: | | |
| gh issue edit "$ISSUE_NUMBER" --remove-label "guide:draft" || true | |
| gh issue edit "$ISSUE_NUMBER" --add-label "guide:blocked" || true | |
| gh issue comment "$ISSUE_NUMBER" --body "Refused to run: $REFUSED_PR_URL already targets this issue and is not a factory branch (\`guide/issue-${ISSUE_NUMBER}-*\`). Close it or finish that PR first, then re-add \`guide:draft\`." | |
| - name: Transition labels | |
| if: steps.preflight.outputs.refused != 'true' | |
| run: | | |
| gh issue edit "$ISSUE_NUMBER" --remove-label "guide:draft" || true | |
| gh issue edit "$ISSUE_NUMBER" --remove-label "guide:blocked" || true | |
| gh issue edit "$ISSUE_NUMBER" --add-label "guide:in-progress" | |
| - name: Checkout main (first run) | |
| if: steps.preflight.outputs.refused != 'true' && steps.preflight.outputs.resume != 'true' | |
| uses: actions/checkout@v4 | |
| with: | |
| ref: main | |
| fetch-depth: 0 | |
| token: ${{ secrets.AGENT_PAT || secrets.GITHUB_TOKEN }} | |
| - name: Checkout factory branch (resume) | |
| if: steps.preflight.outputs.refused != 'true' && steps.preflight.outputs.resume == 'true' | |
| uses: actions/checkout@v4 | |
| with: | |
| ref: ${{ steps.preflight.outputs.resume_branch }} | |
| fetch-depth: 0 | |
| token: ${{ secrets.AGENT_PAT || secrets.GITHUB_TOKEN }} | |
| - name: Setup Node.js | |
| if: steps.preflight.outputs.refused != 'true' | |
| uses: actions/setup-node@v4 | |
| with: | |
| node-version: 22 | |
| cache: npm | |
| cache-dependency-path: pipeline/package-lock.json | |
| - name: Install dependencies | |
| if: steps.preflight.outputs.refused != 'true' | |
| working-directory: pipeline | |
| run: npm ci | |
| - name: Distill issue intent | |
| id: distill | |
| if: steps.preflight.outputs.refused != 'true' | |
| working-directory: pipeline | |
| env: | |
| CURSOR_API_KEY: ${{ secrets.CURSOR_API_KEY }} | |
| run: | | |
| set -euo pipefail | |
| if [ -z "${CURSOR_API_KEY:-}" ]; then | |
| echo "CURSOR_API_KEY secret is not set" > "${RUNNER_TEMP}/failure_reason.txt" | |
| exit 1 | |
| fi | |
| # Fold issue comments into distill context so clarifications on the | |
| # ticket (not only the body) reach --notes on retry. | |
| comments=$(gh api "repos/${GH_REPO}/issues/${ISSUE_NUMBER}/comments" \ | |
| --jq '[.[].body] | join("\n\n---\n\n")' \ | |
| 2>/dev/null || echo "") | |
| if [ -n "${comments}" ]; then | |
| # Keep every line of this assignment indented — bare "## ..." at | |
| # column 0 ends the YAML `run: |` block and breaks the workflow. | |
| { | |
| printf '%s\n\n' "${ISSUE_BODY:-}" | |
| echo "## Issue thread (for clarifications)" | |
| printf '%s\n' "${comments}" | |
| } > "${RUNNER_TEMP}/issue-body-with-thread.txt" | |
| export ISSUE_BODY="$(cat "${RUNNER_TEMP}/issue-body-with-thread.txt")" | |
| fi | |
| set +e | |
| npm run resolve-issue -- --output "${RUNNER_TEMP}/resolved.json" | |
| code=$? | |
| set -e | |
| if [ ! -f "${RUNNER_TEMP}/resolved.json" ]; then | |
| echo "resolve-issue produced no resolved.json (exit ${code})" > "${RUNNER_TEMP}/failure_reason.txt" | |
| exit 1 | |
| fi | |
| status=$(jq -r '.status' "${RUNNER_TEMP}/resolved.json") | |
| if [ "$status" = "ok" ]; then | |
| slug=$(jq -r '.slug' "${RUNNER_TEMP}/resolved.json") | |
| provider=$(jq -r '.provider' "${RUNNER_TEMP}/resolved.json") | |
| persona=$(jq -r '.persona // "it-admin"' "${RUNNER_TEMP}/resolved.json") | |
| notes=$(jq -r '.notes // ""' "${RUNNER_TEMP}/resolved.json") | |
| echo "slug=$slug" >> "$GITHUB_OUTPUT" | |
| echo "provider=$provider" >> "$GITHUB_OUTPUT" | |
| echo "persona=$persona" >> "$GITHUB_OUTPUT" | |
| { | |
| echo "notes<<EOF" | |
| echo "$notes" | |
| echo "EOF" | |
| } >> "$GITHUB_OUTPUT" | |
| exit 0 | |
| fi | |
| if [ "$status" = "needs_clarification" ]; then | |
| reason=$(jq -r '.reason' "${RUNNER_TEMP}/resolved.json") | |
| candidates=$(jq -r '(.candidates // []) | join(", ")' "${RUNNER_TEMP}/resolved.json") | |
| { | |
| echo "Distill needs clarification before drafting." | |
| echo | |
| echo "**Reason:** ${reason}" | |
| if [ -n "$candidates" ]; then | |
| echo | |
| echo "**Candidates:** ${candidates}" | |
| fi | |
| echo | |
| echo "Reply on this issue (or edit the body) clarifying which MCP server, then re-add \`guide:draft\`." | |
| } > "${RUNNER_TEMP}/failure_reason.txt" | |
| exit 1 | |
| fi | |
| echo "Unexpected distill status: ${status}" > "${RUNNER_TEMP}/failure_reason.txt" | |
| exit 1 | |
| - name: Comment resolved intent | |
| if: steps.preflight.outputs.refused != 'true' && success() | |
| env: | |
| SLUG: ${{ steps.distill.outputs.slug }} | |
| PROVIDER: ${{ steps.distill.outputs.provider }} | |
| PERSONA: ${{ steps.distill.outputs.persona }} | |
| NOTES: ${{ steps.distill.outputs.notes }} | |
| RESUME: ${{ steps.preflight.outputs.resume }} | |
| RESUME_PR_URL: ${{ steps.preflight.outputs.resume_pr_url }} | |
| RESUME_BRANCH: ${{ steps.preflight.outputs.resume_branch }} | |
| run: | | |
| set -euo pipefail | |
| body_file="${RUNNER_TEMP}/resolved-comment.md" | |
| { | |
| if [ "${RESUME}" = "true" ]; then | |
| if [ -n "${RESUME_PR_URL}" ]; then | |
| echo "Resuming on existing factory PR: ${RESUME_PR_URL}" | |
| else | |
| echo "Resuming on factory branch \`${RESUME_BRANCH}\` (no open PR yet — will open one after this run)." | |
| fi | |
| echo | |
| echo "Resolved as \`${SLUG}\` (${PROVIDER}), persona \`${PERSONA}\`." | |
| echo "Prior \`guides/${SLUG}/\` stays on the branch — research/draft will revise from those artifacts (lock skips when inputs match)." | |
| else | |
| echo "Resolved as \`${SLUG}\` (${PROVIDER}), persona \`${PERSONA}\`." | |
| fi | |
| if [ -n "${NOTES}" ]; then | |
| echo | |
| echo "Notes handed to the pipeline:" | |
| echo | |
| echo "> ${NOTES}" | |
| fi | |
| echo | |
| echo "Starting \`draft-guide\`…" | |
| } > "$body_file" | |
| gh issue comment "$ISSUE_NUMBER" --body-file "$body_file" | |
| - name: Create branch | |
| id: branch | |
| if: steps.preflight.outputs.refused != 'true' && success() | |
| env: | |
| SLUG: ${{ steps.distill.outputs.slug }} | |
| RESUME: ${{ steps.preflight.outputs.resume }} | |
| RESUME_BRANCH: ${{ steps.preflight.outputs.resume_branch }} | |
| run: | | |
| set -euo pipefail | |
| git config user.name "guide-factory[bot]" | |
| git config user.email "guide-factory[bot]@users.noreply.github.com" | |
| if [ "${RESUME}" = "true" ]; then | |
| echo "name=${RESUME_BRANCH}" >> "$GITHUB_OUTPUT" | |
| echo "Resuming on ${RESUME_BRANCH}" | |
| exit 0 | |
| fi | |
| branch="guide/issue-${ISSUE_NUMBER}-${SLUG}" | |
| echo "name=$branch" >> "$GITHUB_OUTPUT" | |
| git checkout -b "$branch" | |
| - name: Draft guide | |
| id: draft | |
| if: steps.preflight.outputs.refused != 'true' && success() | |
| working-directory: pipeline | |
| env: | |
| CURSOR_API_KEY: ${{ secrets.CURSOR_API_KEY }} | |
| SLUG: ${{ steps.distill.outputs.slug }} | |
| PERSONA: ${{ steps.distill.outputs.persona }} | |
| NOTES: ${{ steps.distill.outputs.notes }} | |
| run: | | |
| set -euo pipefail | |
| # --pause-on-scope: stop after research when material OQs lack Decision N | |
| # replies in notes (exit 3 → research-only PR + Scope check). | |
| args=(--overwrite --pause-on-scope) | |
| if [ -n "${PERSONA}" ] && [ "${PERSONA}" != "it-admin" ]; then | |
| args+=(--persona "${PERSONA}") | |
| fi | |
| if [ -n "${NOTES}" ]; then | |
| args+=(--notes "${NOTES}") | |
| fi | |
| set +e | |
| npm run draft-guide -- "${SLUG}" "${args[@]}" | |
| code=$? | |
| set -e | |
| # Prefer the newest run record for this slug (written even on unconverged). | |
| record="" | |
| if compgen -G "${GITHUB_WORKSPACE}/retro/runs/*-${SLUG}.json" > /dev/null; then | |
| record=$(ls -t "${GITHUB_WORKSPACE}"/retro/runs/*-"${SLUG}".json | head -n1) | |
| echo "record=${record}" >> "$GITHUB_OUTPUT" | |
| cp "$record" "${RUNNER_TEMP}/run-record.json" | |
| fi | |
| # CLI: 0 = converged, 2 = unconverged/blocked/failed, 3 = awaiting_scope, | |
| # 1 = hard failure. Exit 2/3 still wrote files — open a PR for humans. | |
| if [ "$code" -eq 0 ]; then | |
| echo "outcome=converged" >> "$GITHUB_OUTPUT" | |
| exit 0 | |
| fi | |
| if [ "$code" -eq 3 ]; then | |
| if [ -f "${GITHUB_WORKSPACE}/guides/${SLUG}/research.md" ]; then | |
| echo "outcome=awaiting_scope" >> "$GITHUB_OUTPUT" | |
| exit 0 | |
| fi | |
| echo "draft-guide exited 3 (awaiting_scope) but research.md is missing" > "${RUNNER_TEMP}/failure_reason.txt" | |
| exit 1 | |
| fi | |
| if [ "$code" -eq 2 ]; then | |
| if [ -d "${GITHUB_WORKSPACE}/guides/${SLUG}" ]; then | |
| echo "outcome=unconverged" >> "$GITHUB_OUTPUT" | |
| echo "draft-guide exited 2 (unconverged/blocked/failed). Opening a draft PR with whatever was written for human review." > "${RUNNER_TEMP}/failure_reason.txt" | |
| exit 0 | |
| fi | |
| echo "draft-guide exited 2 and guides/${SLUG}/ is missing" > "${RUNNER_TEMP}/failure_reason.txt" | |
| exit 1 | |
| fi | |
| echo "draft-guide exited ${code} (hard failure; see workflow logs)" > "${RUNNER_TEMP}/failure_reason.txt" | |
| exit "$code" | |
| - name: Commit and push | |
| id: push | |
| if: steps.preflight.outputs.refused != 'true' && success() | |
| env: | |
| BRANCH: ${{ steps.branch.outputs.name }} | |
| SLUG: ${{ steps.distill.outputs.slug }} | |
| OUTCOME: ${{ steps.draft.outputs.outcome }} | |
| run: | | |
| set -euo pipefail | |
| git add "guides/${SLUG}/" || true | |
| # Matching run records for this slug (any timestamp prefix). | |
| if compgen -G "retro/runs/*-${SLUG}.json" > /dev/null; then | |
| git add retro/runs/*-"${SLUG}".json | |
| fi | |
| if git diff --cached --quiet; then | |
| # Resume after a PR-create flake (or lock skip): branch already has the tip. | |
| if git ls-remote --exit-code origin "refs/heads/${BRANCH}" >/dev/null 2>&1; then | |
| echo "No new guide changes; keeping existing tip of ${BRANCH}" | |
| echo "pushed=true" >> "$GITHUB_OUTPUT" | |
| exit 0 | |
| fi | |
| echo "No guide or run-record changes to commit" > "${RUNNER_TEMP}/failure_reason.txt" | |
| exit 1 | |
| fi | |
| msg="Draft guide: ${SLUG} (issue #${ISSUE_NUMBER})" | |
| if [ "${OUTCOME:-}" = "awaiting_scope" ]; then | |
| msg="Research (awaiting scope): ${SLUG} (issue #${ISSUE_NUMBER})" | |
| fi | |
| git commit -m "$msg" | |
| # force-with-lease keeps resume pushes from clobbering unexpected remote edits | |
| git push --force-with-lease origin "$BRANCH" | |
| echo "pushed=true" >> "$GITHUB_OUTPUT" | |
| - name: Open or update PR | |
| id: open_pr | |
| if: steps.preflight.outputs.refused != 'true' && success() | |
| env: | |
| BRANCH: ${{ steps.branch.outputs.name }} | |
| SLUG: ${{ steps.distill.outputs.slug }} | |
| PROVIDER: ${{ steps.distill.outputs.provider }} | |
| OUTCOME: ${{ steps.draft.outputs.outcome }} | |
| RESUME: ${{ steps.preflight.outputs.resume }} | |
| RESUME_PR_NUMBER: ${{ steps.preflight.outputs.resume_pr_number }} | |
| RESUME_PR_URL: ${{ steps.preflight.outputs.resume_pr_url }} | |
| run: | | |
| set -euo pipefail | |
| # Title stays short; status lives in draft vs ready + issue comments. | |
| title="guide: ${PROVIDER}" | |
| title="${title:0:256}" | |
| # Needs a human reply (scope Decisions or unresolved blockers) → stay draft. | |
| # Converged → ready for review. | |
| needs_human=false | |
| if [ "${OUTCOME}" = "awaiting_scope" ] || [ "${OUTCOME}" = "unconverged" ]; then | |
| needs_human=true | |
| fi | |
| body_file="${RUNNER_TEMP}/pr-body.md" | |
| { | |
| echo "Closes #${ISSUE_NUMBER}" | |
| echo | |
| if [ "${OUTCOME}" = "awaiting_scope" ]; then | |
| echo "Factory **research-only** pause for \`guides/${SLUG}/\` — material open questions need Decisions before draft." | |
| echo | |
| echo "**Pipeline status:** awaiting scope. See the issue comment **Scope check**." | |
| echo "Answer with \`Decision N: …\`, then re-add \`guide:draft\` to continue." | |
| echo | |
| else | |
| echo "Factory draft of \`guides/${SLUG}/\` via \`draft-guide\` (Cursor SDK)." | |
| echo | |
| fi | |
| if [ "${OUTCOME}" = "unconverged" ]; then | |
| echo "**Pipeline status:** unconverged (reviewers still had blockers after max rounds)." | |
| echo "See the issue comment **Pipeline review** for unresolved blockers and open questions." | |
| echo "Do not merge until those are settled." | |
| echo | |
| fi | |
| if [ "${RESUME}" = "true" ]; then | |
| echo "_Updated by a factory re-run (resume) — prior research/setup on this branch were reused where the lock allowed._" | |
| echo | |
| fi | |
| if [ "$needs_human" = "true" ]; then | |
| echo "This PR is a **draft** until the issue Decisions / blockers are settled." | |
| else | |
| echo "Pipeline **converged** — ready for human review (agents never commit; this Action did)." | |
| fi | |
| } > "$body_file" | |
| if [ -f "${RUNNER_TEMP}/run-record.json" ]; then | |
| echo >> "$body_file" | |
| if [ "${OUTCOME}" = "awaiting_scope" ]; then | |
| bash "${GITHUB_WORKSPACE}/.github/scripts/format-scope-check.sh" \ | |
| "${RUNNER_TEMP}/run-record.json" "" >> "$body_file" | |
| else | |
| bash "${GITHUB_WORKSPACE}/.github/scripts/format-pipeline-review.sh" \ | |
| "${RUNNER_TEMP}/run-record.json" "" \ | |
| "${GITHUB_WORKSPACE}/guides/${SLUG}" >> "$body_file" | |
| fi | |
| fi | |
| # Retry transient GitHub GraphQL / 5xx flakes (seen on gh pr create). | |
| is_transient() { | |
| local err="$1" | |
| echo "$err" | grep -qiE \ | |
| 'GraphQL: Something went wrong|HTTP 50[0-9]|timed out|timeout|ECONNRESET|ECONNREFUSED|secondary rate limit|API rate limit' | |
| } | |
| retry_gh() { | |
| local max=5 attempt=1 delay=10 | |
| local err_file="${RUNNER_TEMP}/gh-pr.err" | |
| while true; do | |
| if "$@" 2>"$err_file"; then | |
| return 0 | |
| fi | |
| local err | |
| err=$(cat "$err_file") | |
| if [ "$attempt" -ge "$max" ] || ! is_transient "$err"; then | |
| echo "$err" >&2 | |
| return 1 | |
| fi | |
| echo "Transient GitHub error on attempt ${attempt}/${max}; retrying in ${delay}s…" >&2 | |
| echo "$err" >&2 | |
| sleep "$delay" | |
| attempt=$((attempt + 1)) | |
| delay=$((delay * 2)) | |
| done | |
| } | |
| if [ "${RESUME}" = "true" ] && [ -n "${RESUME_PR_NUMBER}" ]; then | |
| if ! retry_gh gh pr edit "${RESUME_PR_NUMBER}" --title "$title" --body-file "$body_file"; then | |
| { | |
| echo "Pushed \`${BRANCH}\` but failed to update PR #${RESUME_PR_NUMBER} after retries." | |
| echo "Re-add \`guide:draft\` to resume from that branch/PR." | |
| } > "${RUNNER_TEMP}/failure_reason.txt" | |
| exit 1 | |
| fi | |
| if [ "$needs_human" = "true" ]; then | |
| gh pr ready "${RESUME_PR_NUMBER}" --undo || true | |
| else | |
| gh pr ready "${RESUME_PR_NUMBER}" || true | |
| fi | |
| echo "pr_url=${RESUME_PR_URL}" >> "$GITHUB_OUTPUT" | |
| exit 0 | |
| fi | |
| create_args=(--base main --head "$BRANCH" --title "$title" --body-file "$body_file") | |
| if [ "$needs_human" = "true" ]; then | |
| create_args+=(--draft) | |
| fi | |
| err_file="${RUNNER_TEMP}/gh-pr.err" | |
| pr_url="" | |
| max=5 | |
| attempt=1 | |
| delay=10 | |
| while true; do | |
| set +e | |
| pr_url=$(gh pr create "${create_args[@]}" 2>"$err_file") | |
| create_code=$? | |
| set -e | |
| if [ "$create_code" -eq 0 ]; then | |
| pr_url=$(printf '%s\n' "$pr_url" | tail -n1) | |
| break | |
| fi | |
| err=$(cat "$err_file") | |
| if [ "$attempt" -ge "$max" ] || ! is_transient "$err"; then | |
| # Maybe create succeeded and only the response flaked — recover by URL. | |
| existing_url=$(gh pr list --head "$BRANCH" --state open --json url --jq '.[0].url // empty' 2>/dev/null || true) | |
| if [ -n "$existing_url" ]; then | |
| echo "gh pr create failed but open PR exists for ${BRANCH}: ${existing_url}" | |
| pr_url="$existing_url" | |
| break | |
| fi | |
| echo "$err" >&2 | |
| { | |
| echo "Guide was pushed to \`${BRANCH}\` but opening the PR failed after retries (likely a GitHub API flake)." | |
| echo | |
| echo "Re-add \`guide:draft\` — the next run will resume from that branch and retry PR create (without a blank-tree restart)." | |
| } > "${RUNNER_TEMP}/failure_reason.txt" | |
| exit 1 | |
| fi | |
| echo "Transient GitHub error on attempt ${attempt}/${max}; retrying in ${delay}s…" >&2 | |
| echo "$err" >&2 | |
| sleep "$delay" | |
| attempt=$((attempt + 1)) | |
| delay=$((delay * 2)) | |
| done | |
| echo "pr_url=$pr_url" >> "$GITHUB_OUTPUT" | |
| - name: Comment pipeline review or scope check on issue | |
| if: steps.preflight.outputs.refused != 'true' && success() | |
| env: | |
| PR_URL: ${{ steps.open_pr.outputs.pr_url }} | |
| OUTCOME: ${{ steps.draft.outputs.outcome }} | |
| SLUG: ${{ steps.distill.outputs.slug }} | |
| run: | | |
| set -euo pipefail | |
| body_file="${RUNNER_TEMP}/pipeline-review-comment.md" | |
| if [ "${OUTCOME}" = "awaiting_scope" ]; then | |
| if [ -f "${RUNNER_TEMP}/run-record.json" ]; then | |
| bash "${GITHUB_WORKSPACE}/.github/scripts/format-scope-check.sh" \ | |
| "${RUNNER_TEMP}/run-record.json" "${PR_URL}" > "$body_file" | |
| else | |
| { | |
| echo "## Scope check" | |
| echo | |
| echo "Research paused before draft (awaiting scope). Draft PR: ${PR_URL}" | |
| echo | |
| echo "_No run record found to list Decisions._" | |
| } > "$body_file" | |
| fi | |
| gh issue comment "$ISSUE_NUMBER" --body-file "$body_file" | |
| gh issue edit "$ISSUE_NUMBER" --add-label "guide:blocked" || true | |
| exit 0 | |
| fi | |
| if [ -f "${RUNNER_TEMP}/run-record.json" ]; then | |
| bash "${GITHUB_WORKSPACE}/.github/scripts/format-pipeline-review.sh" \ | |
| "${RUNNER_TEMP}/run-record.json" "${PR_URL}" \ | |
| "${GITHUB_WORKSPACE}/guides/${SLUG}" > "$body_file" | |
| else | |
| { | |
| echo "## Pipeline review" | |
| echo | |
| if [ "${OUTCOME}" = "unconverged" ]; then | |
| echo "Draft PR opened (pipeline **unconverged**): ${PR_URL}" | |
| else | |
| echo "Draft PR opened: ${PR_URL}" | |
| fi | |
| echo | |
| echo "_No run record found to summarize blockers / open questions._" | |
| } > "$body_file" | |
| fi | |
| gh issue comment "$ISSUE_NUMBER" --body-file "$body_file" | |
| - name: Mark blocked on failure | |
| if: steps.preflight.outputs.refused != 'true' && failure() | |
| env: | |
| RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} | |
| SLUG: ${{ steps.distill.outputs.slug }} | |
| PUSHED: ${{ steps.push.outputs.pushed }} | |
| BRANCH: ${{ steps.branch.outputs.name }} | |
| run: | | |
| reason="(no reason file written; check workflow logs)" | |
| if [ -f "${RUNNER_TEMP}/failure_reason.txt" ]; then | |
| reason=$(cat "${RUNNER_TEMP}/failure_reason.txt") | |
| fi | |
| body_file="${RUNNER_TEMP}/failure-comment.md" | |
| { | |
| if [ "${PUSHED}" = "true" ]; then | |
| echo "\`guide:draft\` run failed after pushing \`${BRANCH:-guide/issue-${ISSUE_NUMBER}-…}\`." | |
| echo | |
| echo "$reason" | |
| echo | |
| echo "The guide branch is on the remote — re-add \`guide:draft\` to resume from it (skipping a blank-tree restart)." | |
| else | |
| echo "\`guide:draft\` run failed (no PR opened)." | |
| echo | |
| echo "$reason" | |
| fi | |
| echo | |
| echo "**Workflow run:** ${RUN_URL}" | |
| echo | |
| } > "$body_file" | |
| # If a run record exists from a partial draft, surface blockers here too. | |
| if [ -f "${RUNNER_TEMP}/run-record.json" ]; then | |
| echo >> "$body_file" | |
| bash "${GITHUB_WORKSPACE}/.github/scripts/format-pipeline-review.sh" \ | |
| "${RUNNER_TEMP}/run-record.json" "" \ | |
| "${GITHUB_WORKSPACE}/guides/${SLUG}" >> "$body_file" || true | |
| elif [ -n "${SLUG}" ] && compgen -G "${GITHUB_WORKSPACE}/retro/runs/*-${SLUG}.json" > /dev/null; then | |
| record=$(ls -t "${GITHUB_WORKSPACE}"/retro/runs/*-"${SLUG}".json | head -n1) | |
| echo >> "$body_file" | |
| bash "${GITHUB_WORKSPACE}/.github/scripts/format-pipeline-review.sh" \ | |
| "$record" "" "${GITHUB_WORKSPACE}/guides/${SLUG}" >> "$body_file" || true | |
| else | |
| { | |
| echo "Reply on this issue with clarifications, then re-add \`guide:draft\`." | |
| } >> "$body_file" | |
| fi | |
| gh issue edit "$ISSUE_NUMBER" --add-label "guide:blocked" || true | |
| gh issue comment "$ISSUE_NUMBER" --body-file "$body_file" | |
| - name: Always remove in-progress | |
| if: always() && steps.preflight.outputs.refused != 'true' | |
| run: gh issue edit "$ISSUE_NUMBER" --remove-label "guide:in-progress" || true |