diff --git a/.github/workflows/droid-review.yml b/.github/workflows/droid-review.yml index 051fff7..3df3ff5 100644 --- a/.github/workflows/droid-review.yml +++ b/.github/workflows/droid-review.yml @@ -5,7 +5,7 @@ on: types: [opened, ready_for_review, reopened] jobs: - droid-review: + prepare: if: github.event.pull_request.draft == false runs-on: ubuntu-latest permissions: @@ -13,15 +13,125 @@ jobs: pull-requests: write issues: write id-token: write - actions: read + outputs: + comment_id: ${{ steps.prepare.outputs.comment_id }} + run_code_review: ${{ steps.prepare.outputs.run_code_review }} + run_security_review: ${{ steps.prepare.outputs.run_security_review }} steps: - name: Checkout repository uses: actions/checkout@v5 with: fetch-depth: 1 - - name: Run Droid Auto Review - uses: Factory-AI/droid-action@v1 + - name: Prepare + id: prepare + uses: Factory-AI/droid-action/prepare@v1 with: factory_api_key: ${{ secrets.FACTORY_API_KEY }} automatic_review: true + automatic_security_review: true + + code-review: + needs: prepare + if: needs.prepare.outputs.run_code_review == 'true' + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write + issues: write + id-token: write + actions: read + steps: + - name: Checkout repository + uses: actions/checkout@v5 + with: + fetch-depth: 1 + + - name: Run Code Review + uses: Factory-AI/droid-action/review@v1 + with: + factory_api_key: ${{ secrets.FACTORY_API_KEY }} + tracking_comment_id: ${{ needs.prepare.outputs.comment_id }} + output_file: ${{ runner.temp }}/code-review-results.json + + - name: Upload Results + uses: actions/upload-artifact@v4 + with: + name: code-review-results + path: ${{ runner.temp }}/code-review-results.json + if-no-files-found: ignore + + security-review: + needs: prepare + if: needs.prepare.outputs.run_security_review == 'true' + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write + issues: write + id-token: write + actions: read + steps: + - name: Checkout repository + uses: actions/checkout@v5 + with: + fetch-depth: 1 + + - name: Run Security Review + uses: Factory-AI/droid-action/security@v1 + with: + factory_api_key: ${{ secrets.FACTORY_API_KEY }} + tracking_comment_id: ${{ needs.prepare.outputs.comment_id }} + security_severity_threshold: medium + output_file: ${{ runner.temp }}/security-results.json + + - name: Upload Results + uses: actions/upload-artifact@v4 + with: + name: security-results + path: ${{ runner.temp }}/security-results.json + if-no-files-found: ignore + + combine: + needs: [prepare, code-review, security-review] + # Run combine when EITHER code review OR security review was executed + if: | + always() && + (needs.prepare.outputs.run_code_review == 'true' || + needs.prepare.outputs.run_security_review == 'true') + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write + issues: write + id-token: write + actions: read + steps: + - name: Checkout repository + uses: actions/checkout@v5 + with: + fetch-depth: 1 + + - name: Download Code Review Results + uses: actions/download-artifact@v4 + with: + name: code-review-results + path: ${{ runner.temp }} + continue-on-error: true + + - name: Download Security Results + uses: actions/download-artifact@v4 + with: + name: security-results + path: ${{ runner.temp }} + continue-on-error: true + + - name: Combine Results + uses: Factory-AI/droid-action/combine@v1 + with: + factory_api_key: ${{ secrets.FACTORY_API_KEY }} + tracking_comment_id: ${{ needs.prepare.outputs.comment_id }} + code_review_results: ${{ runner.temp }}/code-review-results.json + security_results: ${{ runner.temp }}/security-results.json + code_review_status: ${{ needs.code-review.result }} + security_review_status: ${{ needs.security-review.result }} diff --git a/action.yml b/action.yml index f259fef..7938014 100644 --- a/action.yml +++ b/action.yml @@ -213,6 +213,67 @@ runs: env: EXPERIMENTAL_ALLOWED_DOMAINS: ${{ inputs.experimental_allowed_domains }} + - name: Install Security Skills + if: steps.prepare.outputs.contains_trigger == 'true' && steps.prepare.outputs.install_security_skills == 'true' + shell: bash + run: | + echo "Installing security skills from Factory-AI/skills..." + SKILLS_DIR="$HOME/.factory/skills" + mkdir -p "$SKILLS_DIR" + + # Clone public skills repo (sparse checkout for efficiency) + TEMP_DIR=$(mktemp -d) + git clone --filter=blob:none --sparse \ + "https://github.com/Factory-AI/skills.git" \ + "$TEMP_DIR" 2>/dev/null || { + echo "Warning: Could not clone skills repo. Security skills will not be available." + exit 0 + } + + cd "$TEMP_DIR" + git sparse-checkout set \ + skills/threat-model-generation \ + skills/commit-security-scan \ + skills/vulnerability-validation \ + skills/security-review 2>/dev/null || true + + # Copy skills to ~/.factory/skills/ and track installed count + INSTALLED_COUNT=0 + for skill in threat-model-generation commit-security-scan vulnerability-validation security-review; do + if [ -d "skills/$skill" ]; then + cp -r "skills/$skill" "$SKILLS_DIR/" + echo " Installed skill: $skill" + INSTALLED_COUNT=$((INSTALLED_COUNT + 1)) + else + echo " Warning: Skill not found in repo: $skill" + fi + done + + # Cleanup + rm -rf "$TEMP_DIR" + + # Verify at least one skill was installed + if [ "$INSTALLED_COUNT" -eq 0 ]; then + echo "Warning: No security skills were installed. The skills may not exist in the Factory-AI/skills repository." + echo "Security review will proceed but may have limited functionality." + else + echo "Security skills installation complete ($INSTALLED_COUNT skills installed)" + fi + + # Verify skills exist in the target directory + echo "Verifying installed skills in $SKILLS_DIR..." + VERIFIED_COUNT=0 + for skill in threat-model-generation commit-security-scan vulnerability-validation security-review; do + if [ -d "$SKILLS_DIR/$skill" ]; then + echo " Verified: $skill" + VERIFIED_COUNT=$((VERIFIED_COUNT + 1)) + fi + done + + if [ "$VERIFIED_COUNT" -ne "$INSTALLED_COUNT" ]; then + echo "Warning: Skill verification mismatch. Expected $INSTALLED_COUNT, found $VERIFIED_COUNT in $SKILLS_DIR" + fi + - name: Checkout PR branch for review if: steps.prepare.outputs.contains_trigger == 'true' && steps.prepare.outputs.review_pr_number != '' shell: bash diff --git a/base-action/action.yml b/base-action/action.yml index 40c069a..8365d3f 100644 --- a/base-action/action.yml +++ b/base-action/action.yml @@ -19,6 +19,11 @@ inputs: required: false default: "" + reasoning_effort: + description: "Optional reasoning effort to pass to Droid Exec via --reasoning-effort (e.g., 'low', 'medium', 'high', 'xhigh'). If empty, no --reasoning-effort flag is passed." + required: false + default: "" + # Action settings droid_args: description: "Additional arguments to pass directly to Droid CLI (e.g., '--auto', '--enabled-tools read,edit')" @@ -113,6 +118,7 @@ runs: INPUT_PROMPT_FILE: ${{ inputs.prompt_file }} INPUT_SETTINGS: ${{ inputs.settings }} INPUT_DROID_ARGS: ${{ inputs.droid_args }} + INPUT_REASONING_EFFORT: ${{ inputs.reasoning_effort }} INPUT_PATH_TO_DROID_EXECUTABLE: ${{ inputs.path_to_droid_executable }} INPUT_PATH_TO_BUN_EXECUTABLE: ${{ inputs.path_to_bun_executable }} INPUT_SHOW_FULL_OUTPUT: ${{ inputs.show_full_output }} diff --git a/base-action/src/index.ts b/base-action/src/index.ts index d854ab3..860884a 100644 --- a/base-action/src/index.ts +++ b/base-action/src/index.ts @@ -22,14 +22,14 @@ async function run() { await runDroid(promptConfig.path, { droidArgs: process.env.INPUT_DROID_ARGS, + reasoningEffort: process.env.INPUT_REASONING_EFFORT, allowedTools: process.env.INPUT_ALLOWED_TOOLS, disallowedTools: process.env.INPUT_DISALLOWED_TOOLS, maxTurns: process.env.INPUT_MAX_TURNS, mcpTools: process.env.INPUT_MCP_TOOLS, systemPrompt: process.env.INPUT_SYSTEM_PROMPT, appendSystemPrompt: process.env.INPUT_APPEND_SYSTEM_PROMPT, - pathToDroidExecutable: - process.env.INPUT_PATH_TO_DROID_EXECUTABLE, + pathToDroidExecutable: process.env.INPUT_PATH_TO_DROID_EXECUTABLE, showFullOutput: process.env.INPUT_SHOW_FULL_OUTPUT, }); } catch (error) { diff --git a/combine/action.yml b/combine/action.yml new file mode 100644 index 0000000..e35eabc --- /dev/null +++ b/combine/action.yml @@ -0,0 +1,81 @@ +name: "Droid Combine Results" +description: "Combine code review and security review results, post inline comments, update summary" + +inputs: + factory_api_key: + description: "Factory API key" + required: true + tracking_comment_id: + description: "ID of the tracking comment to update" + required: true + code_review_results: + description: "Path to code review results JSON (optional)" + required: false + default: "" + security_results: + description: "Path to security results JSON (optional)" + required: false + default: "" + code_review_status: + description: "Code review job status (success/failure/skipped)" + required: false + default: "skipped" + security_review_status: + description: "Security review job status (success/failure/skipped)" + required: false + default: "skipped" + +runs: + using: "composite" + steps: + - name: Install Bun + uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76 + with: + bun-version: 1.2.11 + + - name: Install Dependencies + shell: bash + run: | + cd ${{ github.action_path }}/.. + bun install + cd ${{ github.action_path }}/../base-action + bun install + + - name: Install Droid CLI + shell: bash + run: | + curl --retry 5 --retry-delay 2 --retry-all-errors -fsSL https://app.factory.ai/cli | sh + echo "$HOME/.local/bin" >> "$GITHUB_PATH" + "$HOME/.local/bin/droid" --version + + - name: Get GitHub Token + id: token + shell: bash + run: | + bun run ${{ github.action_path }}/../src/entrypoints/get-token.ts + env: + FACTORY_API_KEY: ${{ inputs.factory_api_key }} + + - name: Generate Combine Prompt + id: prompt + shell: bash + run: | + bun run ${{ github.action_path }}/../src/entrypoints/generate-combine-prompt.ts + env: + GITHUB_TOKEN: ${{ steps.token.outputs.github_token }} + DROID_COMMENT_ID: ${{ inputs.tracking_comment_id }} + CODE_REVIEW_RESULTS: ${{ inputs.code_review_results }} + SECURITY_RESULTS: ${{ inputs.security_results }} + CODE_REVIEW_STATUS: ${{ inputs.code_review_status }} + SECURITY_REVIEW_STATUS: ${{ inputs.security_review_status }} + + - name: Run Combine + shell: bash + run: | + bun run ${{ github.action_path }}/../base-action/src/index.ts + env: + INPUT_PROMPT_FILE: ${{ runner.temp }}/droid-prompts/droid-prompt.txt + INPUT_DROID_ARGS: ${{ steps.prompt.outputs.droid_args }} + INPUT_MCP_TOOLS: ${{ steps.prompt.outputs.mcp_tools }} + FACTORY_API_KEY: ${{ inputs.factory_api_key }} + GITHUB_TOKEN: ${{ steps.token.outputs.github_token }} diff --git a/prepare/action.yml b/prepare/action.yml new file mode 100644 index 0000000..9ca7862 --- /dev/null +++ b/prepare/action.yml @@ -0,0 +1,95 @@ +name: "Droid Prepare" +description: "Initialize Droid review - creates tracking comment and detects review modes" + +inputs: + factory_api_key: + description: "Factory API key" + required: true + github_token: + description: "GitHub token" + required: false + automatic_review: + description: "Run automatic code review" + required: false + default: "false" + automatic_security_review: + description: "Run automatic security review" + required: false + default: "false" + +outputs: + github_token: + description: "GitHub token (from OIDC or input)" + value: ${{ steps.prepare.outputs.github_token }} + comment_id: + description: "Tracking comment ID" + value: ${{ steps.prepare.outputs.droid_comment_id }} + run_code_review: + description: "Whether to run code review" + value: ${{ steps.detect.outputs.run_code_review }} + run_security_review: + description: "Whether to run security review" + value: ${{ steps.detect.outputs.run_security_review }} + contains_trigger: + description: "Whether a trigger was detected" + value: ${{ steps.prepare.outputs.contains_trigger }} + pr_number: + description: "PR number" + value: ${{ steps.prepare.outputs.pr_number }} + base_branch: + description: "Base branch name" + value: ${{ steps.prepare.outputs.base_branch }} + head_branch: + description: "Head branch name" + value: ${{ steps.prepare.outputs.head_branch }} + +runs: + using: "composite" + steps: + - name: Install Bun + uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76 + with: + bun-version: 1.2.11 + + - name: Install Dependencies + shell: bash + run: | + cd ${{ github.action_path }}/.. + bun install + + - name: Prepare + id: prepare + shell: bash + run: | + bun run ${{ github.action_path }}/../src/entrypoints/prepare.ts + env: + FACTORY_API_KEY: ${{ inputs.factory_api_key }} + OVERRIDE_GITHUB_TOKEN: ${{ inputs.github_token }} + AUTOMATIC_REVIEW: ${{ inputs.automatic_review }} + AUTOMATIC_SECURITY_REVIEW: ${{ inputs.automatic_security_review }} + TRIGGER_PHRASE: "@droid" + ALLOWED_BOTS: "" + ALLOWED_NON_WRITE_USERS: "" + USE_STICKY_COMMENT: "false" + TRACK_PROGRESS: "false" + + - name: Detect Review Types + id: detect + shell: bash + run: | + # Use outputs from prepare step (set by src/tag/index.ts) + # Fall back to input flags if outputs not set + RUN_CODE="${{ steps.prepare.outputs.run_code_review }}" + RUN_SEC="${{ steps.prepare.outputs.run_security_review }}" + + # Default to input flags if prepare didn't set outputs + if [ -z "$RUN_CODE" ]; then + RUN_CODE="${{ inputs.automatic_review }}" + fi + if [ -z "$RUN_SEC" ]; then + RUN_SEC="${{ inputs.automatic_security_review }}" + fi + + echo "run_code_review=$RUN_CODE" >> $GITHUB_OUTPUT + echo "run_security_review=$RUN_SEC" >> $GITHUB_OUTPUT + echo "Detected: code_review=$RUN_CODE, security_review=$RUN_SEC" diff --git a/review/action.yml b/review/action.yml new file mode 100644 index 0000000..257ad9c --- /dev/null +++ b/review/action.yml @@ -0,0 +1,83 @@ +name: "Droid Code Review" +description: "Run Droid code review on a PR" + +inputs: + factory_api_key: + description: "Factory API key" + required: true + github_token: + description: "GitHub token (optional - will use OIDC if not provided)" + required: false + default: "" + tracking_comment_id: + description: "ID of the tracking comment to update" + required: true + review_model: + description: "Model to use for review" + required: false + default: "" + output_file: + description: "Path to write review results JSON" + required: false + default: "" + +outputs: + conclusion: + description: "Review conclusion (success/failure)" + value: ${{ steps.review.outputs.conclusion }} + +runs: + using: "composite" + steps: + - name: Install Bun + uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76 + with: + bun-version: 1.2.11 + + - name: Install Dependencies + shell: bash + run: | + cd ${{ github.action_path }}/.. + bun install + cd ${{ github.action_path }}/../base-action + bun install + + - name: Install Droid CLI + shell: bash + run: | + curl --retry 5 --retry-delay 2 --retry-all-errors -fsSL https://app.factory.ai/cli | sh + echo "$HOME/.local/bin" >> "$GITHUB_PATH" + "$HOME/.local/bin/droid" --version + + - name: Get GitHub Token + id: token + shell: bash + run: | + bun run ${{ github.action_path }}/../src/entrypoints/get-token.ts + env: + FACTORY_API_KEY: ${{ inputs.factory_api_key }} + OVERRIDE_GITHUB_TOKEN: ${{ inputs.github_token }} + + - name: Generate Review Prompt + id: prompt + shell: bash + run: | + bun run ${{ github.action_path }}/../src/entrypoints/generate-review-prompt.ts + env: + GITHUB_TOKEN: ${{ steps.token.outputs.github_token }} + DROID_COMMENT_ID: ${{ inputs.tracking_comment_id }} + REVIEW_MODEL: ${{ inputs.review_model }} + REVIEW_TYPE: "code" + + - name: Run Code Review + id: review + shell: bash + run: | + bun run ${{ github.action_path }}/../base-action/src/index.ts + env: + INPUT_PROMPT_FILE: ${{ runner.temp }}/droid-prompts/droid-prompt.txt + INPUT_DROID_ARGS: ${{ steps.prompt.outputs.droid_args }} + INPUT_MCP_TOOLS: ${{ steps.prompt.outputs.mcp_tools }} + FACTORY_API_KEY: ${{ inputs.factory_api_key }} + GITHUB_TOKEN: ${{ steps.token.outputs.github_token }} + DROID_OUTPUT_FILE: ${{ inputs.output_file }} diff --git a/security/action.yml b/security/action.yml new file mode 100644 index 0000000..84655d4 --- /dev/null +++ b/security/action.yml @@ -0,0 +1,130 @@ +name: "Droid Security Review" +description: "Run Droid security review on a PR" + +inputs: + factory_api_key: + description: "Factory API key" + required: true + github_token: + description: "GitHub token (optional - will use OIDC if not provided)" + required: false + default: "" + tracking_comment_id: + description: "ID of the tracking comment to update" + required: true + security_model: + description: "Model to use for security review" + required: false + default: "" + security_severity_threshold: + description: "Minimum severity to report" + required: false + default: "medium" + security_block_on_critical: + description: "Block PR on critical findings" + required: false + default: "true" + security_block_on_high: + description: "Block PR on high findings" + required: false + default: "false" + output_file: + description: "Path to write security results JSON" + required: false + default: "" + +outputs: + conclusion: + description: "Review conclusion (success/failure)" + value: ${{ steps.review.outputs.conclusion }} + +runs: + using: "composite" + steps: + - name: Install Bun + uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76 + with: + bun-version: 1.2.11 + + - name: Install Dependencies + shell: bash + run: | + cd ${{ github.action_path }}/.. + bun install + cd ${{ github.action_path }}/../base-action + bun install + + - name: Install Droid CLI + shell: bash + run: | + curl --retry 5 --retry-delay 2 --retry-all-errors -fsSL https://app.factory.ai/cli | sh + echo "$HOME/.local/bin" >> "$GITHUB_PATH" + "$HOME/.local/bin/droid" --version + + - name: Get GitHub Token + id: token + shell: bash + run: | + bun run ${{ github.action_path }}/../src/entrypoints/get-token.ts + env: + FACTORY_API_KEY: ${{ inputs.factory_api_key }} + OVERRIDE_GITHUB_TOKEN: ${{ inputs.github_token }} + + - name: Install Security Skills + shell: bash + run: | + echo "Installing security skills from Factory-AI/skills..." + SKILLS_DIR="$HOME/.factory/skills" + mkdir -p "$SKILLS_DIR" + + TEMP_DIR=$(mktemp -d) + git clone --filter=blob:none --sparse \ + "https://github.com/Factory-AI/skills.git" \ + "$TEMP_DIR" 2>/dev/null || { + echo "Warning: Could not clone skills repo." + exit 0 + } + + cd "$TEMP_DIR" + git sparse-checkout set \ + skills/threat-model-generation \ + skills/commit-security-scan \ + skills/vulnerability-validation \ + skills/security-review 2>/dev/null || true + + for skill in threat-model-generation commit-security-scan vulnerability-validation security-review; do + if [ -d "skills/$skill" ]; then + cp -r "skills/$skill" "$SKILLS_DIR/" + echo " Installed skill: $skill" + fi + done + + rm -rf "$TEMP_DIR" + echo "Security skills installation complete" + + - name: Generate Security Prompt + id: prompt + shell: bash + run: | + bun run ${{ github.action_path }}/../src/entrypoints/generate-review-prompt.ts + env: + GITHUB_TOKEN: ${{ steps.token.outputs.github_token }} + DROID_COMMENT_ID: ${{ inputs.tracking_comment_id }} + SECURITY_MODEL: ${{ inputs.security_model }} + SECURITY_SEVERITY_THRESHOLD: ${{ inputs.security_severity_threshold }} + SECURITY_BLOCK_ON_CRITICAL: ${{ inputs.security_block_on_critical }} + SECURITY_BLOCK_ON_HIGH: ${{ inputs.security_block_on_high }} + REVIEW_TYPE: "security" + + - name: Run Security Review + id: review + shell: bash + run: | + bun run ${{ github.action_path }}/../base-action/src/index.ts + env: + INPUT_PROMPT_FILE: ${{ runner.temp }}/droid-prompts/droid-prompt.txt + INPUT_DROID_ARGS: ${{ steps.prompt.outputs.droid_args }} + INPUT_MCP_TOOLS: ${{ steps.prompt.outputs.mcp_tools }} + FACTORY_API_KEY: ${{ inputs.factory_api_key }} + GITHUB_TOKEN: ${{ steps.token.outputs.github_token }} + DROID_OUTPUT_FILE: ${{ inputs.output_file }} diff --git a/src/create-prompt/templates/combine-prompt.ts b/src/create-prompt/templates/combine-prompt.ts new file mode 100644 index 0000000..73d2de5 --- /dev/null +++ b/src/create-prompt/templates/combine-prompt.ts @@ -0,0 +1,101 @@ +import type { PreparedContext } from "../types"; + +export function generateCombinePrompt( + context: PreparedContext, + codeReviewResultsPath: string, + securityResultsPath: string, +): string { + const prNumber = context.eventData.isPR + ? context.eventData.prNumber + : context.githubContext && "entityNumber" in context.githubContext + ? String(context.githubContext.entityNumber) + : "unknown"; + + const repoFullName = context.repository; + + return `You are combining code review and security review results for PR #${prNumber} in ${repoFullName}. +The gh CLI is installed and authenticated via GH_TOKEN. + +## Context +- Repo: ${repoFullName} +- PR Number: ${prNumber} +- Code Review Results: ${codeReviewResultsPath} +- Security Review Results: ${securityResultsPath} + +## Task + +1. Read the results files (if they exist): + - ${codeReviewResultsPath} - Code review findings + - ${securityResultsPath} - Security review findings + +2. Combine and deduplicate findings: + - Merge findings from both reviews + - Remove duplicates (same file + line + similar description) + - Prioritize security findings over code review findings for overlaps + +3. Post inline comments for all unique findings using github_inline_comment___create_inline_comment: + - Use side="RIGHT" for new/modified code + - Include severity, description, and suggested fix where available + - For security findings, include CWE reference + +4. Analyze the PR diff to generate: + - A concise 1-2 sentence summary of what the PR does + - 3-5 key changes extracted from the diff + - The most important files changed (up to 5-7 files) + +5. Update the tracking comment with combined summary using github_comment___update_droid_comment: + +IMPORTANT: Do NOT use github_pr___submit_review. Only update the tracking comment and post inline comments. +The tracking comment IS the summary - do not create any other summary comments. + +\`\`\`markdown +## Code review completed + +### Summary +{Brief 1-2 sentence description of what this PR does} + +### Key Changes +- {Change 1} +- {Change 2} +- {Change 3} + +### Important Files Changed +- \`path/to/file1.ts\` - {Brief description of changes} +- \`path/to/file2.ts\` - {Brief description of changes} + +### Code Review +| Type | Count | +|------|-------| +| 🐛 Bugs | X | +| ⚠️ Issues | X | +| 💡 Suggestions | X | + +### Security Review +| Severity | Count | +|----------|-------| +| 🚨 CRITICAL | X | +| 🔴 HIGH | X | +| 🟡 MEDIUM | X | +| 🟢 LOW | X | + +### Findings +| ID | Type | Severity | File | Description | +|----|------|----------|------|-------------| +| ... | ... | ... | ... | ... | + +[View workflow run](link) +\`\`\` + +## Available Tools +- github_comment___update_droid_comment - Update tracking comment (this is the ONLY place for the summary) +- github_inline_comment___create_inline_comment - Post inline comments on specific lines +- Read, Grep, Glob, LS, Execute - File operations + +DO NOT use github_pr___submit_review - it creates duplicate summary comments. + +## Important +- If no results files exist or they're empty, report "No issues found" +- Maximum 10 inline comments total +- Deduplicate findings that appear in both reviews +`; +} diff --git a/src/entrypoints/combine-reviews.ts b/src/entrypoints/combine-reviews.ts new file mode 100644 index 0000000..fe8933f --- /dev/null +++ b/src/entrypoints/combine-reviews.ts @@ -0,0 +1,213 @@ +#!/usr/bin/env bun + +/** + * Combine results from code review and security review into a single summary + * Updates the tracking comment with the combined results + */ + +import * as core from "@actions/core"; +import { readFile } from "fs/promises"; +import { createOctokit } from "../github/api/client"; +import { parseGitHubContext } from "../github/context"; +import { GITHUB_SERVER_URL } from "../github/api/config"; + +interface ReviewFinding { + id: string; + type: string; + severity?: string; + file: string; + line: number; + description: string; + cwe?: string; +} + +interface ReviewResults { + type: "code" | "security"; + findings: ReviewFinding[]; + summary?: string; +} + +async function loadResults(filePath: string): Promise { + if (!filePath || filePath === "") { + return null; + } + + try { + const content = await readFile(filePath, "utf-8"); + return JSON.parse(content); + } catch (error) { + console.warn(`Could not load results from ${filePath}:`, error); + return null; + } +} + +function generateCombinedSummary( + codeResults: ReviewResults | null, + securityResults: ReviewResults | null, + codeStatus: string, + securityStatus: string, + jobUrl: string, +): string { + const sections: string[] = []; + + // Header + sections.push("## 🔍 PR Review Summary\n"); + + // Status overview + const statusTable = ["| Review Type | Status |", "|-------------|--------|"]; + + if (codeStatus !== "skipped") { + const codeIcon = codeStatus === "success" ? "✅" : "❌"; + statusTable.push(`| Code Review | ${codeIcon} ${codeStatus} |`); + } + + if (securityStatus !== "skipped") { + const securityIcon = securityStatus === "success" ? "✅" : "❌"; + statusTable.push(`| Security Review | ${securityIcon} ${securityStatus} |`); + } + + if (statusTable.length > 2) { + sections.push(statusTable.join("\n")); + sections.push(""); + } + + // Code Review Section + if (codeResults && codeResults.findings.length > 0) { + sections.push("### 📝 Code Review Findings\n"); + sections.push("| ID | Type | File | Line | Description |"); + sections.push("|----|------|------|------|-------------|"); + + for (const finding of codeResults.findings.slice(0, 10)) { + sections.push( + `| ${finding.id} | ${finding.type} | \`${finding.file}\` | ${finding.line} | ${finding.description} |`, + ); + } + + if (codeResults.findings.length > 10) { + sections.push( + `\n*...and ${codeResults.findings.length - 10} more findings*`, + ); + } + sections.push(""); + } else if (codeStatus === "success") { + sections.push("### 📝 Code Review\n"); + sections.push("✅ No code quality issues found.\n"); + } + + // Security Review Section + if (securityResults && securityResults.findings.length > 0) { + sections.push("### 🔐 Security Review Findings\n"); + + // Severity counts + const severityCounts = { + CRITICAL: 0, + HIGH: 0, + MEDIUM: 0, + LOW: 0, + }; + + for (const finding of securityResults.findings) { + const sev = (finding.severity?.toUpperCase() || + "MEDIUM") as keyof typeof severityCounts; + if (sev in severityCounts) { + severityCounts[sev]++; + } + } + + sections.push("| Severity | Count |"); + sections.push("|----------|-------|"); + if (severityCounts.CRITICAL > 0) + sections.push(`| 🚨 CRITICAL | ${severityCounts.CRITICAL} |`); + if (severityCounts.HIGH > 0) + sections.push(`| 🔴 HIGH | ${severityCounts.HIGH} |`); + if (severityCounts.MEDIUM > 0) + sections.push(`| 🟡 MEDIUM | ${severityCounts.MEDIUM} |`); + if (severityCounts.LOW > 0) + sections.push(`| 🟢 LOW | ${severityCounts.LOW} |`); + sections.push(""); + + // Findings table + sections.push("| ID | Severity | Type | File | Line | Reference |"); + sections.push("|----|----------|------|------|------|-----------|"); + + for (const finding of securityResults.findings.slice(0, 10)) { + const cweLink = finding.cwe + ? `[${finding.cwe}](https://cwe.mitre.org/data/definitions/${finding.cwe.replace("CWE-", "")}.html)` + : "-"; + sections.push( + `| ${finding.id} | ${finding.severity || "MEDIUM"} | ${finding.type} | \`${finding.file}\` | ${finding.line} | ${cweLink} |`, + ); + } + + if (securityResults.findings.length > 10) { + sections.push( + `\n*...and ${securityResults.findings.length - 10} more findings*`, + ); + } + sections.push(""); + } else if (securityStatus === "success") { + sections.push("### 🔐 Security Review\n"); + sections.push("✅ No security vulnerabilities found.\n"); + } + + // Footer with job link + sections.push(`---\n[View job run](${jobUrl})`); + + return sections.join("\n"); +} + +async function run() { + try { + const githubToken = process.env.GITHUB_TOKEN!; + const commentId = parseInt(process.env.DROID_COMMENT_ID || "0"); + const codeResultsPath = process.env.CODE_REVIEW_RESULTS || ""; + const securityResultsPath = process.env.SECURITY_RESULTS || ""; + const codeStatus = process.env.CODE_REVIEW_STATUS || "skipped"; + const securityStatus = process.env.SECURITY_REVIEW_STATUS || "skipped"; + const runId = process.env.GITHUB_RUN_ID || ""; + + if (!commentId) { + throw new Error("DROID_COMMENT_ID is required"); + } + + const context = parseGitHubContext(); + const { owner, repo } = context.repository; + const octokit = createOctokit(githubToken); + + // Load results from artifacts + const codeResults = await loadResults(codeResultsPath); + const securityResults = await loadResults(securityResultsPath); + + // Generate job URL + const jobUrl = `${GITHUB_SERVER_URL}/${owner}/${repo}/actions/runs/${runId}`; + + // Generate combined summary + const summary = generateCombinedSummary( + codeResults, + securityResults, + codeStatus, + securityStatus, + jobUrl, + ); + + // Update the tracking comment + await octokit.rest.issues.updateComment({ + owner, + repo, + comment_id: commentId, + body: summary, + }); + + console.log( + `✅ Updated tracking comment ${commentId} with combined summary`, + ); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + core.setFailed(`Combine reviews failed: ${errorMessage}`); + process.exit(1); + } +} + +if (import.meta.main) { + run(); +} diff --git a/src/entrypoints/generate-combine-prompt.ts b/src/entrypoints/generate-combine-prompt.ts new file mode 100644 index 0000000..bcafe32 --- /dev/null +++ b/src/entrypoints/generate-combine-prompt.ts @@ -0,0 +1,112 @@ +#!/usr/bin/env bun + +/** + * Generate combine prompt for finalizing parallel reviews + */ + +import * as core from "@actions/core"; +import { createOctokit } from "../github/api/client"; +import { parseGitHubContext, isEntityContext } from "../github/context"; +import { fetchPRBranchData } from "../github/data/pr-fetcher"; +import { createPrompt } from "../create-prompt"; +import { prepareMcpTools } from "../mcp/install-mcp-server"; +import { generateCombinePrompt } from "../create-prompt/templates/combine-prompt"; +import { normalizeDroidArgs, parseAllowedTools } from "../utils/parse-tools"; + +async function run() { + try { + const githubToken = process.env.GITHUB_TOKEN!; + const commentId = parseInt(process.env.DROID_COMMENT_ID || "0"); + const codeReviewResults = process.env.CODE_REVIEW_RESULTS || ""; + const securityResults = process.env.SECURITY_RESULTS || ""; + + const context = parseGitHubContext(); + + if (!isEntityContext(context)) { + throw new Error("Combine requires entity context (PR or issue)"); + } + + if (!context.isPR) { + throw new Error("Combine is only supported on pull requests"); + } + + const octokit = createOctokit(githubToken); + + const prData = await fetchPRBranchData({ + octokits: octokit, + repository: context.repository, + prNumber: context.entityNumber, + }); + + // Generate combine prompt with paths to result files + await createPrompt({ + githubContext: context, + commentId, + baseBranch: prData.baseRefName, + prBranchData: { + headRefName: prData.headRefName, + headRefOid: prData.headRefOid, + }, + generatePrompt: (ctx) => + generateCombinePrompt(ctx, codeReviewResults, securityResults), + }); + + core.exportVariable("DROID_EXEC_RUN_TYPE", "droid-combine"); + + const rawUserArgs = process.env.DROID_ARGS || ""; + const normalizedUserArgs = normalizeDroidArgs(rawUserArgs); + const userAllowedMCPTools = parseAllowedTools(normalizedUserArgs).filter( + (tool) => tool.startsWith("github_") && tool.includes("___"), + ); + + // Combine step has tools for inline comments and tracking comment update + // NO github_pr___submit_review - it creates duplicate summary comments + const baseTools = [ + "Read", + "Grep", + "Glob", + "LS", + "Execute", + "github_comment___update_droid_comment", + "github_inline_comment___create_inline_comment", + ]; + + const allowedTools = Array.from( + new Set([...baseTools, ...userAllowedMCPTools]), + ); + + const mcpTools = await prepareMcpTools({ + githubToken, + owner: context.repository.owner, + repo: context.repository.repo, + droidCommentId: commentId.toString(), + allowedTools, + mode: "tag", + context, + }); + + const droidArgParts: string[] = []; + // Only include built-in tools in --enabled-tools + const builtInTools = allowedTools.filter((t) => !t.includes("___")); + if (builtInTools.length > 0) { + droidArgParts.push(`--enabled-tools "${builtInTools.join(",")}"`); + } + + if (normalizedUserArgs) { + droidArgParts.push(normalizedUserArgs); + } + + core.setOutput("droid_args", droidArgParts.join(" ").trim()); + core.setOutput("mcp_tools", mcpTools); + + console.log(`Generated combine prompt`); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + core.setFailed(`Generate combine prompt failed: ${errorMessage}`); + process.exit(1); + } +} + +if (import.meta.main) { + run(); +} diff --git a/src/entrypoints/generate-review-prompt.ts b/src/entrypoints/generate-review-prompt.ts new file mode 100644 index 0000000..0f74e89 --- /dev/null +++ b/src/entrypoints/generate-review-prompt.ts @@ -0,0 +1,142 @@ +#!/usr/bin/env bun + +/** + * Generate review prompt for standalone review/security actions + */ + +import * as core from "@actions/core"; +import { createOctokit } from "../github/api/client"; +import { parseGitHubContext, isEntityContext } from "../github/context"; +import { fetchPRBranchData } from "../github/data/pr-fetcher"; +import { createPrompt } from "../create-prompt"; +import { prepareMcpTools } from "../mcp/install-mcp-server"; +import { generateReviewPrompt } from "../create-prompt/templates/review-prompt"; +import { generateSecurityReviewPrompt } from "../create-prompt/templates/security-review-prompt"; +import { normalizeDroidArgs, parseAllowedTools } from "../utils/parse-tools"; + +async function run() { + try { + const githubToken = process.env.GITHUB_TOKEN!; + const reviewType = process.env.REVIEW_TYPE || "code"; + const commentId = parseInt(process.env.DROID_COMMENT_ID || "0"); + + if (!commentId) { + throw new Error("DROID_COMMENT_ID is required and must be non-zero"); + } + + const context = parseGitHubContext(); + + if (!isEntityContext(context)) { + throw new Error("Review requires entity context (PR or issue)"); + } + + if (!context.isPR) { + throw new Error("Review is only supported on pull requests"); + } + + const octokit = createOctokit(githubToken); + + const prData = await fetchPRBranchData({ + octokits: octokit, + repository: context.repository, + prNumber: context.entityNumber, + }); + + const branchInfo = { + baseBranch: prData.baseRefName, + currentBranch: prData.headRefName, + }; + + // Select prompt generator based on review type + const generatePrompt = + reviewType === "security" + ? generateSecurityReviewPrompt + : generateReviewPrompt; + + await createPrompt({ + githubContext: context, + commentId, + baseBranch: branchInfo.baseBranch, + prBranchData: { + headRefName: prData.headRefName, + headRefOid: prData.headRefOid, + }, + generatePrompt, + }); + + // Set run type + const runType = + reviewType === "security" ? "droid-security-review" : "droid-review"; + core.exportVariable("DROID_EXEC_RUN_TYPE", runType); + + const rawUserArgs = process.env.DROID_ARGS || ""; + const normalizedUserArgs = normalizeDroidArgs(rawUserArgs); + const userAllowedMCPTools = parseAllowedTools(normalizedUserArgs).filter( + (tool) => tool.startsWith("github_") && tool.includes("___"), + ); + + // Base tools for analysis only - NO inline comment tools + // Inline comments will be posted by the finalize step to avoid overlaps + const baseTools = [ + "Read", + "Grep", + "Glob", + "LS", + "Execute", + "github_comment___update_droid_comment", + ]; + + // Review tools for reading existing comments only + const reviewTools = ["github_pr___list_review_comments"]; + + const allowedTools = Array.from( + new Set([...baseTools, ...reviewTools, ...userAllowedMCPTools]), + ); + + const mcpTools = await prepareMcpTools({ + githubToken, + owner: context.repository.owner, + repo: context.repository.repo, + droidCommentId: commentId.toString(), + allowedTools, + mode: "tag", + context, + }); + + const droidArgParts: string[] = []; + // Only include built-in tools in --enabled-tools + // MCP tools are discovered dynamically from registered servers + const builtInTools = allowedTools.filter((t) => !t.includes("___")); + if (builtInTools.length > 0) { + droidArgParts.push(`--enabled-tools "${builtInTools.join(",")}"`); + } + + // Add model override if specified + const model = + reviewType === "security" + ? process.env.SECURITY_MODEL?.trim() || process.env.REVIEW_MODEL?.trim() + : process.env.REVIEW_MODEL?.trim(); + + if (model) { + droidArgParts.push(`--model "${model}"`); + } + + if (normalizedUserArgs) { + droidArgParts.push(normalizedUserArgs); + } + + // Output for next step - use core.setOutput which handles GITHUB_OUTPUT internally + core.setOutput("droid_args", droidArgParts.join(" ").trim()); + core.setOutput("mcp_tools", mcpTools); + + console.log(`Generated ${reviewType} review prompt`); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + core.setFailed(`Generate prompt failed: ${errorMessage}`); + process.exit(1); + } +} + +if (import.meta.main) { + run(); +} diff --git a/src/entrypoints/get-token.ts b/src/entrypoints/get-token.ts new file mode 100644 index 0000000..ca6c697 --- /dev/null +++ b/src/entrypoints/get-token.ts @@ -0,0 +1,40 @@ +#!/usr/bin/env bun + +/** + * Gets GitHub token via OIDC or uses provided token + */ + +import * as core from "@actions/core"; +import { appendFileSync } from "fs"; +import { setupGitHubToken } from "../github/token"; + +async function run() { + try { + const overrideToken = process.env.OVERRIDE_GITHUB_TOKEN?.trim(); + + let token: string; + if (overrideToken) { + console.log("Using provided GitHub token"); + token = overrideToken; + } else { + console.log("Requesting OIDC token..."); + token = await setupGitHubToken(); + console.log("GitHub token obtained via OIDC"); + } + + // Set output for next steps + const githubOutput = process.env.GITHUB_OUTPUT; + if (githubOutput) { + appendFileSync(githubOutput, `github_token=${token}\n`); + } + core.setOutput("github_token", token); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + core.setFailed(`Failed to get GitHub token: ${errorMessage}`); + process.exit(1); + } +} + +if (import.meta.main) { + run(); +} diff --git a/src/github/utils/command-parser.test.ts b/src/github/utils/command-parser.test.ts index 602a13b..7c94a82 100644 --- a/src/github/utils/command-parser.test.ts +++ b/src/github/utils/command-parser.test.ts @@ -79,26 +79,16 @@ describe("Command Parser", () => { expect(result?.raw).toBe("@droid review"); }); - it("should detect @droid review security (combined)", () => { + it("should parse @droid review security as just review", () => { const result = parseDroidCommand("@droid review security"); - expect(result?.command).toBe("review-security"); - expect(result?.raw).toBe("@droid review security"); + expect(result?.command).toBe("review"); + expect(result?.raw).toBe("@droid review"); }); - it("should detect @droid security review (combined)", () => { + it("should parse @droid security review as security", () => { const result = parseDroidCommand("@droid security review"); - expect(result?.command).toBe("review-security"); - expect(result?.raw).toBe("@droid security review"); - }); - - it("should be case insensitive for combined commands", () => { - const result = parseDroidCommand("@DROID REVIEW SECURITY"); - expect(result?.command).toBe("review-security"); - }); - - it("should prioritize combined over individual review", () => { - const result = parseDroidCommand("@droid review security please"); - expect(result?.command).toBe("review-security"); + expect(result?.command).toBe("security"); + expect(result?.raw).toBe("@droid security"); }); it("should detect @droid security", () => { diff --git a/src/github/utils/command-parser.ts b/src/github/utils/command-parser.ts index 182cac5..5a9ca27 100644 --- a/src/github/utils/command-parser.ts +++ b/src/github/utils/command-parser.ts @@ -8,7 +8,6 @@ export type DroidCommand = | "fill" | "review" | "security" - | "review-security" | "security-full" | "default"; @@ -39,20 +38,8 @@ export function parseDroidCommand(text: string): ParsedCommand | null { }; } - // Check for @droid review security OR @droid security review (both reviews) - // Must check before individual review/security to avoid false matches - const combinedMatch = text.match( - /@droid\s+(?:review\s+security|security\s+review)/i, - ); - if (combinedMatch) { - return { - command: "review-security", - raw: combinedMatch[0], - location: "body", // Will be set by caller - }; - } - // Check for @droid review command (case insensitive) + // Note: @droid review security will match as just @droid review const reviewMatch = text.match(/@droid\s+review/i); if (reviewMatch) { return { diff --git a/src/prepare/types.ts b/src/prepare/types.ts index 0235a20..2276ec2 100644 --- a/src/prepare/types.ts +++ b/src/prepare/types.ts @@ -9,6 +9,8 @@ export type PrepareResult = { currentBranch: string; }; mcpTools: string; + skipped?: boolean; + reason?: string; }; export type PrepareOptions = { diff --git a/src/tag/commands/review.ts b/src/tag/commands/review.ts index 146ac60..bc734c5 100644 --- a/src/tag/commands/review.ts +++ b/src/tag/commands/review.ts @@ -101,10 +101,22 @@ export async function prepareReviewMode({ const droidArgParts: string[] = []; droidArgParts.push(`--enabled-tools "${allowedTools.join(",")}"`); - // Add model override if specified const reviewModel = process.env.REVIEW_MODEL?.trim(); - if (reviewModel) { - droidArgParts.push(`--model "${reviewModel}"`); + const reasoningEffort = process.env.REASONING_EFFORT?.trim(); + + // Default behavior (behind the scenes): if neither is provided, run GPT-5.2 at high reasoning. + if (!reviewModel && !reasoningEffort) { + droidArgParts.push(`--model "gpt-5.2"`); + droidArgParts.push(`--reasoning-effort "high"`); + } else { + // Add model override if specified + if (reviewModel) { + droidArgParts.push(`--model "${reviewModel}"`); + } + // Add reasoning effort override if specified + if (reasoningEffort) { + droidArgParts.push(`--reasoning-effort "${reasoningEffort}"`); + } } if (normalizedUserArgs) { diff --git a/src/tag/index.ts b/src/tag/index.ts index a231476..35ffe6f 100644 --- a/src/tag/index.ts +++ b/src/tag/index.ts @@ -1,7 +1,8 @@ +import * as core from "@actions/core"; import { checkContainsTrigger } from "../github/validation/trigger"; import { checkHumanActor } from "../github/validation/actor"; import { createInitialComment } from "../github/operations/comments/create-initial"; -import { isEntityContext } from "../github/context"; +import { isEntityContext, type ParsedGitHubContext } from "../github/context"; import { extractCommandFromContext } from "../github/utils/command-parser"; import { prepareFillMode } from "./commands/fill"; import { prepareReviewMode } from "./commands/review"; @@ -11,6 +12,9 @@ import type { GitHubContext } from "../github/context"; import type { PrepareResult } from "../prepare/types"; import type { Octokits } from "../github/api/client"; +const DROID_APP_BOT_ID = 209825114; +const SECURITY_REVIEW_MARKER = "## Security Review Summary"; + export function shouldTriggerTag(context: GitHubContext): boolean { if (!isEntityContext(context)) { return false; @@ -24,6 +28,39 @@ export function shouldTriggerTag(context: GitHubContext): boolean { return checkContainsTrigger(context); } +/** + * Checks if a security review has already been performed on this PR. + * Used to implement "run once" behavior for automatic security reviews. + */ +async function hasExistingSecurityReview( + octokit: Octokits, + context: ParsedGitHubContext, +): Promise { + const { owner, repo } = context.repository; + + try { + const comments = await octokit.rest.issues.listComments({ + owner, + repo, + issue_number: context.entityNumber, + per_page: 100, + }); + + const hasSecurityReview = comments.data.some((comment) => { + const isOurBot = + comment.user?.id === DROID_APP_BOT_ID || + (comment.user?.type === "Bot" && + comment.user?.login.toLowerCase().includes("droid")); + return isOurBot && comment.body?.includes(SECURITY_REVIEW_MARKER); + }); + + return hasSecurityReview; + } catch (error) { + console.warn("Failed to check for existing security review:", error); + return false; + } +} + type PrepareTagOptions = { context: GitHubContext; octokit: Octokits; @@ -66,7 +103,42 @@ export async function prepareTagExecution({ ); const commentId = commentData.id; + // Handle parallel review mode when both flags are set + if ( + context.inputs.automaticReview && + context.inputs.automaticSecurityReview + ) { + // Output flags for parallel workflow jobs + const runCodeReview = true; + let runSecurityReview = true; + + // Check if security review already exists on this PR (run once behavior) + const hasExisting = await hasExistingSecurityReview(octokit, context); + if (hasExisting) { + console.log( + "Security review already exists on this PR, skipping security", + ); + runSecurityReview = false; + } + + // Set outputs for downstream jobs + core.setOutput("run_code_review", runCodeReview.toString()); + core.setOutput("run_security_review", runSecurityReview.toString()); + + // For parallel mode, return early - individual jobs will run their own reviews + return { + skipped: false, + branchInfo: { + baseBranch: "", + currentBranch: "", + }, + mcpTools: "", + }; + } + if (context.inputs.automaticReview) { + core.setOutput("run_code_review", "true"); + core.setOutput("run_security_review", "false"); return prepareReviewMode({ context, octokit, @@ -76,6 +148,25 @@ export async function prepareTagExecution({ } if (context.inputs.automaticSecurityReview) { + // Check if security review already exists on this PR (run once behavior) + const hasExisting = await hasExistingSecurityReview(octokit, context); + if (hasExisting) { + console.log("Security review already exists on this PR, skipping"); + core.setOutput("run_code_review", "false"); + core.setOutput("run_security_review", "false"); + return { + skipped: true, + reason: "security_review_exists", + branchInfo: { + baseBranch: "", + currentBranch: "", + }, + mcpTools: "", + }; + } + + core.setOutput("run_code_review", "false"); + core.setOutput("run_security_review", "true"); return prepareSecurityReviewMode({ context, octokit, @@ -94,12 +185,16 @@ export async function prepareTagExecution({ } if (commandContext?.command === "security") { - return prepareSecurityReviewMode({ - context, - octokit, - githubToken, - trackingCommentId: commentId, - }); + core.setOutput("run_code_review", "false"); + core.setOutput("run_security_review", "true"); + return { + skipped: false, + branchInfo: { + baseBranch: "", + currentBranch: "", + }, + mcpTools: "", + }; } if (commandContext?.command === "security-full") { @@ -111,17 +206,22 @@ export async function prepareTagExecution({ }); } + // @droid review or @droid (default) = code review only if ( commandContext?.command === "review" || !commandContext || commandContext.command === "default" ) { - return prepareReviewMode({ - context, - octokit, - githubToken, - trackingCommentId: commentId, - }); + core.setOutput("run_code_review", "true"); + core.setOutput("run_security_review", "false"); + return { + skipped: false, + branchInfo: { + baseBranch: "", + currentBranch: "", + }, + mcpTools: "", + }; } throw new Error(`Unexpected command: ${commandContext?.command}`); diff --git a/test/integration/review-flow.test.ts b/test/integration/review-flow.test.ts index 2bd8c57..aa70bb5 100644 --- a/test/integration/review-flow.test.ts +++ b/test/integration/review-flow.test.ts @@ -1,14 +1,7 @@ -import { - afterEach, - beforeEach, - describe, - expect, - it, - spyOn, -} from "bun:test"; +import { afterEach, beforeEach, describe, expect, it, spyOn } from "bun:test"; import path from "node:path"; import os from "node:os"; -import { mkdtemp, readFile, rm } from "node:fs/promises"; +import { mkdtemp, rm } from "node:fs/promises"; import { prepareTagExecution } from "../../src/tag"; import { createMockContext } from "../mockContext"; import * as createInitial from "../../src/github/operations/comments/create-initial"; @@ -32,8 +25,6 @@ describe("review command integration", () => { process.env.RUNNER_TEMP = tmpDir; process.env.DROID_ARGS = ""; - - createCommentSpy = spyOn( createInitial, "createInitialComment", @@ -95,75 +86,95 @@ describe("review command integration", () => { } as any, }); - const octokit = { - rest: {}, - graphql: () => Promise.resolve({ - repository: { - pullRequest: { - baseRefName: "main", - headRefName: "feature/review", - headRefOid: "def456", - } - } - }) + const octokit = { + rest: {}, + graphql: () => + Promise.resolve({ + repository: { + pullRequest: { + baseRefName: "main", + headRefName: "feature/review", + headRefOid: "def456", + }, + }, + }), } as any; graphqlSpy = spyOn(octokit, "graphql").mockResolvedValue({ repository: { pullRequest: { baseRefName: "main", - headRefName: "feature/review", + headRefName: "feature/review", headRefOid: "def456", - } - } + }, + }, + }); + + const result = await prepareTagExecution({ + context, + octokit, + githubToken: "token", + }); + + // In the parallel workflow, @droid review sets output flags and returns early + // The actual review is done by downstream workflow jobs + expect(result.skipped).toBe(false); + + // Verify output flags were set correctly for code review only + const runCodeReviewCall = setOutputSpy.mock.calls.find( + (call: unknown[]) => call[0] === "run_code_review", + ) as [string, string] | undefined; + const runSecurityReviewCall = setOutputSpy.mock.calls.find( + (call: unknown[]) => call[0] === "run_security_review", + ) as [string, string] | undefined; + + expect(runCodeReviewCall?.[1]).toBe("true"); + expect(runSecurityReviewCall?.[1]).toBe("false"); + }); + + it("sets security flag only for @droid security", async () => { + const context = createMockContext({ + eventName: "issue_comment", + isPR: true, + actor: "human-reviewer", + entityNumber: 7, + repository: { + owner: "test-owner", + repo: "test-repo", + full_name: "test-owner/test-repo", + }, + payload: { + comment: { + id: 888, + body: "@droid security", + user: { login: "human-reviewer" }, + created_at: "2024-02-02T00:00:00Z", + }, + issue: { + number: 7, + pull_request: {}, + }, + } as any, }); + const octokit = { rest: {} } as any; + const result = await prepareTagExecution({ context, octokit, githubToken: "token", }); - expect(result.commentId).toBe(202); - expect(graphqlSpy).toHaveBeenCalled(); - expect(mcpSpy).toHaveBeenCalledWith( - expect.objectContaining({ - allowedTools: expect.arrayContaining([ - "github_pr___list_review_comments", - "github_pr___submit_review", - "github_inline_comment___create_inline_comment", - "github_pr___resolve_review_thread", - ]), - }), - ); - - const promptPath = path.join( - process.env.RUNNER_TEMP!, - "droid-prompts", - "droid-prompt.txt", - ); - const prompt = await readFile(promptPath, "utf8"); - - expect(prompt).toContain("You are performing an automated code review"); - expect(prompt).toContain("How Many Findings to Return:"); - expect(prompt).toContain("Output all findings that the original author would fix"); - expect(prompt).toContain("Key Guidelines for Bug Detection:"); - expect(prompt).toContain("Priority Levels:"); - expect(prompt).toContain("gh pr view 7 --repo test-owner/test-repo --json comments,reviews"); - expect(prompt).toContain("code-review-results.json"); - expect(prompt).toContain("Do NOT post inline comments"); - - const droidArgsCall = setOutputSpy.mock.calls.find( - (call: unknown[]) => call[0] === "droid_args", + expect(result.skipped).toBe(false); + + const runCodeReviewCall = setOutputSpy.mock.calls.find( + (call: unknown[]) => call[0] === "run_code_review", + ) as [string, string] | undefined; + const runSecurityReviewCall = setOutputSpy.mock.calls.find( + (call: unknown[]) => call[0] === "run_security_review", ) as [string, string] | undefined; - expect(droidArgsCall?.[1]).toContain( - "github_pr___list_review_comments", - ); - expect(droidArgsCall?.[1]).toContain("github_pr___submit_review"); - expect(droidArgsCall?.[1]).toContain( - "github_inline_comment___create_inline_comment", - ); - expect(droidArgsCall?.[1]).toContain("github_pr___resolve_review_thread"); + expect(runCodeReviewCall?.[1]).toBe("false"); + expect(runSecurityReviewCall?.[1]).toBe("true"); }); }); diff --git a/test/modes/tag/review-command.test.ts b/test/modes/tag/review-command.test.ts index a135ae6..d09be4a 100644 --- a/test/modes/tag/review-command.test.ts +++ b/test/modes/tag/review-command.test.ts @@ -260,11 +260,14 @@ describe("prepareReviewMode", () => { const droidArgsCall = setOutputSpy.mock.calls.find( (call: unknown[]) => call[0] === "droid_args", ) as [string, string] | undefined; - expect(droidArgsCall?.[1]).toContain('--model "claude-sonnet-4-5-20250929"'); + expect(droidArgsCall?.[1]).toContain( + '--model "claude-sonnet-4-5-20250929"', + ); }); it("does not add --model flag when REVIEW_MODEL is empty", async () => { process.env.REVIEW_MODEL = ""; + delete process.env.REASONING_EFFORT; const context = createMockContext({ eventName: "issue_comment", @@ -312,6 +315,8 @@ describe("prepareReviewMode", () => { const droidArgsCall = setOutputSpy.mock.calls.find( (call: unknown[]) => call[0] === "droid_args", ) as [string, string] | undefined; - expect(droidArgsCall?.[1]).not.toContain("--model"); + // When neither REVIEW_MODEL nor REASONING_EFFORT is provided, we default to gpt-5.2 at high reasoning. + expect(droidArgsCall?.[1]).toContain('--model "gpt-5.2"'); + expect(droidArgsCall?.[1]).toContain('--reasoning-effort "high"'); }); });