diff --git a/.github/workflows/issue-implement.yml b/.github/workflows/issue-implement.yml new file mode 100644 index 0000000..60f9fad --- /dev/null +++ b/.github/workflows/issue-implement.yml @@ -0,0 +1,65 @@ +name: issue-implement + +# Dev agent **wiwi**. Fires only when label `agent:try` lands on an +# issue — typically added by the triage agent when verdict=do, or by +# a human bypassing triage. wiwi branches off main, implements the +# change, runs the build, and opens a DRAFT PR labelled `auto-agent`. +# Downstream auto-merge gating happens in pr-review.yml. + +on: + issues: + types: [labeled] + +permissions: + contents: write + issues: write + pull-requests: write + +concurrency: + # Re-labelling `agent:try` on the same issue cancels any in-flight + # wiwi run and starts a fresh attempt. Useful when an operator + # spots a stuck run and wants to retry without manual cleanup. + group: implement-${{ github.event.issue.number }} + cancel-in-progress: true + +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: 'true' + +jobs: + implement: + if: ${{ github.event.label.name == 'agent:try' }} + runs-on: [self-hosted, tokenscope] + timeout-minutes: 45 + env: + ANTHROPIC_BASE_URL: ${{ secrets.LITELLM_BASE_URL }} + ANTHROPIC_API_KEY: ${{ secrets.LITELLM_API_KEY }} + ANTHROPIC_MODEL: claude-3-5-sonnet-20241022 + steps: + - name: Configure no_proxy for LiteLLM + run: | + { + echo "no_proxy=${{ secrets.LITELLM_NO_PROXY }}" + echo "NO_PROXY=${{ secrets.LITELLM_NO_PROXY }}" + } >> "$GITHUB_ENV" + + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + # AGENT_GH_TOKEN is a PAT (not GITHUB_TOKEN) so wiwi's + # `gh pr create` actually triggers the downstream `ci` and + # `pr-review` workflows. With the default GITHUB_TOKEN the + # spawned PR's check runs would be skipped. + token: ${{ secrets.AGENT_GH_TOKEN }} + + - name: Implement issue + env: + ISSUE_NUMBER: ${{ github.event.issue.number }} + ISSUE_TITLE: ${{ github.event.issue.title }} + ISSUE_BODY: ${{ github.event.issue.body }} + ISSUE_AUTHOR: ${{ github.event.issue.user.login }} + GH_TOKEN: ${{ secrets.AGENT_GH_TOKEN }} + run: bash scripts/agent-bot/run_wiwi.sh + + - name: Cleanup transient files + if: always() + run: rm -f /tmp/wiwi-*.md /tmp/wiwi-*.log /tmp/wiwi-*.txt || true diff --git a/.github/workflows/issue-triage.yml b/.github/workflows/issue-triage.yml new file mode 100644 index 0000000..5164ad3 --- /dev/null +++ b/.github/workflows/issue-triage.yml @@ -0,0 +1,55 @@ +name: issue-triage + +# Triage agent. Fires only when a human (or repo automation) adds the +# `agent:assess` label to an issue. The triage agent decides whether +# the issue is small/safe enough for the dev agent **wiwi** to attempt +# unattended. Strict 5-gate verdict — see scripts/agent-bot/run_triage.sh. + +on: + issues: + types: [labeled] + +permissions: + contents: read + issues: write + +concurrency: + group: triage-${{ github.event.issue.number }} + cancel-in-progress: true + +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: 'true' + +jobs: + triage: + if: ${{ github.event.label.name == 'agent:assess' }} + runs-on: [self-hosted, tokenscope] + timeout-minutes: 10 + env: + ANTHROPIC_BASE_URL: ${{ secrets.LITELLM_BASE_URL }} + ANTHROPIC_API_KEY: ${{ secrets.LITELLM_API_KEY }} + ANTHROPIC_MODEL: claude-3-5-sonnet-20241022 + steps: + - name: Configure no_proxy for LiteLLM + run: | + { + echo "no_proxy=${{ secrets.LITELLM_NO_PROXY }}" + echo "NO_PROXY=${{ secrets.LITELLM_NO_PROXY }}" + } >> "$GITHUB_ENV" + + - uses: actions/checkout@v4 + with: + fetch-depth: 1 + + - name: Triage issue + env: + ISSUE_NUMBER: ${{ github.event.issue.number }} + ISSUE_TITLE: ${{ github.event.issue.title }} + ISSUE_BODY: ${{ github.event.issue.body }} + ISSUE_AUTHOR: ${{ github.event.issue.user.login }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: bash scripts/agent-bot/run_triage.sh + + - name: Cleanup transient files + if: always() + run: rm -f /tmp/triage-*.md /tmp/triage-*.log /tmp/triage-*.json || true diff --git a/.github/workflows/pr-review.yml b/.github/workflows/pr-review.yml index 87a5cc7..7483df2 100644 --- a/.github/workflows/pr-review.yml +++ b/.github/workflows/pr-review.yml @@ -132,6 +132,17 @@ jobs: AGENT_EXIT: ${{ steps.review.outcome }} run: python3 scripts/pr-review/post_review.py "$PR_NUMBER" + - name: Auto-merge if eligible + # Only effective for wiwi-spawned PRs (label `auto-agent`) whose + # linked issue author is on the team. Idempotent + safe to call + # for every PR — bails out for human PRs. Needs AGENT_GH_TOKEN + # so the admin-merge call has admin bypass on branch protection. + if: ${{ steps.review.outcome == 'success' }} + env: + GH_TOKEN: ${{ secrets.AGENT_GH_TOKEN }} + PR_NUMBER: ${{ steps.pr.outputs.pr_number }} + run: bash scripts/agent-bot/auto_merge.sh + - name: Cleanup transient files if: always() run: rm -f /tmp/pr-review-*.md /tmp/pr-review-*.log /tmp/pr-review-*.json || true diff --git a/scripts/agent-bot/TEAM b/scripts/agent-bot/TEAM new file mode 100644 index 0000000..bcec67a --- /dev/null +++ b/scripts/agent-bot/TEAM @@ -0,0 +1,5 @@ +# Team logins eligible for auto-merge on wiwi-spawned PRs. +# One GitHub login per line. Lines starting with `#` are ignored. +vaderyang +william +timmy diff --git a/scripts/agent-bot/auto_merge.sh b/scripts/agent-bot/auto_merge.sh new file mode 100755 index 0000000..34c4ad3 --- /dev/null +++ b/scripts/agent-bot/auto_merge.sh @@ -0,0 +1,43 @@ +#!/usr/bin/env bash +# Called from the tail of pr-review.yml AFTER vivi posts her review. +# Auto-merges iff: +# - PR has label `auto-agent` +# - PR is not draft (wiwi may have flipped it; or the linked issue +# author was a team member and we promoted earlier — see below) +# - vivi's latest review state == APPROVED +# - the linked issue's author is in the TEAM file +set -euo pipefail + +HERE=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) +# TEAM file is the single source of truth, shared with run_wiwi.sh. +TEAM=$(grep -vE '^\s*(#|$)' "$HERE/TEAM" | tr '\n' ' ') + +PR="${PR_NUMBER:?PR_NUMBER required}" + +meta=$(gh pr view "$PR" --json isDraft,labels,body) +labels=$(echo "$meta" | jq -r '.labels[].name') +echo "$labels" | grep -qx auto-agent || { echo "not auto-agent PR; skip"; exit 0; } + +# Latest review state. Some reviews have a null .body (e.g. quick +# APPROVE clicks without a comment); coerce to "" before contains() +# or jq aborts the whole pipeline. +state=$(gh pr view "$PR" --json reviews --jq ' + [.reviews[] | select(.author.login=="vivi" or ((.body // "") | contains("vivi")))] + | last | .state // empty') +[ "$state" = "APPROVED" ] || { echo "vivi verdict=$state; skip"; exit 0; } + +# Extract issue number from PR body `Closes #N`. +issue=$(echo "$meta" | jq -r '.body' | grep -oE 'Closes #[0-9]+' | head -1 | tr -dc 0-9) +[ -n "$issue" ] || { echo "no linked issue; skip"; exit 0; } + +author=$(gh issue view "$issue" --json author --jq '.author.login') +for m in $TEAM; do + if [ "$m" = "$author" ]; then + echo "vivi APPROVED + author=$author ∈ TEAM → admin-merge" + # Lift draft (if still draft) and merge. + gh pr ready "$PR" >/dev/null 2>&1 || true + gh pr merge "$PR" --admin --squash --delete-branch + exit 0 + fi +done +echo "author=$author not in TEAM; leaving PR for human review" diff --git a/scripts/agent-bot/run_triage.sh b/scripts/agent-bot/run_triage.sh new file mode 100755 index 0000000..ea1f523 --- /dev/null +++ b/scripts/agent-bot/run_triage.sh @@ -0,0 +1,94 @@ +#!/usr/bin/env bash +# Triage agent: read issue, decide do/skip/needs_info under STRICT gates. +# verdict=do → add `agent:try` label (kicks off wiwi). +# else → post a comment explaining gate failure; human can manually +# add `agent:try` to force, or `agent:skip` to mute. +set -euo pipefail + +PROMPT=$(mktemp) +OUT=$(mktemp) + +cat > "$PROMPT" <","reason":"<≤200 chars>","files":["..."],"gates":{"1":true,"2":true,"3":true,"4":true,"5":true}} + +Issue title: ${ISSUE_TITLE} +Author: ${ISSUE_AUTHOR} +EOF + +# Run claude in print mode against our LiteLLM-style endpoint. +claude --print \ + --allowed-tools Bash Read Grep Glob WebFetch \ + --model "${ANTHROPIC_MODEL:-claude-3-5-sonnet-20241022}" \ + < "$PROMPT" > "$OUT" 2> /tmp/triage-stderr.log || { + echo "triage agent failed (see workflow log)" >&2 + cat /tmp/triage-stderr.log >&2 + exit 1 +} + +# Strict JSON parse: scan every line, keep ones that are valid JSON AND +# carry a `verdict` field, take the last. This rejects lines inside +# code fences, partial fragments, and lines that merely *mention* +# "verdict" in prose — only a parsable JSON object survives. +LAST=$(jq -Rrc 'fromjson? | select(.verdict)' "$OUT" 2>/dev/null | tail -1) +if [ -z "$LAST" ]; then + echo "triage agent produced no parsable JSON verdict; aborting" >&2 + cat "$OUT" >&2 + exit 1 +fi + +VERDICT=$(echo "$LAST" | jq -r '.verdict') +REASON=$(echo "$LAST" | jq -r '.reason') +SCOPE=$(echo "$LAST" | jq -r '.scope') + +# Defense-in-depth: require all 5 gates true for verdict=do. +if [ "$VERDICT" = "do" ]; then + ALLPASS=$(echo "$LAST" | jq -r '[.gates."1",.gates."2",.gates."3",.gates."4",.gates."5"] | all') + if [ "$ALLPASS" != "true" ]; then + echo "verdict=do but not all gates true; downgrading to needs_info" >&2 + VERDICT=needs_info + REASON="triage gates incomplete: $REASON" + fi +fi + +case "$VERDICT" in + do) + gh issue edit "$ISSUE_NUMBER" --add-label "agent:try" + gh issue comment "$ISSUE_NUMBER" --body "🤖 Triage: **${VERDICT}** — scope: ${SCOPE} + +${REASON} + +Auto-labeled \`agent:try\`. **wiwi** will pick this up shortly." + ;; + needs_info|skip) + gh issue comment "$ISSUE_NUMBER" --body "🤖 Triage: **${VERDICT}** + +${REASON} + +Manually add the \`agent:try\` label to override this verdict and run **wiwi** anyway, or \`agent:skip\` to mute future re-triage." + ;; + *) + echo "unknown verdict: $VERDICT" >&2; exit 1 ;; +esac diff --git a/scripts/agent-bot/run_wiwi.sh b/scripts/agent-bot/run_wiwi.sh new file mode 100755 index 0000000..7b0b5a1 --- /dev/null +++ b/scripts/agent-bot/run_wiwi.sh @@ -0,0 +1,87 @@ +#!/usr/bin/env bash +# wiwi: dev agent. Branch off main, implement, ensure cargo build + tests +# green, open a DRAFT PR labelled `auto-agent`. Auto-merge gating happens +# downstream in pr-review.yml. +set -euo pipefail + +HERE=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) +TEAM=$(grep -vE '^\s*(#|$)' "$HERE/TEAM" | tr '\n' ' ') +is_team_member() { + local who="$1" + for m in $TEAM; do [ "$m" = "$who" ] && return 0; done + return 1 +} + +# Branch name includes a short UTC timestamp so re-runs against the +# same issue don't collide with leftover branches from prior attempts. +STAMP=$(date -u +%Y%m%d-%H%M%S) +BRANCH="agent/wiwi/issue-${ISSUE_NUMBER}-${STAMP}" +git config user.email "wiwi-agent@noreply.local" +git config user.name "wiwi" +git fetch origin main +git checkout -B "$BRANCH" origin/main + +PROMPT=$(mktemp) +cat > "$PROMPT" <300 LOC or cross-cutting), STOP, leave + a note in /tmp/wiwi-abort.txt explaining why, and exit non-zero. +- Add a deterministic test for the change (unit / integration / a tiny + fixture). Don't claim done without one. +- After edits, run \`just build\` (or \`cargo check\` + \`bun run build\` + in console/) — must be green before you stop. +- Do NOT add new dependencies, new secrets, new network calls. +- Do NOT modify CI workflows, branch protection, or this script. +- Commit in logical chunks; sign-off line not required. + +When done, write a brief summary to /tmp/wiwi-summary.md (Markdown) for +the PR body. End it with the literal line: + + Closes #${ISSUE_NUMBER} + +Issue title: ${ISSUE_TITLE} +EOF + +claude --print \ + --allowed-tools Bash Read Write Edit Grep Glob \ + --model "${ANTHROPIC_MODEL:-claude-3-5-sonnet-20241022}" \ + < "$PROMPT" > /tmp/wiwi-run.log 2>&1 || { + echo "wiwi run failed (see /tmp/wiwi-run.log)" >&2 + gh issue comment "$ISSUE_NUMBER" --body "🤖 wiwi could not complete this task. See workflow log." + exit 1 +} + +if [ -f /tmp/wiwi-abort.txt ]; then + gh issue comment "$ISSUE_NUMBER" --body "🤖 wiwi aborted: $(cat /tmp/wiwi-abort.txt)" + exit 0 +fi + +# Sanity: must have produced commits. +if [ "$(git rev-list --count origin/main..HEAD)" = "0" ]; then + gh issue comment "$ISSUE_NUMBER" --body "🤖 wiwi finished without any commit; nothing to PR." + exit 0 +fi + +git push -u origin "$BRANCH" + +BODY_FILE=$(mktemp) +{ + cat /tmp/wiwi-summary.md 2>/dev/null || echo "(wiwi did not write a summary)" + echo + echo "---" + echo "🤖 Implemented by **wiwi** • issue author: @${ISSUE_AUTHOR}" + if is_team_member "$ISSUE_AUTHOR"; then + echo "Eligible for auto-merge on vivi APPROVE." + fi +} > "$BODY_FILE" + +gh pr create \ + --draft \ + --base main \ + --head "$BRANCH" \ + --title "${ISSUE_TITLE}" \ + --body-file "$BODY_FILE" \ + --label auto-agent diff --git a/scripts/pr-review/post_review.py b/scripts/pr-review/post_review.py index 359ec45..d4af8bb 100755 --- a/scripts/pr-review/post_review.py +++ b/scripts/pr-review/post_review.py @@ -140,6 +140,19 @@ def pr_author(number: str) -> str | None: return proc.stdout.strip() or None +def pr_has_label(number: str, name: str) -> bool: + proc = subprocess.run( + ["gh", "pr", "view", number, "--json", "labels", "--jq", + f'any(.labels[]; .name == "{name}")'], + capture_output=True, + text=True, + ) + if proc.returncode != 0: + sys.stderr.write(f"gh pr view (labels) failed: {proc.stderr}\n") + return False + return proc.stdout.strip() == "true" + + def auto_merge(number: str) -> None: """Squash-merge with admin bypass. Repo doesn't have native `--auto` enabled, so we squash inline. Branch is deleted on @@ -200,13 +213,21 @@ def main() -> int: # that an APPROVE from the AI is enough signal for low-stakes # changes by the project maintainer, but anyone else's PR # still gets human review. + # + # PRs labelled `auto-agent` are wiwi-spawned; their auto-merge + # decision is owned by scripts/agent-bot/auto_merge.sh (different + # gates: linked-issue author, not PR author). Skip here so the + # two paths never race on the same PR. if event == "APPROVE": - author = pr_author(PR_NUMBER) - if author and author in AUTO_MERGE_AUTHORS: - print(f"author={author} in AUTO_MERGE_AUTHORS — squash-merging") - auto_merge(PR_NUMBER) + if pr_has_label(PR_NUMBER, "auto-agent"): + print("PR has `auto-agent` label — leaving auto-merge to agent-bot/auto_merge.sh") else: - print(f"author={author} not in AUTO_MERGE_AUTHORS={AUTO_MERGE_AUTHORS} — left for human") + author = pr_author(PR_NUMBER) + if author and author in AUTO_MERGE_AUTHORS: + print(f"author={author} in AUTO_MERGE_AUTHORS — squash-merging") + auto_merge(PR_NUMBER) + else: + print(f"author={author} not in AUTO_MERGE_AUTHORS={AUTO_MERGE_AUTHORS} — left for human") return 0