From 472724b202b68686d490effbf7732852d6f49678 Mon Sep 17 00:00:00 2001 From: David East Date: Thu, 12 Feb 2026 15:26:08 -0500 Subject: [PATCH] fix: update automate github issues skill for retry mechanisms --- skills/automate-github-issues/README.md | 82 +++++++- .../assets/fleet-merge.yml | 192 ++++++++++++------ .../scripts/fleet-merge.ts | 154 ++++++++++---- 3 files changed, 319 insertions(+), 109 deletions(-) diff --git a/skills/automate-github-issues/README.md b/skills/automate-github-issues/README.md index ee6fe55..85b84c9 100644 --- a/skills/automate-github-issues/README.md +++ b/skills/automate-github-issues/README.md @@ -6,12 +6,6 @@ An Agent Skill that sets up your repository to automatically triage and fix GitH When activated, this skill **bootstraps your repository** with a 5-phase automated pipeline: -1. **Analyze**: Fetches all open GitHub issues and formats them as a structured markdown document for analysis (`fleet-analyze.ts`). -2. **Plan**: A Jules session performs deep code-level triage โ€” diagnosing root causes, proposing implementations, and producing self-contained task prompts. -3. **Validate**: Ownership validation ensures no two tasks modify the same file, preventing merge conflicts. -4. **Dispatch**: Spawns parallel Jules sessions โ€” one per task โ€” each with a code-rich, self-contained prompt (`fleet-dispatch.ts`). -5. **Merge**: A sequential merge workflow waits for CI, updates branches, and merges PRs in risk order (lowest first). - ## Example Prompt ```text @@ -49,6 +43,82 @@ scripts/fleet/ # Pipeline scripts (committed to your repo) - A [Jules API key](https://jules.google.com/) - GitHub token with repo access +### Pipeline Overview + +```mermaid +flowchart LR + A["๐Ÿ“Š Analyze"] --> B["๐Ÿง  Plan"] + B --> C["โœ… Validate"] + C --> D["๐Ÿš€ Dispatch"] + D --> E["๐Ÿ”€ Merge"] +``` + +| Phase | Script | What it does | +|-------|--------|-------------| +| **Analyze** | `fleet-analyze.ts` | Fetches open issues โ†’ structured markdown | +| **Plan** | `fleet-plan.ts` | Jules diagnoses root causes, builds File Ownership Matrix | +| **Validate** | `fleet-dispatch.ts` | Checks no two tasks claim the same file | +| **Dispatch** | `fleet-dispatch.ts` | Spawns parallel Jules sessions via `jules.all()` | +| **Merge** | `fleet-merge.ts` | Sequential merge: update branch โ†’ CI โ†’ squash | + +### Detailed Flow + +```mermaid +flowchart TD + subgraph analyze ["Phase 1: Analyze"] + A1["Fetch open GitHub issues"] --> A2["Format as structured markdown"] + end + + subgraph plan ["Phase 2: Plan"] + A2 --> B1["Create Jules planning session"] + B1 --> B2["Investigate: trace issues to source code"] + B2 --> B3["Architect: design solutions with diffs"] + B3 --> B4["Build File Ownership Matrix"] + B4 --> B5{"Any file in 2+ tasks?"} + B5 -- Yes --> B6["Merge overlapping tasks"] + B6 --> B4 + B5 -- No --> B7["Write task plan to .fleet/"] + end + + subgraph validate ["Phase 3: Validate"] + B7 --> C1["Read issue_tasks.json"] + C1 --> C2{"Ownership conflict?"} + C2 -- Yes --> C3["โŒ Abort before dispatch"] + C2 -- No --> C4["โœ… Safe to parallelize"] + end + + subgraph dispatch ["Phase 4: Dispatch"] + C4 --> D1["jules.all โ€” spawn parallel sessions"] + D1 --> D2["Each session targets same base branch"] + D2 --> D3["Sessions produce PRs"] + end + + subgraph merge ["Phase 5: Merge"] + D3 --> E1["Process PRs sequentially by risk"] + E1 --> E2["Update branch from base"] + E2 --> E3{"Merge conflict?"} + E3 -- No --> E4["Wait for CI"] + E4 --> E5{"CI passed?"} + E5 -- Yes --> E6["Squash merge"] + E5 -- No --> E7["โŒ Abort"] + E6 --> E8{"More PRs?"} + E8 -- Yes --> E1 + E8 -- No --> E9["โœ… All merged"] + E3 -- Yes --> E10{"Retries left?"} + E10 -- No --> E11["โŒ Escalate to human"] + E10 -- Yes --> E12["Close old PR"] + E12 --> E13["Re-dispatch: new Jules session\nagainst current base"] + E13 --> E14["Wait for new PR"] + E14 --> E2 + end + + style analyze fill:#1a2332,stroke:#2a4a6b,color:#e0e0e0 + style plan fill:#1a2332,stroke:#2a4a6b,color:#e0e0e0 + style validate fill:#1a2332,stroke:#2a4a6b,color:#e0e0e0 + style dispatch fill:#1a2332,stroke:#2a4a6b,color:#e0e0e0 + style merge fill:#1a2332,stroke:#2a4a6b,color:#e0e0e0 +``` + ## Manual Usage After setup, run the pipeline locally: diff --git a/skills/automate-github-issues/assets/fleet-merge.yml b/skills/automate-github-issues/assets/fleet-merge.yml index cc4d34b..de48439 100644 --- a/skills/automate-github-issues/assets/fleet-merge.yml +++ b/skills/automate-github-issues/assets/fleet-merge.yml @@ -15,16 +15,17 @@ # .github/workflows/fleet-merge.yml # # Sequentially merges Jules-authored PRs: update branch โ†’ wait for CI โ†’ squash merge. -# This workflow is self-contained โ€” it uses only the gh CLI (pre-installed on runners). +# On merge conflict, re-dispatches the task as a new Jules session against current base. name: Fleet Sequential Merge on: - # Trigger when a Jules-created PR is opened - pull_request: - types: [opened] - - # Allow manual trigger + # Allow manual trigger (recommended: review PRs first, then trigger) workflow_dispatch: + inputs: + base_branch: + description: "Base branch for merge" + type: string + default: "main" concurrency: group: fleet-merge @@ -32,24 +33,28 @@ concurrency: jobs: sequential-merge: - # Only run for Jules-authored PRs or manual triggers - if: > - github.event_name == 'workflow_dispatch' || - github.event.pull_request.user.login == 'google-labs-jules' || - endsWith(github.event.pull_request.user.login, '[bot]') runs-on: ubuntu-latest env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + JULES_API_KEY: ${{ secrets.JULES_API_KEY }} steps: - name: Checkout code uses: actions/checkout@v4 - - name: Find and merge Jules PRs sequentially + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + + - name: Install fleet dependencies + run: cd scripts/fleet && bun install + + - name: Find and merge fleet PRs sequentially run: | set -euo pipefail - BASE_BRANCH="${{ github.event.pull_request.base.ref || 'main' }}" - MAX_CI_WAIT=600 # 10 minutes per PR + BASE_BRANCH="${{ inputs.base_branch || 'main' }}" + MAX_CI_WAIT=600 # 10 minutes per PR + MAX_RETRIES=2 # Max re-dispatch attempts per PR + PR_POLL_TIMEOUT=900 # 15 minutes to wait for re-dispatched PR echo "๐Ÿ” Finding open Jules-authored PRs targeting ${BASE_BRANCH}..." @@ -71,58 +76,119 @@ jobs: for PR_NUM in $PRS; do echo "" echo "๐Ÿ“ฆ Processing PR #${PR_NUM}..." - - # Update branch from base - echo " ๐Ÿ”„ Updating branch from ${BASE_BRANCH}..." - gh pr update-branch "$PR_NUM" --rebase 2>/dev/null || true - - # Wait for branch update to propagate - sleep 5 - - # Wait for CI checks to pass - echo " ๐Ÿงช Waiting for CI checks..." - ELAPSED=0 - CI_PASSED=false - - while [ $ELAPSED -lt $MAX_CI_WAIT ]; do - STATUS=$(gh pr checks "$PR_NUM" --json name,state --jq '[.[].state] | if length == 0 then "none" elif all(. == "SUCCESS" or . == "SKIPPED") then "pass" elif any(. == "FAILURE") then "fail" else "pending" end') - - case "$STATUS" in - "pass") - CI_PASSED=true - break - ;; - "none") - echo " โ„น๏ธ No CI checks configured. Proceeding." - CI_PASSED=true - break - ;; - "fail") - echo " โŒ CI failed for PR #${PR_NUM}. Skipping." - break - ;; - *) - echo " โณ CI pending... (${ELAPSED}s/${MAX_CI_WAIT}s)" + RETRY_COUNT=0 + + while true; do + # Update branch from base + echo " ๐Ÿ”„ Updating branch from ${BASE_BRANCH}..." + UPDATE_OUTPUT=$(gh pr update-branch "$PR_NUM" --rebase 2>&1) || true + + # Check for merge conflict + if echo "$UPDATE_OUTPUT" | grep -qi "conflict\|cannot be rebased"; then + if [ $RETRY_COUNT -ge $MAX_RETRIES ]; then + echo " โŒ Conflict persists after ${MAX_RETRIES} retries. Human intervention required." + echo " PR: $(gh pr view "$PR_NUM" --json url --jq '.url')" + exit 1 + fi + + echo " โš ๏ธ Merge conflict detected. Re-dispatching task..." + + # Get the PR's task prompt from the fleet data + TASK_PROMPT=$(gh pr view "$PR_NUM" --json body --jq '.body') + + # Close the conflicting PR + echo " ๐Ÿ”’ Closing conflicting PR #${PR_NUM}..." + gh pr close "$PR_NUM" --comment "โš ๏ธ Closed by fleet-merge: merge conflict detected. Task re-dispatched as a new session." + + # Re-dispatch via Jules SDK + echo " ๐Ÿš€ Re-dispatching against current ${BASE_BRANCH}..." + REPO_FULL="${{ github.repository }}" + NEW_SESSION_ID=$(bun -e " + import { jules } from '@google/jules-sdk'; + const session = await jules.createSession({ + prompt: process.env.TASK_PROMPT, + source: { github: '${REPO_FULL}', baseBranch: '${BASE_BRANCH}' }, + }); + console.log(session.id); + " 2>/dev/null) + echo " ๐Ÿ“ New session: ${NEW_SESSION_ID}" + + # Poll for new PR + echo " โณ Waiting for new PR from session ${NEW_SESSION_ID}..." + POLL_ELAPSED=0 + NEW_PR_NUM="" + while [ $POLL_ELAPSED -lt $PR_POLL_TIMEOUT ]; do sleep 30 - ELAPSED=$((ELAPSED + 30)) - ;; - esac + POLL_ELAPSED=$((POLL_ELAPSED + 30)) + NEW_PR_NUM=$(gh pr list --state open --base "$BASE_BRANCH" --json number,headRefName,body \ + --jq "[.[] | select(.headRefName | contains(\"${NEW_SESSION_ID}\")) // select(.body | contains(\"${NEW_SESSION_ID}\"))] | .[0].number // empty") + if [ -n "$NEW_PR_NUM" ]; then + echo " โœ… New PR #${NEW_PR_NUM} found." + break + fi + echo " โณ No PR yet... (${POLL_ELAPSED}s/${PR_POLL_TIMEOUT}s)" + done + + if [ -z "$NEW_PR_NUM" ]; then + echo " โŒ Timed out waiting for re-dispatched PR. Human intervention required." + exit 1 + fi + + PR_NUM="$NEW_PR_NUM" + RETRY_COUNT=$((RETRY_COUNT + 1)) + continue + fi + + # Wait for branch update to propagate + sleep 5 + + # Wait for CI checks to pass + echo " ๐Ÿงช Waiting for CI checks..." + ELAPSED=0 + CI_PASSED=false + + while [ $ELAPSED -lt $MAX_CI_WAIT ]; do + STATUS=$(gh pr checks "$PR_NUM" --json name,state --jq '[.[].state] | if length == 0 then "none" elif all(. == "SUCCESS" or . == "SKIPPED") then "pass" elif any(. == "FAILURE") then "fail" else "pending" end') + + case "$STATUS" in + "pass") + CI_PASSED=true + break + ;; + "none") + echo " โ„น๏ธ No CI checks configured. Proceeding." + CI_PASSED=true + break + ;; + "fail") + echo " โŒ CI failed for PR #${PR_NUM}. Skipping." + break + ;; + *) + echo " โณ CI pending... (${ELAPSED}s/${MAX_CI_WAIT}s)" + sleep 30 + ELAPSED=$((ELAPSED + 30)) + ;; + esac + done + + if [ "$CI_PASSED" = false ]; then + echo " โญ๏ธ Skipping PR #${PR_NUM} (CI did not pass)" + else + # Squash merge + echo " โœ… CI passed. Merging PR #${PR_NUM}..." + if gh pr merge "$PR_NUM" --squash; then + echo " ๐ŸŽ‰ PR #${PR_NUM} merged successfully." + else + echo " โŒ Failed to merge PR #${PR_NUM}. Stopping sequential merge." + exit 1 + fi + fi + + # Exit the retry loop for this PR + break done - if [ "$CI_PASSED" = false ]; then - echo " โญ๏ธ Skipping PR #${PR_NUM} (CI did not pass)" - continue - fi - - # Squash merge - echo " โœ… CI passed. Merging PR #${PR_NUM}..." - if gh pr merge "$PR_NUM" --squash --auto; then - echo " ๐ŸŽ‰ PR #${PR_NUM} merged successfully." - else - echo " โŒ Failed to merge PR #${PR_NUM}. Stopping sequential merge." - exit 1 - fi - # Brief pause for merge to propagate before next PR sleep 5 done diff --git a/skills/automate-github-issues/scripts/fleet-merge.ts b/skills/automate-github-issues/scripts/fleet-merge.ts index 7f3aec1..7d91a9c 100644 --- a/skills/automate-github-issues/scripts/fleet-merge.ts +++ b/skills/automate-github-issues/scripts/fleet-merge.ts @@ -14,8 +14,9 @@ import path from "node:path"; import { findUpSync } from "find-up"; -import type { IssueAnalysis } from "./types.js"; -import { getGitRepoInfo } from "./github/git.js"; +import type { IssueAnalysis, Task } from "./types.js"; +import { getGitRepoInfo, getCurrentBranch } from "./github/git.js"; +import { jules } from "@google/jules-sdk"; const repoInfo = await getGitRepoInfo(); const OWNER = repoInfo.owner; @@ -23,6 +24,11 @@ const REPO = repoInfo.repo; const BASE_BRANCH = process.env.FLEET_BASE_BRANCH ?? "main"; const GITHUB_TOKEN = process.env.GITHUB_TOKEN; +// Re-dispatch configuration +const MAX_RETRIES = Number(process.env.FLEET_MAX_RETRIES ?? 2); +const PR_POLL_INTERVAL_MS = 30_000; +const PR_POLL_TIMEOUT_MS = 15 * 60 * 1000; + if (!GITHUB_TOKEN) { console.error("โŒ GITHUB_TOKEN environment variable is required."); process.exit(1); @@ -114,6 +120,62 @@ async function waitForCI(prNumber: number, maxWaitMs = 10 * 60 * 1000): Promise< return false; } +// Re-dispatch a task as a new Jules session against current main +async function redispatchTask( + task: Task, + oldPr: GitHubPR, +): Promise { + // Close the conflicting PR + console.log(` ๐Ÿ”’ Closing conflicting PR #${oldPr.number}...`); + await fetch(`${API}/pulls/${oldPr.number}`, { + method: "PATCH", + headers: { ...headers, "Content-Type": "application/json" }, + body: JSON.stringify({ + state: "closed", + body: `${oldPr.body ?? ""}\n\n---\nโš ๏ธ Closed by fleet-merge: merge conflict detected. Task re-dispatched as a new session.`, + }), + }); + + // Create a new Jules session with the same prompt + console.log(` ๐Ÿš€ Re-dispatching task "${task.id}" against current ${BASE_BRANCH}...`); + const session = await jules.createSession({ + prompt: task.prompt, + source: { + github: `${OWNER}/${REPO}`, + baseBranch: BASE_BRANCH, + }, + }); + console.log(` ๐Ÿ“ New session: ${session.id}`); + + // Update sessions.json with new session ID + const sessionEntry = sessions.find(s => s.taskId === task.id); + if (sessionEntry) { + sessionEntry.sessionId = session.id; + const sessionsPath = path.join(fleetDir, "sessions.json"); + await Bun.write(sessionsPath, JSON.stringify(sessions, null, 2)); + } + + // Poll for the new PR + console.log(` โณ Waiting for new PR from session ${session.id}...`); + const start = Date.now(); + while (Date.now() - start < PR_POLL_TIMEOUT_MS) { + await new Promise(r => setTimeout(r, PR_POLL_INTERVAL_MS)); + const res = await fetch(`${API}/pulls?state=open&per_page=100`, { headers }); + const pulls = (await res.json()) as GitHubPR[]; + const newPr = pulls.find( + (pr: GitHubPR) => + pr.head.ref.includes(session.id) || + pr.body?.includes(session.id) + ); + if (newPr) { + console.log(` โœ… New PR #${newPr.number} found (${newPr.head.ref})`); + return newPr; + } + console.log(` โณ No PR yet... polling again in 30s`); + } + throw new Error(`Timed out waiting for new PR from re-dispatched session ${session.id}`); +} + // Main: sequential merge in task order const prMap = await findFleetPRs(); @@ -128,55 +190,67 @@ if (prMap.size !== analysis.tasks.length) { } for (const task of analysis.tasks) { - const pr = prMap.get(task.id); + let pr = prMap.get(task.id); if (!pr) { console.error(`โŒ No PR found for task "${task.id}". Aborting.`); process.exit(1); } - console.log(`\n๐Ÿ“ฆ Processing Task "${task.id}" โ†’ PR #${pr.number}`); + let retryCount = 0; + let merged = false; - // Update branch from base before merging (skip for first PR) - if (analysis.tasks.indexOf(task) > 0) { - console.log(` ๐Ÿ”„ Updating PR #${pr.number} branch from ${BASE_BRANCH}...`); - const updateRes = await fetch(`${API}/pulls/${pr.number}/update-branch`, { - method: "PUT", - headers: { ...headers, "Content-Type": "application/json" }, - }); - if (!updateRes.ok) { - const body = await updateRes.text(); - if (updateRes.status === 422) { - console.error(` โŒ Merge conflict detected when updating PR #${pr.number}. Human intervention required.`); - console.error(` PR: https://github.com/${OWNER}/${REPO}/pull/${pr.number}`); - process.exit(1); + while (!merged) { + console.log(`\n๐Ÿ“ฆ Processing Task "${task.id}" โ†’ PR #${pr!.number}${retryCount > 0 ? ` (retry ${retryCount})` : ""}`); + + // Update branch from base before merging (skip for first PR on first attempt) + if (analysis.tasks.indexOf(task) > 0 || retryCount > 0) { + console.log(` ๐Ÿ”„ Updating PR #${pr!.number} branch from ${BASE_BRANCH}...`); + const updateRes = await fetch(`${API}/pulls/${pr!.number}/update-branch`, { + method: "PUT", + headers: { ...headers, "Content-Type": "application/json" }, + }); + if (!updateRes.ok) { + const body = await updateRes.text(); + if (updateRes.status === 422) { + if (retryCount >= MAX_RETRIES) { + console.error(` โŒ Conflict persists after ${MAX_RETRIES} retries. Human intervention required.`); + console.error(` PR: https://github.com/${OWNER}/${REPO}/pull/${pr!.number}`); + process.exit(1); + } + console.log(` โš ๏ธ Merge conflict detected. Re-dispatching task "${task.id}"...`); + pr = await redispatchTask(task, pr!); + retryCount++; + continue; + } + throw new Error(`Update branch failed (${updateRes.status}): ${body}`); } - throw new Error(`Update branch failed (${updateRes.status}): ${body}`); + // Wait for the update to propagate + await new Promise(r => setTimeout(r, 5_000)); } - // Wait for the update to propagate - await new Promise(r => setTimeout(r, 5_000)); - } - // Wait for CI to pass - console.log(` ๐Ÿงช Waiting for CI on PR #${pr.number}...`); - const ciPassed = await waitForCI(pr.number); - if (!ciPassed) { - console.error(` โŒ CI failed for PR #${pr.number}. Aborting sequential merge.`); - process.exit(1); - } + // Wait for CI to pass + console.log(` ๐Ÿงช Waiting for CI on PR #${pr!.number}...`); + const ciPassed = await waitForCI(pr!.number); + if (!ciPassed) { + console.error(` โŒ CI failed for PR #${pr!.number}. Aborting sequential merge.`); + process.exit(1); + } - // Merge - console.log(` โœ… CI passed. Merging PR #${pr.number}...`); - const mergeRes = await fetch(`${API}/pulls/${pr.number}/merge`, { - method: "PUT", - headers: { ...headers, "Content-Type": "application/json" }, - body: JSON.stringify({ merge_method: "squash" }), - }); - if (!mergeRes.ok) { - const body = await mergeRes.text(); - console.error(` โŒ Failed to merge PR #${pr.number}: ${body}`); - process.exit(1); + // Merge + console.log(` โœ… CI passed. Merging PR #${pr!.number}...`); + const mergeRes = await fetch(`${API}/pulls/${pr!.number}/merge`, { + method: "PUT", + headers: { ...headers, "Content-Type": "application/json" }, + body: JSON.stringify({ merge_method: "squash" }), + }); + if (!mergeRes.ok) { + const body = await mergeRes.text(); + console.error(` โŒ Failed to merge PR #${pr!.number}: ${body}`); + process.exit(1); + } + console.log(` ๐ŸŽ‰ PR #${pr!.number} merged successfully.`); + merged = true; } - console.log(` ๐ŸŽ‰ PR #${pr.number} merged successfully.`); } console.log(`\nโœ… All ${analysis.tasks.length} PRs merged sequentially. No conflicts.`);