From 440809c5a125a26dd06a09b86eba7ae9a3a13f28 Mon Sep 17 00:00:00 2001 From: Alan Pope Date: Thu, 6 Aug 2026 11:39:19 +0100 Subject: [PATCH] Add agentic code review workflow --- .github/change-review/publish-review.mjs | 228 +++++++++++++++++++++++ .github/workflows/change-review.yml | 181 ++++++++++++++++++ 2 files changed, 409 insertions(+) create mode 100644 .github/change-review/publish-review.mjs create mode 100644 .github/workflows/change-review.yml diff --git a/.github/change-review/publish-review.mjs b/.github/change-review/publish-review.mjs new file mode 100644 index 0000000..4b874d9 --- /dev/null +++ b/.github/change-review/publish-review.mjs @@ -0,0 +1,228 @@ +#!/usr/bin/env node +import { readFileSync, writeFileSync } from 'node:fs'; + +function fail(message) { + console.error(`publish-review.mjs: ${message}`); + process.exit(1); +} + +function env(name, required = true) { + const value = process.env[name]; + if ((value === undefined || value === '') && required) { + fail(`missing required env ${name}`); + } + return value; +} + +function readJson(path) { + try { + return JSON.parse(readFileSync(path, 'utf8')); + } catch (error) { + fail(`could not read JSON from ${path}: ${error.message}`); + } +} + +function skillDisplayName(ref) { + const selected = String(ref).split('#').pop(); + const withoutVersion = selected.replace(/@[^/@#]+$/, ''); + const parts = withoutVersion.split('/').filter(Boolean); + const leaf = parts.at(-1) ?? withoutVersion; + return leaf === 'SKILL.md' && parts.length > 1 ? parts.at(-2) : leaf; +} + +const REPO = env('REPO'); +const PR_NUMBER = env('PR_NUMBER'); +const GH_TOKEN = env('GH_TOKEN'); +const REVIEW_OUTPUT = env('REVIEW_OUTPUT', false) ?? 'change-review.json'; +const REVIEW_ACTION = env('REVIEW_ACTION', false) ?? 'comment'; +const OUT = env('OUT', false) ?? 'review-publish.json'; + +if (REVIEW_ACTION !== 'comment' && REVIEW_ACTION !== 'request-changes-on-findings') { + fail(`REVIEW_ACTION must be "comment" or "request-changes-on-findings"`); +} + +const review = readJson(REVIEW_OUTPUT); +const REVIEW_MARKER = ''; + +if (!review.summary || typeof review.summary !== 'object') { + fail('review JSON missing object "summary"'); +} +if (!Array.isArray(review.comments)) { + fail('review JSON missing array "comments"'); +} +if (!review.metadata || typeof review.metadata !== 'object') { + fail('review JSON missing object "metadata"'); +} + +const headSha = review.metadata.headSha; +if (typeof headSha !== 'string' || headSha === '') { + fail('review metadata missing string "headSha"'); +} +if (!Array.isArray(review.metadata.skills)) { + fail('review metadata missing array "skills"'); +} +if (typeof review.summary.overview !== 'string') { + fail('review summary missing string "overview"'); +} +if (!Array.isArray(review.summary.warnings)) { + fail('review summary missing array "warnings"'); +} +if (!Array.isArray(review.summary.unplacedFindings)) { + fail('review summary missing array "unplacedFindings"'); +} + +const overview = review.summary.overview + .trim() + .replace(/^#{1,6}\s+findings\s*\n+/i, '') + .trim(); + +const skillNames = review.metadata.skills.map(skillDisplayName); +const skillLine = skillNames.length > 0 + ? `Reviewed against skills: ${skillNames.map((name) => `\`${name}\``).join(', ')}` + : 'Reviewed against skills: _none reported_'; + +const bodyParts = [ + REVIEW_MARKER, + '## tessl change review:', + skillLine, + overview === '' ? '_No summary was produced for this diff._' : overview, +]; + +const { warnings, unplacedFindings } = review.summary; + +if (unplacedFindings.length > 0) { + const items = unplacedFindings.map((finding) => { + const locationParts = []; + if (finding.path) locationParts.push(finding.path); + if (finding.line) locationParts.push(`line ${finding.line}`); + if (finding.side) locationParts.push(finding.side); + + const where = locationParts.length > 0 ? ` \`${locationParts.join(':')}\`` : ''; + const reason = finding.reason ? ` (${finding.reason})` : ''; + const lines = [`- **Unplaced finding**${where}${reason}`]; + + for (const bodyLine of String(finding.body ?? '').split('\n')) { + lines.push(` ${bodyLine}`); + } + + return lines.join('\n'); + }); + + bodyParts.push([ + '
', + `Unplaced findings (${unplacedFindings.length}) — could not anchor to a changed hunk`, + '', + items.join('\n'), + '
', + ].join('\n')); +} + +if (warnings.length > 0) { + bodyParts.push([ + '
', + `Warnings (${warnings.length})`, + '', + warnings.map((warning) => `- ${warning}`).join('\n'), + '
', + ].join('\n')); +} + +bodyParts.push('---\nTo trigger a re-review write a comment that says `@tessl-change-review`.'); + +const body = bodyParts.join('\n\n'); + +const comments = review.comments.map((comment, index) => { + if (!comment || typeof comment !== 'object') { + fail(`comments[${index}] is not an object`); + } + if (typeof comment.path !== 'string' || comment.path === '') { + fail(`comments[${index}] missing string "path"`); + } + if (!Number.isInteger(comment.line) || comment.line < 1) { + fail(`comments[${index}] "line" must be a positive integer`); + } + if (comment.side !== 'LEFT' && comment.side !== 'RIGHT') { + fail(`comments[${index}] "side" must be LEFT or RIGHT`); + } + if (typeof comment.body !== 'string' || comment.body === '') { + fail(`comments[${index}] missing string "body"`); + } + + const mapped = { + path: comment.path, + line: comment.line, + side: comment.side, + body: comment.body, + }; + + if (comment.startLine !== undefined) { + if (!Number.isInteger(comment.startLine) || comment.startLine < 1) { + fail(`comments[${index}] "startLine" must be a positive integer`); + } + if (comment.startLine < comment.line) { + mapped.start_line = comment.startLine; + if (comment.startSide !== undefined) { + if (comment.startSide !== 'LEFT' && comment.startSide !== 'RIGHT') { + fail(`comments[${index}] "startSide" must be LEFT or RIGHT`); + } + mapped.start_side = comment.startSide; + } + } + } + + return mapped; +}); + +const hasFindings = comments.length > 0 || unplacedFindings.length > 0; +const event = REVIEW_ACTION === 'request-changes-on-findings' && hasFindings + ? 'REQUEST_CHANGES' + : 'COMMENT'; + +const [owner, repo, ...rest] = REPO.split('/'); +if (rest.length > 0 || !owner || !repo) { + fail(`REPO must be "owner/repo"`); +} + +const response = await fetch( + `https://api.github.com/repos/${owner}/${repo}/pulls/${PR_NUMBER}/reviews`, + { + method: 'POST', + headers: { + accept: 'application/vnd.github+json', + authorization: `Bearer ${GH_TOKEN}`, + 'content-type': 'application/json', + 'x-github-api-version': '2022-11-28', + }, + body: JSON.stringify({ + commit_id: headSha, + event, + body, + comments, + }), + }, +); + +const text = await response.text(); +if (!response.ok) { + fail(`GitHub createReview failed (HTTP ${response.status}): ${text}`); +} + +let created; +try { + created = JSON.parse(text); +} catch (error) { + fail(`could not parse GitHub createReview response: ${error.message}`); +} + +writeFileSync( + OUT, + `${JSON.stringify({ + reviewId: created.id ?? null, + reviewUrl: created.html_url ?? null, + commitId: headSha, + event, + commentCount: comments.length, + }, null, 2)}\n`, +); + +console.error(`publish-review.mjs: created review ${created.id ?? '(unknown id)'}`); diff --git a/.github/workflows/change-review.yml b/.github/workflows/change-review.yml new file mode 100644 index 0000000..58e8842 --- /dev/null +++ b/.github/workflows/change-review.yml @@ -0,0 +1,181 @@ +name: Change Review + +on: + pull_request: + types: [opened, reopened, ready_for_review] + issue_comment: + types: [created] + workflow_dispatch: + inputs: + pr-number: + description: PR number to review + required: true + type: string + +concurrency: + group: change-review-${{ github.event.issue.number || github.event.pull_request.number || github.event.inputs['pr-number'] }} + cancel-in-progress: ${{ github.event_name != 'issue_comment' }} + +jobs: + change-review: + if: >- + (github.event_name != 'pull_request' || !github.event.pull_request.draft) && + (github.event_name != 'issue_comment' || + (github.event.issue.pull_request && + startsWith(github.event.comment.body, '@tessl-change-review') && + (github.event.comment.author_association == 'OWNER' || + github.event.comment.author_association == 'MEMBER' || + github.event.comment.author_association == 'COLLABORATOR' || + github.event.comment.author_association == 'ADMIN'))) + runs-on: ubuntu-latest + timeout-minutes: 30 + permissions: + contents: read + pull-requests: write + env: + REPO: ${{ github.repository }} + REVIEW_BASE: origin/main + REVIEW_ACTION: comment + REVIEW_OUTPUT: change-review.json + REVIEW_SKILLS: >- + tessl/code-review#review-code-legibility + + steps: + - name: Resolve PR + id: pr + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + EVENT_NAME: ${{ github.event_name }} + INPUT_PR_NUMBER: ${{ github.event.inputs['pr-number'] }} + COMMENT_PR_NUMBER: ${{ github.event.issue.number }} + EVENT_PR_NUMBER: ${{ github.event.pull_request.number }} + EVENT_HEAD_SHA: ${{ github.event.pull_request.head.sha }} + EVENT_IS_CROSS_REPOSITORY: ${{ github.event.pull_request.head.repo.full_name != github.repository }} + run: | + set -euo pipefail + + if [ "$EVENT_NAME" = "workflow_dispatch" ] || [ "$EVENT_NAME" = "issue_comment" ]; then + if [ "$EVENT_NAME" = "workflow_dispatch" ]; then + PR_NUMBER="$INPUT_PR_NUMBER" + else + PR_NUMBER="$COMMENT_PR_NUMBER" + fi + PR_JSON=$(gh pr view "$PR_NUMBER" --repo "$REPO" --json headRefOid,isCrossRepository) + HEAD_SHA=$(jq -r .headRefOid <<< "$PR_JSON") + IS_CROSS_REPOSITORY=$(jq -r .isCrossRepository <<< "$PR_JSON") + else + PR_NUMBER="$EVENT_PR_NUMBER" + HEAD_SHA="$EVENT_HEAD_SHA" + IS_CROSS_REPOSITORY="$EVENT_IS_CROSS_REPOSITORY" + fi + + echo "pr-number=$PR_NUMBER" >> "$GITHUB_OUTPUT" + echo "head-sha=$HEAD_SHA" >> "$GITHUB_OUTPUT" + echo "is-cross-repository=$IS_CROSS_REPOSITORY" >> "$GITHUB_OUTPUT" + + - name: Reject cross-repository PRs + if: steps.pr.outputs.is-cross-repository == 'true' + run: | + echo "Refusing to run privileged change review on a cross-repository PR." >&2 + exit 1 + + - name: Checkout workflow support + uses: actions/checkout@v4 + with: + ref: ${{ github.sha }} + path: _workflow + sparse-checkout: | + .github/change-review/publish-review.mjs + persist-credentials: false + + - name: Checkout PR head + uses: actions/checkout@v4 + with: + ref: ${{ steps.pr.outputs.head-sha }} + path: _pr + fetch-depth: 0 + persist-credentials: false + + - name: Check review policy + id: review-policy + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + EVENT_NAME: ${{ github.event_name }} + PR_NUMBER: ${{ steps.pr.outputs.pr-number }} + run: | + set -euo pipefail + + if [ "$EVENT_NAME" = "issue_comment" ] || [ "$EVENT_NAME" = "workflow_dispatch" ]; then + echo "should-run=true" >> "$GITHUB_OUTPUT" + echo "Explicit human request; bypassing automatic one-review policy." + exit 0 + fi + + gh api --paginate "repos/$REPO/pulls/$PR_NUMBER/reviews" > reviews.json + COUNT=$(jq -s ' + [ .[][] + | select(.user.login == "github-actions[bot]") + | select((.body // "") | contains("")) + ] | length' reviews.json) + + if [ "$COUNT" -ge 1 ]; then + echo "should-run=false" >> "$GITHUB_OUTPUT" + echo "Skipping: $COUNT existing Tessl review(s); automatic review already ran." + else + echo "should-run=true" >> "$GITHUB_OUTPUT" + fi + + - name: Setup Tessl + if: steps.review-policy.outputs.should-run == 'true' + uses: tesslio/setup-tessl@v2 + with: + token: ${{ secrets.TESSL_TOKEN }} + + - name: Run change review + if: steps.review-policy.outputs.should-run == 'true' + timeout-minutes: 20 + working-directory: _pr + env: + REVIEW_OUTPUT: ${{ env.REVIEW_OUTPUT }} + REVIEW_SKILLS: ${{ env.REVIEW_SKILLS }} + REVIEW_BASE: ${{ env.REVIEW_BASE }} + run: | + set -euo pipefail + + read -r -a REVIEW_SKILL_REFS <<< "$REVIEW_SKILLS" + if [ "${#REVIEW_SKILL_REFS[@]}" -eq 0 ]; then + echo "::error::REVIEW_SKILLS must include at least one skill" + exit 1 + fi + + skill_args=() + for skill in "${REVIEW_SKILL_REFS[@]}"; do + skill_args+=(--skill "$skill") + done + + tessl change review run \ + --json \ + --output "$REVIEW_OUTPUT" \ + "${skill_args[@]}" \ + --base "$REVIEW_BASE" + + - name: Publish PR review + if: steps.review-policy.outputs.should-run == 'true' + working-directory: _pr + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PR_NUMBER: ${{ steps.pr.outputs.pr-number }} + REVIEW_ACTION: ${{ env.REVIEW_ACTION }} + REVIEW_OUTPUT: ${{ env.REVIEW_OUTPUT }} + OUT: review-publish.json + run: node ../_workflow/.github/change-review/publish-review.mjs + + - name: Upload artifacts + if: always() && steps.review-policy.outputs.should-run == 'true' + uses: actions/upload-artifact@v4 + with: + name: change-review-${{ steps.pr.outputs.head-sha }} + path: | + _pr/${{ env.REVIEW_OUTPUT }} + _pr/review-publish.json + if-no-files-found: ignore