diff --git a/dist/index.js b/dist/index.js index 141142e..7a3a573 100644 --- a/dist/index.js +++ b/dist/index.js @@ -9325,6 +9325,7 @@ exports.processResults = exports.VoteResult = void 0; const fs_1 = __nccwpck_require__(7147); const config_1 = __nccwpck_require__(6373); const bot_1 = __nccwpck_require__(8104); +const voteSync_1 = __nccwpck_require__(2564); const octokit_1 = __importDefault(__nccwpck_require__(6161)); var VoteResult; (function (VoteResult) { @@ -9413,6 +9414,7 @@ function run(context) { return; } } + yield (0, voteSync_1.syncIssueVoteLog)(owner, repo, number); const reactions = yield octokit_1.default.reactions.listForIssueComment({ owner, repo, @@ -9536,6 +9538,142 @@ function run(context) { exports["default"] = run; +/***/ }), + +/***/ 2564: +/***/ (function(__unused_webpack_module, exports, __nccwpck_require__) { + +"use strict"; + +var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) { + function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); } + return new (P || (P = Promise))(function (resolve, reject) { + function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } + function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } } + function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); } + step((generator = generator.apply(thisArg, _arguments || [])).next()); + }); +}; +var __importDefault = (this && this.__importDefault) || function (mod) { + return (mod && mod.__esModule) ? mod : { "default": mod }; +}; +Object.defineProperty(exports, "__esModule", ({ value: true })); +exports.syncIssueVoteLog = exports.buildUpdatedComment = exports.diffVotes = exports.parseStateMarker = void 0; +const config_1 = __nccwpck_require__(6373); +const octokit_1 = __importDefault(__nccwpck_require__(6161)); +const STATE_MARKER_RE = //; +function parseStateMarker(body) { + const match = body.match(STATE_MARKER_RE); + if (!match || !match[1]) + return {}; + try { + return JSON.parse(match[1]); + } + catch (_a) { + return {}; + } +} +exports.parseStateMarker = parseStateMarker; +function afterDeadlineSuffix(ts, deadline) { + if (!deadline || ts <= deadline) + return ''; + return ', after the deadline'; +} +function diffVotes(state, liveReactions, deadline, now) { + const liveMap = Object.fromEntries(liveReactions.map(r => [r.login, r])); + const changedOrNew = liveReactions.flatMap(r => { + const prev = state[r.login]; + if (prev === r.content) + return []; + const removalLines = prev !== undefined + ? [`@${r.login} removed their vote (detected on ${now.toISOString()}${afterDeadlineSuffix(now, deadline)})`] + : []; + const emoji = r.content === '+1' ? ':+1:' : ':-1:'; + const voteLine = `@${r.login} voted ${emoji} on ${r.created_at}${afterDeadlineSuffix(new Date(r.created_at), deadline)}`; + return [...removalLines, voteLine]; + }); + const removed = Object.keys(state) + .filter(login => !liveMap[login]) + .map(login => `@${login} removed their vote (detected on ${now.toISOString()}${afterDeadlineSuffix(now, deadline)})`); + return [...changedOrNew, ...removed]; +} +exports.diffVotes = diffVotes; +const REMOVE_STATE_MARKER_RE = /\n\n$/; +function buildUpdatedComment(body, newLines, newState) { + // GitHub rewrites the whole body to CRLF when a human edits the comment in + // the web UI, which breaks the LF-based marker and section matching below. + const normalized = body.replace(/\r\n?/g, '\n'); + const withoutMarker = normalized.replace(REMOVE_STATE_MARKER_RE, ''); + const stateMarker = ``; + if (!withoutMarker.includes('\n### Vote log\n')) { + return `${withoutMarker}\n\n---\n\n### Vote log\n\n${newLines.join('\n')}\n\n${stateMarker}`; + } + return `${withoutMarker}\n${newLines.join('\n')}\n\n${stateMarker}`; +} +exports.buildUpdatedComment = buildUpdatedComment; +function steeringCommitteeMembers() { + return __awaiter(this, void 0, void 0, function* () { + return (yield octokit_1.default.teams.listMembersInOrg({ + org: 'publiccodeyml', + team_slug: 'steering-committee', + })).data.map(m => m.login); + }); +} +function syncIssueVoteLog(owner, repo, issueNumber, members) { + var _a; + return __awaiter(this, void 0, void 0, function* () { + const comments = yield octokit_1.default.paginate('GET /repos/:owner/:repo/issues/:issue_number/comments', { owner, repo, issue_number: issueNumber }); + const voteComment = comments + .slice() + .reverse() + .find(c => { var _a, _b; return ((_a = c.user) === null || _a === void 0 ? void 0 : _a.login) === config_1.BOT_USERNAME && ((_b = c.body) === null || _b === void 0 ? void 0 : _b.startsWith('')); }); + if (!voteComment) { + console.error(`Issue #${issueNumber}: can't find voting comment, skipping sync`); + return; + } + const reactions = yield octokit_1.default.reactions.listForIssueComment({ + owner, + repo, + comment_id: voteComment.id, + }); + const committee = members !== null && members !== void 0 ? members : yield steeringCommitteeMembers(); + const liveReactions = reactions.data + .filter(r => { var _a, _b; return (r.content === '+1' || r.content === '-1') && committee.includes((_b = (_a = r.user) === null || _a === void 0 ? void 0 : _a.login) !== null && _b !== void 0 ? _b : ''); }) + .map(r => ({ + login: r.user.login, + content: r.content, + created_at: r.created_at, + })); + const body = (_a = voteComment.body) !== null && _a !== void 0 ? _a : ''; + const state = parseStateMarker(body); + const deadlineMatch = body.match(//); + const deadline = deadlineMatch ? new Date(deadlineMatch[1]) : null; + const now = new Date(); + const newLines = diffVotes(state, liveReactions, deadline, now); + if (newLines.length === 0) + return; + const newState = Object.fromEntries(liveReactions.map(r => [r.login, r.content])); + yield octokit_1.default.issues.updateComment({ + owner, + repo, + comment_id: voteComment.id, + body: buildUpdatedComment(body, newLines, newState), + }); + }); +} +exports.syncIssueVoteLog = syncIssueVoteLog; +function run(owner, repo) { + return __awaiter(this, void 0, void 0, function* () { + const issues = yield octokit_1.default.paginate('GET /repos/:owner/:repo/issues', { + owner, repo, state: 'open', labels: 'vote-start', + }); + const members = yield steeringCommitteeMembers(); + yield Promise.all(issues.map(issue => syncIssueVoteLog(owner, repo, issue.number, members))); + }); +} +exports["default"] = run; + + /***/ }), /***/ 6373: @@ -9569,15 +9707,25 @@ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, ge step((generator = generator.apply(thisArg, _arguments || [])).next()); }); }; +var __importDefault = (this && this.__importDefault) || function (mod) { + return (mod && mod.__esModule) ? mod : { "default": mod }; +}; Object.defineProperty(exports, "__esModule", ({ value: true })); const github_1 = __nccwpck_require__(5438); const bot_1 = __nccwpck_require__(8104); const commands_1 = __nccwpck_require__(4362); const config_1 = __nccwpck_require__(6373); +const voteSync_1 = __importDefault(__nccwpck_require__(2564)); function run() { var _a; return __awaiter(this, void 0, void 0, function* () { // TODO: Check for github.context.eventName == 'issue_comment' + const { eventName } = github_1.context; + if (eventName === 'schedule' || eventName === 'workflow_dispatch') { + const { owner, repo } = github_1.context.repo; + yield (0, voteSync_1.default)(owner, repo); + return; + } const { comment } = github_1.context.payload; if (!comment) { console.error('No comment object found'); diff --git a/src/commands/voteEnd.ts b/src/commands/voteEnd.ts index 252993e..0d161d0 100644 --- a/src/commands/voteEnd.ts +++ b/src/commands/voteEnd.ts @@ -5,6 +5,7 @@ import { BOT_USERNAME, MAINTAINERS_TEAM } from '../config'; import { reactToComment, commentToIssue, addLabels, removeLabel, hasLabel, } from '../bot'; +import { syncIssueVoteLog } from './voteSync'; import { LabelName } from '../labels'; import octokit from '../octokit'; @@ -130,6 +131,8 @@ export default async function run(context: Context) { } } + await syncIssueVoteLog(owner, repo, number); + const reactions = await octokit.reactions.listForIssueComment({ owner, repo, diff --git a/src/commands/voteSync.test.ts b/src/commands/voteSync.test.ts new file mode 100644 index 0000000..cb1e50b --- /dev/null +++ b/src/commands/voteSync.test.ts @@ -0,0 +1,136 @@ +import { parseStateMarker, diffVotes, buildUpdatedComment } from './voteSync'; + +test('parseStateMarker returns empty object when marker is absent', () => { + expect(parseStateMarker('\nsome body')).toEqual({}); +}); + +test('parseStateMarker returns empty object on malformed JSON', () => { + expect(parseStateMarker('')).toEqual({}); +}); + +test('parseStateMarker parses a valid state', () => { + const body = 'body\n\n'; + expect(parseStateMarker(body)).toEqual({ alice: '+1', bob: '-1' }); +}); + +const DEADLINE = new Date('2026-06-25T07:00:00Z'); +const BEFORE_DEADLINE = new Date('2026-06-20T10:00:00Z'); +const AFTER_DEADLINE = new Date('2026-06-26T08:00:00Z'); + +test('diffVotes: no change returns empty array', () => { + expect( + diffVotes( + { alice: '+1' }, + [{ login: 'alice', content: '+1', created_at: '2026-06-12T10:00:00Z' }], + DEADLINE, + BEFORE_DEADLINE, + ), + ).toEqual([]); +}); + +test('diffVotes: new vote appended', () => { + expect( + diffVotes( + {}, + [{ login: 'alice', content: '+1', created_at: '2026-06-12T10:00:00Z' }], + DEADLINE, + BEFORE_DEADLINE, + ), + ).toEqual(['@alice voted :+1: on 2026-06-12T10:00:00Z']); +}); + +test('diffVotes: new vote after deadline gets suffix', () => { + expect( + diffVotes( + {}, + [{ login: 'alice', content: '+1', created_at: '2026-06-26T10:00:00Z' }], + DEADLINE, + AFTER_DEADLINE, + ), + ).toEqual(['@alice voted :+1: on 2026-06-26T10:00:00Z, after the deadline']); +}); + +test('diffVotes: removed vote appended', () => { + const result = diffVotes( + { alice: '+1' }, + [], + DEADLINE, + BEFORE_DEADLINE, + ); + expect(result).toEqual([ + `@alice removed their vote (detected on ${BEFORE_DEADLINE.toISOString()})`, + ]); +}); + +test('diffVotes: removal after deadline gets suffix inside parens', () => { + const result = diffVotes( + { alice: '+1' }, + [], + DEADLINE, + AFTER_DEADLINE, + ); + expect(result).toEqual([ + `@alice removed their vote (detected on ${AFTER_DEADLINE.toISOString()}, after the deadline)`, + ]); +}); + +test('diffVotes: changed vote emits removal then new vote', () => { + const result = diffVotes( + { alice: '+1' }, + [{ login: 'alice', content: '-1', created_at: '2026-06-15T09:00:00Z' }], + DEADLINE, + BEFORE_DEADLINE, + ); + expect(result).toEqual([ + `@alice removed their vote (detected on ${BEFORE_DEADLINE.toISOString()})`, + '@alice voted :-1: on 2026-06-15T09:00:00Z', + ]); +}); + +test('diffVotes: no deadline — no suffix even when sync is late', () => { + expect( + diffVotes( + {}, + [{ login: 'alice', content: '+1', created_at: '2026-07-01T10:00:00Z' }], + null, + new Date('2026-07-05T00:00:00Z'), + ), + ).toEqual(['@alice voted :+1: on 2026-07-01T10:00:00Z']); +}); + +const ORIGINAL_BODY = '\noriginal body'; + +test('buildUpdatedComment: creates vote log section on first sync', () => { + const result = buildUpdatedComment( + ORIGINAL_BODY, + ['@alice voted :+1: on 2026-06-12T10:00:00Z'], + { alice: '+1' }, + ); + expect(result).toBe( + '\noriginal body\n\n---\n\n### Vote log\n\n@alice voted :+1: on 2026-06-12T10:00:00Z\n\n', + ); +}); + +test('buildUpdatedComment: appends to existing vote log section', () => { + const body = '\noriginal body\n\n---\n\n### Vote log\n\n@alice voted :+1: on 2026-06-12T10:00:00Z\n\n'; + const result = buildUpdatedComment( + body, + ['@bob voted :-1: on 2026-06-13T10:00:00Z'], + { alice: '+1', bob: '-1' }, + ); + expect(result).toBe( + '\noriginal body\n\n---\n\n### Vote log\n\n@alice voted :+1: on 2026-06-12T10:00:00Z\n@bob voted :-1: on 2026-06-13T10:00:00Z\n\n', + ); +}); + +test('buildUpdatedComment: appends to a CRLF body without duplicating section or marker', () => { + const body = '\r\noriginal body\r\n\r\n---\r\n\r\n### Vote log\r\n\r\n@alice voted :+1: on 2026-06-12T10:00:00Z\r\n\r\n'; + const result = buildUpdatedComment( + body, + ['@bob voted :-1: on 2026-06-13T10:00:00Z'], + { alice: '+1', bob: '-1' }, + ); + expect(result).toBe( + '\noriginal body\n\n---\n\n### Vote log\n\n@alice voted :+1: on 2026-06-12T10:00:00Z\n@bob voted :-1: on 2026-06-13T10:00:00Z\n\n', + ); +}); diff --git a/src/commands/voteSync.ts b/src/commands/voteSync.ts new file mode 100644 index 0000000..12efbc5 --- /dev/null +++ b/src/commands/voteSync.ts @@ -0,0 +1,160 @@ +import { GetResponseDataTypeFromEndpointMethod } from '@octokit/types'; +import { BOT_USERNAME } from '../config'; +import octokit from '../octokit'; + +type Comment = GetResponseDataTypeFromEndpointMethod< + typeof octokit.issues.getComment +>; + +export type VoteState = Record; + +export interface LiveReaction { + login: string; + content: '+1' | '-1'; + created_at: string; +} + +const STATE_MARKER_RE = //; + +export function parseStateMarker(body: string): VoteState { + const match = body.match(STATE_MARKER_RE); + if (!match || !match[1]) return {}; + try { + return JSON.parse(match[1]) as VoteState; + } catch { + return {}; + } +} + +function afterDeadlineSuffix(ts: Date, deadline: Date | null): string { + if (!deadline || ts <= deadline) return ''; + return ', after the deadline'; +} + +export function diffVotes( + state: VoteState, + liveReactions: LiveReaction[], + deadline: Date | null, + now: Date, +): string[] { + const liveMap: Record = Object.fromEntries( + liveReactions.map(r => [r.login, r]), + ); + + const changedOrNew = liveReactions.flatMap(r => { + const prev = state[r.login]; + if (prev === r.content) return []; + + const removalLines: string[] = prev !== undefined + ? [`@${r.login} removed their vote (detected on ${now.toISOString()}${afterDeadlineSuffix(now, deadline)})`] + : []; + + const emoji = r.content === '+1' ? ':+1:' : ':-1:'; + const voteLine = `@${r.login} voted ${emoji} on ${r.created_at}${afterDeadlineSuffix(new Date(r.created_at), deadline)}`; + + return [...removalLines, voteLine]; + }); + + const removed = Object.keys(state) + .filter(login => !liveMap[login]) + .map(login => `@${login} removed their vote (detected on ${now.toISOString()}${afterDeadlineSuffix(now, deadline)})`); + + return [...changedOrNew, ...removed]; +} + +const REMOVE_STATE_MARKER_RE = /\n\n$/; + +export function buildUpdatedComment( + body: string, + newLines: string[], + newState: VoteState, +): string { + // GitHub rewrites the whole body to CRLF when a human edits the comment in + // the web UI, which breaks the LF-based marker and section matching below. + const normalized = body.replace(/\r\n?/g, '\n'); + const withoutMarker = normalized.replace(REMOVE_STATE_MARKER_RE, ''); + const stateMarker = ``; + + if (!withoutMarker.includes('\n### Vote log\n')) { + return `${withoutMarker}\n\n---\n\n### Vote log\n\n${newLines.join('\n')}\n\n${stateMarker}`; + } + return `${withoutMarker}\n${newLines.join('\n')}\n\n${stateMarker}`; +} + +async function steeringCommitteeMembers(): Promise { + return (await octokit.teams.listMembersInOrg({ + org: 'publiccodeyml', + team_slug: 'steering-committee', + })).data.map(m => m.login); +} + +export async function syncIssueVoteLog( + owner: string, + repo: string, + issueNumber: number, + members?: string[], +): Promise { + const comments = await octokit.paginate( + 'GET /repos/:owner/:repo/issues/:issue_number/comments', + { owner, repo, issue_number: issueNumber }, + ) as Comment[]; + + const voteComment = comments + .slice() + .reverse() + .find(c => c.user?.login === BOT_USERNAME && c.body?.startsWith('')); + + if (!voteComment) { + console.error(`Issue #${issueNumber}: can't find voting comment, skipping sync`); + return; + } + + const reactions = await octokit.reactions.listForIssueComment({ + owner, + repo, + comment_id: voteComment.id, + }); + + const committee = members ?? await steeringCommitteeMembers(); + + const liveReactions: LiveReaction[] = reactions.data + .filter(r => (r.content === '+1' || r.content === '-1') && committee.includes(r.user?.login ?? '')) + .map(r => ({ + login: r.user!.login, + content: r.content as '+1' | '-1', + created_at: r.created_at, + })); + + const body = voteComment.body ?? ''; + const state = parseStateMarker(body); + + const deadlineMatch = body.match(//); + const deadline = deadlineMatch ? new Date(deadlineMatch[1]!) : null; + + const now = new Date(); + const newLines = diffVotes(state, liveReactions, deadline, now); + + if (newLines.length === 0) return; + + const newState: VoteState = Object.fromEntries(liveReactions.map(r => [r.login, r.content])); + + await octokit.issues.updateComment({ + owner, + repo, + comment_id: voteComment.id, + body: buildUpdatedComment(body, newLines, newState), + }); +} + +export default async function run(owner: string, repo: string): Promise { + const issues = await octokit.paginate( + 'GET /repos/:owner/:repo/issues', + { + owner, repo, state: 'open', labels: 'vote-start', + }, + ) as Array<{ number: number }>; + + const members = await steeringCommitteeMembers(); + + await Promise.all(issues.map(issue => syncIssueVoteLog(owner, repo, issue.number, members))); +} diff --git a/src/index.ts b/src/index.ts index b55e5c9..e9c4b83 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,10 +3,18 @@ import { context } from '@actions/github'; import { getCommandsFromComment, isChair, isMaintainer } from './bot'; import { runCommand } from './commands'; import { BOT_USERNAME } from './config'; +import voteSync from './commands/voteSync'; async function run() { // TODO: Check for github.context.eventName == 'issue_comment' + const { eventName } = context; + if (eventName === 'schedule' || eventName === 'workflow_dispatch') { + const { owner, repo } = context.repo; + await voteSync(owner, repo); + return; + } + const { comment } = context.payload; if (!comment) { console.error('No comment object found');