Skip to content

[BUG] Cycle to review mismatch #643

[BUG] Cycle to review mismatch

[BUG] Cycle to review mismatch #643

name: Issue Validator
on:
issues:
types: [opened, edited, reopened]
issue_comment:
types: [created]
workflow_dispatch:
inputs:
issue_body:
description: "Paste a simulated issue body to validate (dry-run - nothing is posted)"
required: true
issue_number:
description: "Real issue number to post the result to (leave blank for dry-run only)"
required: false
default: ""
permissions:
issues: write
jobs:
handle-fine-command:
runs-on: ubuntu-latest
if: >
github.event_name == 'issue_comment' &&
startsWith(github.event.comment.body, '/fine') &&
(github.event.comment.author_association == 'OWNER' ||
github.event.comment.author_association == 'MEMBER' ||
github.event.comment.author_association == 'COLLABORATOR')
steps:
- name: Handle /fine command
uses: actions/github-script@v7
with:
script: |
const issueNumber = context.payload.issue.number;
const incompleteLabel = 'more info required';
const currentLabels = context.payload.issue.labels.map(l => l.name);
if (currentLabels.includes(incompleteLabel)) {
await github.rest.issues.removeLabel({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issueNumber,
name: incompleteLabel,
});
}
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issueNumber,
body: `✅ Cleared by @${context.payload.comment.user.login} - issue marked as fine and queued for review.`,
});
handle-reporter-response:
runs-on: ubuntu-latest
# When the reporter responds via a comment (instead of editing the issue body),
# transition the issue from "more info required" to "awaiting maintainer review"
# so the auto-closer doesn't close it as unanswered. The validator can't re-check
# comment content (it inspects body fields), so a maintainer reviews instead.
if: >
github.event_name == 'issue_comment' &&
github.event.comment.user.type != 'Bot' &&
github.event.comment.user.login == github.event.issue.user.login &&
!startsWith(github.event.comment.body, '/fine') &&
contains(github.event.issue.labels.*.name, 'more info required')
steps:
- name: Swap labels on reporter response
uses: actions/github-script@v7
with:
script: |
const issueNumber = context.payload.issue.number;
const incompleteLabel = 'more info required';
const reviewLabel = 'awaiting maintainer review';
await github.rest.issues.removeLabel({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issueNumber,
name: incompleteLabel,
}).catch(e => console.log(`Could not remove ${incompleteLabel}: ${e.message}`));
await github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issueNumber,
labels: [reviewLabel],
});
console.log(`#${issueNumber}: reporter responded via comment, swapped to "${reviewLabel}".`);
validate-bug-report:
runs-on: ubuntu-latest
if: >
github.event_name == 'workflow_dispatch' ||
(github.event_name == 'issues' &&
contains(github.event.issue.labels.*.name, 'bug'))
steps:
- name: Validate bug report completeness
uses: actions/github-script@v7
with:
script: |
const isDryRun = context.eventName === 'workflow_dispatch';
const issue = isDryRun ? {
body: context.payload.inputs.issue_body,
number: parseInt(context.payload.inputs.issue_number) || null,
user: { login: 'dry-run-user', type: 'User' },
labels: [],
} : context.payload.issue;
const body = issue.body || '';
const incompleteLabel = 'more info required';
// ── Checks ────────────────────────────────────────────────────────────
const problems = [];
// 1. Device Brand & Model - must not be blank or the placeholder
const brandMatch = body.match(/###\s*Device Brand & Model\s*\n+([^\n#]+)/);
const brand = brandMatch ? brandMatch[1].trim() : '';
const brandIsNA = /^n\/?a$/i.test(brand);
if (!brand || brand === 'Brand Model Number' || brand === 'Brand Model Number or N/A' || brand === '_No response_') {
problems.push('**Device Brand & Model** is missing or still set to the placeholder value. Provide the actual brand and model (e.g. `Miele W1 WCG370`), or enter `N/A` if this bug is not related to a specific device.');
}
// 2. WashData version - must not be blank or placeholder; warn if outdated
const versionMatch = body.match(/###\s*WashData Integration Version\s*\n+([^\n#]+)/);
const version = versionMatch ? versionMatch[1].trim() : '';
if (!version || version === 'vX.Y.Z' || version === '_No response_') {
problems.push('**WashData Integration Version** is missing. You can find it in HACS or Settings → Integrations.');
} else {
try {
const releasesResp = await github.rest.repos.listReleases({
owner: context.repo.owner,
repo: context.repo.repo,
per_page: 10,
});
const published = releasesResp.data.filter(r => !r.draft);
const latestStable = published.find(r => !r.prerelease);
const latestPre = published.find(r => r.prerelease);
// Parse "1.2.3" or "1.2.3-alpha" into { parts: [1,2,3], isPre: bool }
const parseVer = v => {
const s = v.replace(/^v/i, '').trim().toLowerCase();
const [base, ...rest] = s.split('-');
return { parts: base.split('.').map(Number), isPre: rest.length > 0 };
};
// Returns true only when releaseTag is strictly newer than reportedStr
const isNewer = (releaseTag, reportedStr) => {
const rel = parseVer(releaseTag);
const rep = parseVer(reportedStr);
const len = Math.max(rel.parts.length, rep.parts.length);
for (let i = 0; i < len; i++) {
const a = rel.parts[i] || 0, b = rep.parts[i] || 0;
if (a > b) return true;
if (a < b) return false;
}
// Same numeric base: stable is newer than a pre-release of the same version
return !rel.isPre && rep.isPre;
};
// A pre-release is only a valid upgrade target when it is numerically
// newer than the latest stable. If stable is ahead (or equal), it is
// always the one true latest and the pre-release is never suggested.
const alphaIsLatest = latestPre
? (!latestStable || isNewer(latestPre.tag_name, latestStable.tag_name))
: false;
const suggestStable = latestStable && isNewer(latestStable.tag_name, version);
const suggestPre = alphaIsLatest && latestPre && isNewer(latestPre.tag_name, version);
if (suggestStable || suggestPre) {
const parts = [];
if (suggestStable) parts.push(`\`${latestStable.tag_name}\` (stable)`);
if (suggestPre) parts.push(`\`${latestPre.tag_name}\` (pre-release)`);
problems.push(
`**Outdated version** (\`${version}\`): the current release${parts.length > 1 ? 's are' : ' is'} ${parts.join(' and ')}. ` +
`Please upgrade and confirm the bug still exists on the latest version before we investigate — ` +
`it may already be fixed. If it still reproduces, update this field with the version you tested.`
);
}
} catch (releaseErr) {
console.log('Could not fetch releases for version check:', releaseErr.message);
}
}
// 3. HA version - must not be blank or the placeholder
const haVersionMatch = body.match(/###\s*Home Assistant Version\s*\n+([^\n#]+)/);
const haVersion = haVersionMatch ? haVersionMatch[1].trim() : '';
if (!haVersion || haVersion === '2024.12.0' || haVersion === '_No response_') {
problems.push('**Home Assistant Version** is missing. You can find it in Settings → About.');
}
// 4. Logs / Error Evidence - must contain actual log/error output, an attached file, or a clear N/A with stated reason
const logMatch = body.match(/###\s*Logs \/ Error Evidence\s*\n+([\s\S]*?)(?=\n###|$)/);
const logContent = logMatch ? logMatch[1].trim() : '';
const logIsExplainedNA = /^n\/?a/i.test(logContent);
// Detect GitHub file attachments (pasted as markdown links)
const hasAttachment = /https:\/\/github\.com\/[^)]+\/files\//.test(logContent) ||
/https:\/\/github\.com\/user-attachments\//.test(logContent);
const hasLogs = logContent &&
logContent !== '_No response_' &&
logContent !== 'Paste log output or error messages here, or describe what file(s) you have attached above...' &&
(logContent.length > 50 || hasAttachment);
if (!hasLogs && !logIsExplainedNA) {
problems.push(
'**Logs / Error Evidence** are missing or appear empty. This is the most important piece of information for diagnosing bugs. ' +
'Please paste any relevant logs, error messages, or console output — debug logs if you have them, but even a single error line from the HA logbook is helpful. ' +
'See the **Logs / Error Evidence** section of the issue form for instructions on enabling debug logging. ' +
'If logs are **genuinely not applicable** to this report (for example: a documentation typo, a translation string fix, or a pure UI/config-flow layout issue where no runtime code path is exercised), ' +
'replace the placeholder with `N/A` followed by a short one-line reason — for example: `N/A - typo in README, no runtime behavior involved`. ' +
'Do not write `N/A` just because collecting logs is inconvenient; bug reports without logs and without a valid N/A reason cannot be investigated.'
);
}
// ── Label management ─────────────────────────────────────────────────
const currentLabels = issue.labels.map(l => l.name);
const hasIncompleteLabel = currentLabels.includes(incompleteLabel);
const targetIssueNumber = isDryRun ? issue.number : issue.number;
const canPost = !isDryRun || (isDryRun && targetIssueNumber);
if (problems.length > 0) {
const problemList = problems.map(p => `- ${p}`).join('\n');
const comment = `👋 Hey @${issue.user.login}, thanks for the report!\n\nLooks like a few things are missing that we need before we can investigate:\n\n${problemList}\n\nPlease update the issue with the missing information. Without it, we won't be able to reproduce or debug the problem, and the issue may be closed.\n\n> **How to edit this issue:** click the three-dot menu (⋯) at the top-right of your post → *Edit*.\n\n_This check runs automatically. Once the issue is updated, it will be re-evaluated._`;
if (isDryRun) {
console.log('🧪 DRY RUN - validation result: INCOMPLETE');
console.log('Problems found:\n' + problemList);
console.log('Comment that would be posted:\n' + comment);
if (!canPost) process.exit(0);
}
if (canPost) {
// Add label if not already present
if (!hasIncompleteLabel) {
await github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: targetIssueNumber,
labels: [incompleteLabel],
});
}
// Only post a new comment if we haven't already (avoid comment spam on edits)
const comments = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: targetIssueNumber,
});
const botComments = comments.data.filter(
c => c.user.type === 'Bot' && (c.body || '').includes('missing that we need before we can investigate')
);
if (botComments.length === 0) {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: targetIssueNumber,
body: comment,
});
} else {
// Update the existing bot comment instead
await github.rest.issues.updateComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: botComments[0].id,
body: comment,
});
}
}
} else if (hasIncompleteLabel || isDryRun) {
if (isDryRun) {
console.log('✅ DRY RUN - validation result: COMPLETE (all required fields present)');
if (!canPost) process.exit(0);
}
if (!isDryRun && hasIncompleteLabel) {
// Everything looks good - remove the label and leave a positive note
await github.rest.issues.removeLabel({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issue.number,
name: incompleteLabel,
});
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issue.number,
body: `Thanks for filling in the missing details, @${issue.user.login}! The issue looks complete now and has been queued for review. 🙌`,
});
}
}