From 885aeed91bd7f8ccb2d94c14421158f847025fcc Mon Sep 17 00:00:00 2001 From: DevCats Date: Mon, 2 Feb 2026 11:49:45 -0600 Subject: [PATCH] feat: add code-review skill and align workflow with doc-check (#21668) This pull request adds a new documentation file that defines the "code-review" skill for use in the project. The document outlines a standard workflow, severity levels, key areas to focus on during code reviews, and Coder-specific review guidelines. This aims to standardize and improve the quality and consistency of code reviews across the team. Documentation and process standardization: * Added `.claude/skills/code-review/SKILL.md`, which describes the code-review skill, including workflow steps, severity levels, what to look for in reviews, and what not to comment on. It also provides Coder-specific patterns and best practices for authorization, error handling, and shell scripting. --- .claude/skills/code-review/SKILL.md | 96 +++++++ .github/workflows/code-review.yaml | 424 +++++++++++++++++----------- 2 files changed, 360 insertions(+), 160 deletions(-) create mode 100644 .claude/skills/code-review/SKILL.md diff --git a/.claude/skills/code-review/SKILL.md b/.claude/skills/code-review/SKILL.md new file mode 100644 index 0000000000..96036cfc3a --- /dev/null +++ b/.claude/skills/code-review/SKILL.md @@ -0,0 +1,96 @@ +--- +name: code-review +description: Reviews code changes for bugs, security issues, and quality problems +--- + +# Code Review Skill + +Review code changes in coder/coder and identify bugs, security issues, and +quality problems. + +## Workflow + +1. **Get the code changes** - Use the method provided in the prompt, or if none + specified: + - For a PR: `gh pr diff --repo coder/coder` + - For local changes: `git diff main` or `git diff --staged` + +2. **Read full files and related code** before commenting - verify issues exist + and consider how similar code is implemented elsewhere in the codebase + +3. **Analyze for issues** - Focus on what could break production + +4. **Report findings** - Use the method provided in the prompt, or summarize + directly + +## Severity Levels + +- **šŸ”“ CRITICAL**: Security vulnerabilities, auth bypass, data corruption, + crashes +- **🟔 IMPORTANT**: Logic bugs, race conditions, resource leaks, unhandled + errors +- **šŸ”µ NITPICK**: Minor improvements, style issues, portability concerns + +## What to Look For + +- **Security**: Auth bypass, injection, data exposure, improper access control +- **Correctness**: Logic errors, off-by-one, nil/null handling, error paths +- **Concurrency**: Race conditions, deadlocks, missing synchronization +- **Resources**: Leaks, unclosed handles, missing cleanup +- **Error handling**: Swallowed errors, missing validation, panic paths + +## What NOT to Comment On + +- Style that matches existing Coder patterns (check AGENTS.md first) +- Code that already exists unchanged +- Theoretical issues without concrete impact +- Changes unrelated to the PR's purpose + +## Coder-Specific Patterns + +### Authorization Context + +```go +// Public endpoints needing system access +dbauthz.AsSystemRestricted(ctx) + +// Authenticated endpoints with user context - just use ctx +api.Database.GetResource(ctx, id) +``` + +### Error Handling + +```go +// OAuth2 endpoints use RFC-compliant errors +writeOAuth2Error(ctx, rw, http.StatusBadRequest, "invalid_grant", "description") + +// Regular endpoints use httpapi +httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{...}) +``` + +### Shell Scripts + +`set -u` only catches UNDEFINED variables, not empty strings: + +```sh +unset VAR; echo ${VAR} # ERROR with set -u +VAR=""; echo ${VAR} # OK with set -u (empty is fine) +VAR="${INPUT:-}"; echo ${VAR} # OK - always defined +``` + +GitHub Actions context variables (`github.*`, `inputs.*`) are always defined. + +## Review Quality + +- Explain **impact** ("causes crash when X" not "could be better") +- Make observations **actionable** with specific fixes +- Read the **full context** before commenting on a line +- Check **AGENTS.md** for project conventions before flagging style + +## Comment Standards + +- **Only comment when confident** - If you're not 80%+ sure it's a real issue, + don't comment. Verify claims before posting. +- **No speculation** - Avoid "might", "could", "consider". State facts or skip. +- **Verify technical claims** - Check documentation or code before asserting how + something works. Don't guess at API behavior or syntax rules. diff --git a/.github/workflows/code-review.yaml b/.github/workflows/code-review.yaml index d2b485579a..53eb886b20 100644 --- a/.github/workflows/code-review.yaml +++ b/.github/workflows/code-review.yaml @@ -5,18 +5,24 @@ # The AI agent posts a single review with inline comments using GitHub's # native suggestion syntax, allowing one-click commits of suggested changes. # -# Triggered by: Adding the "code-review" label to a PR, or manual dispatch. +# Triggers: +# - New PR opened: Initial code review +# - Label "code-review" added: Re-run review on demand +# - PR marked ready for review: Review when draft is promoted +# - Workflow dispatch: Manual run with PR URL # -# Required secrets: -# - DOC_CHECK_CODER_URL: URL of your Coder deployment (shared with doc-check) -# - DOC_CHECK_CODER_SESSION_TOKEN: Session token for Coder API (shared with doc-check) +# Note: This workflow requires access to secrets and will be skipped for: +# - Any PR where secrets are not available +# For these PRs, maintainers can manually trigger via workflow_dispatch. name: AI Code Review on: pull_request: types: + - opened - labeled + - ready_for_review workflow_dispatch: inputs: pr_url: @@ -33,46 +39,72 @@ jobs: code-review: name: AI Code Review runs-on: ubuntu-latest + concurrency: + group: code-review-${{ github.event.pull_request.number || inputs.pr_url }} + cancel-in-progress: true if: | - (github.event.label.name == 'code-review' || github.event_name == 'workflow_dispatch') && + ( + github.event.action == 'opened' || + github.event.label.name == 'code-review' || + github.event.action == 'ready_for_review' || + github.event_name == 'workflow_dispatch' + ) && (github.event.pull_request.draft == false || github.event_name == 'workflow_dispatch') timeout-minutes: 30 env: - CODER_URL: ${{ secrets.DOC_CHECK_CODER_URL }} - CODER_SESSION_TOKEN: ${{ secrets.DOC_CHECK_CODER_SESSION_TOKEN }} + CODER_URL: ${{ secrets.CODE_REVIEW_CODER_URL }} + CODER_SESSION_TOKEN: ${{ secrets.CODE_REVIEW_CODER_SESSION_TOKEN }} permissions: - contents: read # Read repository contents and PR diff - pull-requests: write # Post review comments and suggestions - actions: write # Create workflow summaries + contents: read + pull-requests: write + actions: write steps: + - name: Check if secrets are available + id: check-secrets + env: + CODER_URL: ${{ secrets.CODE_REVIEW_CODER_URL }} + CODER_TOKEN: ${{ secrets.CODE_REVIEW_CODER_SESSION_TOKEN }} + run: | + if [[ -z "${CODER_URL}" || -z "${CODER_TOKEN}" ]]; then + echo "skip=true" >> "${GITHUB_OUTPUT}" + echo "Secrets not available - skipping code-review." + echo "This is expected for PRs where secrets are not available." + echo "Maintainers can manually trigger via workflow_dispatch if needed." + { + echo "āš ļø Workflow skipped: Secrets not available" + echo "" + echo "This workflow requires secrets that are unavailable for this run." + echo "Maintainers can manually trigger via workflow_dispatch if needed." + } >> "${GITHUB_STEP_SUMMARY}" + else + echo "skip=false" >> "${GITHUB_OUTPUT}" + fi + + - name: Setup Coder CLI + if: steps.check-secrets.outputs.skip != 'true' + uses: coder/setup-action@4a607a8113d4e676e2d7c34caa20a814bc88bfda # v1 + with: + access_url: ${{ secrets.CODE_REVIEW_CODER_URL }} + coder_session_token: ${{ secrets.CODE_REVIEW_CODER_SESSION_TOKEN }} + - name: Determine PR Context + if: steps.check-secrets.outputs.skip != 'true' id: determine-context env: - GITHUB_ACTOR: ${{ github.actor }} GITHUB_EVENT_NAME: ${{ github.event_name }} + GITHUB_EVENT_ACTION: ${{ github.event.action }} GITHUB_EVENT_PR_HTML_URL: ${{ github.event.pull_request.html_url }} GITHUB_EVENT_PR_NUMBER: ${{ github.event.pull_request.number }} - GITHUB_EVENT_SENDER_ID: ${{ github.event.sender.id }} - GITHUB_EVENT_SENDER_LOGIN: ${{ github.event.sender.login }} INPUTS_PR_URL: ${{ inputs.pr_url }} INPUTS_TEMPLATE_PRESET: ${{ inputs.template_preset || '' }} - GH_TOKEN: ${{ github.token }} run: | - set -euo pipefail echo "Using template preset: ${INPUTS_TEMPLATE_PRESET}" echo "template_preset=${INPUTS_TEMPLATE_PRESET}" >> "${GITHUB_OUTPUT}" - # For workflow_dispatch, use the provided PR URL + # Determine trigger type for task context if [[ "${GITHUB_EVENT_NAME}" == "workflow_dispatch" ]]; then - if ! GITHUB_USER_ID=$(gh api "users/${GITHUB_ACTOR}" --jq '.id'); then - echo "::error::Failed to get GitHub user ID for actor ${GITHUB_ACTOR}" - exit 1 - fi - echo "Using workflow_dispatch actor: ${GITHUB_ACTOR} (ID: ${GITHUB_USER_ID})" - echo "github_user_id=${GITHUB_USER_ID}" >> "${GITHUB_OUTPUT}" - echo "github_username=${GITHUB_ACTOR}" >> "${GITHUB_OUTPUT}" - + echo "trigger_type=manual" >> "${GITHUB_OUTPUT}" echo "Using PR URL: ${INPUTS_PR_URL}" # Validate PR URL format @@ -82,164 +114,99 @@ jobs: exit 1 fi - # Convert /pull/ to /issues/ for create-task-action compatibility ISSUE_URL="${INPUTS_PR_URL/\/pull\//\/issues\/}" echo "pr_url=${ISSUE_URL}" >> "${GITHUB_OUTPUT}" - - # Extract PR number from URL - PR_NUMBER=$(echo "${INPUTS_PR_URL}" | sed -n 's|.*/pull/\([0-9]*\)$|\1|p') - if [[ -z "${PR_NUMBER}" ]]; then - echo "::error::Failed to extract PR number from URL: ${INPUTS_PR_URL}" - exit 1 - fi + PR_NUMBER="${INPUTS_PR_URL##*/}" echo "pr_number=${PR_NUMBER}" >> "${GITHUB_OUTPUT}" elif [[ "${GITHUB_EVENT_NAME}" == "pull_request" ]]; then - GITHUB_USER_ID=${GITHUB_EVENT_SENDER_ID} - echo "Using label adder: ${GITHUB_EVENT_SENDER_LOGIN} (ID: ${GITHUB_USER_ID})" - echo "github_user_id=${GITHUB_USER_ID}" >> "${GITHUB_OUTPUT}" - echo "github_username=${GITHUB_EVENT_SENDER_LOGIN}" >> "${GITHUB_OUTPUT}" - echo "Using PR URL: ${GITHUB_EVENT_PR_HTML_URL}" - # Convert /pull/ to /issues/ for create-task-action compatibility ISSUE_URL="${GITHUB_EVENT_PR_HTML_URL/\/pull\//\/issues\/}" echo "pr_url=${ISSUE_URL}" >> "${GITHUB_OUTPUT}" echo "pr_number=${GITHUB_EVENT_PR_NUMBER}" >> "${GITHUB_OUTPUT}" + # Set trigger type based on action + case "${GITHUB_EVENT_ACTION}" in + opened) + echo "trigger_type=new_pr" >> "${GITHUB_OUTPUT}" + ;; + labeled) + echo "trigger_type=label_requested" >> "${GITHUB_OUTPUT}" + ;; + ready_for_review) + echo "trigger_type=ready_for_review" >> "${GITHUB_OUTPUT}" + ;; + *) + echo "trigger_type=unknown" >> "${GITHUB_OUTPUT}" + ;; + esac + else echo "::error::Unsupported event type: ${GITHUB_EVENT_NAME}" exit 1 fi - - name: Extract repository info - id: repo-info + - name: Build task prompt + if: steps.check-secrets.outputs.skip != 'true' + id: extract-context env: - REPO_OWNER: ${{ github.repository_owner }} - REPO_NAME: ${{ github.event.repository.name }} - run: | - echo "owner=${REPO_OWNER}" >> "${GITHUB_OUTPUT}" - echo "repo=${REPO_NAME}" >> "${GITHUB_OUTPUT}" - - - name: Build code review prompt - id: build-prompt - env: - PR_URL: ${{ steps.determine-context.outputs.pr_url }} PR_NUMBER: ${{ steps.determine-context.outputs.pr_number }} - REPO_OWNER: ${{ steps.repo-info.outputs.owner }} - REPO_NAME: ${{ steps.repo-info.outputs.repo }} - GH_TOKEN: ${{ github.token }} + TRIGGER_TYPE: ${{ steps.determine-context.outputs.trigger_type }} run: | - echo "Building code review prompt for PR #${PR_NUMBER}" + echo "Analyzing PR #${PR_NUMBER} (trigger: ${TRIGGER_TYPE})" + + # Build context based on trigger type + case "${TRIGGER_TYPE}" in + new_pr) + CONTEXT="This is a NEW PR. Perform a thorough code review." + ;; + label_requested) + CONTEXT="A code review was REQUESTED via label. Perform a thorough code review." + ;; + ready_for_review) + CONTEXT="This PR was marked READY FOR REVIEW. Perform a thorough code review." + ;; + manual) + CONTEXT="This is a MANUAL review request. Perform a thorough code review." + ;; + *) + CONTEXT="Perform a thorough code review." + ;; + esac # Build task prompt - TASK_PROMPT=$(cat < - - - HOW GITHUB SUGGESTIONS WORK: - Your suggestion block REPLACES the commented line(s). Don't include surrounding context! - - Example (fictional): - 49: # Comment line - 50: OLDCODE=\$(bad command) - 51: echo "done" - - āŒ WRONG - includes unchanged lines 49 and 51: - {"line": 50, "body": "Issue\\n\\n\`\`\`suggestion\\n# Comment line\\nNEWCODE\\necho \\"done\\"\\n\`\`\`"} - Result: Lines 49 and 51 duplicated! - - āœ… CORRECT - only the replacement for line 50: - {"line": 50, "body": "Issue\\n\\n\`\`\`suggestion\\nNEWCODE=\$(good command)\\n\`\`\`"} - Result: Only line 50 replaced. Perfect! - - COMMENT FORMAT: - Single line: {"path": "file.go", "line": 50, "side": "RIGHT", "body": "Issue\\n\\n\`\`\`suggestion\\n[code]\\n\`\`\`"} - Multi-line: {"path": "file.go", "start_line": 50, "line": 52, "side": "RIGHT", "body": "Issue\\n\\n\`\`\`suggestion\\n[code]\\n\`\`\`"} - - SUMMARY FORMAT (1-10 lines, conversational): - With issues: "## šŸ” Code Review\\n\\nReviewed [5-8 words].\\n\\n**Found X issues** (Y critical, Z nitpicks).\\n\\n---\\n*AI review via [Coder Tasks](https://coder.com/docs/ai-coder/tasks)*" - No issues: "## šŸ” Code Review\\n\\nReviewed [5-8 words].\\n\\nāœ… **Looks good** - no production issues found.\\n\\n---\\n*AI review via [Coder Tasks](https://coder.com/docs/ai-coder/tasks)*" - - - - 1. Read ENTIRE files before commenting - use read_file or grep to verify - 2. Check the EXACT line you're commenting on - does the issue actually exist there? - 3. Suggestion block = ONLY replacement lines (never include unchanged surrounding lines) - 4. Single line: {"line": 50} | Multi-line: {"start_line": 50, "line": 52} - 5. Explain IMPACT ("causes crash/leak/bypass" not "could be better") - 6. Make ALL observations actionable with suggestions (not just summary mentions) - 7. set -u = undefined vars only. Don't claim it catches empty strings. It doesn't. - 8. No issues = {"event": "COMMENT", "comments": [], "body": "[summary with Coder Tasks link]"} - - - ============================================================ - BEGIN YOUR ACTUAL TASK - REVIEW THIS REAL PR - ============================================================ - - PR: ${PR_URL} - PR Number: #${PR_NUMBER} - Repo: ${REPO_OWNER}/${REPO_NAME} - - SETUP COMMANDS: - cd ~/coder - export GH_TOKEN=\$(coder external-auth access-token github) - export GITHUB_TOKEN="\${GH_TOKEN}" - gh auth status || exit 1 - git fetch origin pull/${PR_NUMBER}/head:pr-${PR_NUMBER} - git checkout pr-${PR_NUMBER} - - SUBMIT YOUR REVIEW: - Get commit SHA: gh api repos/${REPO_OWNER}/${REPO_NAME}/pulls/${PR_NUMBER} --jq '.head.sha' - Create review.json with structure (comments array can have 0+ items): - {"event": "COMMENT", "commit_id": "[sha]", "body": "[summary]", "comments": [comment1, comment2, ...]} - Submit: gh api repos/${REPO_OWNER}/${REPO_NAME}/pulls/${PR_NUMBER}/reviews --method POST --input review.json - - Now review this PR. Be thorough but accurate. Make all observations actionable. - - EOF - ) + \`\`\`sh + gh api repos/coder/coder/pulls/${PR_NUMBER} --jq '.head.sha' + jq . review.json && gh api repos/coder/coder/pulls/${PR_NUMBER}/reviews --method POST --input review.json + \`\`\`" # Output the prompt { @@ -249,6 +216,7 @@ jobs: } >> "${GITHUB_OUTPUT}" - name: Checkout create-task-action + if: steps.check-secrets.outputs.skip != 'true' uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: fetch-depth: 1 @@ -258,23 +226,25 @@ jobs: repository: coder/create-task-action - name: Create Coder Task for Code Review + if: steps.check-secrets.outputs.skip != 'true' id: create_task uses: ./.github/actions/create-task-action with: - coder-url: ${{ secrets.DOC_CHECK_CODER_URL }} - coder-token: ${{ secrets.DOC_CHECK_CODER_SESSION_TOKEN }} + coder-url: ${{ secrets.CODE_REVIEW_CODER_URL }} + coder-token: ${{ secrets.CODE_REVIEW_CODER_SESSION_TOKEN }} coder-organization: "default" - coder-template-name: coder + coder-template-name: coder-workflow-bot coder-template-preset: ${{ steps.determine-context.outputs.template_preset }} coder-task-name-prefix: code-review - coder-task-prompt: ${{ steps.build-prompt.outputs.task_prompt }} - github-user-id: ${{ steps.determine-context.outputs.github_user_id }} + coder-task-prompt: ${{ steps.extract-context.outputs.task_prompt }} + coder-username: code-review-bot github-token: ${{ github.token }} github-issue-url: ${{ steps.determine-context.outputs.pr_url }} - # The AI will post the review itself, not as a general comment + # The AI will post the review itself via gh api comment-on-issue: false - - name: Write outputs + - name: Write Task Info + if: steps.check-secrets.outputs.skip != 'true' env: TASK_CREATED: ${{ steps.create_task.outputs.task-created }} TASK_NAME: ${{ steps.create_task.outputs.task-name }} @@ -289,6 +259,140 @@ jobs: echo "**Task name:** ${TASK_NAME}" echo "**Task URL:** ${TASK_URL}" echo "" - echo "The Coder task is analyzing the PR and will comment with a code review." } >> "${GITHUB_STEP_SUMMARY}" + - name: Wait for Task Completion + if: steps.check-secrets.outputs.skip != 'true' + id: wait_task + env: + TASK_NAME: ${{ steps.create_task.outputs.task-name }} + run: | + echo "Waiting for task to complete..." + echo "Task name: ${TASK_NAME}" + + if [[ -z "${TASK_NAME}" ]]; then + echo "::error::TASK_NAME is empty" + exit 1 + fi + + MAX_WAIT=600 # 10 minutes + WAITED=0 + POLL_INTERVAL=3 + LAST_STATUS="" + + is_workspace_message() { + local msg="$1" + [[ -z "$msg" ]] && return 0 # Empty = treat as workspace/startup + [[ "$msg" =~ ^Workspace ]] && return 0 + [[ "$msg" =~ ^Agent ]] && return 0 + return 1 + } + + while [[ $WAITED -lt $MAX_WAIT ]]; do + # Get task status (|| true prevents set -e from exiting on non-zero) + RAW_OUTPUT=$(coder task status "${TASK_NAME}" -o json 2>&1) || true + STATUS_JSON=$(echo "$RAW_OUTPUT" | grep -v "^version mismatch\|^download v" || true) + + # Debug: show first poll's raw output + if [[ $WAITED -eq 0 ]]; then + echo "Raw status output: ${RAW_OUTPUT:0:500}" + fi + + if [[ -z "$STATUS_JSON" ]] || ! echo "$STATUS_JSON" | jq -e . >/dev/null 2>&1; then + if [[ "$LAST_STATUS" != "waiting" ]]; then + echo "[${WAITED}s] Waiting for task status..." + LAST_STATUS="waiting" + fi + sleep $POLL_INTERVAL + WAITED=$((WAITED + POLL_INTERVAL)) + continue + fi + + TASK_STATE=$(echo "$STATUS_JSON" | jq -r '.current_state.state // "unknown"') + TASK_MESSAGE=$(echo "$STATUS_JSON" | jq -r '.current_state.message // ""') + WORKSPACE_STATUS=$(echo "$STATUS_JSON" | jq -r '.workspace_status // "unknown"') + + # Build current status string for comparison + CURRENT_STATUS="${TASK_STATE}|${WORKSPACE_STATUS}|${TASK_MESSAGE}" + + # Only log if status changed + if [[ "$CURRENT_STATUS" != "$LAST_STATUS" ]]; then + if [[ "$TASK_STATE" == "idle" ]] && is_workspace_message "$TASK_MESSAGE"; then + echo "[${WAITED}s] Workspace ready, waiting for Agent..." + else + echo "[${WAITED}s] State: ${TASK_STATE} | Workspace: ${WORKSPACE_STATUS} | ${TASK_MESSAGE}" + fi + LAST_STATUS="$CURRENT_STATUS" + fi + + if [[ "$WORKSPACE_STATUS" == "failed" || "$WORKSPACE_STATUS" == "canceled" ]]; then + echo "::error::Workspace failed: ${WORKSPACE_STATUS}" + exit 1 + fi + + if [[ "$TASK_STATE" == "idle" ]]; then + if ! is_workspace_message "$TASK_MESSAGE"; then + # Real completion message from Claude! + echo "" + echo "Task completed: ${TASK_MESSAGE}" + RESULT_URI=$(echo "$STATUS_JSON" | jq -r '.current_state.uri // ""') + echo "result_uri=${RESULT_URI}" >> "${GITHUB_OUTPUT}" + echo "task_message=${TASK_MESSAGE}" >> "${GITHUB_OUTPUT}" + break + fi + fi + + sleep $POLL_INTERVAL + WAITED=$((WAITED + POLL_INTERVAL)) + done + + if [[ $WAITED -ge $MAX_WAIT ]]; then + echo "::error::Task monitoring timed out after ${MAX_WAIT}s" + exit 1 + fi + + - name: Fetch Task Logs + if: always() && steps.check-secrets.outputs.skip != 'true' + env: + TASK_NAME: ${{ steps.create_task.outputs.task-name }} + run: | + echo "::group::Task Conversation Log" + if [[ -n "${TASK_NAME}" ]]; then + coder task logs "${TASK_NAME}" 2>&1 || echo "Failed to fetch logs" + else + echo "No task name, skipping log fetch" + fi + echo "::endgroup::" + + - name: Cleanup Task + if: always() && steps.check-secrets.outputs.skip != 'true' + env: + TASK_NAME: ${{ steps.create_task.outputs.task-name }} + run: | + if [[ -n "${TASK_NAME}" ]]; then + echo "Deleting task: ${TASK_NAME}" + coder task delete "${TASK_NAME}" -y 2>&1 || echo "Task deletion failed or already deleted" + else + echo "No task name, skipping cleanup" + fi + + - name: Write Final Summary + if: always() && steps.check-secrets.outputs.skip != 'true' + env: + TASK_NAME: ${{ steps.create_task.outputs.task-name }} + TASK_MESSAGE: ${{ steps.wait_task.outputs.task_message }} + RESULT_URI: ${{ steps.wait_task.outputs.result_uri }} + PR_NUMBER: ${{ steps.determine-context.outputs.pr_number }} + run: | + { + echo "" + echo "---" + echo "### Result" + echo "" + echo "**Status:** ${TASK_MESSAGE:-Task completed}" + if [[ -n "${RESULT_URI}" ]]; then + echo "**Review:** ${RESULT_URI}" + fi + echo "" + echo "Task \`${TASK_NAME}\` has been cleaned up." + } >> "${GITHUB_STEP_SUMMARY}"