diff --git a/common/AGENTS.md b/common/AGENTS.md index 98ff247..b26feba 100644 --- a/common/AGENTS.md +++ b/common/AGENTS.md @@ -34,6 +34,15 @@ looking for any other issues). If you are performing a review of other's code, the same principles apply. +## Tasks + +The [agents/tasks/](agents/tasks/) directory contains reusable task definitions that you +can execute. These are roughly equivalent to OpenCode "commands" or Claude Code +"skills". + +If the user gives an instruction of the form "run task ..." or similar +then you should read the agents/tasks/README.md file and find the relevant task and continue. + ## Follow other guidelines Look at the project README.md and look for guidelines diff --git a/common/agents/tasks/README.md b/common/agents/tasks/README.md new file mode 100644 index 0000000..1c1defe --- /dev/null +++ b/common/agents/tasks/README.md @@ -0,0 +1,14 @@ +# Tasks + +Reusable task definitions for AI agents. See [AGENTS.md](../../AGENTS.md) +for how to execute these tasks. + +Each `.md` file uses YAML frontmatter (`name`, `description`) followed +by markdown instructions — compatible with Claude Code skills and +OpenCode commands. + +## Available Tasks + +- **[perform-forge-review](perform-forge-review.md)** — Create AI-assisted code + reviews on GitHub, GitLab, or Forgejo. Builds review comments in a local JSONL + file for human inspection before submitting as a pending/draft review. diff --git a/common/agents/tasks/perform-forge-review.md b/common/agents/tasks/perform-forge-review.md new file mode 100644 index 0000000..6d687df --- /dev/null +++ b/common/agents/tasks/perform-forge-review.md @@ -0,0 +1,475 @@ +--- +name: perform-forge-review +description: Create AI-assisted code reviews on GitHub, GitLab, or Forgejo. Use when asked to review a PR/MR, analyze code changes, or provide review feedback. +--- + +# Perform Forge Review + +This task describes how to create and manage code reviews on GitHub, GitLab, +and Forgejo/Gitea with human oversight. + +## Overview + +The recommended workflow: + +1. AI analyzes the PR diff and builds comments in a local JSONL file +2. Submit the review as pending/draft (not visible to others yet) +3. Human reviews in the forge UI, can add `@ai:` tasks for follow-up +4. Human submits when satisfied + +Optionally after step 1, the JSONL review state can be passed off +into another security context that has write access (or at least +the ability to create draft reviews). + +## Attribution Convention + +**Review header:** +``` +< your text here > + +--- +Assisted-by: ToolName (ModelName) + +
+Comments prefixed with "AI:" are unedited AI output. +Executed from bootc-dev/infra task. +
+``` + +**Comment prefixes:** +- `AI: ` — unedited AI output +- `@ai: ` — human question/task for AI to process +- No prefix — human has reviewed/edited + +**Comment types:** + +- `*Important*:` — should be resolved before merge +- (no marker) — normal suggestion, can be addressed post-merge or ignored +- `(low)` — minor nit, feel free to ignore +- `**Question**:` — clarification needed (can combine with priority) + +Examples: +- `AI: *Important*: This will panic on empty input` +- `AI: Consider using iterators here` +- `AI: (low) Rename to follow naming convention` +- `AI: **Question**: Is this intentional?` + +**Filtering by priority:** + +If user instructions specify a priority filter (e.g., "important only"), create +inline comments only for that priority level. Summarize the most relevant +normal/low priority items in a `
` section in the review body: + +```markdown +
+Additional suggestions (normal/low priority) + +- `src/lib.rs:42` — Consider using iterators +- `src/main.rs:15` — Minor: rename to follow convention +
+``` + +Avoid inline comments for purely positive observations (e.g., "Good approach here"). +These create noise and require manual resolution. If positive aspects are worth +noting, briefly mention them in the review body. + +**Review body content:** + +Do not summarize or re-describe the PR changes — the commit messages already +contain that information. The review body should only contain: +- Attribution header (required) +- Positive observations worth highlighting (optional, brief) +- Concerns not tied to specific lines (optional) +- Notes about missing context in the PR description (if any) +- Collapsed lower-priority items when filtering (see above) + +--- + +## Workflow + +**Note**: If you already have a pending review on this PR, you cannot create +another. Check for existing pending reviews first (see API Reference) and +either add comments to it or delete it before proceeding. + +### Step 1: Check Out the PR + +Check out the PR branch locally. This lets you read files directly to get +accurate line numbers (diff line numbers are error-prone): + +```bash +# GitHub +gh pr checkout PR_NUMBER + +# GitLab +glab mr checkout MR_IID + +# Forgejo (using forgejo-cli, or fall back to git fetch) +forgejo-cli pr checkout PR_INDEX +# or: git fetch origin pull/PR_INDEX/head:pr-PR_INDEX && git checkout pr-PR_INDEX +``` + +### Step 2: See the Code + +After checkout, determine the merge base (fork point) and review the changes: + +```bash +# Find the merge base with the target branch (usually main) +MERGE_BASE=$(git merge-base HEAD main) + +# View commit history since fork point +git log --oneline $MERGE_BASE..HEAD + +# View the combined diff of all changes +git diff $MERGE_BASE..HEAD + +# Or view each commit's diff separately +git log -p $MERGE_BASE..HEAD +``` + +Review commit-by-commit to understand the logical structure of the changes. +Pay attention to commit messages — they should explain the "why" behind each +change. For larger PRs, reviewing each commit separately often provides better +context than a single combined diff. Note any commits where the message doesn't +match the code changes or where the reasoning is unclear. + +### Step 3: Build the Review + +The scripts in this task are located in the `scripts/` subdirectory relative to this +file (i.e., `common/agents/tasks/scripts/` from the repo root, or wherever `common/` +is synced in your project). + +First, start the review with metadata (including attribution): + +```bash +scripts/forge-review-start.sh \ + --body "Assisted-by: OpenCode (Claude Sonnet 4) + +AI-generated review. Comments prefixed with AI: are unedited." \ + --review-file .git/review-123.jsonl +``` + +Then use `forge-review-append-comment.sh` to add comments. It validates that +your comment targets the correct line by requiring a matching text fragment: + +```bash +scripts/forge-review-append-comment.sh \ + --file src/lib.rs \ + --line 42 \ + --match "fn process_data" \ + --body "AI: *Important*: Missing error handling for empty input" \ + --review-file .git/review-123.jsonl +``` + +This prevents comments from being attached to wrong lines if the file has changed. +The script will error if the match text is not found on the specified line. + +Use a PR-specific review file (e.g., `.git/review-123.jsonl`) to avoid conflicts +when reviewing multiple PRs. + +The JSONL file format: +``` +{"body": "Review body with attribution..."} +{"path": "src/lib.rs", "line": 42, "body": "AI: ..."} +{"path": "src/main.rs", "line": 10, "body": "AI: ..."} +``` + +After adding comments, validate the review before submitting: + +```bash +scripts/forge-review-submit-github.sh --dry-run owner repo 123 .git/review-123.jsonl +# Output: Review validated: 5 pending comment(s) for owner/repo#123 +``` + +### Step 4: Submit the Review + +Submit the review using the appropriate script for your forge: + +#### GitHub + +```bash +scripts/forge-review-submit-github.sh owner repo 123 .git/review-123.jsonl +``` + +Requires: `gh` CLI configured with authentication. + +#### GitLab + +```bash +export GITLAB_TOKEN="glpat-xxxx" +scripts/forge-review-submit-gitlab.sh 12345 67 .git/review-67.jsonl +``` + +For GitLab renames, use `--old-path` when appending comments. + +#### Forgejo + +```bash +export FORGEJO_TOKEN="xxxx" +export FORGEJO_URL="https://codeberg.org" +scripts/forge-review-submit-forgejo.sh owner repo 123 .git/review-123.jsonl +``` + +All submit scripts read the review body from the metadata entry in the JSONL file, +so no separate body argument is needed. + +--- + +## Processing @ai: Tasks + +When a human adds `@ai: ` to a comment, the AI should: + +1. Check for existing pending review (see API Reference below) +2. Find comments containing `@ai:` +3. Read the question/task and relevant code context +4. Generate a response +5. Update the comment, appending: + +```markdown +@ai: Is this error handling correct? + +--- +**AI Response**: No, the error is being silently ignored. Consider... +``` + +The human can then: +- Edit to remove the `@ai:` prefix if satisfied +- Add follow-up `@ai:` tasks +- Delete the comment if not useful +- Submit when done + +--- + +## Platform Comparison + +| Feature | GitHub | GitLab | Forgejo | +|---------|--------|--------|---------| +| Pending/Draft Reviews | ✅ | ✅ | ✅ | +| Edit Pending Comments | ✅ (GraphQL) | ✅ (REST) | ❌ | +| Delete Pending Comments | ✅ | ✅ | ✅ | +| Add to Pending Review | ✅ | ✅ | ✅ | +| Inline Comments | ✅ | ✅ | ✅ | +| Submit with State | ✅ | ✅* | ✅ | +| CLI Support | gh | glab | tea | + +*GitLab handles APPROVE separately via Approvals API. + +--- + +## API Reference + +Direct API calls for advanced operations (editing, deleting, submitting). + +### GitHub + +#### Check for Existing Pending Review + +```bash +gh api graphql -f query=' +{ + repository(owner: "OWNER", name: "REPO") { + pullRequest(number: PR_NUMBER) { + reviews(last: 10, states: [PENDING]) { + nodes { + id + databaseId + body + comments(first: 100) { + nodes { id body path line } + } + } + } + } + } +}' +``` + +#### Edit a Pending Comment + +REST returns 404 for pending comments. Use GraphQL: + +```bash +gh api graphql -f query=' +mutation { + updatePullRequestReviewComment(input: { + pullRequestReviewCommentId: "PRRC_xxxxx", + body: "Updated comment text" + }) { + pullRequestReviewComment { id body } + } +}' +``` + +#### Add Comment to Existing Pending Review + +```bash +gh api graphql -f query=' +mutation { + addPullRequestReviewThread(input: { + pullRequestReviewId: "PRR_xxxxx", + body: "AI: New comment", + path: "src/lib.rs", + line: 50, + side: RIGHT + }) { + thread { id } + } +}' +``` + +#### Delete a Pending Comment + +```bash +gh api graphql -f query=' +mutation { + deletePullRequestReviewComment(input: { + id: "PRRC_xxxxx" + }) { + pullRequestReviewComment { id } + } +}' +``` + +#### Submit the Review + +```bash +gh api repos/OWNER/REPO/pulls/PR_NUMBER/reviews/REVIEW_ID/events \ + -X POST -f event="COMMENT" # or REQUEST_CHANGES or APPROVE +``` + +### GitLab + +#### Check for Existing Draft Notes + +```bash +curl --header "PRIVATE-TOKEN: $TOKEN" \ + "$GITLAB_URL/api/v4/projects/$PROJECT_ID/merge_requests/$MR_IID/draft_notes" +``` + +#### Edit a Draft Note + +```bash +curl --request PUT \ + --header "PRIVATE-TOKEN: $TOKEN" \ + --form-string "note=Updated comment text" \ + "$GITLAB_URL/api/v4/projects/$PROJECT_ID/merge_requests/$MR_IID/draft_notes/$NOTE_ID" +``` + +#### Delete a Draft Note + +```bash +curl --request DELETE \ + --header "PRIVATE-TOKEN: $TOKEN" \ + "$GITLAB_URL/api/v4/projects/$PROJECT_ID/merge_requests/$MR_IID/draft_notes/$NOTE_ID" +``` + +#### Submit Review (Publish All Drafts) + +```bash +curl --request POST \ + --header "PRIVATE-TOKEN: $TOKEN" \ + "$GITLAB_URL/api/v4/projects/$PROJECT_ID/merge_requests/$MR_IID/draft_notes/bulk_publish" +``` + +#### Approve MR (Separate from Review) + +```bash +curl --request POST \ + --header "PRIVATE-TOKEN: $TOKEN" \ + "$GITLAB_URL/api/v4/projects/$PROJECT_ID/merge_requests/$MR_IID/approve" +``` + +### Forgejo + +#### List Reviews (Find Pending) + +```bash +curl -H "Authorization: token $TOKEN" \ + "$FORGEJO_URL/api/v1/repos/$OWNER/$REPO/pulls/$PR_INDEX/reviews" +``` + +Look for reviews with `state: "PENDING"`. + +#### Add Comment to Existing Pending Review + +```bash +curl -X POST \ + -H "Authorization: token $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"path": "src/lib.rs", "new_position": 50, "body": "AI: Comment"}' \ + "$FORGEJO_URL/api/v1/repos/$OWNER/$REPO/pulls/$PR_INDEX/reviews/$REVIEW_ID/comments" +``` + +#### Delete a Comment + +```bash +curl -X DELETE \ + -H "Authorization: token $TOKEN" \ + "$FORGEJO_URL/api/v1/repos/$OWNER/$REPO/pulls/$PR_INDEX/reviews/$REVIEW_ID/comments/$COMMENT_ID" +``` + +#### Submit the Review + +```bash +curl -X POST \ + -H "Authorization: token $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"event": "COMMENT", "body": "Review complete"}' \ + "$FORGEJO_URL/api/v1/repos/$OWNER/$REPO/pulls/$PR_INDEX/reviews/$REVIEW_ID" +``` + +Valid event values: `APPROVE`, `REQUEST_CHANGES`, `COMMENT` + +**Note**: Forgejo does not support editing review comments via API. +Workaround: Delete and recreate the comment. + +--- + +## Notes + +- Always get the diff first to understand line positioning +- Node IDs (GitHub) come from GraphQL queries +- Project IDs (GitLab) can be numeric or URL-encoded paths +- Pending reviews are only visible to their author +- The JSONL workflow enables future sandboxed execution where the agent + runs with read-only access and a separate privileged process submits the review + +--- + +## Related Projects + +This workflow was influenced by existing tools for distributed/local code review: + +### git-appraise + +https://github.com/google/git-appraise (5k+ stars) + +Google's distributed code review system stores reviews entirely in git using +`git-notes` under `refs/notes/devtools/`. Key design choices: + +- **Single-line JSON** per entry with `cat_sort_uniq` merge strategy for + conflict-free merging +- Separate refs for requests, comments, CI status, and robot comments +- Reviews can be pushed/pulled like regular git data +- Mirrors available for GitHub PRs and Phabricator + +The JSONL format in this workflow is compatible with potentially storing +reviews in git-notes in the future. However, we intentionally avoid pushing +review notes to remotes by default—the primary goal is local editing before +forge submission, not distributed review storage. + +### git-bug + +https://github.com/git-bug/git-bug (9k+ stars) + +Distributed, offline-first issue tracker embedded in git. Uses git objects +(not files) for storage with bridges to sync with GitHub/GitLab. Focused on +issues rather than code review, but similar local-first philosophy. + +### Radicle + +https://radicle.xyz + +Fully decentralized code collaboration platform with its own P2P protocol. +Issues and patches are stored in git. A complete forge alternative rather +than a review tool, but demonstrates the broader space of distributed +development tooling. diff --git a/common/agents/tasks/scripts/forge-review-append-comment.sh b/common/agents/tasks/scripts/forge-review-append-comment.sh new file mode 100755 index 0000000..2f61897 --- /dev/null +++ b/common/agents/tasks/scripts/forge-review-append-comment.sh @@ -0,0 +1,160 @@ +#!/usr/bin/env bash +# Append a review comment to a JSONL file with line content validation +# +# Usage: forge-review-append-comment.sh --file PATH --line NUM --match TEXT --body COMMENT [--review-file FILE] +# +# Arguments: +# --file PATH File path relative to repo root (e.g., src/lib.rs) +# --line NUM Line number in the file +# --match TEXT Text that must appear on that line (for validation) +# --body COMMENT The review comment body +# --review-file FILE Output JSONL file (default: .git/review.jsonl) +# --old-path PATH Original file path for renames (GitLab only) +# +# The review file must first be created with forge-review-start.sh. +# +# The script verifies that --match text appears on the specified line before +# appending. This prevents comments from being attached to wrong lines when +# the file has changed. +# +# Example: +# forge-review-append-comment.sh \ +# --file src/lib.rs \ +# --line 42 \ +# --match "fn process_data" \ +# --body "AI: *Important*: Missing error handling for empty input" + +set -euo pipefail + +usage() { + cat >&2 <&2 + usage + ;; + esac +done + +# Validate required arguments +if [[ -z "$FILE" ]]; then + echo "Error: --file is required" >&2 + usage +fi +if [[ -z "$LINE" ]]; then + echo "Error: --line is required" >&2 + usage +fi +if [[ -z "$MATCH" ]]; then + echo "Error: --match is required" >&2 + usage +fi +if [[ -z "$BODY" ]]; then + echo "Error: --body is required" >&2 + usage +fi + +# Validate line is a number +if ! [[ "$LINE" =~ ^[0-9]+$ ]]; then + echo "Error: --line must be a positive integer, got: $LINE" >&2 + exit 1 +fi + +# Check file exists +if [[ ! -f "$FILE" ]]; then + echo "Error: File not found: $FILE" >&2 + exit 1 +fi + +# Extract the actual line content +ACTUAL_LINE=$(sed -n "${LINE}p" "$FILE") + +if [[ -z "$ACTUAL_LINE" ]]; then + echo "Error: Line $LINE does not exist in $FILE" >&2 + exit 1 +fi + +# Check if match text appears on the line +if [[ "$ACTUAL_LINE" != *"$MATCH"* ]]; then + echo "Error: Match text not found on line $LINE" >&2 + echo " Expected to find: $MATCH" >&2 + echo " Actual line content: $ACTUAL_LINE" >&2 + exit 1 +fi + +# Default review file +if [[ -z "$REVIEW_FILE" ]]; then + REVIEW_FILE=".git/review.jsonl" +fi + +# Verify review file exists (must be created with forge-review-start.sh first) +if [[ ! -f "$REVIEW_FILE" ]]; then + echo "Error: Review file not found: $REVIEW_FILE" >&2 + echo "Create it first with forge-review-start.sh" >&2 + exit 1 +fi + +# Build the JSON object +if [[ -n "$OLD_PATH" ]]; then + # Include old_path for GitLab renames + jq -n --arg path "$FILE" --argjson line "$LINE" --arg body "$BODY" --arg old_path "$OLD_PATH" \ + '{path: $path, line: $line, body: $body, old_path: $old_path}' >> "$REVIEW_FILE" +else + jq -n --arg path "$FILE" --argjson line "$LINE" --arg body "$BODY" \ + '{path: $path, line: $line, body: $body}' >> "$REVIEW_FILE" +fi + +echo "Added comment for $FILE:$LINE" diff --git a/common/agents/tasks/scripts/forge-review-start.sh b/common/agents/tasks/scripts/forge-review-start.sh new file mode 100755 index 0000000..47e7efc --- /dev/null +++ b/common/agents/tasks/scripts/forge-review-start.sh @@ -0,0 +1,87 @@ +#!/usr/bin/env bash +# Start a new forge review by creating a JSONL file with metadata +# +# Usage: forge-review-start.sh --body TEXT [--review-file FILE] +# +# Arguments: +# --body TEXT Review body text (must include attribution) +# --review-file FILE Output JSONL file (default: .git/review.jsonl) +# +# The first line of the JSONL file will contain review metadata: +# {"body": "Review body text..."} +# +# Subsequent lines (added by forge-review-append-comment.sh) contain comments: +# {"path": "src/lib.rs", "line": 42, "body": "..."} +# +# Example: +# forge-review-start.sh --body "Assisted-by: OpenCode (Claude Sonnet 4) +# +# AI-generated review. Comments prefixed with AI: are unedited." + +set -euo pipefail + +usage() { + cat >&2 <&2 + usage + ;; + esac +done + +# Validate required arguments +if [[ -z "$BODY" ]]; then + echo "Error: --body is required" >&2 + usage +fi + +# Default review file +if [[ -z "$REVIEW_FILE" ]]; then + REVIEW_FILE=".git/review.jsonl" +fi + +# Check if file already exists +if [[ -f "$REVIEW_FILE" ]]; then + echo "Error: Review file already exists: $REVIEW_FILE" >&2 + echo "Delete it first or use a different --review-file" >&2 + exit 1 +fi + +# Ensure parent directory exists +mkdir -p "$(dirname "$REVIEW_FILE")" + +# Write metadata as first line +jq -n --arg body "$BODY" \ + '{body: $body}' > "$REVIEW_FILE" + +echo "Review started: $REVIEW_FILE" diff --git a/common/agents/tasks/scripts/forge-review-submit-forgejo.sh b/common/agents/tasks/scripts/forge-review-submit-forgejo.sh new file mode 100755 index 0000000..11d621f --- /dev/null +++ b/common/agents/tasks/scripts/forge-review-submit-forgejo.sh @@ -0,0 +1,137 @@ +#!/usr/bin/env bash +# Submit a forge review to Forgejo/Gitea +# +# STATUS: DRAFT/UNTESTED - This script has not been tested against a real Forgejo/Gitea instance. +# +# Usage: forge-review-submit-forgejo.sh [--dry-run] OWNER REPO PR_INDEX [REVIEW_FILE] +# +# Options: +# --dry-run Validate the review file without submitting +# +# Arguments: +# OWNER Repository owner +# REPO Repository name +# PR_INDEX Pull request index number +# REVIEW_FILE Path to JSONL file (default: .git/review.jsonl) +# +# Environment: +# FORGEJO_TOKEN or GITEA_TOKEN - Forgejo/Gitea authentication token (required) +# FORGEJO_URL or GITEA_URL - Instance URL (required, e.g., https://codeberg.org) +# +# The JSONL file must be created with forge-review-start.sh and contain: +# Line 1: {"body": "Review body with attribution..."} +# Line 2+: {"path": "src/lib.rs", "line": 42, "body": "..."} +# +# On success, removes the review file. On failure, preserves it. + +set -euo pipefail + +usage() { + echo "Usage: $0 [--dry-run] OWNER REPO PR_INDEX [REVIEW_FILE]" >&2 + echo "" >&2 + echo "Options:" >&2 + echo " --dry-run Validate the review file without submitting" >&2 + echo "" >&2 + echo "REVIEW_FILE must be created with forge-review-start.sh first." >&2 + echo "" >&2 + echo "Environment variables:" >&2 + echo " FORGEJO_TOKEN or GITEA_TOKEN - Authentication token (required)" >&2 + echo " FORGEJO_URL or GITEA_URL - Instance URL (required)" >&2 + exit 1 +} + +DRY_RUN=0 +if [[ "${1:-}" == "--dry-run" ]]; then + DRY_RUN=1 + shift +fi + +if [[ $# -lt 3 ]]; then + usage +fi + +OWNER="$1" +REPO="$2" +PR_INDEX="$3" +REVIEW_FILE="${4:-.git/review.jsonl}" + +if [[ ! -f "$REVIEW_FILE" ]]; then + echo "Error: Review file not found: $REVIEW_FILE" >&2 + exit 1 +fi + +# Parse JSONL: extract metadata and comments separately +if ! ALL_ENTRIES=$(jq -s '. // []' < "$REVIEW_FILE" 2>&1); then + echo "Error: Invalid JSONL syntax in $REVIEW_FILE" >&2 + echo "$ALL_ENTRIES" >&2 + exit 1 +fi + +# Extract metadata (first line) and comments (remaining lines) +METADATA=$(echo "$ALL_ENTRIES" | jq '.[0]') +if [[ "$METADATA" == "null" ]]; then + echo "Error: Empty review file: $REVIEW_FILE" >&2 + echo "Create the review file with forge-review-start.sh first" >&2 + exit 1 +fi + +REVIEW_BODY=$(echo "$METADATA" | jq -r '.body') +if [[ -z "$REVIEW_BODY" || "$REVIEW_BODY" == "null" ]]; then + echo "Error: First line missing 'body' field" >&2 + exit 1 +fi + +# Extract comments (all lines after the first) +COMMENTS_RAW=$(echo "$ALL_ENTRIES" | jq '.[1:]') +COMMENT_COUNT=$(echo "$COMMENTS_RAW" | jq 'length') + +# Validate each comment has required fields +INVALID=$(echo "$COMMENTS_RAW" | jq '[.[] | select(.path == null or .body == null)] | length') +if [[ "$INVALID" -gt 0 ]]; then + echo "Error: $INVALID comment(s) missing required fields (path, body)" >&2 + exit 1 +fi + +if [[ $DRY_RUN -eq 1 ]]; then + echo "Review validated: $COMMENT_COUNT pending comment(s) for $OWNER/$REPO#$PR_INDEX" + exit 0 +fi + +TOKEN="${FORGEJO_TOKEN:-${GITEA_TOKEN:-}}" +BASE_URL="${FORGEJO_URL:-${GITEA_URL:-}}" + +if [[ -z "$TOKEN" ]]; then + echo "Error: FORGEJO_TOKEN or GITEA_TOKEN environment variable required" >&2 + exit 1 +fi + +if [[ -z "$BASE_URL" ]]; then + echo "Error: FORGEJO_URL or GITEA_URL environment variable required" >&2 + exit 1 +fi + +# Strip trailing slash from URL +BASE_URL="${BASE_URL%/}" + +# Transform comments to Forgejo format (line -> new_position) +COMMENTS=$(echo "$COMMENTS_RAW" | jq 'map({path, new_position: .line, body})') + +# Create the review (pending by default - omit event param) +RESULT=$(jq -n \ + --arg body "$REVIEW_BODY" \ + --argjson comments "$COMMENTS" \ + '{body: $body, comments: $comments}' | \ +curl -s -X POST \ + -H "Authorization: token $TOKEN" \ + -H "Content-Type: application/json" \ + -d @- \ + "$BASE_URL/api/v1/repos/$OWNER/$REPO/pulls/$PR_INDEX/reviews" 2>&1) + +if echo "$RESULT" | jq -e '.id' > /dev/null 2>&1; then + echo "Review created successfully" + rm "$REVIEW_FILE" +else + echo "Failed to create review: $RESULT" >&2 + echo "Review file preserved at $REVIEW_FILE" >&2 + exit 1 +fi diff --git a/common/agents/tasks/scripts/forge-review-submit-github.sh b/common/agents/tasks/scripts/forge-review-submit-github.sh new file mode 100755 index 0000000..f04256d --- /dev/null +++ b/common/agents/tasks/scripts/forge-review-submit-github.sh @@ -0,0 +1,116 @@ +#!/usr/bin/env bash +# Submit a forge review to GitHub +# +# Usage: forge-review-submit-github.sh [--dry-run] OWNER REPO PR_NUMBER [REVIEW_FILE] +# +# Options: +# --dry-run Validate the review file without submitting +# +# Arguments: +# OWNER Repository owner +# REPO Repository name +# PR_NUMBER Pull request number +# REVIEW_FILE Path to JSONL file (default: .git/review.jsonl) +# +# Environment: +# GH_TOKEN or GITHUB_TOKEN - GitHub authentication token (optional if gh is configured) +# +# The JSONL file must be created with forge-review-start.sh and contain: +# Line 1: {"body": "Review body with attribution..."} +# Line 2+: {"path": "src/lib.rs", "line": 42, "body": "..."} +# +# On success, removes the review file. On failure, preserves it. + +set -euo pipefail + +usage() { + echo "Usage: $0 [--dry-run] OWNER REPO PR_NUMBER [REVIEW_FILE]" >&2 + echo "" >&2 + echo "Options:" >&2 + echo " --dry-run Validate the review file without submitting" >&2 + echo "" >&2 + echo "REVIEW_FILE must be created with forge-review-start.sh first." >&2 + exit 1 +} + +DRY_RUN=0 +if [[ "${1:-}" == "--dry-run" ]]; then + DRY_RUN=1 + shift +fi + +if [[ $# -lt 3 ]]; then + usage +fi + +OWNER="$1" +REPO="$2" +PR_NUMBER="$3" +REVIEW_FILE="${4:-.git/review.jsonl}" + +if [[ ! -f "$REVIEW_FILE" ]]; then + echo "Error: Review file not found: $REVIEW_FILE" >&2 + exit 1 +fi + +# Parse JSONL: extract metadata and comments separately +if ! ALL_ENTRIES=$(jq -s '. // []' < "$REVIEW_FILE" 2>&1); then + echo "Error: Invalid JSONL syntax in $REVIEW_FILE" >&2 + echo "$ALL_ENTRIES" >&2 + exit 1 +fi + +# Extract metadata (first line) and comments (remaining lines) +METADATA=$(echo "$ALL_ENTRIES" | jq '.[0]') +if [[ "$METADATA" == "null" ]]; then + echo "Error: Empty review file: $REVIEW_FILE" >&2 + echo "Create the review file with forge-review-start.sh first" >&2 + exit 1 +fi + +REVIEW_BODY=$(echo "$METADATA" | jq -r '.body') +if [[ -z "$REVIEW_BODY" || "$REVIEW_BODY" == "null" ]]; then + echo "Error: First line missing 'body' field" >&2 + exit 1 +fi + +# Extract comments (all lines after the first) +COMMENTS=$(echo "$ALL_ENTRIES" | jq '.[1:]') +COMMENT_COUNT=$(echo "$COMMENTS" | jq 'length') + +# Validate each comment has required fields +INVALID=$(echo "$COMMENTS" | jq '[.[] | select(.path == null or .body == null)] | length') +if [[ "$INVALID" -gt 0 ]]; then + echo "Error: $INVALID comment(s) missing required fields (path, body)" >&2 + exit 1 +fi + +if [[ $DRY_RUN -eq 1 ]]; then + echo "Review validated: $COMMENT_COUNT pending comment(s) for $OWNER/$REPO#$PR_NUMBER" + exit 0 +fi + +# Check gh CLI is available +if ! command -v gh >/dev/null 2>&1; then + echo "Error: gh CLI not found. Install from https://cli.github.com/" >&2 + exit 1 +fi + +# Create the review (pending by default - omit event param) +RESULT=$(jq -n \ + --arg body "$REVIEW_BODY" \ + --argjson comments "$COMMENTS" \ + '{body: $body, comments: $comments}' | \ +gh api "repos/$OWNER/$REPO/pulls/$PR_NUMBER/reviews" \ + -X POST --input - 2>&1) || true + +# Check result and clean up +if echo "$RESULT" | jq -e '.id' > /dev/null 2>&1; then + echo "Review created: $(echo "$RESULT" | jq -r '.html_url // .id')" + rm "$REVIEW_FILE" +else + echo "Failed to create review: $RESULT" >&2 + echo "Review file preserved at $REVIEW_FILE" >&2 + # Common error: "User can only have one pending review per pull request" + exit 1 +fi diff --git a/common/agents/tasks/scripts/forge-review-submit-gitlab.sh b/common/agents/tasks/scripts/forge-review-submit-gitlab.sh new file mode 100755 index 0000000..ecea7ec --- /dev/null +++ b/common/agents/tasks/scripts/forge-review-submit-gitlab.sh @@ -0,0 +1,177 @@ +#!/usr/bin/env bash +# Submit a forge review to GitLab +# +# STATUS: DRAFT/UNTESTED - This script has not been tested against a real GitLab instance. +# +# Usage: forge-review-submit-gitlab.sh [--dry-run] PROJECT_ID MR_IID [REVIEW_FILE] +# +# Options: +# --dry-run Validate the review file without submitting +# +# Arguments: +# PROJECT_ID GitLab project ID (numeric or URL-encoded path) +# MR_IID Merge request IID +# REVIEW_FILE Path to JSONL file (default: .git/review.jsonl) +# +# Environment: +# GITLAB_TOKEN or PRIVATE_TOKEN - GitLab authentication token (required) +# GITLAB_URL - GitLab instance URL (default: https://gitlab.com) +# +# The JSONL file must be created with forge-review-start.sh and contain: +# Line 1: {"body": "Review body with attribution..."} +# Line 2+: {"path": "src/lib.rs", "line": 42, "body": "...", "old_path": "..."} +# +# Note: old_path is optional for comments, defaults to path if not specified. +# +# On success, removes the review file. On failure, preserves it. + +set -euo pipefail + +usage() { + echo "Usage: $0 [--dry-run] PROJECT_ID MR_IID [REVIEW_FILE]" >&2 + echo "" >&2 + echo "Options:" >&2 + echo " --dry-run Validate the review file without submitting" >&2 + echo "" >&2 + echo "REVIEW_FILE must be created with forge-review-start.sh first." >&2 + echo "" >&2 + echo "Environment variables:" >&2 + echo " GITLAB_TOKEN or PRIVATE_TOKEN - GitLab authentication token (required)" >&2 + echo " GITLAB_URL - GitLab instance URL (default: https://gitlab.com)" >&2 + exit 1 +} + +DRY_RUN=0 +if [[ "${1:-}" == "--dry-run" ]]; then + DRY_RUN=1 + shift +fi + +if [[ $# -lt 2 ]]; then + usage +fi + +PROJECT_ID="$1" +MR_IID="$2" +REVIEW_FILE="${3:-.git/review.jsonl}" + +TOKEN="${GITLAB_TOKEN:-${PRIVATE_TOKEN:-}}" +GITLAB_URL="${GITLAB_URL:-https://gitlab.com}" +# Strip trailing slash from URL +GITLAB_URL="${GITLAB_URL%/}" + +if [[ ! -f "$REVIEW_FILE" ]]; then + echo "Error: Review file not found: $REVIEW_FILE" >&2 + exit 1 +fi + +# Parse JSONL: extract metadata and comments separately +if ! ALL_ENTRIES=$(jq -s '. // []' < "$REVIEW_FILE" 2>&1); then + echo "Error: Invalid JSONL syntax in $REVIEW_FILE" >&2 + echo "$ALL_ENTRIES" >&2 + exit 1 +fi + +# Extract metadata (first line) and comments (remaining lines) +METADATA=$(echo "$ALL_ENTRIES" | jq '.[0]') +if [[ "$METADATA" == "null" ]]; then + echo "Error: Empty review file: $REVIEW_FILE" >&2 + echo "Create the review file with forge-review-start.sh first" >&2 + exit 1 +fi + +REVIEW_BODY=$(echo "$METADATA" | jq -r '.body') +if [[ -z "$REVIEW_BODY" || "$REVIEW_BODY" == "null" ]]; then + echo "Error: First line missing 'body' field" >&2 + exit 1 +fi + +# Extract comments (all lines after the first) +COMMENTS=$(echo "$ALL_ENTRIES" | jq '.[1:]') +COMMENT_COUNT=$(echo "$COMMENTS" | jq 'length') + +# Validate each comment has required fields +INVALID=$(echo "$COMMENTS" | jq '[.[] | select(.path == null or .body == null)] | length') +if [[ "$INVALID" -gt 0 ]]; then + echo "Error: $INVALID comment(s) missing required fields (path, body)" >&2 + exit 1 +fi + +if [[ $DRY_RUN -eq 1 ]]; then + echo "Review validated: $COMMENT_COUNT pending comment(s) for GitLab project $PROJECT_ID MR !$MR_IID" + exit 0 +fi + +if [[ -z "$TOKEN" ]]; then + echo "Error: GITLAB_TOKEN or PRIVATE_TOKEN environment variable required" >&2 + exit 1 +fi + +# Get MR version info first +VERSION_RESPONSE=$(curl -s --header "PRIVATE-TOKEN: $TOKEN" \ + "$GITLAB_URL/api/v4/projects/$PROJECT_ID/merge_requests/$MR_IID/versions" 2>&1) + +VERSION_INFO=$(echo "$VERSION_RESPONSE" | jq '.[0]' 2>/dev/null) + +if [[ -z "$VERSION_INFO" || "$VERSION_INFO" == "null" ]]; then + echo "Error: Failed to get MR version info: $VERSION_RESPONSE" >&2 + echo "Review file preserved at $REVIEW_FILE" >&2 + exit 1 +fi + +BASE_SHA=$(echo "$VERSION_INFO" | jq -r '.base_commit_sha') +HEAD_SHA=$(echo "$VERSION_INFO" | jq -r '.head_commit_sha') +START_SHA=$(echo "$VERSION_INFO" | jq -r '.start_commit_sha') + +# Validate we got valid SHAs +if [[ "$BASE_SHA" == "null" || -z "$BASE_SHA" ]]; then + echo "Error: Invalid version info - missing base_commit_sha" >&2 + echo "Review file preserved at $REVIEW_FILE" >&2 + exit 1 +fi + +# Create draft notes from comments +# Use process substitution to avoid subshell exit propagation issues +COMMENT_COUNT_INT=$(echo "$COMMENTS" | jq 'length') +if [[ "$COMMENT_COUNT_INT" -gt 0 ]]; then + while IFS= read -r comment; do + [[ -z "$comment" ]] && continue + + path=$(echo "$comment" | jq -r '.path') + line_num=$(echo "$comment" | jq -r '.line') + body=$(echo "$comment" | jq -r '.body') + old_path=$(echo "$comment" | jq -r '.old_path // .path') + + RESULT=$(curl -s --request POST \ + --header "PRIVATE-TOKEN: $TOKEN" \ + --form-string "note=$body" \ + --form-string "position[position_type]=text" \ + --form-string "position[base_sha]=$BASE_SHA" \ + --form-string "position[head_sha]=$HEAD_SHA" \ + --form-string "position[start_sha]=$START_SHA" \ + --form-string "position[old_path]=$old_path" \ + --form-string "position[new_path]=$path" \ + --form-string "position[new_line]=$line_num" \ + "$GITLAB_URL/api/v4/projects/$PROJECT_ID/merge_requests/$MR_IID/draft_notes" 2>&1) + + if ! echo "$RESULT" | jq -e '.id' > /dev/null 2>&1; then + echo "Failed to create draft note for $path:$line_num: $RESULT" >&2 + echo "Review file preserved at $REVIEW_FILE" >&2 + exit 1 + fi + done < <(echo "$COMMENTS" | jq -c '.[]') +fi + +# Post the main review body as a note +NOTE_RESULT=$(curl -s --request POST \ + --header "PRIVATE-TOKEN: $TOKEN" \ + --form-string "body=$REVIEW_BODY" \ + "$GITLAB_URL/api/v4/projects/$PROJECT_ID/merge_requests/$MR_IID/notes" 2>&1) + +if ! echo "$NOTE_RESULT" | jq -e '.id' > /dev/null 2>&1; then + echo "Warning: Failed to post review summary note: $NOTE_RESULT" >&2 + echo "Draft comments were created successfully" >&2 +fi + +echo "Review created successfully" +rm "$REVIEW_FILE"