diff --git a/.claude/skills/bug-fix-tdd/SKILL.md b/.claude/skills/bug-fix-tdd/SKILL.md new file mode 100644 index 000000000..9fffc1b39 --- /dev/null +++ b/.claude/skills/bug-fix-tdd/SKILL.md @@ -0,0 +1,189 @@ +--- +name: bug-fix-tdd +description: Reproduce and fix bugs using TDD. Use when analyzing a bug report, writing a regression test, or applying a minimal fix. Covers test placement, mock patterns, and the red-green-refactor workflow for automated bug fixing. +--- + +# Bug Fix TDD + +Reproduce bugs with a failing test, then apply the minimum fix. This skill is used by the automated bug-fix agent in CI but can also be invoked manually. + +## TDD Workflow + +### Phase 1 — Analysis & Failing Test (Red) + +1. **Parse the bug report**: extract description, steps to reproduce, expected vs actual behavior +2. **Find relevant code**: use Grep/Glob to locate the component, hook, or route mentioned in the bug +3. **Write a unit test** that reproduces the bug — the test MUST FAIL +4. **Run the test**: `pnpm run test:nonInteractive -- ` +5. **Verify failure reason**: the test must fail because of the bug, not because of import errors or unrelated issues +6. **Retry if needed**: if the test passes (bug not reproduced), try a different approach (max 5 attempts) +7. **Write `bug-analysis.md`** with findings (see format below) + +**Constraints**: Do NOT modify source files in Phase 1. Only create/edit test files and `bug-analysis.md`. + +### Phase 2 — Fix (Green) + +1. **Read the failing test** and `bug-analysis.md` +2. **Apply the MINIMUM fix** to make the test pass — do not over-engineer +3. **Run the single test**: `pnpm run test:nonInteractive -- ` +4. **Run the full suite**: `pnpm run test:nonInteractive` +5. **Run static checks**: `pnpm run lint` and `pnpm run type-check` +6. **Retry if needed**: if any check fails, adjust the fix (max 5 attempts) +7. **Write `pr-body.md` and `fix-title.txt`** + +**Constraints**: Do NOT run git, gh, or modify .env files. + +## Test Placement Rules + +- Tests go in `__tests__/` directories colocated with the source file +- If a test file already exists for the component, **add a new `describe('Bug #N', ...)` block** instead of creating a new file +- Naming: `.test.tsx` or `.test.ts` +- Example: source at `renderer/src/features/skills/components/card-skill.tsx` → test at `renderer/src/features/skills/components/__tests__/card-skill.test.tsx` + +## Test Patterns + +### Component test (simplest) + +```typescript +import { render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' + +const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, +}) + +render( + + + +) + +await userEvent.click(screen.getByRole('button', { name: /save/i })) +await waitFor(() => { + expect(screen.getByText('Saved')).toBeVisible() +}) +``` + +### Route-level component test + +```typescript +import { createTestRouter } from '@/common/test/create-test-router' +import { renderRoute } from '@/common/test/render-route' + +const router = createTestRouter(MyPage, '/my-page') +renderRoute(router, { permissions: { canManageClients: true } }) + +await waitFor(() => { + expect(screen.getByRole('heading', { name: /my page/i })).toBeVisible() +}) +``` + +### Hook test + +```typescript +import { renderHook, waitFor } from '@testing-library/react' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' + +const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, +}) +const Wrapper = ({ children }) => + {children} + +const { result } = renderHook(() => useMyHook(), { wrapper: Wrapper }) + +await waitFor(() => expect(result.current.isLoading).toBe(false)) +expect(result.current.data).toEqual({ ... }) +``` + +### API mock override (return different data) + +```typescript +import { mockedGetApiV1BetaWorkloads } from '@mocks/fixtures/workloads/get' + +mockedGetApiV1BetaWorkloads.override((data) => ({ + ...data, + workloads: [], // Force empty state +})) +``` + +### API mock error response + +```typescript +import { HttpResponse } from 'msw' + +mockedGetApiV1BetaWorkloads.overrideHandler(() => + HttpResponse.json({ error: 'Server error' }, { status: 500 }) +) +``` + +### Request recording (for mutations) + +```typescript +import { recordRequests } from '@/common/mocks/node' + +const rec = recordRequests() +// ... trigger action ... +const request = rec.recordedRequests.find( + (r) => r.method === 'POST' && r.pathname === '/api/v1beta/workloads' +) +expect(request?.payload).toMatchObject({ name: 'my-server' }) +``` + +## bug-analysis.md Format + +```markdown +## Bug Summary +<1-2 sentences describing the bug> + +## Root Cause + + +## Relevant Files +- `path/to/source.tsx` — +- `path/to/related.ts` — + +Test file: path/to/__tests__/component.test.tsx + +## Proposed Fix + + +## Files to Modify +- `path/to/file.tsx` — +``` + +**Important**: The `Test file:` line must be on its own line starting with exactly `Test file: ` followed by the path. This is parsed by the CI workflow. + +## pr-body.md Format + +```markdown +## Summary + +Fixes #. + +- <1-2 bullet points describing the fix> + +## Test + +- Added regression test in `` +- Test reproduces the bug (fails before fix, passes after) + +--- +*Automated fix by Claude Code TDD Agent* +``` + +## fix-title.txt Format + +Single line, conventional commit format: +``` +fix(): (#) +``` + +Example: `fix(skills): prevent crash when metadata is undefined (#423)` + +## Related Skills + +- **testing-with-api-mocks** — Auto-generated MSW fixtures and mock basics +- **testing-api-assertions** — Verifying mutations with `recordRequests()` +- **testing-api-overrides** — Conditional mock responses for testing filters/params diff --git a/.codex/skills/bug-fix-tdd/SKILL.md b/.codex/skills/bug-fix-tdd/SKILL.md new file mode 100644 index 000000000..9fffc1b39 --- /dev/null +++ b/.codex/skills/bug-fix-tdd/SKILL.md @@ -0,0 +1,189 @@ +--- +name: bug-fix-tdd +description: Reproduce and fix bugs using TDD. Use when analyzing a bug report, writing a regression test, or applying a minimal fix. Covers test placement, mock patterns, and the red-green-refactor workflow for automated bug fixing. +--- + +# Bug Fix TDD + +Reproduce bugs with a failing test, then apply the minimum fix. This skill is used by the automated bug-fix agent in CI but can also be invoked manually. + +## TDD Workflow + +### Phase 1 — Analysis & Failing Test (Red) + +1. **Parse the bug report**: extract description, steps to reproduce, expected vs actual behavior +2. **Find relevant code**: use Grep/Glob to locate the component, hook, or route mentioned in the bug +3. **Write a unit test** that reproduces the bug — the test MUST FAIL +4. **Run the test**: `pnpm run test:nonInteractive -- ` +5. **Verify failure reason**: the test must fail because of the bug, not because of import errors or unrelated issues +6. **Retry if needed**: if the test passes (bug not reproduced), try a different approach (max 5 attempts) +7. **Write `bug-analysis.md`** with findings (see format below) + +**Constraints**: Do NOT modify source files in Phase 1. Only create/edit test files and `bug-analysis.md`. + +### Phase 2 — Fix (Green) + +1. **Read the failing test** and `bug-analysis.md` +2. **Apply the MINIMUM fix** to make the test pass — do not over-engineer +3. **Run the single test**: `pnpm run test:nonInteractive -- ` +4. **Run the full suite**: `pnpm run test:nonInteractive` +5. **Run static checks**: `pnpm run lint` and `pnpm run type-check` +6. **Retry if needed**: if any check fails, adjust the fix (max 5 attempts) +7. **Write `pr-body.md` and `fix-title.txt`** + +**Constraints**: Do NOT run git, gh, or modify .env files. + +## Test Placement Rules + +- Tests go in `__tests__/` directories colocated with the source file +- If a test file already exists for the component, **add a new `describe('Bug #N', ...)` block** instead of creating a new file +- Naming: `.test.tsx` or `.test.ts` +- Example: source at `renderer/src/features/skills/components/card-skill.tsx` → test at `renderer/src/features/skills/components/__tests__/card-skill.test.tsx` + +## Test Patterns + +### Component test (simplest) + +```typescript +import { render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' + +const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, +}) + +render( + + + +) + +await userEvent.click(screen.getByRole('button', { name: /save/i })) +await waitFor(() => { + expect(screen.getByText('Saved')).toBeVisible() +}) +``` + +### Route-level component test + +```typescript +import { createTestRouter } from '@/common/test/create-test-router' +import { renderRoute } from '@/common/test/render-route' + +const router = createTestRouter(MyPage, '/my-page') +renderRoute(router, { permissions: { canManageClients: true } }) + +await waitFor(() => { + expect(screen.getByRole('heading', { name: /my page/i })).toBeVisible() +}) +``` + +### Hook test + +```typescript +import { renderHook, waitFor } from '@testing-library/react' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' + +const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, +}) +const Wrapper = ({ children }) => + {children} + +const { result } = renderHook(() => useMyHook(), { wrapper: Wrapper }) + +await waitFor(() => expect(result.current.isLoading).toBe(false)) +expect(result.current.data).toEqual({ ... }) +``` + +### API mock override (return different data) + +```typescript +import { mockedGetApiV1BetaWorkloads } from '@mocks/fixtures/workloads/get' + +mockedGetApiV1BetaWorkloads.override((data) => ({ + ...data, + workloads: [], // Force empty state +})) +``` + +### API mock error response + +```typescript +import { HttpResponse } from 'msw' + +mockedGetApiV1BetaWorkloads.overrideHandler(() => + HttpResponse.json({ error: 'Server error' }, { status: 500 }) +) +``` + +### Request recording (for mutations) + +```typescript +import { recordRequests } from '@/common/mocks/node' + +const rec = recordRequests() +// ... trigger action ... +const request = rec.recordedRequests.find( + (r) => r.method === 'POST' && r.pathname === '/api/v1beta/workloads' +) +expect(request?.payload).toMatchObject({ name: 'my-server' }) +``` + +## bug-analysis.md Format + +```markdown +## Bug Summary +<1-2 sentences describing the bug> + +## Root Cause + + +## Relevant Files +- `path/to/source.tsx` — +- `path/to/related.ts` — + +Test file: path/to/__tests__/component.test.tsx + +## Proposed Fix + + +## Files to Modify +- `path/to/file.tsx` — +``` + +**Important**: The `Test file:` line must be on its own line starting with exactly `Test file: ` followed by the path. This is parsed by the CI workflow. + +## pr-body.md Format + +```markdown +## Summary + +Fixes #. + +- <1-2 bullet points describing the fix> + +## Test + +- Added regression test in `` +- Test reproduces the bug (fails before fix, passes after) + +--- +*Automated fix by Claude Code TDD Agent* +``` + +## fix-title.txt Format + +Single line, conventional commit format: +``` +fix(): (#) +``` + +Example: `fix(skills): prevent crash when metadata is undefined (#423)` + +## Related Skills + +- **testing-with-api-mocks** — Auto-generated MSW fixtures and mock basics +- **testing-api-assertions** — Verifying mutations with `recordRequests()` +- **testing-api-overrides** — Conditional mock responses for testing filters/params diff --git a/.cursor/skills/bug-fix-tdd/SKILL.md b/.cursor/skills/bug-fix-tdd/SKILL.md new file mode 100644 index 000000000..9fffc1b39 --- /dev/null +++ b/.cursor/skills/bug-fix-tdd/SKILL.md @@ -0,0 +1,189 @@ +--- +name: bug-fix-tdd +description: Reproduce and fix bugs using TDD. Use when analyzing a bug report, writing a regression test, or applying a minimal fix. Covers test placement, mock patterns, and the red-green-refactor workflow for automated bug fixing. +--- + +# Bug Fix TDD + +Reproduce bugs with a failing test, then apply the minimum fix. This skill is used by the automated bug-fix agent in CI but can also be invoked manually. + +## TDD Workflow + +### Phase 1 — Analysis & Failing Test (Red) + +1. **Parse the bug report**: extract description, steps to reproduce, expected vs actual behavior +2. **Find relevant code**: use Grep/Glob to locate the component, hook, or route mentioned in the bug +3. **Write a unit test** that reproduces the bug — the test MUST FAIL +4. **Run the test**: `pnpm run test:nonInteractive -- ` +5. **Verify failure reason**: the test must fail because of the bug, not because of import errors or unrelated issues +6. **Retry if needed**: if the test passes (bug not reproduced), try a different approach (max 5 attempts) +7. **Write `bug-analysis.md`** with findings (see format below) + +**Constraints**: Do NOT modify source files in Phase 1. Only create/edit test files and `bug-analysis.md`. + +### Phase 2 — Fix (Green) + +1. **Read the failing test** and `bug-analysis.md` +2. **Apply the MINIMUM fix** to make the test pass — do not over-engineer +3. **Run the single test**: `pnpm run test:nonInteractive -- ` +4. **Run the full suite**: `pnpm run test:nonInteractive` +5. **Run static checks**: `pnpm run lint` and `pnpm run type-check` +6. **Retry if needed**: if any check fails, adjust the fix (max 5 attempts) +7. **Write `pr-body.md` and `fix-title.txt`** + +**Constraints**: Do NOT run git, gh, or modify .env files. + +## Test Placement Rules + +- Tests go in `__tests__/` directories colocated with the source file +- If a test file already exists for the component, **add a new `describe('Bug #N', ...)` block** instead of creating a new file +- Naming: `.test.tsx` or `.test.ts` +- Example: source at `renderer/src/features/skills/components/card-skill.tsx` → test at `renderer/src/features/skills/components/__tests__/card-skill.test.tsx` + +## Test Patterns + +### Component test (simplest) + +```typescript +import { render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' + +const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, +}) + +render( + + + +) + +await userEvent.click(screen.getByRole('button', { name: /save/i })) +await waitFor(() => { + expect(screen.getByText('Saved')).toBeVisible() +}) +``` + +### Route-level component test + +```typescript +import { createTestRouter } from '@/common/test/create-test-router' +import { renderRoute } from '@/common/test/render-route' + +const router = createTestRouter(MyPage, '/my-page') +renderRoute(router, { permissions: { canManageClients: true } }) + +await waitFor(() => { + expect(screen.getByRole('heading', { name: /my page/i })).toBeVisible() +}) +``` + +### Hook test + +```typescript +import { renderHook, waitFor } from '@testing-library/react' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' + +const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, +}) +const Wrapper = ({ children }) => + {children} + +const { result } = renderHook(() => useMyHook(), { wrapper: Wrapper }) + +await waitFor(() => expect(result.current.isLoading).toBe(false)) +expect(result.current.data).toEqual({ ... }) +``` + +### API mock override (return different data) + +```typescript +import { mockedGetApiV1BetaWorkloads } from '@mocks/fixtures/workloads/get' + +mockedGetApiV1BetaWorkloads.override((data) => ({ + ...data, + workloads: [], // Force empty state +})) +``` + +### API mock error response + +```typescript +import { HttpResponse } from 'msw' + +mockedGetApiV1BetaWorkloads.overrideHandler(() => + HttpResponse.json({ error: 'Server error' }, { status: 500 }) +) +``` + +### Request recording (for mutations) + +```typescript +import { recordRequests } from '@/common/mocks/node' + +const rec = recordRequests() +// ... trigger action ... +const request = rec.recordedRequests.find( + (r) => r.method === 'POST' && r.pathname === '/api/v1beta/workloads' +) +expect(request?.payload).toMatchObject({ name: 'my-server' }) +``` + +## bug-analysis.md Format + +```markdown +## Bug Summary +<1-2 sentences describing the bug> + +## Root Cause + + +## Relevant Files +- `path/to/source.tsx` — +- `path/to/related.ts` — + +Test file: path/to/__tests__/component.test.tsx + +## Proposed Fix + + +## Files to Modify +- `path/to/file.tsx` — +``` + +**Important**: The `Test file:` line must be on its own line starting with exactly `Test file: ` followed by the path. This is parsed by the CI workflow. + +## pr-body.md Format + +```markdown +## Summary + +Fixes #. + +- <1-2 bullet points describing the fix> + +## Test + +- Added regression test in `` +- Test reproduces the bug (fails before fix, passes after) + +--- +*Automated fix by Claude Code TDD Agent* +``` + +## fix-title.txt Format + +Single line, conventional commit format: +``` +fix(): (#) +``` + +Example: `fix(skills): prevent crash when metadata is undefined (#423)` + +## Related Skills + +- **testing-with-api-mocks** — Auto-generated MSW fixtures and mock basics +- **testing-api-assertions** — Verifying mutations with `recordRequests()` +- **testing-api-overrides** — Conditional mock responses for testing filters/params diff --git a/.github/workflows/_bug-fix-agent.yml b/.github/workflows/_bug-fix-agent.yml new file mode 100644 index 000000000..5f55f482a --- /dev/null +++ b/.github/workflows/_bug-fix-agent.yml @@ -0,0 +1,258 @@ +name: Bug Fix Agent + +on: + workflow_call: + inputs: + issue-number: + required: true + type: number + +concurrency: + group: bug-fix-${{ inputs.issue-number }} + cancel-in-progress: true + +jobs: + fix: + name: TDD Bug Fix + runs-on: ubuntu-latest + timeout-minutes: 45 + permissions: + contents: write + pull-requests: write + issues: write + id-token: write + steps: + - name: Generate GitHub App token + id: app-token + uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 # v3 + with: + app-id: ${{ secrets.TOOLHIVE_STUDIO_CI_APP_ID }} + private-key: ${{ secrets.TOOLHIVE_STUDIO_CI_APP_KEY }} + + - name: Checkout Repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + ref: ${{ github.event.repository.default_branch }} + token: ${{ steps.app-token.outputs.token }} + + - name: Check for existing PR or previous failure + id: guard + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} + run: | + BRANCH="fix/auto-${{ inputs.issue-number }}" + BASE="${{ github.event.repository.default_branch }}" + EXISTING_PR=$(gh pr list --head "$BRANCH" --base "$BASE" --state open --json number --jq '.[0].number // empty') + + if [ -n "$EXISTING_PR" ]; then + echo "skip=true" >> $GITHUB_OUTPUT + echo "::notice::Open PR #${EXISTING_PR} already exists for issue #${{ inputs.issue-number }} — skipping" + exit 0 + fi + + # Check if the agent already gave up on this issue (final comment exists) + GAVE_UP=$(gh api "repos/${{ github.repository }}/issues/${{ inputs.issue-number }}/comments" \ + --paginate --jq '[.[] | select(.body | contains("Bug Fix Agent"))] | length' 2>/dev/null || echo "0") + if [ "$GAVE_UP" -gt "0" ]; then + echo "skip=true" >> $GITHUB_OUTPUT + echo "::notice::Agent already exhausted all attempts for issue #${{ inputs.issue-number }} — skipping" + exit 0 + fi + + echo "skip=false" >> $GITHUB_OUTPUT + + - name: Fetch issue body + if: steps.guard.outputs.skip != 'true' + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} + run: | + gh issue view ${{ inputs.issue-number }} --json title,body,labels \ + --template '# {{.title}}{{"\n\n"}}{{.body}}' > issue-body.md + + - name: Setup + if: steps.guard.outputs.skip != 'true' + uses: ./.github/actions/setup + + - name: Install Claude Code + if: steps.guard.outputs.skip != 'true' + run: pnpm add -g @anthropic-ai/claude-code + + - name: 'TDD Bug Fix (up to 3 attempts)' + id: tdd + if: steps.guard.outputs.skip != 'true' + continue-on-error: true + env: + ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} + BUG_ISSUE_NUMBER: ${{ inputs.issue-number }} + run: | + MAX_ATTEMPTS=3 + SUCCESS=false + + for ATTEMPT in $(seq 1 $MAX_ATTEMPTS); do + echo "::group::Attempt $ATTEMPT of $MAX_ATTEMPTS" + + # Clean up previous attempt artifacts + rm -f bug-analysis.md pr-body.md fix-title.txt + git checkout -- . 2>/dev/null || true + git clean -fd -- '**/*.ts' '**/*.tsx' 2>/dev/null || true + + # --- Phase 1: Analyze & Write Failing Test (Opus) --- + echo "::notice::Phase 1 — Writing failing test (attempt $ATTEMPT)" + if ! claude -p --model opus \ + --dangerously-skip-permissions \ + --allowedTools "Read,Grep,Glob,Edit,Write,Bash(pnpm run test:nonInteractive *),Bash(cat *),Bash(ls *)" \ + --max-turns 50 \ + "Read .claude/skills/bug-fix-tdd/SKILL.md and follow it. + Read issue-body.md for the bug report. + + Your task (Phase 1 — Analysis & Test): + 1. Analyze the bug report: understand description, steps to reproduce, expected vs actual behavior. + 2. Search the codebase to find the relevant source code. + 3. Write a unit test that reproduces the bug — it MUST FAIL when you run it. + 4. Run the test with: pnpm run test:nonInteractive -- + 5. Verify the test fails FOR THE RIGHT REASON (not import errors or unrelated failures). + 6. If the test passes (bug not reproduced), try a different approach (max 5 attempts). + + Output: + - The test file in the correct __tests__/ directory. + - bug-analysis.md with your findings (follow the format in the skill). + + Do NOT modify any source files. Only create/edit test files and bug-analysis.md."; then + echo "::warning::Phase 1 failed on attempt $ATTEMPT" + echo "::endgroup::" + continue + fi + + # --- Hard gate: verify test fails --- + if [ ! -f bug-analysis.md ]; then + echo "::warning::bug-analysis.md not found — skipping attempt $ATTEMPT" + echo "::endgroup::" + continue + fi + + TEST_FILE=$(grep "^Test file:" bug-analysis.md | sed 's/^Test file: //') + if [ -z "$TEST_FILE" ]; then + echo "::warning::No 'Test file:' line found in bug-analysis.md — skipping attempt $ATTEMPT" + echo "::endgroup::" + continue + fi + + if pnpm run test:nonInteractive -- "$TEST_FILE" 2>&1; then + echo "::warning::Test passed (bug not reproduced) — skipping attempt $ATTEMPT" + echo "::endgroup::" + continue + fi + + echo "::notice::Test fails as expected — proceeding to Phase 2" + + # --- Phase 2: Implement Fix (Sonnet) --- + export BUG_TEST_FILE="$TEST_FILE" + echo "::notice::Phase 2 — Implementing fix (attempt $ATTEMPT)" + if ! claude -p --model sonnet \ + --dangerously-skip-permissions \ + --allowedTools "Read,Grep,Glob,Edit,Bash(pnpm run test:nonInteractive *),Bash(pnpm run lint),Bash(pnpm run type-check),Bash(cat *),Bash(ls *)" \ + --max-turns 80 \ + "Read .claude/skills/bug-fix-tdd/SKILL.md and bug-analysis.md. + + Your task (Phase 2 — Fix): + 1. Read the failing test and understand what it expects. + 2. Apply the MINIMUM fix to make the test pass. + 3. Run the test to verify it passes: pnpm run test:nonInteractive -- $BUG_TEST_FILE + 4. Run the full test suite: pnpm run test:nonInteractive + 5. Run pnpm run lint and pnpm run type-check. + 6. If any check fails, adjust the fix (max 5 attempts). + 7. Write pr-body.md (include 'Fixes #$BUG_ISSUE_NUMBER') and fix-title.txt. + + Do NOT run git, gh, or modify .env files. + Do NOT over-engineer — apply the smallest change that fixes the bug."; then + echo "::warning::Phase 2 failed on attempt $ATTEMPT" + echo "::endgroup::" + continue + fi + + # --- Hard gate: verify all checks pass --- + if pnpm run test:nonInteractive && pnpm run lint && pnpm run type-check; then + echo "::notice::All checks pass on attempt $ATTEMPT" + SUCCESS=true + echo "::endgroup::" + break + else + echo "::warning::Verification failed on attempt $ATTEMPT" + echo "::endgroup::" + continue + fi + done + + if [ "$SUCCESS" = "true" ]; then + echo "result=success" >> $GITHUB_OUTPUT + echo "test_file=$TEST_FILE" >> $GITHUB_OUTPUT + else + echo "result=failure" >> $GITHUB_OUTPUT + fi + + - name: Check for changes + if: steps.guard.outputs.skip != 'true' && steps.tdd.outputs.result == 'success' + id: changes + run: | + if git diff --quiet && [ -z "$(git ls-files --others --exclude-standard -- '*.ts' '*.tsx')" ]; then + echo "has_changes=false" >> $GITHUB_OUTPUT + else + echo "has_changes=true" >> $GITHUB_OUTPUT + fi + + - name: Create branch and commit + if: steps.guard.outputs.skip != 'true' && steps.tdd.outputs.result == 'success' && steps.changes.outputs.has_changes == 'true' + id: push + run: | + ISSUE_NUM="${{ inputs.issue-number }}" + BRANCH="fix/auto-${ISSUE_NUM}" + TITLE=$(cat fix-title.txt 2>/dev/null || echo "fix: auto-fix for #${ISSUE_NUM}") + + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git checkout -B "$BRANCH" + git add -A -- '**/*.ts' '**/*.tsx' + git diff --cached --quiet && exit 0 + git commit -m "$TITLE" + git push -u origin "$BRANCH" + echo "branch=$BRANCH" >> $GITHUB_OUTPUT + + - name: Create Pull Request + if: steps.guard.outputs.skip != 'true' && steps.tdd.outputs.result == 'success' && steps.changes.outputs.has_changes == 'true' + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} + run: | + BRANCH="${{ steps.push.outputs.branch }}" + BASE="${{ github.event.repository.default_branch }}" + TITLE=$(cat fix-title.txt 2>/dev/null || echo "fix: auto-fix for #${{ inputs.issue-number }}") + + if [ ! -f pr-body.md ]; then + echo "Automated fix for #${{ inputs.issue-number }}." > pr-body.md + fi + + gh label create auto-fix --description "Automated bug fix by TDD agent" --color 0E8A16 2>/dev/null || true + gh pr create \ + --title "$TITLE" \ + --body-file pr-body.md \ + --base "$BASE" \ + --head "$BRANCH" \ + --label "auto-fix" + + - name: Comment on issue (all attempts failed) + if: >- + always() + && steps.guard.outputs.skip != 'true' + && steps.tdd.outputs.result == 'failure' + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} + run: | + if [ -f bug-analysis.md ]; then + ANALYSIS=$(cat bug-analysis.md) + else + ANALYSIS="The agent could not analyze this bug. The issue may be too vague or involve behavior that cannot be captured in a unit test." + fi + + printf '## Bug Fix Agent — Automated Analysis\n\n%s\n\n---\n*Automated analysis by Claude Code TDD Agent. All 3 attempts exhausted. A developer will review this issue manually.*\n' \ + "$ANALYSIS" > /tmp/comment-body.md + + gh issue comment "${{ inputs.issue-number }}" --body-file /tmp/comment-body.md diff --git a/.github/workflows/bug-fix-on-label.yml b/.github/workflows/bug-fix-on-label.yml new file mode 100644 index 000000000..4b467b7a2 --- /dev/null +++ b/.github/workflows/bug-fix-on-label.yml @@ -0,0 +1,19 @@ +name: Bug Fix (On Label) + +on: + issues: + types: [labeled] + +permissions: + contents: read + +jobs: + bug-fix: + name: Bug Fix Agent + if: >- + github.event.label.name == 'auto-fix' + && contains(github.event.issue.labels.*.name, 'Bug') + uses: ./.github/workflows/_bug-fix-agent.yml + with: + issue-number: ${{ github.event.issue.number }} + secrets: inherit diff --git a/.github/workflows/bug-triage-cron.yml b/.github/workflows/bug-triage-cron.yml new file mode 100644 index 000000000..31fb5bd86 --- /dev/null +++ b/.github/workflows/bug-triage-cron.yml @@ -0,0 +1,173 @@ +name: Bug Triage (Cron) + +on: + schedule: + - cron: '0 8 * * 1-5' # Weekdays at 08:00 UTC + workflow_dispatch: + +concurrency: + group: bug-triage + cancel-in-progress: true + +permissions: + contents: read + issues: write + +jobs: + triage: + name: Triage Bug Issues + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - name: Generate GitHub App token + id: app-token + uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 # v3 + with: + app-id: ${{ secrets.TOOLHIVE_STUDIO_CI_APP_ID }} + private-key: ${{ secrets.TOOLHIVE_STUDIO_CI_APP_KEY }} + + - name: Checkout Repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + ref: ${{ github.event.repository.default_branch }} + + - name: Fetch candidate bug issues + id: candidates + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} + run: | + # Get open Bug issues without auto-fix or auto-fix-skip labels + ISSUES=$(gh issue list \ + --label "Bug" \ + --state open \ + --limit 50 \ + --json number,title,body,labels,comments \ + --jq '[.[] | select( + (.labels | map(.name) | (contains(["auto-fix"]) or contains(["auto-fix-skip"])) | not) + and (.comments | length == 0) + )]') + + COUNT=$(echo "$ISSUES" | jq 'length') + echo "::notice::Found $COUNT candidate bug issues" + + if [ "$COUNT" -eq "0" ]; then + echo "skip=true" >> $GITHUB_OUTPUT + else + echo "skip=false" >> $GITHUB_OUTPUT + echo "$ISSUES" > candidate-issues.json + fi + + - name: Filter issues with existing PRs + id: filter + if: steps.candidates.outputs.skip != 'true' + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} + run: | + # Batch-fetch all open fix/auto-* PRs in a single API call + OPEN_BRANCHES=$(gh pr list --state open --limit 200 --json headRefName \ + --jq '[.[].headRefName | select(startswith("fix/auto-"))]') + + FILTERED="[]" + for NUM in $(jq -r '.[].number' candidate-issues.json); do + # Skip if a fix PR already exists + BRANCH="fix/auto-${NUM}" + if echo "$OPEN_BRANCHES" | jq -e "index(\"$BRANCH\")" > /dev/null 2>&1; then + echo "::notice::Issue #${NUM}: skipped — open PR exists" + continue + fi + + # Skip if a cross-repo issue/PR was closed or merged (fix lives in another repo) + CROSS_REF_CLOSED=$(gh api "repos/${{ github.repository }}/issues/${NUM}/timeline" --paginate 2>/dev/null \ + | jq '[.[] | select(.event == "cross-referenced" and .source.issue.state == "closed")] | length' 2>/dev/null || echo "0") + if [ "$CROSS_REF_CLOSED" -gt "0" ]; then + echo "::notice::Issue #${NUM}: skipped — cross-referenced issue/PR closed in another repo" + continue + fi + + ISSUE=$(jq ".[] | select(.number == $NUM)" candidate-issues.json) + FILTERED=$(echo "$FILTERED" | jq ". + [$ISSUE]") + done + + echo "$FILTERED" > filtered-issues.json + COUNT=$(echo "$FILTERED" | jq 'length') + echo "::notice::$COUNT issues remain after PR dedup" + + if [ "$COUNT" -eq "0" ]; then + echo "skip=true" >> $GITHUB_OUTPUT + else + echo "skip=false" >> $GITHUB_OUTPUT + fi + + - name: Install Node.js + if: steps.candidates.outputs.skip != 'true' && steps.filter.outputs.skip != 'true' + uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6 + with: + node-version: 24 + + - name: Install Claude Code + if: steps.candidates.outputs.skip != 'true' && steps.filter.outputs.skip != 'true' + run: npm install -g @anthropic-ai/claude-code + + - name: Evaluate issues with Claude (Sonnet) + if: steps.candidates.outputs.skip != 'true' && steps.filter.outputs.skip != 'true' + env: + ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} + run: | + # Prepare issue summaries for Claude (limit to first 10 candidates) + jq -r '.[:10][] | "--- Issue #\(.number): \(.title)\n\(.body)\n"' filtered-issues.json > issues-for-triage.md + + claude -p --model sonnet \ + --dangerously-skip-permissions \ + --allowedTools "Read,Grep,Glob" \ + --max-turns 20 \ + "Read issues-for-triage.md. These are open bug reports for a React + Electron desktop app. + + Analyze each bug and decide if it is suitable for AUTOMATED fixing by an AI agent that: + - Can only write and run unit tests (Vitest + Testing Library + MSW) + - Cannot launch the Electron app or do visual testing + - Cannot test IPC, native modules, or platform-specific behavior + + A bug IS suitable if: + - Has a clear error message or stack trace + - Mentions a specific component, page, or feature by name + - Is a UI bug (wrong text, missing state, incorrect condition, wrong rendering) + - Has clear steps to reproduce + - Can be reproduced in a jsdom unit test + + A bug is NOT suitable if: + - Mentions IPC, Electron main process, or platform-specific behavior + - Involves timing, race conditions, or flaky behavior + - Is vague or lacks reproduction steps + - Requires manual testing or visual verification + - Involves packaging, code signing, or native modules + - Requires launching the app or E2E testing + - References a fix already merged or in progress in another repository (e.g., stacklok/toolhive). If the bug is caused by an upstream dependency and the fix lives outside this repo, it is NOT suitable. + - Mentions that the root cause is in a backend, CLI, or server component outside this React/Electron codebase + + Search the codebase with Grep/Glob to verify the mentioned components/features exist. + + Output ONLY a JSON array to triage-results.json, one object per issue: + [{\"issue\": , \"suitable\": true/false, \"reason\": \"\"}] + + Write the file triage-results.json with this array. Nothing else." + + - name: Apply auto-fix label (max 3 per run) + if: steps.candidates.outputs.skip != 'true' && steps.filter.outputs.skip != 'true' + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} + run: | + if [ ! -f triage-results.json ]; then + echo "::warning::triage-results.json not found — skipping label assignment" + exit 0 + fi + + SUITABLE=$(jq -r '[.[] | select(.suitable == true)] | .[:3]' triage-results.json) + COUNT=$(echo "$SUITABLE" | jq 'length') + echo "::notice::Labeling $COUNT issues as auto-fix" + + echo "$SUITABLE" | jq -c '.[]' | while IFS= read -r ROW; do + NUM=$(echo "$ROW" | jq -r '.issue') + REASON=$(echo "$ROW" | jq -r '.reason') + echo "::notice::Issue #${NUM}: ${REASON}" + gh issue edit "$NUM" --add-label "auto-fix" + done