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