mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-11-02 16:43:51 +00:00
2eb1cc59f3
Recognise existing bug and decomposition labels regardless of case, retire missing-version labels by provider identity, and preserve observed Community review removals. Keep raw label names for writes and exact live-state fences; add coupled regressions for metadata, idempotence and intervening edits. Contract-Neutral: Silent issue metadata reconciliation and regression tests only; no product API, workflow permission, release policy or subsystem contract change. Change-source: pulse-maintainer
752 lines
32 KiB
JavaScript
752 lines
32 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 BUG_LABEL = "bug";
|
|
const DOCS_LABEL = "documentation";
|
|
const ENHANCEMENT_LABEL = "enhancement";
|
|
|
|
function escapeRegExp(value) {
|
|
return String(value || "").replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
}
|
|
|
|
// Use the same locked GFM lexer as Pulse's Markdown surfaces, without
|
|
// rendering or executing any issue content. Only top-level declarations count.
|
|
const { Lexer, lexer } = require("marked");
|
|
|
|
// Keep one immutable Markdown snapshot per classification decision. Reports can
|
|
// contain long copied logs; reparsing that body for each field adds work without
|
|
// adding evidence. Never share this context across reports or event revisions.
|
|
function parseReportMarkdown(body) {
|
|
const tokens = lexer(String(body || ""), { gfm: true });
|
|
return { lines: projectDeclarationLines(tokens), links: tokens.links };
|
|
}
|
|
|
|
function lexFieldValue(value, report) {
|
|
// Reference links can be defined anywhere in the report. Extracting a field
|
|
// must not discard that context and turn visible declarations into missing
|
|
// versions or invented topics. Use only the locked parser's definitions;
|
|
// destinations are never fetched or treated as declaration text.
|
|
const fieldLexer = new Lexer({ gfm: true });
|
|
// Field lexing can add definitions. Do not let it mutate the shared report
|
|
// definitions or change the meaning of a sibling field.
|
|
fieldLexer.tokens.links = Object.assign(Object.create(null), report.links);
|
|
return fieldLexer.lex(value);
|
|
}
|
|
|
|
function inlineProjection(tokens) {
|
|
return (tokens || []).map((token) => {
|
|
const raw = token.raw || "";
|
|
// Keep line positions when removing hidden comments. A comment inside a
|
|
// code span is a literal, not an HTML token, and remains evidence.
|
|
if (token.type === "html" && /^<!--/.test(raw)) {
|
|
return raw.replace(/[^\r\n]/g, "");
|
|
}
|
|
if (token.tokens && typeof token.text === "string") {
|
|
const offset = raw.indexOf(token.text);
|
|
if (offset !== -1) {
|
|
return raw.slice(0, offset) + inlineProjection(token.tokens) +
|
|
raw.slice(offset + token.text.length);
|
|
}
|
|
}
|
|
return raw;
|
|
}).join("");
|
|
}
|
|
|
|
function projectDeclarationLines(tokens) {
|
|
const lines = [];
|
|
const append = (text, declaration = false, metadata = text, fieldText = text) => {
|
|
const visible = text.split(/\r?\n/);
|
|
const declared = metadata.split(/\r?\n/);
|
|
const field = fieldText.split(/\r?\n/);
|
|
visible.forEach((value, i) => lines.push({
|
|
text: value,
|
|
metadata: declared[i] || "",
|
|
fieldText: field[i] || "",
|
|
delimiter: false,
|
|
declaration,
|
|
heading: null,
|
|
}));
|
|
};
|
|
for (const token of tokens) {
|
|
if (token.type === "heading") {
|
|
const text = inlineProjection(token.tokens).trim();
|
|
lines.push({ text, metadata: text, delimiter: false, declaration: true,
|
|
// Field names follow their visible labels, just like field values.
|
|
// Ordinary emphasis or a release/help link must not hide a declared
|
|
// topic or let the next form field become part of that topic. Keep
|
|
// literal code, images, struck-out text and raw HTML non-declarative.
|
|
heading: inlineDeclarationText(token.tokens, true).trim(), headingDepth: token.depth });
|
|
} else if (token.type === "paragraph") {
|
|
// Legacy field labels follow their visible emphasis/link text too.
|
|
// Mask every code line for declaration detection, but retain literal
|
|
// code values separately once a real field label has been established.
|
|
append(inlineProjection(token.tokens), true, inlineDeclarationText(token.tokens, false, true),
|
|
inlineDeclarationText(token.tokens, true));
|
|
} else if (token.type === "code") {
|
|
// A real version field may have a fenced or indented value. Its contents
|
|
// remain a value, never new metadata or a section boundary.
|
|
append(token.text);
|
|
} else if (token.type === "list") {
|
|
// Preserve legacy "- **Pulse version**: ..." declarations. Only an
|
|
// item's first line can declare one; lazy/nested copied content cannot.
|
|
for (const item of token.items) {
|
|
const first = item.tokens[0];
|
|
if (first && first.type === "text" && first.tokens) {
|
|
const text = inlineProjection(first.tokens);
|
|
const metadata = inlineDeclarationText(first.tokens, false, true);
|
|
const fieldText = inlineDeclarationText(first.tokens, true);
|
|
append(text.split(/\r?\n/)[0], true, metadata.split(/\r?\n/)[0], fieldText.split(/\r?\n/)[0]);
|
|
append(text.split(/\r?\n/).slice(1).join("\n"));
|
|
for (const nested of item.tokens.slice(1)) append(nested.raw || "");
|
|
} else {
|
|
append(item.raw);
|
|
}
|
|
}
|
|
} else if (token.type === "def" ||
|
|
(token.type === "html" && /^\s*<!--/.test(token.raw))) {
|
|
// Reference definitions resolve visible labels but do not render as
|
|
// field content. Their URLs must not invent secondary issue topics.
|
|
append("");
|
|
} else {
|
|
// Block quotes (including lazy continuation), HTML, tables and nested
|
|
// examples are evidence, not top-level form structure.
|
|
append(token.raw || "");
|
|
}
|
|
}
|
|
return lines;
|
|
}
|
|
|
|
function firstFieldValue(lines) {
|
|
const value = lines.find((line) => !line.delimiter && line.text.trim());
|
|
return value ? value.text.trim() : "";
|
|
}
|
|
|
|
function extractSectionValues(body, heading, followingHeadings = [], report = parseReportMarkdown(body)) {
|
|
if (!body) return [];
|
|
const lines = report.lines;
|
|
const pattern = new RegExp(`^${escapeRegExp(heading)}$`, "i");
|
|
const boundary = followingHeadings.length
|
|
? new RegExp(`^(?:${[heading, ...followingHeadings].map(escapeRegExp).join("|")})$`, "i") : null;
|
|
const values = [];
|
|
for (let start = 0; start < lines.length; start += 1) {
|
|
if (lines[start].heading === null || !pattern.test(lines[start].heading)) continue;
|
|
let end = start + 1;
|
|
while (end < lines.length && !(lines[end].heading !== null &&
|
|
(!boundary || (lines[end].headingDepth <= lines[start].headingDepth &&
|
|
boundary.test(lines[end].heading))))) {
|
|
end += 1;
|
|
}
|
|
values.push(lines.slice(start + 1, end).map((line) => line.text).join("\n").trim());
|
|
// A deeper, form-named subheading is part of this value, not another
|
|
// declaration. Only resume at the boundary that ended the field.
|
|
start = end - 1;
|
|
}
|
|
return values;
|
|
}
|
|
|
|
function extractSectionValue(body, heading, followingHeadings = [], report = parseReportMarkdown(body)) {
|
|
const values = extractSectionValues(body, heading, followingHeadings, report);
|
|
// An empty declared field differs from a legacy report with no field.
|
|
return values.length ? values[0] : null;
|
|
}
|
|
|
|
function additionalActionableTopicDeclarations(body, report = parseReportMarkdown(body)) {
|
|
// Edited reports can move this field beside any other form field. Context
|
|
// from those fields must not invent a topic or reopen a completed review.
|
|
// Deeper subheadings still belong to the topic, even with a familiar name.
|
|
const values = extractSectionValues(body, "Additional actionable topics", [
|
|
"Describe the bug",
|
|
"Steps or original sequence",
|
|
"Expected behavior",
|
|
"Feedback type",
|
|
"Pulse version",
|
|
"Last known working Pulse version",
|
|
"Agent version",
|
|
"Image tag or digest",
|
|
"Running Pulse container image tag or digest",
|
|
"Install path",
|
|
"Installation type",
|
|
"OS / environment",
|
|
"Installer or helper source",
|
|
"Upgrade attempt and recovery",
|
|
"License tier",
|
|
"What happened?",
|
|
"Steps or original sequence / usage context",
|
|
"What did you expect instead?",
|
|
"Additional context",
|
|
"Logs, screenshots, or diagnostics",
|
|
"Confirmations",
|
|
"Problem",
|
|
"Current workflow",
|
|
"Desired outcome",
|
|
"Alternatives considered",
|
|
], report);
|
|
if (!values.length) return null;
|
|
// A reporter may append a second field rather than replace the first.
|
|
// Assess each independently: neither an earlier None nor a later empty
|
|
// field can erase a task. Empty/identical copies and field order alone do
|
|
// not create a new task after Community has completed its review.
|
|
return [...new Set(values.filter((value) => isActionableTopicValue(value, report))
|
|
.map((value) => value.replace(/\s+/g, " ").trim()))].sort();
|
|
}
|
|
|
|
function classifyAdditionalActionableTopics(body, report = parseReportMarkdown(body)) {
|
|
const declarations = additionalActionableTopicDeclarations(body, report);
|
|
return declarations === null ? null : declarations.length > 0;
|
|
}
|
|
|
|
function isActionableTopicValue(value, report) {
|
|
// Ordinary inline formatting does not turn "None" into a second topic.
|
|
// Keep block evidence, images and any qualifying text intact for review;
|
|
// only a single paragraph can supply an explicit no-topic response.
|
|
const blocks = lexFieldValue(value, report);
|
|
const text = blocks.length === 1 && blocks[0].type === "paragraph"
|
|
? inlineDeclarationText(blocks[0].tokens) : value;
|
|
const normalized = text
|
|
.trim()
|
|
.toLowerCase()
|
|
.replace(/[.!]+$/g, "");
|
|
if (!normalized || !/[\p{L}\p{N}]/u.test(normalized)) return false;
|
|
|
|
return !new Set([
|
|
"_no response_",
|
|
"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 declaredVersionIsTransition(value) {
|
|
const first = normalizeVersion(value);
|
|
if (!first) return false;
|
|
const remaining = value.slice(value.indexOf(first) + first.length);
|
|
// A source -> target sequence is not a declaration of the failing runtime.
|
|
// Neither endpoint proves that the upgrade completed or which version was
|
|
// observed after recovery. Do not infer either one, or mine another field.
|
|
return /^[ \t]*(?:[-=]>|[→←↔]|&(?:rarr|larr|harr|#8594|#x2192);|to\b)[ \t]*(?:v?\d|(?:pulse(?:[ \t]+pro)?|server)[ \t]+v?\d|(?:[a-z0-9._/-]+\/)?pulse(?:-pro)?[:_-]v?\d|unknown\b|latest\b)/i.test(remaining);
|
|
}
|
|
|
|
function inlineDeclarationText(tokens, preserveCode = false, maskCode = false) {
|
|
return (tokens || []).map((token) => {
|
|
// A link's visible label is evidence; its destination and title are not.
|
|
// Unwrap only ordinary inline formatting, not images, struck-out values
|
|
// or HTML. Code spans keep their literal text rather than parsing links
|
|
// or comments inside them.
|
|
if (["link", "strong", "em"].includes(token.type)) {
|
|
return inlineDeclarationText(token.tokens, preserveCode, maskCode);
|
|
}
|
|
if (token.type === "html" && /^<!--/.test(token.raw || "")) {
|
|
return token.raw.replace(/[^\r\n]/g, "");
|
|
}
|
|
if (maskCode && token.type === "codespan") return token.raw.replace(/[^\r\n]/g, " ");
|
|
if (preserveCode && token.type === "codespan") return token.raw;
|
|
if (["codespan", "escape"].includes(token.type)) return token.text;
|
|
return token.raw || "";
|
|
}).join("");
|
|
}
|
|
|
|
function normalizeLegacyVersionValue(value, report) {
|
|
const raw = String(value).trim();
|
|
const blocks = lexFieldValue(raw, report);
|
|
const text = blocks.length === 1 && blocks[0].type === "paragraph"
|
|
? inlineDeclarationText(blocks[0].tokens) : raw;
|
|
const visible = text.trim().replace(/^[`*_]+/, "");
|
|
// Failed-install forms explicitly accept an attempted server archive. Its
|
|
// packaging suffix is not part of the release version. Recognise only the
|
|
// Community and Pro server archive names; companion binaries and signatures
|
|
// cannot establish the attempted server executable's version.
|
|
const filename = visible.match(/^pulse-[^\s`*_]+/i)?.[0];
|
|
if (filename && /\.(?:tar\.gz|tgz|zip|exe|sha256|sig|sshsig)(?:\.|$)/i.test(filename)) {
|
|
if (declaredVersionIsTransition(visible)) return null;
|
|
const archive = filename.match(
|
|
/^pulse-(?:pro-)?v?(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)\.(?:tar\.gz|tgz)$/i
|
|
);
|
|
return archive ? normalizeVersion(archive[1].replace(
|
|
/-linux-(?:386|amd64|arm64|armv6|armv7)$/i, ""
|
|
)) : null;
|
|
}
|
|
// Keep an explicitly shared server/agent version (as in #1788), without
|
|
// mining a sentence such as "unknown; agent version 6.5.0" for a number.
|
|
// The complete version must follow its server prefix immediately. An
|
|
// unversioned/incomplete image or a companion binary cannot borrow an
|
|
// agent/platform number later in the value (or one in the registry name).
|
|
const declared = visible.match(
|
|
/^(?:(?:pulse|server(?:[ \t]*\+[ \t]*pulse-agent)?)[ \t]+|(?:[a-z0-9._/-]+\/)?pulse(?:-pro)?:[ \t]*|pulse(?:-pro)?[-_])?v?(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)\b/i
|
|
);
|
|
if (!declared) return null;
|
|
const version = declared[1];
|
|
return declaredVersionIsTransition(version + visible.slice(declared[0].length))
|
|
? null : normalizeVersion(version);
|
|
}
|
|
|
|
function extractTitlePulseVersion(title) {
|
|
const text = String(title || "").trim();
|
|
// A starting version in an upgrade title is not a running declaration.
|
|
if (/\b(?:upgrad(?:e|ed|ing)|migrat(?:e|ed|ing))\b[^\n]*\bfrom\b/i.test(text)) return null;
|
|
|
|
const version = String.raw`v?\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?\b`;
|
|
// A title can also name the agent, OS or monitored platform. Prefer a
|
|
// server-qualified value, rather than taking its first arbitrary number.
|
|
const declared = text.match(new RegExp(
|
|
String.raw`\bpulse(?:[ \t]+pro)?[ \t]+(?:version[ \t]*[:=]?[ \t]*)?(${version})`, "i"
|
|
)) || text.match(new RegExp(String.raw`\bpulse(?:-pro)?:(${version})`, "i"));
|
|
if (declared) return declaredVersionIsTransition(text.slice(declared.index))
|
|
? null : normalizeVersion(declared[1]);
|
|
|
|
// Retain legacy version-first titles and the neutral "Bug on v..." form.
|
|
// Do not search through another product's name to reach its version.
|
|
const neutral = text.replace(/^\[(?:bug|v6 pre-release)\][ \t]*:?[ \t]*/i, "");
|
|
const leading = neutral.match(new RegExp(String.raw`^(?:bug[ \t]+on[ \t]+)?(${version})`, "i"));
|
|
return leading && !declaredVersionIsTransition(neutral) ? normalizeVersion(leading[1]) : null;
|
|
}
|
|
|
|
function extractPulseVersion(title, body, report = parseReportMarkdown(body)) {
|
|
if (body) {
|
|
const lines = report.lines;
|
|
const versionHeading = lines.findIndex((line) =>
|
|
line.heading !== null && /^Pulse[ \t]+version$/i.test(line.heading)
|
|
);
|
|
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.
|
|
return normalizeLegacyVersionValue(firstFieldValue(lines.slice(versionHeading + 1)), report);
|
|
}
|
|
for (let i = 0; i < lines.length; i += 1) {
|
|
if (!lines[i].declaration) continue;
|
|
const line = (lines[i].heading ?? lines[i].metadata).replace(/\*\*|__/g, "")
|
|
.replace(/^[ \t]*[-*][ \t]+/, "").trim();
|
|
const field = line.match(
|
|
/^Pulse[ \t]*(?:[|-][ \t]*)?version(?:[ \t]*[:|][ \t]*|[ \t]+|$)(.*)$/i
|
|
);
|
|
if (!field) continue;
|
|
|
|
// Legacy inline/standalone fields have the same authority as the form.
|
|
// Unknown or incomplete must not borrow an agent/platform version or
|
|
// an upgrade's starting version from the title.
|
|
const visibleLine = (lines[i].heading ?? lines[i].fieldText).replace(/\*\*|__/g, "")
|
|
.replace(/^[ \t]*[-*][ \t]+/, "").trim();
|
|
const visibleField = visibleLine.match(
|
|
/^Pulse[ \t]*(?:[|-][ \t]*)?version(?:[ \t]*[:|][ \t]*|[ \t]+|$)(.*)$/i
|
|
);
|
|
const inlineValue = visibleField ? visibleField[1].trim() : "";
|
|
if (inlineValue) return normalizeLegacyVersionValue(inlineValue, report);
|
|
// A declared value may itself be fenced; that does not turn headings
|
|
// elsewhere in a fenced log into declarations.
|
|
return normalizeLegacyVersionValue(firstFieldValue(lines.slice(i + 1)), report);
|
|
}
|
|
}
|
|
|
|
return extractTitlePulseVersion(title);
|
|
}
|
|
|
|
function classifyV6FeedbackType(body, report = parseReportMarkdown(body)) {
|
|
const feedbackType = extractSectionValue(body, "Feedback type", [], report);
|
|
if (!feedbackType) return null;
|
|
|
|
// This is a single-choice field, not free-form evidence. A link's target
|
|
// or title must not turn documentation/workflow feedback into a bug, and
|
|
// prose, images or a withdrawn selection cannot supply a selected option.
|
|
const selected = firstFieldValue(parseReportMarkdown(feedbackType).lines);
|
|
const blocks = lexFieldValue(selected, report);
|
|
const visible = blocks.length === 1 && blocks[0].type === "paragraph"
|
|
? inlineDeclarationText(blocks[0].tokens) : selected;
|
|
const normalized = visible.trim().toLowerCase().replace(/[ \t]+/g, " ");
|
|
switch (normalized) {
|
|
case "bug":
|
|
case "regression":
|
|
case "bug / regression":
|
|
case "upgrade / migration issue":
|
|
case "performance issue":
|
|
return BUG_LABEL;
|
|
case "documentation issue":
|
|
return DOCS_LABEL;
|
|
case "ux / workflow friction":
|
|
case "other actionable feedback":
|
|
return ENHANCEMENT_LABEL;
|
|
default:
|
|
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) {
|
|
const label = {
|
|
owner: context.repo.owner,
|
|
repo: context.repo.repo,
|
|
name,
|
|
};
|
|
try {
|
|
await github.rest.issues.getLabel(label);
|
|
} catch (error) {
|
|
if (error.status !== 404) throw error;
|
|
try {
|
|
await github.rest.issues.createLabel({ ...label, color, description });
|
|
} catch (creationError) {
|
|
// Concurrent reports can both observe a missing repository-wide label.
|
|
// Reconcile only GitHub's explicit name conflict, without replaying the
|
|
// write or treating other validation/access failures as success.
|
|
const errors = creationError.response?.data?.errors;
|
|
if (creationError.status !== 422 || !Array.isArray(errors) || !errors.length ||
|
|
!errors.every((item) => item?.resource === "Label" &&
|
|
item.field === "name" && item.code === "already_exists")) {
|
|
throw creationError;
|
|
}
|
|
const { data: current } = await github.rest.issues.getLabel(label);
|
|
if (typeof current?.name !== "string" ||
|
|
current.name.toLowerCase() !== name.toLowerCase()) {
|
|
throw creationError;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Classification and targeted retirement use GitHub's label identity too.
|
|
// Keep the original names in each snapshot: exact spelling still belongs in
|
|
// provider writes and in the fence against intervening label edits.
|
|
function hasLabel(labels, name) {
|
|
const key = name.toLowerCase();
|
|
return [...labels].some((label) => label.toLowerCase() === key);
|
|
}
|
|
|
|
function deleteLabel(labels, name) {
|
|
const key = name.toLowerCase();
|
|
for (const label of labels) {
|
|
if (label.toLowerCase() === key) labels.delete(label);
|
|
}
|
|
}
|
|
|
|
function buildTriageState(issue, core, latestVersion) {
|
|
const labelNames = new Set((issue.labels || []).map((label) => label.name));
|
|
const nextLabels = new Set(labelNames);
|
|
const report = parseReportMarkdown(issue.body);
|
|
const v6FeedbackClass = classifyV6FeedbackType(issue.body, report);
|
|
if (v6FeedbackClass) {
|
|
core.info(`Detected v6 feedback issue class: ${v6FeedbackClass}`);
|
|
nextLabels.add(v6FeedbackClass);
|
|
}
|
|
|
|
const additionalTopicDeclarations = additionalActionableTopicDeclarations(issue.body, report);
|
|
const hasAdditionalActionableTopics = additionalTopicDeclarations === null
|
|
? null : additionalTopicDeclarations.length > 0;
|
|
|
|
const reportedVersion = extractPulseVersion(issue.title, issue.body, report);
|
|
core.info(`Reported Pulse version: ${reportedVersion || "not found"}`);
|
|
if (latestVersion) core.info(`Latest stable release: ${latestVersion}`);
|
|
|
|
return {
|
|
labelNames,
|
|
nextLabels,
|
|
reportedVersion,
|
|
v6FeedbackClass,
|
|
hasAdditionalActionableTopics,
|
|
additionalTopicDeclarations,
|
|
isBugLike: hasLabel(nextLabels, BUG_LABEL),
|
|
comparison:
|
|
reportedVersion && latestVersion ? compareCore(reportedVersion, latestVersion) : null,
|
|
};
|
|
}
|
|
|
|
function keepOnlyReportedVersionLabel(nextLabels, reportedVersion) {
|
|
const keep = reportedVersion ? `${VERSION_LABEL_PREFIX}${reportedVersion}`.toLowerCase() : null;
|
|
for (const label of [...nextLabels]) {
|
|
if (
|
|
/^affects-\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/i.test(label) &&
|
|
label.toLowerCase() !== keep
|
|
) {
|
|
nextLabels.delete(label);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Apply only this event's classification delta. Replacing the complete label
|
|
// set would overwrite Community changes made after the event was queued.
|
|
async function applyLabelDelta(github, context, issue, before, after, core) {
|
|
const target = {
|
|
owner: context.repo.owner,
|
|
repo: context.repo.repo,
|
|
issue_number: issue.number,
|
|
};
|
|
// GitHub label identity is case-insensitive. Adding RC.1 then removing
|
|
// rc.1 would remove the same label, not replace its spelling. Preserve the
|
|
// provider's names for writes and the exact live-state fence below; only
|
|
// calculate membership in the provider's case-insensitive namespace.
|
|
const beforeKeys = new Set([...before].map((name) => name.toLowerCase()));
|
|
const afterKeys = new Set([...after].map((name) => name.toLowerCase()));
|
|
const additions = [...after].filter((name) => !beforeKeys.has(name.toLowerCase())).sort();
|
|
const removals = [...before].filter((name) => !afterKeys.has(name.toLowerCase())).sort();
|
|
if (!additions.length && !removals.length) return;
|
|
|
|
// Label lookup/creation can take time after the initial report read. Do not
|
|
// apply that prepared decision to a report since edited, closed or reviewed.
|
|
// This narrows the race; GitHub's separate label APIs are not a transaction.
|
|
const readCurrent = async () => {
|
|
const { data: current } = await github.rest.issues.get(target);
|
|
if (!current || current.number !== issue.number) {
|
|
throw new Error("Current issue identity did not match the triage event");
|
|
}
|
|
return current;
|
|
};
|
|
const decisionInputsMatch = (current) => {
|
|
const labels = new Set((current.labels || []).map((label) => label.name));
|
|
return current.state === "open" && !current.pull_request &&
|
|
["title", "body"].every((field) => current[field] === issue[field]) &&
|
|
labels.size === before.size && [...before].every((name) => labels.has(name));
|
|
};
|
|
const changed = () => core.info("Issue changed during metadata preparation. Skipping outdated label delta.");
|
|
const current = await readCurrent();
|
|
if (!decisionInputsMatch(current)) {
|
|
changed();
|
|
return;
|
|
}
|
|
if (["updated_at", "comments"].some((field) => current[field] !== issue[field])) {
|
|
// A comment can arrive during label lookup or the first history read,
|
|
// not just before the job. Preserve only an already eligible declaration,
|
|
// after checking review/lifecycle history from its original event again.
|
|
// Version-only decisions and all changed content/labels stay conservative.
|
|
if (!additions.includes(NEEDS_DECOMPOSITION_LABEL) ||
|
|
!Number.isInteger(issue.comments) || current.comments <= issue.comments ||
|
|
!await declarationSurvivesLaterComments(github, context, current, context.payload.issue)) {
|
|
changed();
|
|
return;
|
|
}
|
|
// History is not a transaction with issue reads. Bind its result to the
|
|
// snapshot it checked; further activity stops here without a write/retry.
|
|
const final = await readCurrent();
|
|
if (!decisionInputsMatch(final) ||
|
|
["updated_at", "comments"].some((field) => final[field] !== current[field])) {
|
|
changed();
|
|
return;
|
|
}
|
|
}
|
|
|
|
if (additions.length) {
|
|
await github.rest.issues.addLabels({ ...target, labels: additions });
|
|
}
|
|
for (const name of removals) {
|
|
try {
|
|
await github.rest.issues.removeLabel({ ...target, name });
|
|
} catch (error) {
|
|
// Another synchronizer may already have removed this label.
|
|
if (error.status !== 404) throw error;
|
|
}
|
|
}
|
|
}
|
|
|
|
async function declarationSurvivesLaterComments(github, context, issue, eventIssue) {
|
|
// A comment can advance the activity count without changing the declaration.
|
|
// The clock alone must not lose its review task, but identical text cannot prove that
|
|
// Community has not already cleared it. Consult complete, bounded history
|
|
// only for this delayed-comment case; never infer a disposition from silence.
|
|
const eventTime = typeof eventIssue.updated_at === "string" ? Date.parse(eventIssue.updated_at) : NaN;
|
|
const currentTime = typeof issue.updated_at === "string" ? Date.parse(issue.updated_at) : NaN;
|
|
// GitHub's clock has second precision. A larger comment count proves later
|
|
// activity even when both timestamps match; its review history still needs
|
|
// checking before either restoring or discarding this declaration.
|
|
if (!Number.isFinite(eventTime) || !Number.isFinite(currentTime) || currentTime < eventTime ||
|
|
!Number.isInteger(eventIssue.comments) || eventIssue.comments < 0 ||
|
|
!Number.isInteger(issue.comments) || issue.comments <= eventIssue.comments ||
|
|
issue.title !== eventIssue.title || issue.body !== eventIssue.body) {
|
|
return false;
|
|
}
|
|
|
|
const perPage = 100;
|
|
for (let page = 1; page <= 10; page += 1) {
|
|
const { data: events } = await github.rest.issues.listEvents({
|
|
owner: context.repo.owner,
|
|
repo: context.repo.repo,
|
|
issue_number: issue.number,
|
|
per_page: perPage,
|
|
page,
|
|
});
|
|
if (!Array.isArray(events) || events.length > perPage) {
|
|
throw new Error("Issue review history was not a complete event page");
|
|
}
|
|
for (const event of events) {
|
|
const time = typeof event?.created_at === "string" ? Date.parse(event.created_at) : NaN;
|
|
if (typeof event?.event !== "string" || !Number.isFinite(time) ||
|
|
(event.event === "unlabeled" && typeof event.label?.name !== "string")) {
|
|
throw new Error("Issue review history contained an unreadable event");
|
|
}
|
|
// Treat same-second dispositions conservatively. A history newer than
|
|
// our issue snapshot, or an intervening lifecycle change, is not proof
|
|
// that this queued declaration is still eligible.
|
|
if (time > currentTime || (time >= eventTime && (
|
|
(event.event === "unlabeled" &&
|
|
event.label.name.toLowerCase() === NEEDS_DECOMPOSITION_LABEL) ||
|
|
["closed", "reopened", "transferred", "converted_to_discussion"].includes(event.event)
|
|
))) return false;
|
|
}
|
|
if (events.length < perPage) return true;
|
|
}
|
|
throw new Error("Issue review history exceeded the bounded event read");
|
|
}
|
|
|
|
async function syncLabels({ github, context, core }) {
|
|
const eventIssue = context.payload.issue;
|
|
// Jobs can start after another edit, comment or Community disposition.
|
|
// Read the report and its labels together; never fall back to stale event
|
|
// metadata when this read fails.
|
|
const { data: issue } = await github.rest.issues.get({
|
|
owner: context.repo.owner,
|
|
repo: context.repo.repo,
|
|
issue_number: eventIssue.number,
|
|
});
|
|
if (!issue || issue.number !== eventIssue.number) {
|
|
throw new Error("Current issue identity did not match the triage event");
|
|
}
|
|
if (issue.state !== "open" || issue.pull_request) {
|
|
core.info("Report is no longer an open issue. Skipping label sync.");
|
|
return;
|
|
}
|
|
const {
|
|
labelNames,
|
|
nextLabels,
|
|
reportedVersion,
|
|
hasAdditionalActionableTopics,
|
|
additionalTopicDeclarations,
|
|
isBugLike,
|
|
} = buildTriageState(issue, core, null);
|
|
|
|
// A new or revised form declaration creates a review task. A later
|
|
// empty field cannot prove that comment topics were dispositioned, and an
|
|
// unrelated or superseded event must not restore a label Community cleared.
|
|
const previousBody = context.payload.changes?.body?.from;
|
|
const eventIsCurrent = typeof issue.updated_at === "string" &&
|
|
issue.body === eventIssue.body &&
|
|
issue.updated_at === eventIssue.updated_at &&
|
|
issue.comments === eventIssue.comments;
|
|
const declarationChanged = hasAdditionalActionableTopics === true && (
|
|
context.payload.action === "opened" ||
|
|
(context.payload.action === "edited" && previousBody !== undefined &&
|
|
JSON.stringify(additionalActionableTopicDeclarations(previousBody) || []) !==
|
|
JSON.stringify(additionalTopicDeclarations))
|
|
);
|
|
// GitHub's updated_at clock has second precision. A review present in the
|
|
// queued event can already have been cleared in the current snapshot even
|
|
// when both clocks (and comment counts) match. That observed removal takes
|
|
// precedence over the queued declaration; do not restore that review task
|
|
// or require another history read to recognise the label difference.
|
|
const reviewClearedAfterEvent = !hasLabel(labelNames, NEEDS_DECOMPOSITION_LABEL) &&
|
|
(eventIssue.labels || []).some((label) => label.name.toLowerCase() === NEEDS_DECOMPOSITION_LABEL);
|
|
const newlyDeclared = declarationChanged && !reviewClearedAfterEvent &&
|
|
!hasLabel(nextLabels, NEEDS_DECOMPOSITION_LABEL) &&
|
|
(eventIsCurrent || await declarationSurvivesLaterComments(github, context, issue, eventIssue));
|
|
if (newlyDeclared) {
|
|
core.info("Issue newly declares additional actionable topics; decomposition is required.");
|
|
nextLabels.add(NEEDS_DECOMPOSITION_LABEL);
|
|
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.");
|
|
await applyLabelDelta(github, context, issue, labelNames, nextLabels, core);
|
|
return;
|
|
}
|
|
|
|
if (reportedVersion) {
|
|
const versionLabel = `${VERSION_LABEL_PREFIX}${reportedVersion}`;
|
|
// GitHub label names stop at 50 characters. A longer declared version
|
|
// still supplies evidence, but cannot be represented as an exact label.
|
|
// Do not truncate it into a different version, or let that API validation
|
|
// failure prevent independent topic review and stale-metadata removal.
|
|
if (versionLabel.length <= 50) {
|
|
await ensureLabel(
|
|
github,
|
|
context,
|
|
versionLabel,
|
|
"0e8a16",
|
|
`Bug reported against Pulse ${reportedVersion}`
|
|
);
|
|
nextLabels.add(versionLabel);
|
|
} else {
|
|
core.info("Declared version exceeds the exact-version label limit; retaining its evidence without a version label.");
|
|
}
|
|
deleteLabel(nextLabels, NEEDS_VERSION_LABEL);
|
|
} else {
|
|
await ensureLabel(
|
|
github,
|
|
context,
|
|
NEEDS_VERSION_LABEL,
|
|
"fbca04",
|
|
"Issue is missing required Pulse version metadata"
|
|
);
|
|
nextLabels.add(NEEDS_VERSION_LABEL);
|
|
}
|
|
|
|
// A correction to unknown, incomplete or absent version evidence retires
|
|
// this synchronizer's old exact-version assertions too. It must not leave
|
|
// a known affected version beside its missing-version classification.
|
|
keepOnlyReportedVersionLabel(nextLabels, reportedVersion);
|
|
|
|
// Retest labels are community-owned: version metadata cannot establish
|
|
// relevant-fix availability or reconcile the live reporter conversation.
|
|
await applyLabelDelta(github, context, issue, labelNames, nextLabels, core);
|
|
}
|
|
|
|
// Compatibility entry points for callers using the old helper API. Version
|
|
// metadata alone cannot justify a public retest request. Community owns
|
|
// whole-thread review, relevant-fix and release-inclusion verification.
|
|
async function postRetestComment({ core }) {
|
|
core.info("Version-only retest posting is retired; Community owns follow-up.");
|
|
}
|
|
|
|
async function postEligibleRetestComments({ core }) {
|
|
core.info("Version-only retest sweep is retired; Community owns follow-up.");
|
|
return { eligibleCount: 0, postedCount: 0 };
|
|
}
|
|
|
|
module.exports = {
|
|
postEligibleRetestComments,
|
|
syncLabels,
|
|
postRetestComment,
|
|
internals: {
|
|
BUG_LABEL,
|
|
DOCS_LABEL,
|
|
ENHANCEMENT_LABEL,
|
|
NEEDS_DECOMPOSITION_LABEL,
|
|
NEEDS_VERSION_LABEL,
|
|
RETEST_LABEL,
|
|
VERSION_LABEL_PREFIX,
|
|
buildTriageState,
|
|
classifyAdditionalActionableTopics,
|
|
classifyV6FeedbackType,
|
|
compareCore,
|
|
extractPulseVersion,
|
|
normalizeVersion,
|
|
},
|
|
};
|