NuvioStreaming/.github/workflows/triage-needs-info.yml
2026-07-15 15:36:32 +05:30

257 lines
11 KiB
YAML

name: Triage (needs-info)
on:
issues:
# A reopen is an explicit human triage decision. Do not run automatic
# closure/needs-info rules again after a maintainer reopens an issue.
types: [opened, edited]
permissions:
issues: write
jobs:
needs_info:
runs-on: ubuntu-latest
steps:
- name: Label low-context issues
uses: actions/github-script@v7
with:
script: |
const owner = context.repo.owner;
const repo = context.repo.repo;
const issue_number = context.payload.issue.number;
const issue = context.payload.issue;
const title = (issue.title || "").trim();
const body = issue.body || "";
const labels = (issue.labels || []).map(l => (typeof l === "string" ? l : l.name).toLowerCase());
// An edit can happen after a human has reopened an issue. Preserve
// that explicit triage decision instead of closing it again.
const events = await github.paginate(github.rest.issues.listEvents, {
owner,
repo,
issue_number,
per_page: 100,
});
if (events.some(event => event.event === "reopened")) {
core.info(`#${issue_number}: skipping triage because it was manually reopened.`);
return;
}
const NEEDS_INFO = "needs-info";
const NEEDS_INFO_COLOR = "d4c5f9";
const NEEDS_INFO_DESC = "More details needed to reproduce / triage.";
function hasLabel(name) {
return labels.includes(name.toLowerCase());
}
function escapeRegExp(value) {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
function extractSection(title) {
const re = new RegExp(`^###\\s+${escapeRegExp(title)}\\s*$`, "m");
const match = body.match(re);
if (!match) return "";
const start = match.index + match[0].length;
const rest = body.slice(start);
const next = rest.search(/^###\s+/m);
const section = (next === -1 ? rest : rest.slice(0, next));
return section.trim();
}
function extractFirstSection(titles) {
for (const sectionTitle of titles) {
const value = extractSection(sectionTitle);
if (value) return value;
}
return "";
}
function normalizeText(value) {
return (value || "").replace(/\s+/g, " ").trim();
}
function normalizedMeaningfulText(value) {
return normalizeText(value)
.replace(/^```[a-zA-Z0-9_-]*/i, "")
.replace(/```$/i, "")
.trim();
}
function stripIssuePrefix(value) {
return normalizeText(value).replace(/^\[[^\]]+\]:\s*/i, "").trim();
}
function isEmptyish(value) {
const text = normalizedMeaningfulText(value);
return !text ||
!/[\p{L}\p{N}]/u.test(text) ||
/^(_?no response_?|n\/a|na|none|no|not available|-|\.)$/i.test(text);
}
function isVague(value) {
const text = normalizedMeaningfulText(value);
return /^(same|same issue|same as above|see above|me too|not working|doesn't work|doesnt work|does not work|nothing happens|broken|bug|issue|problem|error|failed|failure|help|please fix|crash|crashes|it crashes|see video|see screenshot|as title)$/i.test(text);
}
function hasUsefulText(value, minimumLength) {
const text = normalizedMeaningfulText(value);
return !isEmptyish(text) && !isVague(text) && text.length >= minimumLength;
}
const description = extractFirstSection([
"Bug description",
"Describe the bug",
"Detailed description",
]);
const steps = extractSection("Steps to reproduce");
const expected = extractSection("Expected behavior");
const actual = extractSection("Actual behavior");
const logs = extractFirstSection([
"Logs (required for crash reports)",
"Logs (optional but helpful)",
]);
const extra = extractSection("Anything else? (optional)");
const summaryTitle = stripIssuePrefix(title);
const looksLikeBugForm = !!(description || steps || expected || actual);
const isBugIssue = hasLabel("bug") || looksLikeBugForm;
const isFeatureIssue =
hasLabel("enhancement") ||
hasLabel("feature") ||
hasLabel("feature request");
if (!isBugIssue || isFeatureIssue) {
return;
}
const problems = [];
const genericTitle = /^(bug|issue|problem|help|question|crash|broken|error|bug report|short summary here|title here)$/i;
const numericOnlyTitle = /^#?\d+$/;
const crashPattern = /\b(crash|crashes|crashed|crashing|force close|force closes|force closed|fatal exception|app closes|app closed unexpectedly)\b/i;
const crashContext = [summaryTitle, description, steps, actual, extra].map(normalizeText).join("\n");
const isCrashIssue = crashPattern.test(crashContext);
const normalizedLogs = normalizedMeaningfulText(logs);
const hasLogs = normalizedLogs.length >= 20 && !isEmptyish(normalizedLogs);
const hasDetailedDescription = hasUsefulText(description, 80);
if (!summaryTitle || summaryTitle.length < 8 || genericTitle.test(summaryTitle) || numericOnlyTitle.test(summaryTitle)) {
problems.push("Issue title (replace the default `[Bug]:` prefix with a short summary of the actual problem)");
}
if (!hasUsefulText(description, 20)) {
problems.push("Bug description (explain the problem in detail, not just \"not working\" or \"same issue\")");
}
// Some valid bugs only need one short action (for example, "Login" or
// "Open Continue Watching"). Judge the report as a whole instead of
// imposing arbitrary per-field character counts.
if (!hasUsefulText(steps, 5) && !hasDetailedDescription) {
problems.push("Steps to reproduce (please list exact steps)");
}
if (!hasUsefulText(expected, 3) && !hasDetailedDescription) {
problems.push("Expected behavior");
}
if (!hasUsefulText(actual, 3) && !hasDetailedDescription) {
problems.push("Actual behavior (include any on-screen error text)");
}
async function ensureLabel(name, color, description) {
try {
await github.rest.issues.getLabel({ owner, repo, name });
} catch (e) {
try {
await github.rest.issues.createLabel({ owner, repo, name, color, description });
} catch (_) {}
}
}
const hasNeedsInfo = hasLabel(NEEDS_INFO);
if (isCrashIssue && !hasLogs) {
await ensureLabel(NEEDS_INFO, NEEDS_INFO_COLOR, NEEDS_INFO_DESC);
if (!hasNeedsInfo) {
await github.rest.issues.addLabels({
owner,
repo,
issue_number,
labels: [NEEDS_INFO],
});
}
const marker = "<!-- nuvio-bot:crash-logs-required-close -->";
const comments = await github.paginate(github.rest.issues.listComments, {
owner,
repo,
issue_number,
per_page: 100,
});
const alreadyCommented = comments.some(c => (c.body || "").includes(marker));
if (!alreadyCommented) {
await github.rest.issues.createComment({
owner,
repo,
issue_number,
body:
`${marker}\n` +
`Closing this crash report because crash reports must include logs.\n\n` +
`Please open a new report, or ask us to reopen this one, with a log snippet or stack trace from around the crash.\n\n` +
`Useful examples:\n` +
`- Android: \`adb logcat -d | tail -n 300\`\n` +
`- iOS: crash log from Xcode Organizer or Console.app\n` +
`- Desktop: terminal/console output from around the crash`,
});
}
await github.rest.issues.update({
owner,
repo,
issue_number,
state: "closed",
state_reason: "not_planned",
});
return;
}
if (problems.length > 0) {
await ensureLabel(NEEDS_INFO, NEEDS_INFO_COLOR, NEEDS_INFO_DESC);
if (!hasNeedsInfo) {
await github.rest.issues.addLabels({
owner,
repo,
issue_number,
labels: [NEEDS_INFO],
});
}
const marker = "<!-- nuvio-bot:needs-info -->";
const commentBody =
`${marker}\n` +
`Thanks for the report. Could you add more detail so we can reproduce it?\n\n` +
`Missing / too vague:\n` +
problems.map(p => `- ${p}`).join("\n") +
`\n\n` +
`Use a specific title, for example: \`[Bug]: Playback freezes when switching audio tracks on iOS\`.\n` +
`Reports that only say "not working", "broken", or "same issue" are not enough to debug.\n` +
`Logs are optional for most issues, but they help a lot. Crash reports without logs are closed automatically.\n`;
const comments = await github.paginate(github.rest.issues.listComments, {
owner,
repo,
issue_number,
per_page: 100,
});
const alreadyCommented = comments.some(c => (c.body || "").includes(marker));
if (!alreadyCommented) {
await github.rest.issues.createComment({
owner,
repo,
issue_number,
body: commentBody,
});
}
} else if (hasNeedsInfo) {
try {
await github.rest.issues.removeLabel({ owner, repo, issue_number, name: NEEDS_INFO });
} catch (_) {}
}