name: Triage (needs-info) on: issues: types: [opened, edited, reopened] 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()); 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 extractSection(title) { const re = new RegExp(`^###\\s+${title.replace(/[.*+?^${}()|[\\]\\\\]/g, "\\\\$&")}\\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 stripIssuePrefix(value) { return normalizeText(value).replace(/^\[[^\]]+\]:\s*/i, "").trim(); } 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 = !!(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, steps, actual, extra].map(normalizeText).join("\n"); const isCrashIssue = crashPattern.test(crashContext); const normalizedLogs = normalizeText(logs); const hasLogs = normalizedLogs.length >= 20 && !/^(n\/a|na|none|no|not available)$/i.test(normalizedLogs); 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 (!steps || steps.length < 30) problems.push("Steps to reproduce (please list exact steps)"); if (!expected || expected.length < 10) problems.push("Expected behavior"); if (!actual || actual.length < 10) problems.push("Actual behavior (include any on-screen error text)"); if (isCrashIssue && !hasLogs) { problems.push("Logs (required for crash reports; include a log snippet or stack trace)"); } 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 (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 = ""; const commentBody = `${marker}\n` + `Thanks for the report. Could you add a bit more detail so we can reproduce it?\n\n` + `Missing / too short:\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` + `${isCrashIssue ? `Crash reports must include logs.\n` : `Logs are optional for most issues, but they help a lot.\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 (_) {} }