| |
| 'use strict'; |
|
|
| |
| module.exports = async ({ github, context, core }) => { |
| const issue = context.payload.issue; |
| const body = (issue.body || '').trim(); |
| const labels = issue.labels.map(l => l.name); |
| const owner = context.repo.owner; |
| const repo = context.repo.repo; |
|
|
| const isBug = labels.includes('bug'); |
| const isFeature = labels.includes('enhancement'); |
|
|
| |
| |
| |
| function section(heading) { |
| const re = new RegExp(`#+\\s+${heading}\\s*([\\s\\S]*?)(?=\\n#+\\s+|$)`, 'i'); |
| const m = body.match(re); |
| return m ? m[1].replace(/<!--[\s\S]*?-->/g, '').trim() : ''; |
| } |
|
|
| const failures = []; |
|
|
| |
| if (body.length < 50) { |
| failures.push( |
| '**Description** — body is empty or too short. ' + |
| 'Please open the issue using one of the provided templates.', |
| ); |
| } |
|
|
| |
| |
| const type = isBug && isFeature ? 'conflict' : isBug ? 'bug' : isFeature ? 'feature' : 'untyped'; |
|
|
| switch (type) { |
| case 'conflict': |
| failures.push('**Labels** — an issue cannot be both `bug` and `enhancement`. Remove one label.'); |
| break; |
|
|
| case 'bug': { |
| if (!section('Install Method')) { |
| failures.push('**Install Method** — select how you installed Odysseus'); |
| } |
|
|
| if (!section('Operating System')) { |
| failures.push('**Operating System** — select your OS'); |
| } |
|
|
| const stepsText = section('Steps to Reproduce'); |
| if (!stepsText || !/\d+\.|[-*]/.test(stepsText)) { |
| failures.push('**Steps to Reproduce** — must include at least one numbered or bulleted step'); |
| } |
|
|
| if (section('Expected Behaviour').length < 10) { |
| failures.push('**Expected Behaviour** — section is empty or too short'); |
| } |
|
|
| if (section('Actual Behaviour').length < 10) { |
| failures.push('**Actual Behaviour** — section is empty or too short'); |
| } |
| break; |
| } |
|
|
| case 'feature': |
| if (!section('Area')) { |
| failures.push('**Area** — select which part of the application this affects'); |
| } |
|
|
| if (section('Problem or Motivation').length < 20) { |
| failures.push( |
| '**Problem or Motivation** — section is empty or too short ' + |
| '(explain the concrete problem this solves)', |
| ); |
| } |
|
|
| if (section('Proposed Solution').length < 20) { |
| failures.push( |
| '**Proposed Solution** — section is empty or too short ' + |
| '(describe the change you want to see)', |
| ); |
| } |
|
|
| if (!section('Are you willing to implement this\\?')) { |
| failures.push('**Are you willing to implement this?** — select an option'); |
| } |
| break; |
|
|
| |
| } |
|
|
| |
| |
| |
| |
| |
| |
| const PLACEHOLDER = '-- Please Select --'; |
| const headingRe = /^#+\s+(.+?)\s*$/gm; |
| const headings = []; |
| let headingMatch; |
| while ((headingMatch = headingRe.exec(body)) !== null) { |
| headings.push({ |
| name: headingMatch[1].trim(), |
| headStart: headingMatch.index, |
| contentStart: headingMatch.index + headingMatch[0].length, |
| }); |
| } |
| const unfilled = []; |
| for (let i = 0; i < headings.length; i++) { |
| const end = i + 1 < headings.length ? headings[i + 1].headStart : body.length; |
| if (body.slice(headings[i].contentStart, end).includes(PLACEHOLDER)) { |
| unfilled.push(headings[i].name); |
| } |
| } |
| if (unfilled.length > 0) { |
| failures.push( |
| `**Unfilled dropdowns** — please choose a value; these sections still show ` + |
| `the \`${PLACEHOLDER}\` placeholder: ${unfilled.join(', ')}.`, |
| ); |
| } |
|
|
| |
| |
| |
| |
| |
| async function labelExists(name) { |
| try { |
| await github.rest.issues.getLabel({ owner, repo, name }); |
| return true; |
| } catch (e) { |
| if (e.status === 404) return false; |
| throw e; |
| } |
| } |
|
|
| async function addLabel(name) { |
| if (await labelExists(name)) { |
| await github.rest.issues.addLabels({ owner, repo, issue_number: issue.number, labels: [name] }); |
| } else { |
| core.warning(`Label "${name}" does not exist in the repo — skipping. Create it once to enable labelling.`); |
| } |
| } |
|
|
| async function dropLabel(name) { |
| try { |
| await github.rest.issues.removeLabel({ owner, repo, issue_number: issue.number, name }); |
| } catch (e) { |
| if (e.status !== 404 && e.status !== 410) throw e; |
| } |
| } |
|
|
| |
| const MARKER = '<!-- issue-description-check -->'; |
| const { data: comments } = await github.rest.issues.listComments({ |
| owner, repo, issue_number: issue.number, |
| }); |
| const existing = comments.find(c => c.user.type === 'Bot' && c.body.includes(MARKER)); |
|
|
| const LABEL_BAD = 'needs more info'; |
| const LABEL_GOOD = 'ready for review'; |
|
|
| if (failures.length === 0) { |
| if (existing) { |
| await github.rest.issues.deleteComment({ owner, repo, comment_id: existing.id }); |
| } |
|
|
| await dropLabel(LABEL_BAD); |
| await addLabel(LABEL_GOOD); |
|
|
| } else { |
| const list = failures.map(f => `- ${f}`).join('\n'); |
| const commentBody = [ |
| MARKER, |
| '⚠️ **Issue description is incomplete.** Please update the following sections:', |
| '', |
| list, |
| '', |
| '_This comment is deleted automatically once all sections are complete._', |
| ].join('\n'); |
|
|
| if (existing) { |
| await github.rest.issues.updateComment({ owner, repo, comment_id: existing.id, body: commentBody }); |
| } else { |
| await github.rest.issues.createComment({ owner, repo, issue_number: issue.number, body: commentBody }); |
| } |
|
|
| await dropLabel(LABEL_GOOD); |
| await addLabel(LABEL_BAD); |
|
|
| core.setFailed(`Issue description has ${failures.length} issue(s) — see bot comment for details.`); |
| } |
| }; |
|
|