diff --git a/.github/workflows/issue-version-triage.yml b/.github/workflows/issue-version-triage.yml index 46193c23e..188c76a48 100644 --- a/.github/workflows/issue-version-triage.yml +++ b/.github/workflows/issue-version-triage.yml @@ -22,17 +22,29 @@ jobs: script: | const issue = context.payload.issue; const labelNames = new Set((issue.labels || []).map((label) => label.name)); - - // Restrict automation to bug reports only. - if (!labelNames.has("bug")) { - core.info("Issue is not labeled bug. Skipping version triage."); - return; - } - const VERSION_LABEL_PREFIX = "affects-"; const NEEDS_VERSION_LABEL = "needs-version-info"; const RETEST_LABEL = "needs-retest-on-latest"; const RETEST_COMMENT_MARKER = ""; + const BUG_LABEL = "bug"; + const DOCS_LABEL = "documentation"; + const ENHANCEMENT_LABEL = "enhancement"; + + function escapeRegExp(value) { + return String(value || "").replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + } + + function extractSectionValue(body, heading) { + if (!body) return null; + const pattern = new RegExp( + `^#+\\s*${escapeRegExp(heading)}\\s*$\\n+([\\s\\S]*?)(?=^#+\\s+|$)`, + "im" + ); + const match = body.match(pattern); + if (!match) return null; + const value = match[1].trim(); + return value || null; + } function normalizeVersion(value) { if (!value) return null; @@ -40,34 +52,60 @@ jobs: return match ? match[1] : null; } - function extractPulseVersion(body) { - if (!body) return null; + function extractPulseVersion(title, body) { + if (body) { + const lines = body.split(/\r?\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; - const lines = body.split(/\r?\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; - - // Check nearby lines because issue forms often put values on the next line. - 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; + // Check nearby lines because issue forms often put values on the next line. + 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; + } } } + + // Fallback: issue-form block headed by "Pulse version". + 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]); + + // Final fallback for older templates with "Pulse | Version: [6.0.0]". + 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]); } - // Fallback: issue-form block headed by "Pulse version". - 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]); + // Maintainer-created split issues often carry the exact version in the title. + return normalizeVersion(title); + } - // Final fallback for older templates with "Pulse | Version: [5.1.2]". - 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]); + 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; } @@ -117,7 +155,12 @@ jobs: return comments.some((comment) => (comment.body || "").includes(RETEST_COMMENT_MARKER)); } - const reportedVersion = extractPulseVersion(issue.body); + const action = context.payload.action || ""; + const authorAssociation = String(issue.author_association || "").toUpperCase(); + const reporterIsMaintainer = ["OWNER", "MEMBER", "COLLABORATOR"].includes(authorAssociation); + const allowRetestComment = (action === "opened" || action === "reopened") && !reporterIsMaintainer; + + const reportedVersion = extractPulseVersion(issue.title, issue.body); core.info(`Reported Pulse version: ${reportedVersion || "not found"}`); let latestVersion = null; @@ -133,6 +176,25 @@ jobs: core.info(`Latest stable release: ${latestVersion || "unknown"}`); const nextLabels = new Set(labelNames); + const v6FeedbackClass = classifyV6FeedbackType(issue.body); + if (v6FeedbackClass) { + core.info(`Detected v6 feedback issue class: ${v6FeedbackClass}`); + nextLabels.add(v6FeedbackClass); + } + + const isBugLike = nextLabels.has(BUG_LABEL); + if (!isBugLike) { + core.info("Issue is not bug-like after classification. Skipping version triage."); + if (v6FeedbackClass && !labelNames.has(v6FeedbackClass)) { + 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( @@ -160,7 +222,7 @@ jobs: if (comparison !== null && comparison < 0) { nextLabels.add(RETEST_LABEL); - if (!(await hasRetestComment(issue.number))) { + if (allowRetestComment && !(await hasRetestComment(issue.number))) { const body = [ RETEST_COMMENT_MARKER, "Thanks for the report.",