Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
228 changes: 228 additions & 0 deletions .github/change-review/publish-review.mjs
Original file line number Diff line number Diff line change
@@ -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 = '<!-- tessl-change-review -->';

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([
'<details>',
`<summary>Unplaced findings (${unplacedFindings.length}) — could not anchor to a changed hunk</summary>`,
'',
items.join('\n'),
'</details>',
].join('\n'));
}

if (warnings.length > 0) {
bodyParts.push([
'<details>',
`<summary>Warnings (${warnings.length})</summary>`,
'',
warnings.map((warning) => `- ${warning}`).join('\n'),
'</details>',
].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)'}`);
Loading
Loading