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 = ""; const CLOSE_COMMENT_MARKER = ""; 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(/|$)/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, }, };