Files
pulse/.github/scripts/issue-version-triage.cjs
pulse-triage[bot] ed41610c41 fix(triage): keep upgrade source versions out of structured reports
Issue #1913 reports an incomplete v6 running version but names its old v5 image in the upgrade title. Falling back to that title incorrectly starts old-version retest handling. Treat the structured version field as authoritative and stop at the next heading, requesting exact version information when incomplete. Cover the parser and mocked label/comment flows without changing public issue state.

Change-source: pulse-maintainer
2026-09-05 20:01:58 +01:00

532 lines
15 KiB
JavaScript

const VERSION_LABEL_PREFIX = "affects-";
const NEEDS_VERSION_LABEL = "needs-version-info";
const RETEST_LABEL = "needs-retest-on-latest";
const NEEDS_DECOMPOSITION_LABEL = "needs-decomposition";
const RETEST_COMMENT_MARKER = "<!-- issue-version-triage:v1 -->";
const CLOSE_COMMENT_MARKER = "<!-- issue-timeout-close:v1 -->";
const TRIAGE_FOOTER =
"[How Pulse handles triage](https://github.com/rcourtman/Pulse/blob/main/docs/AI_TRANSPARENCY.md)";
const BUG_LABEL = "bug";
const DOCS_LABEL = "documentation";
const ENHANCEMENT_LABEL = "enhancement";
const MAINTAINER_AUTHOR_ASSOCIATIONS = new Set(["OWNER", "MEMBER", "COLLABORATOR"]);
const RETEST_COMMENT_GRACE_MS = 5 * 60 * 1000;
const RETEST_COMMENT_LOOKBACK_MS = 24 * 60 * 60 * 1000;
function escapeRegExp(value) {
return String(value || "").replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
function extractSectionValue(body, heading, followingHeadings = []) {
if (!body) return null;
const boundary = followingHeadings.length
? followingHeadings.map(escapeRegExp).join("|")
: "[^\\n]+";
const pattern = new RegExp(
`^#+\\s*${escapeRegExp(heading)}\\s*$\\n+([\\s\\S]*?)(?=^#+\\s*(?:${boundary})\\s*$|$)`,
"im"
);
const match = body.match(pattern);
if (!match) return null;
const value = match[1].trim();
return value || null;
}
function stripHTMLComments(value) {
let stripped = String(value || "");
let previous;
do {
previous = stripped;
stripped = stripped.replace(/<!--[\s\S]*?(?:-->|$)/g, "");
} while (stripped !== previous);
return stripped;
}
function classifyAdditionalActionableTopics(body) {
const value = extractSectionValue(body, "Additional actionable topics", [
"Pulse version",
"Additional context",
"Logs, screenshots, or diagnostics",
"Confirmations",
]);
if (value === null) return null;
const normalized = stripHTMLComments(value)
.trim()
.toLowerCase()
.replace(/[.!]+$/g, "");
if (!normalized) return false;
return !new Set([
"_no response_",
"n/a",
"na",
"no",
"none",
"none known",
"not applicable",
]).has(normalized);
}
function normalizeVersion(value) {
if (!value) return null;
const match = String(value).match(/\bv?(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)\b/i);
return match ? match[1] : null;
}
function extractPulseVersion(title, body) {
if (body) {
const lines = body.split(/\r?\n/);
const versionHeading = lines.findIndex((line) =>
/^#{1,6}[ \t]+Pulse[ \t]+version[ \t]*$/i.test(line)
);
if (versionHeading !== -1) {
// The explicit running-version field is authoritative, even when it is
// incomplete. A title may name the old image in an upgrade report, and
// neighbouring fields may contain an unrelated agent version.
const value = [];
for (let i = versionHeading + 1; i < lines.length; i += 1) {
if (/^#{1,6}[ \t]+/.test(lines[i])) break;
value.push(lines[i]);
}
return normalizeVersion(stripHTMLComments(value.join("\n")));
}
for (let i = 0; i < lines.length; i += 1) {
const line = lines[i] || "";
if (/pulse\s*(\||-)?\s*version/i.test(line)) {
const inlineVersion = normalizeVersion(line);
if (inlineVersion) return inlineVersion;
for (let j = i + 1; j < Math.min(i + 6, lines.length); j += 1) {
const nearby = (lines[j] || "").trim();
if (!nearby) continue;
const nearbyVersion = normalizeVersion(nearby);
if (nearbyVersion) return nearbyVersion;
}
}
}
const headingMatch = body.match(
/#+\s*Pulse version[\s\S]{0,80}?(\bv?\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?\b)/i
);
if (headingMatch) return normalizeVersion(headingMatch[1]);
const legacyMatch = body.match(
/pulse\s*\|?\s*version[^\n]*?(\bv?\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?\b)/i
);
if (legacyMatch) return normalizeVersion(legacyMatch[1]);
}
return normalizeVersion(title);
}
function classifyV6FeedbackType(body) {
const feedbackType = extractSectionValue(body, "Feedback type");
if (!feedbackType) return null;
const normalized = feedbackType.toLowerCase();
if (
normalized.includes("bug") ||
normalized.includes("regression") ||
normalized.includes("upgrade / migration issue") ||
normalized.includes("performance issue")
) {
return BUG_LABEL;
}
if (normalized.includes("documentation issue")) {
return DOCS_LABEL;
}
if (
normalized.includes("ux / workflow friction") ||
normalized.includes("other actionable feedback")
) {
return ENHANCEMENT_LABEL;
}
return null;
}
function parseCore(version) {
const match = String(version || "").match(/^(\d+)\.(\d+)\.(\d+)/);
if (!match) return null;
return [Number(match[1]), Number(match[2]), Number(match[3])];
}
function compareCore(a, b) {
const av = parseCore(a);
const bv = parseCore(b);
if (!av || !bv) return null;
for (let i = 0; i < 3; i += 1) {
if (av[i] > bv[i]) return 1;
if (av[i] < bv[i]) return -1;
}
return 0;
}
async function ensureLabel(github, context, name, color, description) {
try {
await github.rest.issues.getLabel({
owner: context.repo.owner,
repo: context.repo.repo,
name,
});
} catch (error) {
if (error.status !== 404) throw error;
await github.rest.issues.createLabel({
owner: context.repo.owner,
repo: context.repo.repo,
name,
color,
description,
});
}
}
async function getIssueComments(github, context, issueNumber) {
return github.paginate(github.rest.issues.listComments, {
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issueNumber,
per_page: 100,
});
}
function hasRetestComment(comments) {
return comments.some((comment) => (comment.body || "").includes(RETEST_COMMENT_MARKER));
}
function hasMaintainerResponse(comments) {
return comments.some((comment) =>
MAINTAINER_AUTHOR_ASSOCIATIONS.has(
String(comment.author_association || "").toUpperCase()
)
);
}
async function getLatestStableVersion(github, context, core) {
try {
const latest = await github.rest.repos.getLatestRelease({
owner: context.repo.owner,
repo: context.repo.repo,
});
return normalizeVersion(latest.data.tag_name || latest.data.name || "");
} catch (error) {
core.warning(`Could not determine latest release: ${error.message}`);
return null;
}
}
function buildTriageState(issue, core, latestVersion) {
const labelNames = new Set((issue.labels || []).map((label) => label.name));
const nextLabels = new Set(labelNames);
const v6FeedbackClass = classifyV6FeedbackType(issue.body);
if (v6FeedbackClass) {
core.info(`Detected v6 feedback issue class: ${v6FeedbackClass}`);
nextLabels.add(v6FeedbackClass);
}
const hasAdditionalActionableTopics = classifyAdditionalActionableTopics(issue.body);
if (hasAdditionalActionableTopics === true) {
core.info("Issue declares additional actionable topics; decomposition is required.");
nextLabels.add(NEEDS_DECOMPOSITION_LABEL);
} else if (hasAdditionalActionableTopics === false) {
nextLabels.delete(NEEDS_DECOMPOSITION_LABEL);
}
const reportedVersion = extractPulseVersion(issue.title, issue.body);
core.info(`Reported Pulse version: ${reportedVersion || "not found"}`);
core.info(`Latest stable release: ${latestVersion || "unknown"}`);
return {
labelNames,
nextLabels,
reportedVersion,
v6FeedbackClass,
hasAdditionalActionableTopics,
isBugLike: nextLabels.has(BUG_LABEL),
comparison:
reportedVersion && latestVersion ? compareCore(reportedVersion, latestVersion) : null,
};
}
function keepOnlyReportedVersionLabel(nextLabels, reportedVersion) {
const keep = `${VERSION_LABEL_PREFIX}${reportedVersion}`;
for (const label of [...nextLabels]) {
if (
/^affects-\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(label) &&
label !== keep
) {
nextLabels.delete(label);
}
}
}
function withTriageFooter(lines) {
const body = Array.isArray(lines) ? lines.join("\n") : String(lines || "");
return `${body.trimEnd()}\n\n${TRIAGE_FOOTER}`;
}
function buildRetestCommentBody(reportedVersion, latestVersion) {
return withTriageFooter([
RETEST_COMMENT_MARKER,
"Thanks for the report.",
"",
`I can see this was reported on **v${reportedVersion}**, while the latest stable release is **v${latestVersion}**.`,
`Please retest on **v${latestVersion}** and comment with:`,
"",
"- whether the issue still reproduces",
"- updated logs/diagnostics",
"- exact running image tag or digest",
"",
"If there is no reporter follow-up after 7 days, this issue may be auto-closed until new confirmation is provided.",
"",
"If it still reproduces on the latest version, I will keep this open as an active regression.",
]);
}
function buildTimeoutCloseCommentBody(staleDays) {
return withTriageFooter([
CLOSE_COMMENT_MARKER,
`Closing due to missing reporter retest confirmation for ${staleDays} days.`,
"",
"If this still reproduces on the latest stable release, comment with updated version details and logs and I will reopen.",
]);
}
function canPostRetestComment(issue, action) {
// "opened" only: non-collaborator reporters cannot reopen maintainer-closed
// issues, so a "reopened" event is a deliberate maintainer decision made with
// context. Posting retest boilerplate there contradicts the maintainer and
// plants the auto-close marker on an issue they chose to keep open.
const authorAssociation = String(issue.author_association || "").toUpperCase();
return (
action === "opened" && !MAINTAINER_AUTHOR_ASSOCIATIONS.has(authorAssociation)
);
}
async function syncLabels({ github, context, core }) {
const issue = context.payload.issue;
const latestVersion = await getLatestStableVersion(github, context, core);
const {
labelNames,
nextLabels,
reportedVersion,
v6FeedbackClass,
hasAdditionalActionableTopics,
isBugLike,
comparison,
} = buildTriageState(issue, core, latestVersion);
if (hasAdditionalActionableTopics === true) {
await ensureLabel(
github,
context,
NEEDS_DECOMPOSITION_LABEL,
"fbca04",
"Issue declares additional actionable topics that need linked dispositions"
);
}
if (!isBugLike) {
core.info("Issue is not bug-like after classification. Skipping version triage.");
const labelsChanged =
labelNames.size !== nextLabels.size ||
[...labelNames].some((label) => !nextLabels.has(label));
if (labelsChanged) {
await github.rest.issues.setLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issue.number,
labels: [...nextLabels].sort(),
});
}
return;
}
if (reportedVersion) {
await ensureLabel(
github,
context,
`${VERSION_LABEL_PREFIX}${reportedVersion}`,
"0e8a16",
`Bug reported against Pulse ${reportedVersion}`
);
await ensureLabel(
github,
context,
RETEST_LABEL,
"d93f0b",
"Reporter should retest on current latest stable release"
);
keepOnlyReportedVersionLabel(nextLabels, reportedVersion);
nextLabels.add(`${VERSION_LABEL_PREFIX}${reportedVersion}`);
nextLabels.delete(NEEDS_VERSION_LABEL);
if (comparison !== null && comparison < 0) {
nextLabels.add(RETEST_LABEL);
} else {
nextLabels.delete(RETEST_LABEL);
}
} else {
await ensureLabel(
github,
context,
NEEDS_VERSION_LABEL,
"fbca04",
"Issue is missing required Pulse version metadata"
);
nextLabels.add(NEEDS_VERSION_LABEL);
nextLabels.delete(RETEST_LABEL);
}
await github.rest.issues.setLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issue.number,
labels: [...nextLabels].sort(),
});
}
async function postRetestCommentForIssue({
github,
context,
core,
issue,
latestVersion,
}) {
const { reportedVersion, isBugLike, comparison } = buildTriageState(
issue,
core,
latestVersion
);
if (!isBugLike) {
core.info("Issue is not bug-like after classification. Skipping public retest guidance.");
return false;
}
if (!reportedVersion) {
core.info("Issue is missing Pulse version metadata. Skipping public retest guidance.");
return false;
}
if (comparison === null || comparison >= 0) {
core.info("Issue is already on the latest stable core or newer. Skipping public retest guidance.");
return false;
}
const comments = await getIssueComments(github, context, issue.number);
if (hasRetestComment(comments)) {
core.info("Retest guidance comment already exists.");
return false;
}
if (hasMaintainerResponse(comments)) {
core.info(
"A maintainer has already responded. Skipping generic retest guidance."
);
return false;
}
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issue.number,
body: buildRetestCommentBody(reportedVersion, latestVersion),
});
return true;
}
async function postRetestComment({ github, context, core }) {
const issue = context.payload.issue;
const action = context.payload.action || "";
if (!canPostRetestComment(issue, action)) {
core.info("Public retest guidance is disabled for this issue event.");
return;
}
const latestVersion = await getLatestStableVersion(github, context, core);
await postRetestCommentForIssue({
github,
context,
core,
issue,
latestVersion,
});
}
async function postEligibleRetestComments({
github,
context,
core,
nowMs = Date.now(),
graceMs = RETEST_COMMENT_GRACE_MS,
lookbackMs = RETEST_COMMENT_LOOKBACK_MS,
}) {
const issues = await github.paginate(github.rest.issues.listForRepo, {
owner: context.repo.owner,
repo: context.repo.repo,
state: "open",
sort: "created",
direction: "desc",
since: new Date(nowMs - lookbackMs).toISOString(),
per_page: 100,
});
const latestVersion = await getLatestStableVersion(github, context, core);
let eligibleCount = 0;
let postedCount = 0;
for (const issue of issues) {
if (issue.pull_request || !canPostRetestComment(issue, "opened")) continue;
const createdMs = new Date(issue.created_at || "").getTime();
if (!Number.isFinite(createdMs)) {
core.warning(`Issue #${issue.number} has an invalid created_at value.`);
continue;
}
const ageMs = nowMs - createdMs;
if (ageMs < graceMs || ageMs > lookbackMs) continue;
eligibleCount += 1;
const posted = await postRetestCommentForIssue({
github,
context,
core,
issue,
latestVersion,
});
if (posted) postedCount += 1;
}
core.info(
`Retest guidance sweep complete: eligible=${eligibleCount}, posted=${postedCount}.`
);
return { eligibleCount, postedCount };
}
module.exports = {
postEligibleRetestComments,
syncLabels,
postRetestComment,
internals: {
BUG_LABEL,
CLOSE_COMMENT_MARKER,
DOCS_LABEL,
ENHANCEMENT_LABEL,
NEEDS_DECOMPOSITION_LABEL,
NEEDS_VERSION_LABEL,
RETEST_COMMENT_MARKER,
RETEST_COMMENT_GRACE_MS,
RETEST_COMMENT_LOOKBACK_MS,
RETEST_LABEL,
TRIAGE_FOOTER,
VERSION_LABEL_PREFIX,
buildRetestCommentBody,
buildTimeoutCloseCommentBody,
buildTriageState,
canPostRetestComment,
classifyAdditionalActionableTopics,
classifyV6FeedbackType,
compareCore,
extractPulseVersion,
normalizeVersion,
},
};