diff --git a/.github/workflows/example.yml b/.github/workflows/example.yml new file mode 100644 index 0000000..5124f58 --- /dev/null +++ b/.github/workflows/example.yml @@ -0,0 +1,41 @@ +name: DeepWork Review + +on: + pull_request: + types: [opened, synchronize] + +# Prevent concurrent runs on the same PR +concurrency: + group: deepwork-review-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + deepwork-review: + runs-on: ubuntu-latest + # Don't re-run on commits pushed by the action itself + if: github.actor != 'deepwork-action[bot]' + # Required permissions + permissions: + contents: write # push auto-fix commits to the PR branch + pull-requests: write # post inline PR review comments + + steps: + - name: Checkout PR branch + uses: actions/checkout@v4 + with: + # Fetch full history so DeepWork can diff against the base branch + # Set too `100` or something high but safe if you have a huge git history + fetch-depth: 0 + # Use the merge ref so we operate on the PR's head commit + ref: ${{ github.event.pull_request.head.ref }} + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Run DeepWork Review + uses: Unsupervisedcom/deepwork-action@v1 + with: + anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} + github_token: ${{ secrets.GITHUB_TOKEN }} + # Optional overrides: + # model: claude-opus-4-6 + # max_turns: '50' + # commit_message: 'chore: apply DeepWork review suggestions' diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e30f246 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +scripts/__pycache__/ diff --git a/README.md b/README.md index f67f337..e1e27a2 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,96 @@ # deepwork-action -DeepWork GitHub Action + +A prebuilt GitHub Action that runs [Claude Code](https://docs.anthropic.com/en/docs/claude-code) on a Pull Request with the [DeepWork](https://github.com/Unsupervisedcom/deepwork) plugin installed, triggers the `/review` skill, auto-commits all review-driven improvements back to the PR branch, and posts inline PR review comments explaining each change. + +## How It Works + +1. **DeepWork plugin install** — The action installs the DeepWork plugin from the marketplace using Claude Code's native plugin system, loading all review skills, hooks, and MCP server configuration automatically. +2. **DeepWork review** — Claude Code runs the `/review` skill, which reads your `.deepreview` config files to discover review rules, diffs the PR branch, and dispatches parallel review agents scoped to exactly the right files. +3. **Apply changes** — Claude applies every suggested improvement (bugs, style, performance, security, docs, refactoring) without asking for confirmation. +4. **Auto-commit** — All file changes are committed back to the PR branch under the `deepwork-action[bot]` identity. +5. **Inline PR comments** — A GitHub PR review is posted with one inline comment per changed file, describing what was changed and why, so your team can review each improvement. + +## Prerequisites + +1. **Anthropic API key** — add it as a repository secret named `ANTHROPIC_API_KEY`. +2. **`.deepreview` configuration** — place one or more `.deepreview` files in your repository defining your review rules. See the [DeepWork Reviews documentation](https://github.com/Unsupervisedcom/deepwork/blob/main/README_REVIEWS.md) for details. + +## Usage + +Create a workflow file such as `.github/workflows/deepwork-review.yml`: + +```yaml +name: DeepWork Review + +on: + pull_request: + types: [opened, synchronize] + +concurrency: + group: deepwork-review-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + deepwork-review: + runs-on: ubuntu-latest + # Don't re-run on commits pushed by the action itself + if: github.actor != 'deepwork-action[bot]' + permissions: + contents: write # push auto-fix commits to the PR branch + pull-requests: write # post inline PR review comments + + steps: + - name: Checkout PR branch + uses: actions/checkout@v4 + with: + fetch-depth: 0 + ref: ${{ github.event.pull_request.head.ref }} + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Run DeepWork Review + uses: Unsupervisedcom/deepwork-action@v1 + with: + anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} + github_token: ${{ secrets.GITHUB_TOKEN }} +``` + +## Inputs + +| Input | Required | Default | Description | +|-------|----------|---------|-------------| +| `anthropic_api_key` | ✅ | — | Anthropic API key for Claude Code | +| `github_token` | ✅ | — | GitHub token with `contents: write` and `pull-requests: write` | +| `model` | ❌ | `claude-opus-4-6` | Claude model to use | +| `max_turns` | ❌ | `50` | Maximum agentic turns for Claude Code | +| `commit_message` | ❌ | `chore: apply DeepWork review suggestions` | Commit message for auto-committed changes | + +## What Gets Changed + +The action applies **all** suggestions from your `.deepreview` rules, including: + +- Bug fixes and null-safety checks +- Style and formatting improvements +- Performance optimisations +- Security hardening +- Documentation updates +- Refactoring suggestions + +If no `.deepreview` rules are configured in the repository, the action exits cleanly without making any changes or commits. + +## Review Comments + +After pushing the auto-fix commit, the action posts a GitHub PR review with inline comments on each changed file. The comments appear in the **Files Changed** tab and describe what was changed and why, so your team can accept, request modifications, or revert individual changes as needed. + +## Caching + +Review state is cached per PR using GitHub Actions cache, keyed on the PR number. This means already-passed reviews are not re-run when you push new commits to the same PR — only code that has changed since the last review is re-evaluated. THIS IS A MAJOR TOKEN COST SAVER!!! + +## Security + +- Claude Code is installed and run via the official [`anthropics/claude-code-base-action`](https://github.com/anthropics/claude-code-base-action). +- The action runs with `--dangerously-skip-permissions` in a sandboxed GitHub Actions runner. It has no access to secrets beyond what you explicitly provide. +- Auto-fix commits are pushed under the `deepwork-action[bot]` identity. The example workflow includes `if: github.actor != 'deepwork-action[bot]'` at the job level so the action never triggers itself recursively. + +## License + +See [LICENSE](LICENSE). diff --git a/action.yml b/action.yml new file mode 100644 index 0000000..f81fca9 --- /dev/null +++ b/action.yml @@ -0,0 +1,114 @@ +name: 'DeepWork Review Action' +description: 'Run DeepWork reviews via Claude Code on a PR, auto-commit improvements, and add inline PR review comments' +author: 'Unsupervisedcom' + +branding: + icon: 'check-circle' + color: 'blue' + +inputs: + anthropic_api_key: + description: 'Anthropic API key for Claude Code' + required: true + github_token: + description: 'GitHub token with write access to commit changes and post review comments' + required: true + model: + description: 'Claude model to use (e.g. claude-sonnet-4-6, claude-opus-4-6)' + required: false + default: 'claude-opus-4-6' + max_turns: + description: 'Maximum number of agentic turns for Claude Code' + required: false + default: '50' + commit_message: + description: 'Commit message for auto-committed review changes' + required: false + default: 'chore: apply DeepWork review suggestions' + +runs: + using: 'composite' + steps: + - name: Install uv + uses: astral-sh/setup-uv@v5 + with: + version: 'latest' + + - name: Restore DeepWork review cache + uses: actions/cache@v4 + with: + path: .deepwork/tmp + # A unique save key per run ensures the cache is always updated with the + # latest review state after each run. restore-keys picks up the most recent + # cache for this PR so already-passed reviews are not re-run. + key: deepwork-review-pr-${{ github.event.pull_request.number }}-${{ github.run_id }} + restore-keys: | + deepwork-review-pr-${{ github.event.pull_request.number }}- + + - name: Fetch base branch for git diff + shell: bash + run: | + BASE_REF="${{ github.event.pull_request.base.ref }}" + if [ -n "$BASE_REF" ]; then + git fetch origin "$BASE_REF" --depth=1 || \ + echo "Warning: could not fetch base branch '$BASE_REF'; diff detection may be incomplete" + fi + + - name: Prepare review run + shell: bash + run: | + # Clean up any leftover changes file from a previous run. + rm -f /tmp/deepwork_changes.json + + - name: Run DeepWork review with Claude Code + uses: anthropics/claude-code-base-action@beta + env: + GH_TOKEN: ${{ inputs.github_token }} + with: + anthropic_api_key: ${{ inputs.anthropic_api_key }} + prompt_file: ${{ github.action_path }}/prompts/review.txt + plugin_marketplaces: https://github.com/Unsupervisedcom/deepwork.git + plugins: deepwork@deepwork-plugins + claude_args: >- + --dangerously-skip-permissions + --model ${{ inputs.model }} + --max-turns ${{ inputs.max_turns }} + + - name: Commit and push changes + id: commit + shell: bash + env: + GITHUB_TOKEN: ${{ inputs.github_token }} + run: | + git config user.name "deepwork-action[bot]" + git config user.email "deepwork-action[bot]@users.noreply.github.com" + + # Check for any modified, added, or deleted tracked files + if git diff --quiet && git diff --cached --quiet \ + && [ -z "$(git ls-files --others --exclude-standard)" ]; then + echo "No changes to commit." + echo "changes_made=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + + echo "changes_made=true" >> "$GITHUB_OUTPUT" + + git add -A + git commit -m "${{ inputs.commit_message }}" + + # Authenticate push via the provided token. + REPO="${{ github.repository }}" + git remote set-url origin \ + "https://x-access-token:${GITHUB_TOKEN}@github.com/${REPO}.git" + git push + + - name: Post inline PR review comments + if: steps.commit.outputs.changes_made == 'true' && github.event.pull_request.number != '' + shell: bash + env: + GH_TOKEN: ${{ inputs.github_token }} + PR_NUMBER: ${{ github.event.pull_request.number }} + GITHUB_REPOSITORY: ${{ github.repository }} + GITHUB_BASE_REF: ${{ github.event.pull_request.base.ref }} + run: | + python3 "${{ github.action_path }}/scripts/post-review-comments.py" diff --git a/prompts/review.txt b/prompts/review.txt new file mode 100644 index 0000000..609f093 --- /dev/null +++ b/prompts/review.txt @@ -0,0 +1,38 @@ +/review + +You are running in a fully automated CI environment on a GitHub Pull Request. +There is NO human watching this session. Follow these critical rules at all times: + +## Automation Rules (MANDATORY) + +1. **NEVER use AskUserQuestion** — you are in CI mode; make every decision autonomously. +2. **Make ALL changes** suggested by the review findings — not just "obviously good" ones. + Apply every finding: bugs, style, performance, security, documentation, and refactoring. + When a finding offers multiple approaches, choose the best one yourself. +3. **Iterate** — after making changes, re-run the review until it comes back clean. +4. **If no `.deepreview` rules are configured** — output the message "No review rules configured." + and stop. Do not attempt to configure rules; that is the repository owner's responsibility. + +## Change Tracking (REQUIRED) + +For every file you modify, append an entry to `/tmp/deepwork_changes.json`. +Create the file with `{"changes": []}` if it does not yet exist. + +Each entry must follow this exact JSON structure: + +```json +{ + "file": "relative/path/to/changed/file", + "line": , + "description": "One-sentence description of what was changed", + "reason": "The review finding that prompted this change" +} +``` + +Write the final file when all changes are complete and the review passes. + +## Important + +- You have full permission to edit, create, and delete files in this repository. +- Do NOT commit changes yourself — the CI workflow handles git commit and push. +- Do NOT push changes yourself — the CI workflow handles git commit and push. diff --git a/scripts/post-review-comments.py b/scripts/post-review-comments.py new file mode 100644 index 0000000..7308abf --- /dev/null +++ b/scripts/post-review-comments.py @@ -0,0 +1,204 @@ +#!/usr/bin/env python3 +""" +Post inline PR review comments for each file changed by the DeepWork review. + +Reads /tmp/deepwork_changes.json (written by Claude) for per-change descriptions. +Falls back to a diff-based summary when the file is absent or an entry is missing. +Posts a single GitHub PR review with one inline comment per changed file. +""" + +from __future__ import annotations + +import json +import os +import re +import subprocess +import sys +from pathlib import Path +from typing import Any + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def run(cmd: list[str], **kwargs) -> subprocess.CompletedProcess: + return subprocess.run(cmd, capture_output=True, text=True, **kwargs) + + +def get_head_sha() -> str: + return run(["git", "rev-parse", "HEAD"]).stdout.strip() + + +def get_changed_files(base_ref: str) -> list[str]: + """Return files changed between the base branch and HEAD.""" + # Try the exact remote ref first (available when the base branch was fetched). + for ref in (f"origin/{base_ref}", base_ref, "HEAD~1"): + result = run(["git", "diff", ref, "HEAD", "--name-only", "--diff-filter=ACMR"]) + if result.returncode == 0 and result.stdout.strip(): + return [f for f in result.stdout.splitlines() if f.strip()] + return [] + + +def get_diff(file_path: str, base_ref: str) -> str: + for ref in (f"origin/{base_ref}", base_ref, "HEAD~1"): + result = run(["git", "diff", ref, "HEAD", "--", file_path]) + if result.returncode == 0 and result.stdout.strip(): + return result.stdout + return "" + + +def first_changed_line(diff: str) -> int: + """ + Return the line number (in the new file) of the first added line. + Parses unified diff hunk headers: @@ -old +new,count @@ + """ + current_new = 0 + for line in diff.splitlines(): + if line.startswith("@@"): + m = re.search(r"\+(\d+)", line) + if m: + current_new = int(m.group(1)) + elif line.startswith("+") and not line.startswith("+++"): + return max(current_new, 1) + elif not line.startswith("-") and not line.startswith("\\"): + current_new += 1 + return 1 + + +def count_added_lines(diff: str) -> int: + return sum( + 1 + for line in diff.splitlines() + if line.startswith("+") and not line.startswith("+++") + ) + + +# --------------------------------------------------------------------------- +# Load Claude's change summary (optional) +# --------------------------------------------------------------------------- + +def load_changes_by_file() -> dict[str, list[dict[str, Any]]]: + changes_path = Path("/tmp/deepwork_changes.json") + if not changes_path.exists(): + return {} + try: + data = json.loads(changes_path.read_text()) + by_file: dict[str, list[dict]] = {} + for entry in data.get("changes", []): + fp = entry.get("file", "").lstrip("./") + by_file.setdefault(fp, []).append(entry) + return by_file + except (json.JSONDecodeError, OSError) as exc: + print(f"Warning: could not parse /tmp/deepwork_changes.json: {exc}", file=sys.stderr) + return {} + + +# --------------------------------------------------------------------------- +# Build comment body for a file +# --------------------------------------------------------------------------- + +def build_comment_body( + file_path: str, + diff: str, + changes: list[dict[str, Any]], +) -> str: + if changes: + bullets = "\n".join( + f"- **{c.get('description', 'Change applied')}**" + + (f"\n *{c.get('reason', '')}*" if c.get("reason") else "") + for c in changes + ) + return ( + "🤖 **DeepWork Review** applied the following changes:\n\n" + + bullets + ) + # Fallback: diff statistics + added = count_added_lines(diff) + return ( + f"🤖 **DeepWork Review** applied {added} line(s) of changes to this file " + f"based on review findings." + ) + + +# --------------------------------------------------------------------------- +# Main +# --------------------------------------------------------------------------- + +def main() -> None: + pr_number = os.environ.get("PR_NUMBER", "") + repo = os.environ.get("GITHUB_REPOSITORY", "") + # GITHUB_BASE_REF is set automatically by GitHub Actions for pull_request events. + base_ref = os.environ.get("GITHUB_BASE_REF", "main") + + if not pr_number or not repo: + print("PR_NUMBER or GITHUB_REPOSITORY not set; skipping review comments.", file=sys.stderr) + sys.exit(0) + + commit_sha = get_head_sha() + changed_files = get_changed_files(base_ref) + + if not changed_files: + print("No changed files found between base branch and HEAD; nothing to comment on.") + sys.exit(0) + + changes_by_file = load_changes_by_file() + + inline_comments: list[dict[str, Any]] = [] + for file_path in changed_files: + diff = get_diff(file_path, base_ref) + if not diff.strip(): + continue + + line_number = first_changed_line(diff) + normalised = file_path.lstrip("./") + file_changes = changes_by_file.get(normalised, []) or changes_by_file.get(file_path, []) + body = build_comment_body(file_path, diff, file_changes) + + inline_comments.append({ + "path": file_path, + "line": line_number, + "side": "RIGHT", + "body": body, + }) + + if not inline_comments: + print("No inline comments to post.") + sys.exit(0) + + # Build the review payload + review_body = ( + f"🤖 **DeepWork automated review** applied changes to " + f"{len(inline_comments)} file(s).\n\n" + "Review the inline comments below for details on each change." + ) + review_payload: dict[str, Any] = { + "commit_id": commit_sha, + "body": review_body, + "event": "COMMENT", + "comments": inline_comments, + } + + payload_path = Path("/tmp/deepwork_review_payload.json") + payload_path.write_text(json.dumps(review_payload, indent=2)) + + result = run([ + "gh", "api", + f"repos/{repo}/pulls/{pr_number}/reviews", + "--method", "POST", + "--input", str(payload_path), + ]) + + if result.returncode != 0: + print(f"Error posting PR review: {result.stderr}", file=sys.stderr) + # Non-fatal: the changes are already committed; just warn. + sys.exit(0) + + print( + f"Posted PR review with {len(inline_comments)} inline comment(s) " + f"on PR #{pr_number}." + ) + + +if __name__ == "__main__": + main()