From 30741d49978fea30611b644ea9aa706b30baf037 Mon Sep 17 00:00:00 2001 From: samuelkos17 Date: Mon, 31 Aug 2026 11:42:05 +0200 Subject: [PATCH] #2358: Automated IDEasy project board assigning --- .github/scripts/pr-assignee-reconcile.mjs | 500 ++++++++++++++++++++ .github/scripts/reconcile-core.mjs | 146 ++++++ .github/scripts/reconcile-core.test.mjs | 161 +++++++ .github/workflows/pr-assignee-reconcile.yml | 105 ++++ 4 files changed, 912 insertions(+) create mode 100644 .github/scripts/pr-assignee-reconcile.mjs create mode 100644 .github/scripts/reconcile-core.mjs create mode 100644 .github/scripts/reconcile-core.test.mjs create mode 100644 .github/workflows/pr-assignee-reconcile.yml diff --git a/.github/scripts/pr-assignee-reconcile.mjs b/.github/scripts/pr-assignee-reconcile.mjs new file mode 100644 index 0000000000..1d6d7093b6 --- /dev/null +++ b/.github/scripts/pr-assignee-reconcile.mjs @@ -0,0 +1,500 @@ +#!/usr/bin/env node +// @ts-check +// +// pr-assignee-reconcile.mjs +// +// Enforces the "single-owner board" invariant on a GitHub Projects v2 board: +// - In the "Team Review" column a PR has exactly one assignee: the team +// reviewer, taken from the board's "Team Reviewer" text field. That field +// is the source of truth and is AUTO-FILLED by this script (never typed by +// hand, so no typos): when it is empty and exactly one non-author assignee +// exists (the team's self-assign habit), the bot assigns them and records +// their login in the field. Because the field persists across column +// moves, moving a card back to Team Review re-assigns the same reviewer +// even if they were unassigned in between. +// - In each configured "author-only" column a PR has exactly one assignee: +// the author (the Team Reviewer field is kept as a record, not cleared). +// - Every other column is left untouched. +// - A PR carrying the explicit TAKEOVER label (default "takeover") is left +// completely alone: this is how the team marks a PR handed over after its +// author left the team, so the bot does not re-assign the departed author. +// +// It changes PR assignees (Issues/PRs REST API) and writes the Team Reviewer +// field (GraphQL updateProjectV2ItemFieldValue). It never moves cards between +// columns; humans keep doing that on the board. If the Team Reviewer field +// does not exist yet, it degrades to inference-only behaviour and says so. +// +// Configuration (environment variables): +// GH_TOKEN / GITHUB_TOKEN OAuth or GITHUB_TOKEN with read:project + +// write:repo (needs "Administration" > "Assign +// issues" or the repo's issues:write + pull-requests:write). +// NOTE: a GITHUB_TOKEN issued by one repo manages +// assignees on that repo's PRs. PRs from OTHER repos +// that share the board are best-effort: a 403/404 +// there is logged and skipped (not fatal), so the +// invariant only holds for in-repo PRs. For +// cross-repo writes, use an org-scoped PAT as GH_TOKEN. +// GH_ACCOUNT GitHub account (user OR organization) that owns +// the board (default: devonfw). Personal / fork +// boards live under a user account, so both are +// searched (user first, then org). +// BOARD_TITLE board title to manage (default: "IDEasy board") +// TEAM_REVIEW_COLUMN Status option that means "under team review" +// (default: "Team Review") +// AUTHOR_ONLY_COLUMNS comma-separated Status options forced to +// "author only" (default: "🏗 In progress,👀 In review") +// TAKEOVER_LABEL PR label that exempts a PR from reconciliation +// (default: "takeover"). (A board field cannot be +// used here: Projects v2 exposes no checkbox value +// type via the GraphQL API.) +// DRY_RUN "true" -> report only, change nothing +// (default: "true"; set "false" to apply) +// +// Exit codes: 0 ok (incl. dry run), 1 config/runtime error, 2 API error. + +import { COLUMNS, computeTarget, computeDiff } from './reconcile-core.mjs'; + +const API = 'https://api.github.com'; + +// Boards are looked up separately for the user and the org account of the +// same login, because a combined `user + organization` query in one request +// makes GitHub return a NOT_FOUND error for whichever account type does not +// exist — and the graphql() helper treats any error as fatal. +const USER_BOARDS_QUERY = ` +query ($login: String!, $after: String) { + user(login: $login) { + projectsV2(first: 100, after: $after) { + nodes { id title } + pageInfo { hasNextPage endCursor } + } + } +} +`; + +const ORG_BOARDS_QUERY = ` +query ($login: String!, $after: String) { + organization(login: $login) { + projectsV2(first: 100, after: $after) { + nodes { id title } + pageInfo { hasNextPage endCursor } + } + } +} +`; + +const ITEMS_QUERY = ` +query ($id: ID!, $after: String) { + node(id: $id) { + ... on ProjectV2 { + items(first: 100, after: $after) { + nodes { + ... on ProjectV2Item { + id + content { + __typename + ... on PullRequest { + number + state + mergedAt + isDraft + author { login } + repository { nameWithOwner } + assignees(first: 30) { nodes { login } } + labels(first: 30) { nodes { name } } + } + } + fieldValues(first: 100) { + nodes { + __typename + ... on ProjectV2ItemFieldSingleSelectValue { + name + field { + ... on ProjectV2SingleSelectField { name } + } + } + ... on ProjectV2ItemFieldTextValue { + text + field { + ... on ProjectV2Field { name } + } + } + } + } + } + } + pageInfo { hasNextPage endCursor } + } + } + } +} +`; + +// --------------------------------------------------------------------------- +// HTTP helpers +// --------------------------------------------------------------------------- + +function headers() { + const token = process.env.GH_TOKEN || process.env.GITHUB_TOKEN; + if (!token) { + fail('No GH_TOKEN or GITHUB_TOKEN set.'); + } + return { + Authorization: `Bearer ${token}`, + Accept: 'application/vnd.github+json', + 'X-GitHub-Api-Version': '2022-11-28', + 'Content-Type': 'application/json', + }; +} + +function fail(message) { + console.error(`[reconcile] FATAL: ${message}`); + process.exit(1); +} + +async function graphql(query, variables) { + const res = await fetch(`${API}/graphql`, { + method: 'POST', + headers: headers(), + body: JSON.stringify({ query, variables }), + }); + if (!res.ok) { + fail(`GraphQL HTTP ${res.status}: ${await res.text()}`); + } + const json = await res.json(); + // Reads use GraphQL and are treated as fatal: if the board/field read fails + // we cannot trust the board state, so the whole run aborts. Assignee REST + // writes are per-card and non-fatal (see rest()). A bad single card therefore + // never aborts the run; only a bad board-level read does. + if (json.errors?.length) { + fail(`GraphQL error: ${JSON.stringify(json.errors)}`); + } + return json.data; +} + +/** + * Like {@link graphql}, but treats a GraphQL NOT_FOUND as "the thing was not + * found here" (returns `null`) instead of aborting the run. Used by the board + * lookup: the account may be a USER (fork/personal board) or an ORGANIZATION, + * so the query for whichever account type does not exist is expected to come + * back NOT_FOUND and must not be fatal. Any other error is still fatal. + */ +async function graphqlTolerantNotFound(query, variables) { + const res = await fetch(`${API}/graphql`, { + method: 'POST', + headers: headers(), + body: JSON.stringify({ query, variables }), + }); + if (!res.ok) { + fail(`GraphQL HTTP ${res.status}: ${await res.text()}`); + } + const json = await res.json(); + if (json.errors?.length) { + if (json.errors.every((e) => e.type === 'NOT_FOUND')) { + return null; + } + fail(`GraphQL error: ${JSON.stringify(json.errors)}`); + } + return json.data; +} + +async function rest(method, path, body) { + const res = await fetch(`${API}${path}`, { + method, + headers: headers(), + body: body ? JSON.stringify(body) : undefined, + }); + if (!res.ok) { + // 422/404 on assignee ops is non-fatal for a single card; surface it and continue. + const text = await res.text(); + console.error(`[reconcile] REST ${method} ${path} -> ${res.status}: ${text}`); + return { ok: false, status: res.status }; + } + return { ok: true, status: res.status }; +} + +// --------------------------------------------------------------------------- +// Board access +// --------------------------------------------------------------------------- + +const FIELDS_QUERY = ` +query ($id: ID!) { + node(id: $id) { + ... on ProjectV2 { + fields(first: 100) { + nodes { + ... on ProjectV2Field { id name dataType } + } + } + } + } +} +`; + +/** + * Find the board field named `fieldName` (the "Team Reviewer" text field). + * Returns its node id, or `null` if the field does not exist yet. + */ +async function findField(boardId, fieldName) { + const data = await graphql(FIELDS_QUERY, { id: boardId }); + const node = data.node?.fields?.nodes.find((f) => f.name === fieldName); + return node ? { id: node.id, name: node.name } : null; +} + +async function findBoard(login, title) { + // The board owner is a GitHub account of unknown type: try the user + // account first (personal / fork boards live there), then the organization + // (the production case, e.g. devonfw). Each lookup runs its own query, and + // a NOT_FOUND — "this account type does not exist" (the fork is a user, so + // no org by that login) — is tolerated and the next account type is tried. + const lookups = [ + { query: USER_BOARDS_QUERY, field: 'user' }, + { query: ORG_BOARDS_QUERY, field: 'organization' }, + ]; + for (const { query, field } of lookups) { + let after = null; + for (let page = 0; page < 50; page++) { + const data = await graphqlTolerantNotFound(query, { login, after }); + const proj = data?.[field]?.projectsV2; + if (!proj) { + break; // account does not exist or has no boards; try the next one + } + for (const node of proj.nodes) { + if (node.title === title) { + return { id: node.id, title: node.title }; + } + } + if (!proj.pageInfo.hasNextPage) { + break; + } + after = proj.pageInfo.endCursor; + } + } + fail(`Could not find a project titled "${title}" in account "${login}".`); +} + +/** + * Read every Pull Request item on the board (paginated) and reduce each to a + * plain object: { itemId, number, author, repo, assignees, statusName, + * reviewer, takeover } where `reviewer` is the current "Team Reviewer" text + * field value (a GitHub login, or '' when the field is unset / does not exist) + * and `takeover` is true when the PR has the configured takeover label. + * (A board field cannot be used for this: Projects v2 exposes no value type + * for checkbox fields via the GraphQL API.) + */ +async function readPrItems(boardId, reviewerFieldName, takeoverLabel) { + const items = []; + let after = null; + for (let page = 0; page < 100; page++) { + const data = await graphql(ITEMS_QUERY, { id: boardId, after }); + const conn = data.node.items; + for (const node of conn.nodes) { + const content = node.content; + if (!content || content.__typename !== 'PullRequest') { + continue; // skip issues / drafts + } + // With "... on PullRequest { number ... }" the fields merge directly onto + // `content` (there is no content.PullRequest wrapper). + const pr = content; + // The Status value is the single-select whose field is named "Status"; + // the Team Reviewer value is the text field named `reviewerFieldName`. + // In both cases the field name merges directly onto `v.field` (no wrapper). + const statusValue = node.fieldValues.nodes.find( + (v) => + v.__typename === 'ProjectV2ItemFieldSingleSelectValue' && + v.field?.name === 'Status', + ); + const reviewerValue = node.fieldValues.nodes.find( + (v) => v.__typename === 'ProjectV2ItemFieldTextValue' && v.field?.name === reviewerFieldName, + ); + // Takeover exemption: the configured PR label marks the card as handed + // over (e.g. author left the team) so the bot never reverts the taker's + // assignment. + const takeover = pr.labels.nodes.some((label) => label.name === takeoverLabel); + // Only manage open, not-yet-merged PRs authored by a human. The board + // holds hundreds of stale/merged PRs (incl. dependabot); we must not + // rewrite their assignees. + // Skip automated authors. GitHub reports the dependabot login as + // "dependabot" via GraphQL (the board query) but as "dependabot[bot]" + // via the REST API, so match both; any other "[bot]" login is a bot too. + const isBot = (login) => login === 'dependabot' || login.endsWith('[bot]'); + const manageable = pr.state === 'OPEN' && pr.mergedAt === null && !isBot(pr.author.login); + if (!manageable) { + continue; + } + items.push({ + itemId: node.id, + number: pr.number, + author: pr.author.login, + repo: pr.repository.nameWithOwner, + assignees: pr.assignees.nodes.map((a) => a.login), + statusName: statusValue?.name ?? '', + reviewer: (reviewerValue?.text || '').trim(), + takeover, + }); + } + if (!conn.pageInfo.hasNextPage) { + break; + } + after = conn.pageInfo.endCursor; + } + return items; +} + +// --------------------------------------------------------------------------- +// Reconciliation +// --------------------------------------------------------------------------- + +// Add the target first and only then remove the others, so a failed add +// (e.g. a hand-typed typo in the Team Reviewer field) can never leave the +// card with zero assignees. +async function applyAssignees(repo, number, toAdd, toRemove) { + if (toAdd.length) { + const r = await rest('POST', `/repos/${repo}/issues/${number}/assignees`, { assignees: toAdd }); + if (!r.ok) { + console.warn(`[reconcile] ! could not assign ${toAdd.join(', ')} on ${repo}#${number}; skipping unassign`); + return; + } + } + // Remove via a JSON body { assignees: [...] } — the API retired the old + // "?assignee=" query form (it now 400s "Body should be a JSON + // object"). Mirrors the add call above, which already uses the body form. + for (const login of toRemove) { + const r = await rest('DELETE', `/repos/${repo}/issues/${number}/assignees`, { assignees: [login] }); + if (!r.ok) { + console.warn(`[reconcile] ! could not unassign ${login} on ${repo}#${number}`); + } + } +} + +/** + * Auto-fill the "Team Reviewer" text field with a login (the record of who + * did the team review). Only called when the field was empty. + */ +async function setReviewerField(boardId, itemId, fieldId, login) { + // NOTE: updateProjectV2ItemFieldValue takes a single `input` object + // (UpdateProjectV2ItemFieldValueInput), not top-level arguments — the + // old projectId/itemId/fieldId/value form is retired by the API. + const mutation = ` +mutation ($input: UpdateProjectV2ItemFieldValueInput!) { + updateProjectV2ItemFieldValue(input: $input) { + projectV2Item { id } + } +} +`; + const res = await fetch(`${API}/graphql`, { + method: 'POST', + headers: headers(), + body: JSON.stringify({ + query: mutation, + variables: { input: { projectId: boardId, itemId, fieldId, value: { text: login } } }, + }), + }); + if (!res.ok) { + console.warn(`[reconcile] ! field write HTTP ${res.status}: ${await res.text()}`); + return; + } + const json = await res.json(); + if (json.errors?.length) { + console.warn(`[reconcile] ! field write error: ${JSON.stringify(json.errors)}`); + return; + } + const item = json.data.updateProjectV2ItemFieldValue.projectV2Item; + console.log(`[reconcile] wrote Team Reviewer field = ${login} (item ${item?.id})`); +} + +async function main() { + const login = process.env.GH_ACCOUNT || process.env.GH_ORG || 'devonfw'; + const title = process.env.BOARD_TITLE || 'IDEasy board'; + const teamReviewColumn = process.env.TEAM_REVIEW_COLUMN || 'Team Review'; + const reviewerFieldName = process.env.REVIEWER_FIELD || 'Team Reviewer'; + // Takeover exemption: PRs with the configured label are left alone (see + // readPrItems / computeTarget). + const takeoverLabel = process.env.TAKEOVER_LABEL || 'takeover'; + const authorOnlyColumns = (process.env.AUTHOR_ONLY_COLUMNS || '🏗 In progress,👀 In review') + .split(',') + .map((s) => s.trim()) + .filter(Boolean); + const dryRun = (process.env.DRY_RUN ?? 'true').toLowerCase() !== 'false'; + + const cfg = { teamReviewColumn, authorOnlyColumns }; + + console.log(`[reconcile] account=${login} board="${title}" dryRun=${dryRun}`); + console.log(`[reconcile] teamReview="${teamReviewColumn}" authorOnly=[${authorOnlyColumns.join(', ')}] reviewerField="${reviewerFieldName}" takeoverLabel="${takeoverLabel}"`); + + const board = await findBoard(login, title); + console.log(`[reconcile] board id=${board.id}`); + + // The "Team Reviewer" field is optional: if it does not exist yet, the + // reconciler degrades to inference-only behaviour (no field reads/writes). + const reviewerField = await findField(board.id, reviewerFieldName); + if (!reviewerField) { + console.warn( + `[reconcile] WARNING: board has no "${reviewerFieldName}" field; running inference-only (no reviewer field will be recorded). Create a TEXT field named exactly "${reviewerFieldName}" to enable the record.`, + ); + } + + const prItems = await readPrItems(board.id, reviewerFieldName, takeoverLabel); + const prCards = prItems.filter((it) => it.statusName); // skip items without a readable Status + console.log(`[reconcile] found ${prCards.length} open, non-merged PR items to manage`); + + let changes = 0; + let skipped = 0; + let takeovers = 0; + let fieldWrites = 0; + + for (const pr of prCards) { + const target = computeTarget(pr, cfg); + if (target.kind === COLUMNS.UNMANAGED) { + skipped++; + continue; + } + if (target.kind === COLUMNS.TAKEOVER) { + // Explicitly taken-over card: leave the assignees as they are. + takeovers++; + console.log( + `[reconcile] #${pr.number} [${pr.statusName}] (takeover: assignees left as-is: ${pr.assignees.join(', ') || '(none)'})`, + ); + continue; + } + const diff = computeDiff(pr.assignees, target.target); + const willFillField = target.setField !== null && reviewerField !== null; + if (!diff.changed && !willFillField) { + continue; + } + + changes++; + const who = target.target ? ` -> ${target.target}` : ' -> (none)'; + const verb = dryRun ? 'WOULD SET' : 'SET'; + console.log( + `[reconcile] #${pr.number} [${pr.statusName}] ${pr.assignees.join(', ') || '(none)'}${who} ` + + `(author=${pr.author}, ${diff.toRemove.length} removed, ${diff.toAdd.length} added)` + + (willFillField ? ` [field: "${reviewerFieldName}" = ${target.setField}]` : ''), + ); + if (target.anomaly) { + console.warn(`[reconcile] ! ${pr.number}: ${target.anomaly}`); + } + if (!dryRun) { + // Apply the assignee change first and only then record the reviewer in + // the field, so the field never points at a reviewer who was not actually + // assigned. If the field write then fails, the card is correct and the + // field stays empty -> the next run re-infers and re-fills it (self-heals). + if (diff.changed) { + await applyAssignees(pr.repo, pr.number, diff.toAdd, diff.toRemove); + } + if (willFillField) { + fieldWrites++; + await setReviewerField(board.id, pr.itemId, reviewerField.id, target.setField); + } + } + } + + console.log( + `[reconcile] done. cards=${prCards.length} changed=${changes} skipped=${skipped} takeovers=${takeovers}` + + ` fieldWrites=${dryRun ? `(dry-run) ${fieldWrites}` : fieldWrites} mode=${dryRun ? 'dry-run' : 'apply'}`, + ); +} + +main().catch((err) => { + console.error(`[reconcile] unhandled: ${err.stack || err}`); + process.exit(2); +}); diff --git a/.github/scripts/reconcile-core.mjs b/.github/scripts/reconcile-core.mjs new file mode 100644 index 0000000000..8a5b3110a7 --- /dev/null +++ b/.github/scripts/reconcile-core.mjs @@ -0,0 +1,146 @@ +// @ts-check +// +// Pure decision logic for the "single-owner board" reconciler. +// +// Invariant being enforced: +// - In the "Team Review" column the PR has exactly ONE assignee: the team +// reviewer, taken from the board's "Team Reviewer" field. That field is the +// source of truth and is auto-filled by the bot (never typed by hand): when +// it is empty and exactly one non-author assignee exists (the team's +// self-assign habit), the bot assigns them and records them in the field. +// Because the field persists, moving a card back to Team Review re-assigns +// the same reviewer even if they were unassigned in between. +// - In every configured "author-only" column the PR has exactly ONE assignee: +// the author (the Team Reviewer field is kept as a record, not cleared). +// - Columns that are neither (e.g. New / Research / Refinement / Done) are +// left untouched so the bot never fights the team's other conventions. +// +// This file has no I/O so the rules can be unit tested in isolation. + +export const COLUMNS = Object.freeze({ + TEAM_REVIEW: 'team-review', + AUTHOR_ONLY: 'author-only', + UNMANAGED: 'unmanaged', + TAKEOVER: 'takeover', +}); + +/** + * Classify a Status option name against the configuration. + * @param {string} statusName The Status value of a card (e.g. "Team Review"). + * @param {{ teamReviewColumn: string, authorOnlyColumns: string[] }} cfg + * @returns {typeof COLUMNS[keyof typeof COLUMNS]} + */ +export function classifyStatus(statusName, cfg) { + if (statusName === cfg.teamReviewColumn) { + return COLUMNS.TEAM_REVIEW; + } + if (cfg.authorOnlyColumns.includes(statusName)) { + return COLUMNS.AUTHOR_ONLY; + } + return COLUMNS.UNMANAGED; +} + +/** + * Decide the desired assignee for a card, given its current state. + * + * In the "Team Review" column the **Team Reviewer field** is the source of + * truth for who is reviewing: + * - Field set to R -> the card's assignee is R (even if R is not currently + * assigned — this is what makes moving a card back to Team Review after + * "In progress" re-assign the original reviewer without a re-self-assign). + * - Field empty -> the reviewer is inferred as the one current assignee + * who is not the author (the team's self-assign habit). If exactly one such + * person exists, they are assigned AND the field is auto-filled so the + * review is recorded. + * + * @param {{ author: string, assignees: string[], statusName: string, + * reviewer: string, takeover?: boolean }} pr `reviewer` is the current + * Team Reviewer field value (a login, or '' when unset). `takeover` is + * set when the card carries an explicit takeover marker (a configured + * PR label), exempting it from reconciliation. + * @param {{ teamReviewColumn: string, authorOnlyColumns: string[] }} cfg + * @returns {{ kind: string, target: string | null, setField: string | null, + * anomaly: string | null }} + * `target` is the single desired assignee, or `null` when the card is in an + * unmanaged column (skip it). `setField` is the value to write to the Team + * Reviewer field (the auto-fill) or `null` to leave the field untouched. + * `anomaly` describes a data problem worth logging (never changes the safe + * fallback of keeping the author). + */ +export function computeTarget(pr, cfg) { + // Explicit takeover (PR label): the bot never touches such a + // card — e.g. a PR taken over after its author left the team. + if (pr.takeover) { + return { kind: COLUMNS.TAKEOVER, target: null, setField: null, anomaly: null }; + } + + const kind = classifyStatus(pr.statusName, cfg); + + if (kind === COLUMNS.UNMANAGED) { + // Leave unmanaged columns alone; the reviewer field is a record we don't clear. + return { kind, target: null, setField: null, anomaly: null }; + } + + if (kind === COLUMNS.AUTHOR_ONLY) { + // Author-only columns: the field is ignored for assignment but never clobbered. + const authorAssigned = pr.assignees.includes(pr.author); + if (!authorAssigned) { + // The author is absent from the card (e.g. they left the team). Keep the + // author (safe, no silent ownership transfer) and flag it so the takeover + // can be made explicit (Takeover label) or the author unassigned. + const others = pr.assignees.filter((login) => login !== pr.author); + const anomaly = + others.length === 0 + ? 'author not assigned on author-only card; re-adding author' + : `author not assigned but ${others.join(', ')} self-assigned on author-only card; kept author (add the Takeover label if ${others.join(', ')} took over)`; + return { kind, target: pr.author, setField: null, anomaly }; + } + return { kind, target: pr.author, setField: null, anomaly: null }; + } + + // TEAM_REVIEW: the Team Reviewer field is the source of truth. + const reviewer = pr.reviewer || ''; + + if (reviewer) { + // Field names someone: they are the reviewer. Assign them and (idempotently) + // leave the field as-is. + const nonAuthors = pr.assignees.filter((login) => login !== pr.author); + const other = nonAuthors.find((login) => login !== reviewer); + const anomaly = + other !== undefined + ? `field says ${reviewer} but ${other} is also self-assigned; field wins` + : null; + return { kind, target: reviewer, setField: null, anomaly }; + } + + // Field empty: infer the reviewer from the self-assign habit. + const nonAuthors = pr.assignees.filter((login) => login !== pr.author); + if (nonAuthors.length === 1) { + // Exactly one reviewer self-assigned: assign them and record it in the field. + return { kind, target: nonAuthors[0], setField: nonAuthors[0], anomaly: null }; + } + if (nonAuthors.length === 0) { + // No reviewer has self-assigned yet -> keep the author (silently). + return { kind, target: pr.author, setField: null, anomaly: null }; + } + // More than one non-author: ambiguous. Keep the author (safe) and flag it. + return { + kind, + target: pr.author, + setField: null, + anomaly: `multiple reviewers assigned (${nonAuthors.join(', ')}); kept author`, + }; +} + +/** + * Compute which assignees to add/remove so the card ends up with exactly the + * single `target` assignee. + * @param {string[]} currentAssignees + * @param {string | null} target + * @returns {{ toAdd: string[], toRemove: string[], changed: boolean }} + */ +export function computeDiff(currentAssignees, target) { + const toRemove = currentAssignees.filter((login) => login !== target); + const toAdd = target !== null && !currentAssignees.includes(target) ? [target] : []; + return { toAdd, toRemove, changed: toAdd.length > 0 || toRemove.length > 0 }; +} diff --git a/.github/scripts/reconcile-core.test.mjs b/.github/scripts/reconcile-core.test.mjs new file mode 100644 index 0000000000..04c67ff299 --- /dev/null +++ b/.github/scripts/reconcile-core.test.mjs @@ -0,0 +1,161 @@ +// @ts-check +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { + classifyStatus, + computeTarget, + computeDiff, + COLUMNS, +} from './reconcile-core.mjs'; + +// The board's real column names (matched against the Status field option names). +const CFG = { + teamReviewColumn: 'Team Review', + authorOnlyColumns: ['🏗 In progress', '👀 In review'], +}; + +// pr helper: `reviewer` is the current "Team Reviewer" field value (a login or ''). +const pr = (o) => ({ reviewer: '', ...o }); + +test('classifyStatus: Team Review is the reviewer column', () => { + assert.equal(classifyStatus('Team Review', CFG), COLUMNS.TEAM_REVIEW); +}); + +test('classifyStatus: configured columns are author-only', () => { + assert.equal(classifyStatus('🏗 In progress', CFG), COLUMNS.AUTHOR_ONLY); + assert.equal(classifyStatus('👀 In review', CFG), COLUMNS.AUTHOR_ONLY); +}); + +test('classifyStatus: unconfigured columns are unmanaged (left alone)', () => { + assert.equal(classifyStatus('🆕 New', CFG), COLUMNS.UNMANAGED); + assert.equal(classifyStatus('Research', CFG), COLUMNS.UNMANAGED); + assert.equal(classifyStatus('Refinement', CFG), COLUMNS.UNMANAGED); + assert.equal(classifyStatus('✅ Done', CFG), COLUMNS.UNMANAGED); +}); + +// --- Team Review: field is the source of truth ---------------------------- + +test('Team Review, field set + reviewer assigned -> reviewer sole assignee, field untouched', () => { + const r = computeTarget(pr({ author: 'A', assignees: ['A', 'R'], reviewer: 'R', statusName: 'Team Review' }), CFG); + assert.equal(r.kind, COLUMNS.TEAM_REVIEW); + assert.equal(r.target, 'R'); + assert.equal(r.setField, null); +}); + +test('Team Review, field set but reviewer NOT assigned -> auto-reassign reviewer (re-entry)', () => { + // Author moved the card back to Team Review after resolving comments; the + // reviewer was unassigned in "In progress". The field still remembers R. + const r = computeTarget(pr({ author: 'A', assignees: ['A'], reviewer: 'R', statusName: 'Team Review' }), CFG); + assert.equal(r.target, 'R'); + assert.equal(r.setField, null); +}); + +test('Team Review, field set to R1 but a different person self-assigned -> field wins + flag', () => { + const r = computeTarget(pr({ author: 'A', assignees: ['A', 'R2'], reviewer: 'R1', statusName: 'Team Review' }), CFG); + assert.equal(r.target, 'R1'); + assert.match(r.anomaly, /R2/); + assert.equal(r.setField, null); +}); + +test('Team Review, field empty + single self-assigner -> assign them AND auto-fill field', () => { + const r = computeTarget(pr({ author: 'A', assignees: ['A', 'M'], reviewer: '', statusName: 'Team Review' }), CFG); + assert.equal(r.kind, COLUMNS.TEAM_REVIEW); + assert.equal(r.target, 'M'); + assert.equal(r.setField, 'M'); +}); + +test('Team Review, field empty + no reviewer yet -> keep author, silent, no field write', () => { + const r = computeTarget(pr({ author: 'A', assignees: ['A'], reviewer: '', statusName: 'Team Review' }), CFG); + assert.equal(r.target, 'A'); + assert.equal(r.setField, null); + assert.equal(r.anomaly, null); +}); + +test('Team Review, field empty + author unassigned -> reassign author, no field write', () => { + const r = computeTarget(pr({ author: 'A', assignees: [], reviewer: '', statusName: 'Team Review' }), CFG); + assert.equal(r.target, 'A'); + assert.equal(r.setField, null); +}); + +test('Team Review, field empty + multiple self-assigners -> ambiguous, keep author, no field write', () => { + const r = computeTarget(pr({ author: 'A', assignees: ['B', 'C', 'A'], reviewer: '', statusName: 'Team Review' }), CFG); + assert.equal(r.target, 'A'); + assert.match(r.anomaly, /multiple reviewers/); + assert.equal(r.setField, null); +}); + +// --- Author-only / unmanaged: field is ignored for assignment, never clobbered --- + +test('Author-only columns -> author sole assignee, reviewer field left alone', () => { + const r = computeTarget(pr({ author: 'A', assignees: ['A', 'krystynaShatkovska'], reviewer: 'R', statusName: '👀 In review' }), CFG); + assert.equal(r.kind, COLUMNS.AUTHOR_ONLY); + assert.equal(r.target, 'A'); + assert.equal(r.setField, null); +}); + +test('Unmanaged columns -> no target (skip), reviewer field left alone', () => { + const r = computeTarget(pr({ author: 'MeShehi', assignees: ['majeteSil', 'MeShehi'], reviewer: 'majeteSil', statusName: '✅ Done' }), CFG); + assert.equal(r.kind, COLUMNS.UNMANAGED); + assert.equal(r.target, null); + assert.equal(r.setField, null); +}); + +// --- Takeover exemption ----------------------------------------------------- +// A card with the Takeover flag (a configured label on the PR) is exempt from +// reconciliation: the bot never touches its assignees (e.g. a PR taken over +// after its author left the team). + +test('takeover flag -> card is skipped (author-only column)', () => { + const r = computeTarget(pr({ author: 'A', assignees: ['B'], reviewer: '', statusName: '👀 In review', takeover: true }), CFG); + assert.equal(r.kind, COLUMNS.TAKEOVER); + assert.equal(r.target, null); + assert.equal(r.setField, null); + assert.equal(r.anomaly, null); +}); + +test('takeover flag -> card is skipped (Team Review, reviewer field set)', () => { + const r = computeTarget(pr({ author: 'A', assignees: ['R'], reviewer: 'R', statusName: 'Team Review', takeover: true }), CFG); + assert.equal(r.kind, COLUMNS.TAKEOVER); + assert.equal(r.target, null); + assert.equal(r.setField, null); +}); + +test('takeover flag -> card is skipped (unmanaged column)', () => { + const r = computeTarget(pr({ author: 'A', assignees: ['B'], reviewer: '', statusName: '🆕 New', takeover: true }), CFG); + assert.equal(r.kind, COLUMNS.TAKEOVER); + assert.equal(r.target, null); +}); + +test('author-only, author unassigned + one self-assigner -> still targets author, anomaly suggests the Takeover flag', () => { + const r = computeTarget(pr({ author: 'A', assignees: ['B'], reviewer: '', statusName: '🏗 In progress' }), CFG); + assert.equal(r.target, 'A'); + assert.equal(r.setField, null); + assert.match(r.anomaly, /Takeover/); +}); + +// --- computeDiff ---------------------------------------------------------- + +test('computeDiff: no-op when already at target', () => { + const d = computeDiff(['Hiepiscus'], 'Hiepiscus'); + assert.deepEqual(d, { toAdd: [], toRemove: [], changed: false }); +}); + +test('computeDiff: swaps author out and reviewer in (Team Review handoff)', () => { + const d = computeDiff(['Hiepiscus'], 'majeteSil'); + assert.deepEqual(d.toRemove, ['Hiepiscus']); + assert.deepEqual(d.toAdd, ['majeteSil']); + assert.equal(d.changed, true); +}); + +test('computeDiff: unassigns the extra assignee (In review -> author only)', () => { + const d = computeDiff(['samuelkos17', 'krystynaShatkovska'], 'samuelkos17'); + assert.deepEqual(d.toAdd, []); + assert.deepEqual(d.toRemove, ['krystynaShatkovska']); + assert.equal(d.changed, true); +}); + +test('computeDiff: null target removes everyone (defensive)', () => { + const d = computeDiff(['a', 'b'], null); + assert.deepEqual(d.toRemove, ['a', 'b']); + assert.deepEqual(d.toAdd, []); +}); diff --git a/.github/workflows/pr-assignee-reconcile.yml b/.github/workflows/pr-assignee-reconcile.yml new file mode 100644 index 0000000000..a09788b501 --- /dev/null +++ b/.github/workflows/pr-assignee-reconcile.yml @@ -0,0 +1,105 @@ +name: Reconcile PR assignees + +# Enforces the "single-owner board" invariant on the IDEasy board (Projects v2): +# - In "Team Review" a PR has exactly one assignee: the team reviewer, taken +# from the board's "Team Reviewer" text field. The field is the source of +# truth and is auto-filled by this workflow (never typed by hand): when it +# is empty and exactly one non-author assignee exists (the team's +# self-assign habit), the reviewer is assigned and their login is recorded +# in the field. Because the field persists, moving a card back to +# "Team Review" re-assigns the same reviewer without a re-self-assign. +# - In "🏗 In progress" and "👀 In review" a PR has exactly one assignee: the author. +# - Every other column (New / Research / Refinement / Done) is left untouched. +# - A PR with the TAKEOVER label (default "takeover") is exempted entirely: +# this is how the team marks a PR taken over after its author left, so the +# bot does not revert it. +# +# GitHub emits no event when a card is moved between columns, so this runs on a +# schedule and self-heals assignees to match the board. It changes PR assignees +# (Issues/PRs REST API) and writes the Team Reviewer field (GraphQL); it never +# moves cards — humans do that. Requires a TEXT field named "Team Reviewer" +# on the board (see REVIEWER_FIELD). If it is missing, the run degrades to +# inference-only and reports it. +# +# Safety: the default (and cron) behaviour is DRY RUN = report only. Enable +# writes by setting the repo VARIABLE RECONCILE_APPLY to "true", or trigger +# manually with dry_run=false. Merged / closed / dependabot PRs are ignored. + +on: + schedule: + - cron: '*/10 * * * *' # every 10 minutes + workflow_dispatch: + inputs: + dry_run: + description: 'Report only (true) or apply assignee changes (false)' + type: choice + default: 'true' + options: [ 'true', 'false' ] + +permissions: + contents: read + # The Projects permission is named "repository-projects" (there is no + # "projects" key); its values are read/write/none ("write-all" only exists + # for the "contents" scope). "write" grants the write:project scope that + # the Team Reviewer field write (updateProjectV2ItemFieldValue) needs. + repository-projects: write + issues: write + pull-requests: write + +env: + # Token used for the API calls. The script reads GH_TOKEN first. If a + # RECONCILE_PAT secret is present (a PAT with the "project" scope) it is used; + # otherwise it falls back to the built-in GITHUB_TOKEN. + # WHY a PAT is needed for fork / personal-board testing: the GITHUB_TOKEN is + # scoped to THIS repository and cannot see a user account's project board + # (boards under /users//projects/...), nor write to it. A personal PAT + # with the "project" scope can read AND write your own boards. In production + # the RECONCILE_PAT secret is absent, so this resolves to the GITHUB_TOKEN. + GH_TOKEN: ${{ secrets.RECONCILE_PAT || secrets.GITHUB_TOKEN }} + # Account that owns the board. This is an org (devonfw); the script also + # supports a USER account, which is how you can dry-run it against a + # personal / fork board before it touches the real one. + GH_ACCOUNT: devonfw + BOARD_TITLE: 'IDEasy board' + TEAM_REVIEW_COLUMN: 'Team Review' + # Matches the board's Status option names EXACTLY (matched literally), so if + # a column is renamed on the board these must be updated too. + AUTHOR_ONLY_COLUMNS: '🏗 In progress,👀 In review' + TAKEOVER_LABEL: 'takeover' + # Repo VARIABLE RECONCILE_APPLY (set to "true") turns on writes. Default false. + RECONCILE_APPLY: ${{ vars.RECONCILE_APPLY }} + +jobs: + reconcile: + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v7 + + - name: Decide dry-run vs apply + id: mode + env: + DISPATCH: ${{ github.event_name }} + INPUT_DRY_RUN: ${{ github.event.inputs.dry_run }} + APPLY_VAR: ${{ env.RECONCILE_APPLY }} + run: | + # Manual dispatch with dry_run=false forces a real run. Otherwise the + # RECONCILE_APPLY variable (default empty) decides. Anything other than + # the literal "true" keeps it in the safe dry-run mode. + if [ "$DISPATCH" = "workflow_dispatch" ] && [ "$INPUT_DRY_RUN" = "false" ]; then + APPLY=true + elif [ "$APPLY_VAR" = "true" ]; then + APPLY=true + else + APPLY=false + fi + if [ "$APPLY" = "true" ]; then + echo "dry_run=false" >> "$GITHUB_OUTPUT" + else + echo "dry_run=true" >> "$GITHUB_OUTPUT" + fi + + - name: Reconcile assignees + env: + DRY_RUN: ${{ steps.mode.outputs.dry_run }} + run: node .github/scripts/pr-assignee-reconcile.mjs