From e55f592c4235359d0d9a821393504fa15edcb188 Mon Sep 17 00:00:00 2001 From: grohith327 Date: Fri, 3 Jul 2026 17:26:23 -0700 Subject: [PATCH 1/2] add templates and rules --- .github/ISSUE_TEMPLATE/bug_report.md | 33 ----- .github/ISSUE_TEMPLATE/bug_report.yml | 83 +++++++++++ .github/ISSUE_TEMPLATE/config.yml | 8 ++ .github/ISSUE_TEMPLATE/feature_request.md | 19 --- .github/ISSUE_TEMPLATE/feature_request.yml | 36 +++++ .github/policy/enforce-issue-intake.mjs | 109 ++++++++++++++ .github/policy/enforce-pr-policy.mjs | 134 +++++++++++++++++ .github/policy/github-api.mjs | 111 ++++++++++++++ .github/policy/github-policy.mjs | 159 +++++++++++++++++++++ .github/pull_request_template.md | 30 ++-- .github/workflows/issue-intake.yml | 33 +++++ .github/workflows/pr-policy.yml | 34 +++++ CONTRIBUTING.md | 55 +++++++ 13 files changed, 781 insertions(+), 63 deletions(-) delete mode 100644 .github/ISSUE_TEMPLATE/bug_report.md create mode 100644 .github/ISSUE_TEMPLATE/bug_report.yml create mode 100644 .github/ISSUE_TEMPLATE/config.yml delete mode 100644 .github/ISSUE_TEMPLATE/feature_request.md create mode 100644 .github/ISSUE_TEMPLATE/feature_request.yml create mode 100644 .github/policy/enforce-issue-intake.mjs create mode 100644 .github/policy/enforce-pr-policy.mjs create mode 100644 .github/policy/github-api.mjs create mode 100644 .github/policy/github-policy.mjs create mode 100644 .github/workflows/issue-intake.yml create mode 100644 .github/workflows/pr-policy.yml create mode 100644 CONTRIBUTING.md diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md deleted file mode 100644 index c64d3267..00000000 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ /dev/null @@ -1,33 +0,0 @@ ---- -name: ๐Ÿž Bug Report -about: Create a report to help us improve -title: '[๐Ÿž BUG] ' -labels: bug -assignees: '' ---- - -**Describe the bug** -A clear description of what the bug is. - -**To Reproduce** -Steps to reproduce the behavior: -1. Go to '...' -2. Click on '....' -3. See error - -**Expected behavior** -What you expected to happen. - -**Screenshots** -If applicable, add screenshots. - -**Environment:** - - macOS Version: [e.g. Sonoma 14.2, Ventura 13.5] - - App Version: [e.g. 1.2.3] - - Architecture: [Intel/Apple Silicon] - -**Additional context** -Add any other context about the problem here. - -**Crash Logs** -If the app crashed, please attach relevant crash logs \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 00000000..2c3ce743 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,83 @@ +name: Bug Report +description: Report a reproducible FluidVoice bug. +title: "[BUG] " +labels: ["bug"] +body: + - type: markdown + attributes: + value: | + If you are unsure whether this is a FluidVoice bug, please start a Discussion first: + https://github.com/altic-dev/FluidVoice/discussions + - type: textarea + id: description + attributes: + label: Describe the bug + description: Briefly describe what is broken. + placeholder: FluidVoice crashes when... + validations: + required: true + - type: textarea + id: reproduction + attributes: + label: Reproduction steps + description: Provide exact steps a maintainer can follow. + placeholder: | + 1. Open FluidVoice + 2. Configure... + 3. Press... + 4. Observe... + validations: + required: true + - type: textarea + id: expected + attributes: + label: Expected behavior + description: What should have happened? + validations: + required: true + - type: textarea + id: actual + attributes: + label: Actual behavior + description: What happened instead? + validations: + required: true + - type: input + id: app-version + attributes: + label: App Version + placeholder: "1.2.3" + validations: + required: true + - type: input + id: macos-version + attributes: + label: macOS Version + placeholder: "macOS 15.5" + validations: + required: true + - type: dropdown + id: architecture + attributes: + label: Architecture + options: + - Apple Silicon + - Intel + - Unknown + validations: + required: true + - type: textarea + id: logs + attributes: + label: Logs or crash report + description: Paste relevant logs or crash output when available. + render: text + validations: + required: false + - type: upload + id: media + attributes: + label: Screenshots or screen recording + description: Attach screenshots or a recording if they help explain the bug. + validations: + required: false diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 00000000..193469cc --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,8 @@ +blank_issues_enabled: false +contact_links: + - name: Start a discussion or ask a question + url: https://github.com/altic-dev/FluidVoice/discussions + about: Use Discussions for support questions, unclear reports, ideas, and open-ended design conversations. + - name: Propose a feature idea + url: https://github.com/altic-dev/FluidVoice/discussions/new?category=ideas + about: Feature ideas should start as Discussions before they become prioritized issues. diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md deleted file mode 100644 index 7836b285..00000000 --- a/.github/ISSUE_TEMPLATE/feature_request.md +++ /dev/null @@ -1,19 +0,0 @@ ---- -name: โœจ Feature Request -about: Suggest an idea for this project -title: '[โœจ FEATURE] ' -labels: enhancement -assignees: '' ---- - -**Is your feature request related to a problem?** -A clear description of the problem. - -**Describe the solution you'd like** -What you want to happen. - -**Describe alternatives you've considered** -Other solutions or features you've considered. - -**Additional context** -Any other context or screenshots. \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 00000000..8c61402a --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,36 @@ +name: Feature Request +description: Suggest an idea for FluidVoice. +title: "[FEATURE] " +labels: ["enhancement"] +body: + - type: markdown + attributes: + value: | + Feature ideas should start in Discussions so maintainers and users can shape the request before it becomes tracked work. + + Open a feature idea Discussion here: + https://github.com/altic-dev/FluidVoice/discussions/new?category=ideas + + Use this issue form only for features that already have maintainer acceptance, roadmap priority, or a linked Discussion that has been accepted. + - type: textarea + id: problem + attributes: + label: Problem + description: What user problem does this solve? + placeholder: Users currently cannot... + validations: + required: true + - type: textarea + id: proposal + attributes: + label: Proposed solution + description: Describe the requested behavior. + validations: + required: true + - type: textarea + id: alternatives + attributes: + label: Alternatives considered + description: Mention workarounds or other approaches that were discussed. + validations: + required: false diff --git a/.github/policy/enforce-issue-intake.mjs b/.github/policy/enforce-issue-intake.mjs new file mode 100644 index 00000000..bc3d958b --- /dev/null +++ b/.github/policy/enforce-issue-intake.mjs @@ -0,0 +1,109 @@ +import { readFileSync } from "node:fs"; + +import { validateBugIssue } from "./github-policy.mjs"; +import { + addLabels, + ensureLabel, + githubRequest, + removeLabel, + repoContext, + upsertIssueComment, +} from "./github-api.mjs"; + +const BUG_MARKER = ""; +const STALE_MARKER = ""; +const STALE_REPRODUCTION_DAYS = 14; + +const LABELS = { + needsReproduction: { + name: "needs reproduction", + color: "D93F0B", + description: "Bug report needs enough detail to reproduce.", + }, +}; + +function eventPayload() { + return JSON.parse(readFileSync(process.env.GITHUB_EVENT_PATH, "utf8")); +} + +function missingFieldsText(fields) { + return fields.map((field) => `- ${field}`).join("\n"); +} + +async function enforceBugIssue(context, issue) { + const result = validateBugIssue(issue.body ?? ""); + + if (result.ok) { + await removeLabel(context, issue.number, LABELS.needsReproduction.name); + return; + } + + await addLabels(context, issue.number, [LABELS.needsReproduction.name]); + await upsertIssueComment( + context, + issue.number, + BUG_MARKER, + [ + "This bug report is missing information required for maintainers to reproduce it.", + "", + "Please update the issue with:", + missingFieldsText(result.missing), + "", + `Issues still missing reproduction details after ${STALE_REPRODUCTION_DAYS} days may be closed.`, + ].join("\n"), + ); +} + +async function closeStaleReproductionIssues(context) { + const staleBefore = new Date( + Date.now() - STALE_REPRODUCTION_DAYS * 24 * 60 * 60 * 1000, + ) + .toISOString() + .slice(0, 10); + const query = encodeURIComponent( + `repo:${context.owner}/${context.repo} is:issue is:open label:"${LABELS.needsReproduction.name}" updated:<${staleBefore}`, + ); + const results = await githubRequest("GET", `/search/issues?q=${query}&per_page=100`); + + for (const issue of results.items) { + await upsertIssueComment( + context, + issue.number, + STALE_MARKER, + [ + `Closing this issue because it has been labeled \`needs reproduction\` for at least ${STALE_REPRODUCTION_DAYS} days without the missing reproduction details.`, + "", + "If you can still reproduce the problem, please open a new bug report with exact steps, expected behavior, actual behavior, app version, macOS version, and architecture.", + ].join("\n"), + ); + await githubRequest("PATCH", `/repos/${context.owner}/${context.repo}/issues/${issue.number}`, { + state: "closed", + state_reason: "not_planned", + }); + } +} + +async function main() { + const context = repoContext(); + await Promise.all(Object.values(LABELS).map((label) => ensureLabel(context, label))); + + const eventName = process.env.GITHUB_EVENT_NAME; + if (eventName === "schedule" || eventName === "workflow_dispatch") { + await closeStaleReproductionIssues(context); + return; + } + + const event = eventPayload(); + const issue = event.issue; + if (!issue || issue.pull_request) return; + + const labels = issue.labels.map((label) => + typeof label === "string" ? label : label.name, + ); + + if (labels.includes("bug")) { + await enforceBugIssue(context, issue); + } +} + +await main(); diff --git a/.github/policy/enforce-pr-policy.mjs b/.github/policy/enforce-pr-policy.mjs new file mode 100644 index 00000000..343a68ae --- /dev/null +++ b/.github/policy/enforce-pr-policy.mjs @@ -0,0 +1,134 @@ +import { readFileSync } from "node:fs"; + +import { validatePullRequest } from "./github-policy.mjs"; +import { + addLabels, + ensureLabel, + githubRequest, + paginate, + removeLabel, + repoContext, + upsertIssueComment, +} from "./github-api.mjs"; + +const MARKER = ""; +const HOURS_BEFORE_CLOSE = 48; + +const LABELS = { + template: { + name: "needs PR template", + color: "D93F0B", + description: "Pull request is missing required template content.", + }, + screenshots: { + name: "needs screenshots", + color: "FBCA04", + description: "Pull request needs screenshot or video evidence.", + }, +}; + +function eventPayload() { + return JSON.parse(readFileSync(process.env.GITHUB_EVENT_PATH, "utf8")); +} + +function hoursSince(value) { + return (Date.now() - new Date(value).getTime()) / 36e5; +} + +async function changedFiles(context, pullNumber) { + const { owner, repo } = context; + const files = await paginate(`/repos/${owner}/${repo}/pulls/${pullNumber}/files`); + return files.map((file) => file.filename); +} + +function failureBody(result) { + const missingList = result.missing.map((field) => `- ${field}`).join("\n"); + const visualFiles = result.visualFiles.length + ? `\n\nVisual files detected:\n${result.visualFiles.map((file) => `- \`${file}\``).join("\n")}` + : ""; + + return [ + "The PR Policy check is blocking this PR because required template information is missing.", + "", + "Please update the PR description with:", + missingList, + visualFiles, + "", + "Screenshots or video are required for UI, UX, settings, onboarding, overlay, menu bar, or visual behavior changes. If this PR has no visual changes, check the no-visual-change box in the template.", + "", + `If this remains incomplete for ${HOURS_BEFORE_CLOSE} hours after opening, the PR may be closed.`, + ].join("\n"); +} + +async function enforceOne(context, pr, { closeExpired, failOnInvalid }) { + const files = await changedFiles(context, pr.number); + const result = validatePullRequest({ body: pr.body ?? "", changedFiles: files }); + + if (result.ok) { + await removeLabel(context, pr.number, LABELS.template.name); + await removeLabel(context, pr.number, LABELS.screenshots.name); + return true; + } + + const labelsToAdd = []; + if (result.missing.some((field) => field !== "Screenshots / Video")) { + labelsToAdd.push(LABELS.template.name); + } + if (result.missing.includes("Screenshots / Video")) { + labelsToAdd.push(LABELS.screenshots.name); + } + + await addLabels(context, pr.number, labelsToAdd); + await upsertIssueComment(context, pr.number, MARKER, failureBody(result)); + + const expired = !pr.draft && hoursSince(pr.created_at) >= HOURS_BEFORE_CLOSE; + if (closeExpired && expired) { + await githubRequest("PATCH", `/repos/${context.owner}/${context.repo}/pulls/${pr.number}`, { + state: "closed", + }); + await upsertIssueComment( + context, + pr.number, + MARKER, + [ + "This PR has been closed because it still does not follow the required PR template after the 48-hour correction window.", + "", + "Please open a new PR with the required description, related issue or accepted Discussion, testing notes, and screenshot/video evidence when applicable.", + ].join("\n"), + ); + } + + if (failOnInvalid) { + console.error(`PR #${pr.number} is missing: ${result.missing.join(", ")}`); + process.exitCode = 1; + } + + return false; +} + +async function openPullRequests(context) { + return paginate(`/repos/${context.owner}/${context.repo}/pulls?state=open`); +} + +async function main() { + const context = repoContext(); + await Promise.all(Object.values(LABELS).map((label) => ensureLabel(context, label))); + + const eventName = process.env.GITHUB_EVENT_NAME; + + if (eventName === "schedule" || eventName === "workflow_dispatch") { + const pulls = await openPullRequests(context); + for (const pr of pulls) { + await enforceOne(context, pr, { closeExpired: true, failOnInvalid: false }); + } + return; + } + + const event = eventPayload(); + await enforceOne(context, event.pull_request, { + closeExpired: true, + failOnInvalid: true, + }); +} + +await main(); diff --git a/.github/policy/github-api.mjs b/.github/policy/github-api.mjs new file mode 100644 index 00000000..8fb7c318 --- /dev/null +++ b/.github/policy/github-api.mjs @@ -0,0 +1,111 @@ +const API_VERSION = "2022-11-28"; + +export function repoContext() { + const repository = process.env.GITHUB_REPOSITORY; + if (!repository) throw new Error("GITHUB_REPOSITORY is required"); + + const [owner, repo] = repository.split("/"); + if (!owner || !repo) throw new Error(`Invalid GITHUB_REPOSITORY: ${repository}`); + + return { owner, repo }; +} + +export async function githubRequest(method, path, body) { + const token = process.env.GITHUB_TOKEN; + if (!token) throw new Error("GITHUB_TOKEN is required"); + + const response = await fetch(`https://api.github.com${path}`, { + method, + headers: { + Accept: "application/vnd.github+json", + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + "X-GitHub-Api-Version": API_VERSION, + }, + body: body === undefined ? undefined : JSON.stringify(body), + }); + + if (response.status === 204) return null; + + const text = await response.text(); + const data = text ? JSON.parse(text) : null; + + if (!response.ok) { + const message = data?.message ?? response.statusText; + const error = new Error(`${method} ${path} failed: ${message}`); + error.status = response.status; + error.data = data; + throw error; + } + + return data; +} + +export async function ignoreNotFound(promise) { + try { + return await promise; + } catch (error) { + if (error.status === 404) return null; + throw error; + } +} + +export async function paginate(path) { + const results = []; + let page = 1; + + while (true) { + const separator = path.includes("?") ? "&" : "?"; + const data = await githubRequest( + "GET", + `${path}${separator}per_page=100&page=${page}`, + ); + results.push(...data); + if (data.length < 100) break; + page += 1; + } + + return results; +} + +export async function ensureLabel({ owner, repo }, label) { + const encoded = encodeURIComponent(label.name); + const existing = await ignoreNotFound( + githubRequest("GET", `/repos/${owner}/${repo}/labels/${encoded}`), + ); + + if (existing) return; + + await githubRequest("POST", `/repos/${owner}/${repo}/labels`, label); +} + +export async function addLabels({ owner, repo }, issueNumber, labels) { + if (labels.length === 0) return; + await githubRequest("POST", `/repos/${owner}/${repo}/issues/${issueNumber}/labels`, { + labels, + }); +} + +export async function removeLabel({ owner, repo }, issueNumber, label) { + const encoded = encodeURIComponent(label); + await ignoreNotFound( + githubRequest("DELETE", `/repos/${owner}/${repo}/issues/${issueNumber}/labels/${encoded}`), + ); +} + +export async function upsertIssueComment({ owner, repo }, issueNumber, marker, body) { + const comments = await paginate(`/repos/${owner}/${repo}/issues/${issueNumber}/comments`); + const existing = comments.find((comment) => comment.body?.includes(marker)); + const markedBody = `${marker}\n${body}`; + + if (existing) { + await githubRequest("PATCH", `/repos/${owner}/${repo}/issues/comments/${existing.id}`, { + body: markedBody, + }); + return; + } + + await githubRequest("POST", `/repos/${owner}/${repo}/issues/${issueNumber}/comments`, { + body: markedBody, + }); +} diff --git a/.github/policy/github-policy.mjs b/.github/policy/github-policy.mjs new file mode 100644 index 00000000..c6b8d48a --- /dev/null +++ b/.github/policy/github-policy.mjs @@ -0,0 +1,159 @@ +const PLACEHOLDER_PATTERNS = [ + /brief description of what this pr does/i, + /add screenshots or video recording/i, + /issue number if applicable/i, + /what you expected to happen/i, + /what actually happened/i, + /steps to reproduce/i, + /^\s*(n\/a|none|todo|tbd|\.\.\.)\s*$/i, +]; + +const VISUAL_PATH_PATTERNS = [ + /^Sources\/Fluid\/UI\//, + /^Sources\/Fluid\/Views\//, + /^Sources\/Fluid\/Theme\//, + /^Sources\/Fluid\/Assets\.xcassets\//, + /^\.github\/screenshots\//, + /(^|\/)([^/]*View|[^/]*Views|Settings|Onboarding|Overlay|MenuBar|Icon|Animation|Animations)[^/]*\.swift$/, + /\.xcassets\//, +]; + +const MEDIA_PATTERNS = [ + /!\[[^\]]*]\([^)]+\)/i, + /]*>/i, + /]*>/i, + /https:\/\/github\.com\/[^/\s]+\/[^/\s]+\/assets\/[^\s)]+/i, + /https:\/\/github\.com\/user-attachments\/assets\/[^\s)]+/i, + /https?:\/\/[^\s)]+\.(png|jpe?g|gif|webp|mov|mp4|webm)(\?[^\s)]*)?/i, +]; + +const LINK_PATTERNS = [ + /\b(closes|close|closed|fixes|fix|fixed|resolves|resolve|resolved)\s+#\d+\b/i, + /(^|\s)#\d+\b/, + /github\.com\/[^/\s]+\/[^/\s]+\/(issues|pull)\/\d+/i, + /github\.com\/[^/\s]+\/[^/\s]+\/discussions\/\d+/i, + /\broadmap\b.*https?:\/\//i, +]; + +export function normalizeText(value = "") { + return String(value).replace(/\r\n/g, "\n").trim(); +} + +export function hasNonPlaceholderContent(value = "") { + const text = normalizeText(value) + .replace(//g, "") + .replace(/^[-*]\s*\[[ xX]\]\s*/gm, "") + .trim(); + + if (!text) return false; + return !PLACEHOLDER_PATTERNS.some((pattern) => pattern.test(text)); +} + +export function section(body = "", heading) { + const lines = normalizeText(body).split("\n"); + const normalizedHeading = heading.toLowerCase(); + const start = lines.findIndex((line) => { + const match = line.match(/^#{2,3}\s*(.*?)\s*$/); + return match?.[1]?.toLowerCase() === normalizedHeading; + }); + + if (start === -1) return ""; + + const end = lines.findIndex( + (line, index) => index > start && /^#{2,3}\s+\S/.test(line), + ); + return lines.slice(start + 1, end === -1 ? undefined : end).join("\n").trim(); +} + +export function hasCheckedCheckbox(value = "") { + return /^[-*]\s*\[[xX]\]\s+\S+/m.test(value); +} + +export function hasRelatedReference(value = "") { + const text = normalizeText(value); + return LINK_PATTERNS.some((pattern) => pattern.test(text)); +} + +export function hasMedia(value = "") { + const text = normalizeText(value); + return MEDIA_PATTERNS.some((pattern) => pattern.test(text)); +} + +export function hasNoVisualChangeAttestation(value = "") { + return /^[-*]\s*\[[xX]\]\s+No UI\/visual changes; screenshots\/video are not applicable\./im.test( + value, + ); +} + +export function findVisualFiles(files = []) { + return files.filter((file) => { + const basename = file.split("/").at(-1) ?? file; + return ( + VISUAL_PATH_PATTERNS.some((pattern) => pattern.test(file)) || + /View|Settings|Onboarding|Overlay|MenuBar|Icon|Animation/i.test(basename) + ); + }); +} + +export function hasTestingEvidence(value = "") { + const text = normalizeText(value); + const containsCheckbox = /^[-*]\s*\[[ xX]\]\s+\S+/m.test(text); + return containsCheckbox ? hasCheckedCheckbox(text) : hasNonPlaceholderContent(text); +} + +export function validatePullRequest({ body = "", changedFiles = [] } = {}) { + const description = section(body, "Description"); + const typeOfChange = section(body, "Type of Change"); + const related = section(body, "Related Issue or Discussion"); + const testing = section(body, "Testing"); + const screenshots = section(body, "Screenshots / Video"); + const visualFiles = findVisualFiles(changedFiles); + const attestsNoVisualChange = hasNoVisualChangeAttestation(screenshots); + const requiresMedia = visualFiles.length > 0 || !attestsNoVisualChange; + + const checks = [ + ["Description", hasNonPlaceholderContent(description)], + ["Type of Change", hasCheckedCheckbox(typeOfChange)], + ["Related Issue or Discussion", hasRelatedReference(related)], + ["Testing", hasTestingEvidence(testing)], + [ + "Screenshots / Video", + requiresMedia ? hasMedia(screenshots) : attestsNoVisualChange, + ], + ]; + + const missing = checks + .filter(([, passed]) => !passed) + .map(([name]) => name); + + return { + ok: missing.length === 0, + missing, + requiresMedia, + visualFiles, + attestsNoVisualChange, + }; +} + +export function validateIssueFields(body = "", requiredHeadings = []) { + const missing = requiredHeadings.filter( + (heading) => !hasNonPlaceholderContent(section(body, heading)), + ); + + return { + ok: missing.length === 0, + missing, + }; +} + +export function validateBugIssue(body = "") { + return validateIssueFields(body, [ + "Describe the bug", + "Reproduction steps", + "Expected behavior", + "Actual behavior", + "App Version", + "macOS Version", + "Architecture", + ]); +} diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 024a82c5..8dfb2206 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -1,25 +1,33 @@ + + ## Description -Brief description of what this PR does. +Brief description of what this PR does and why it is needed. ## Type of Change -- [ ] ๐Ÿž Bug fix (non-breaking change which fixes an issue) -- [ ] โœจ New feature (non-breaking change which adds functionality) -- [ ] ๐Ÿ’ฅ Breaking change (fix or feature that would cause existing functionality to not work as expected) +- [ ] ๐Ÿž Bug fix +- [ ] โœจ New feature +- [ ] ๐Ÿ’ฅ Breaking change - [ ] ๐Ÿงน Chore - [ ] ๐Ÿ“ Documentation update -## Related Issues -- Closes #(issue number if applicable) +## Related Issue or Discussion +Closes #(issue number), or links to an accepted Discussion/roadmap item. ## Testing - [ ] Tested on Intel Mac - [ ] Tested on Apple Silicon Mac -- [ ] Tested on macOS [version] +- [ ] Tested on macOS version: - [ ] Ran linter locally: `swiftlint --strict --config .swiftlint.yml Sources` - [ ] Ran formatter locally: `swiftformat --config .swiftformat Sources` +- [ ] Ran tests locally: -## Notes -- Add any important reviewer context, rollout notes, or known tradeoffs here. +## Screenshots / Video +Attach screenshots or a video for UI, UX, settings, onboarding, overlay, menu bar, or visual behavior changes. -## Screenshots / Video -Add screenshots or Video recording of the app after you have made your changes +- [ ] No UI/visual changes; screenshots/video are not applicable. + +## Notes +Add reviewer context, rollout notes, or known tradeoffs here. diff --git a/.github/workflows/issue-intake.yml b/.github/workflows/issue-intake.yml new file mode 100644 index 00000000..d799826f --- /dev/null +++ b/.github/workflows/issue-intake.yml @@ -0,0 +1,33 @@ +name: Issue Intake + +on: + issues: + types: [opened, edited, reopened] + schedule: + - cron: "41 7 * * *" + workflow_dispatch: + +permissions: + contents: read + issues: write + +jobs: + issue-intake: + name: Issue Intake + runs-on: ubuntu-latest + steps: + - name: Checkout policy from default branch + uses: actions/checkout@v4 + with: + ref: ${{ github.event.repository.default_branch }} + persist-credentials: false + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: "20" + + - name: Validate issue intake + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: node .github/policy/enforce-issue-intake.mjs diff --git a/.github/workflows/pr-policy.yml b/.github/workflows/pr-policy.yml new file mode 100644 index 00000000..aff4d123 --- /dev/null +++ b/.github/workflows/pr-policy.yml @@ -0,0 +1,34 @@ +name: PR Policy + +on: + pull_request_target: + types: [opened, edited, synchronize, reopened, ready_for_review] + schedule: + - cron: "17 * * * *" + workflow_dispatch: + +permissions: + contents: read + issues: write + pull-requests: write + +jobs: + pr-policy: + name: PR Policy + runs-on: ubuntu-latest + steps: + - name: Checkout policy from base branch + uses: actions/checkout@v4 + with: + ref: ${{ github.event.repository.default_branch }} + persist-credentials: false + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: "20" + + - name: Validate PR template and visual evidence + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: node .github/policy/enforce-pr-policy.mjs diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 00000000..a9b52328 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,55 @@ +# Contributing to FluidVoice + +Thanks for taking the time to improve FluidVoice. This repository keeps GitHub Issues focused on actionable work, and uses Discussions for questions, ideas, and early design conversations. + +## Start with Discussions + +Start a GitHub Discussion first when you want to: + +- Ask a support question. +- Propose a broad idea or feature. +- Explore a design direction. +- Report behavior that you are not sure is a FluidVoice bug. +- Ask whether a change would be accepted before writing code. + +Feature ideas should usually begin in the Ideas discussion category. Maintainers may turn an accepted or prioritized discussion into a tracked issue. + +## Issues + +Issues are for work that maintainers can triage and act on. + +Use a bug issue only when you can provide: + +- A clear description of the bug. +- Exact reproduction steps. +- Expected behavior and actual behavior. +- FluidVoice version, macOS version, and architecture. +- Logs, crash reports, screenshots, or recordings when relevant. + +Feature issues show guidance to start with Discussions first, but this is advisory for now. Maintainers may still redirect broad or unclear feature ideas to Discussions during triage. + +Incomplete bug reports may be labeled `needs reproduction`. If the missing reproduction details are not provided after 14 days, the issue may be closed. + +## Pull Requests + +Pull requests should be tied to an accepted issue, Discussion, or roadmap item. Before opening a PR: + +- Fill out every required section of the PR template. +- Select a type of change. +- Link the related issue or accepted Discussion. +- Describe how you tested the change. +- Attach screenshots or video for UI, UX, settings, onboarding, overlay, menu bar, or visual behavior changes. + +If a PR has no UI or visual behavior changes, check the "No UI/visual changes" box in the template. The PR Policy workflow still requires screenshots or video when changed files touch visual surfaces. + +PRs that do not follow the template will be blocked by the `PR Policy` check. If required information is still missing after 48 hours, the PR may be closed so maintainers can keep review queues focused. + +## Repository Settings + +Maintainers should enable GitHub Discussions with these categories: + +- Ideas +- Help +- General + +Maintainers should require the existing build/test check and the `PR Policy` check before merging to `main`. From 61918344988c856001a3c103e24c6c0f1289e871 Mon Sep 17 00:00:00 2001 From: grohith327 Date: Fri, 3 Jul 2026 17:50:00 -0700 Subject: [PATCH 2/2] tweaks to checks --- .github/policy/enforce-issue-intake.mjs | 14 +++++++++++ .github/policy/enforce-pr-policy.mjs | 31 ++++++++++++++++++++++--- .github/policy/github-policy.mjs | 13 ++++++----- 3 files changed, 49 insertions(+), 9 deletions(-) diff --git a/.github/policy/enforce-issue-intake.mjs b/.github/policy/enforce-issue-intake.mjs index bc3d958b..e5dd2d44 100644 --- a/.github/policy/enforce-issue-intake.mjs +++ b/.github/policy/enforce-issue-intake.mjs @@ -5,6 +5,7 @@ import { addLabels, ensureLabel, githubRequest, + paginate, removeLabel, repoContext, upsertIssueComment, @@ -30,6 +31,13 @@ function missingFieldsText(fields) { return fields.map((field) => `- ${field}`).join("\n"); } +async function existingBugWarningComment(context, issueNumber) { + const comments = await paginate( + `/repos/${context.owner}/${context.repo}/issues/${issueNumber}/comments`, + ); + return comments.find((comment) => comment.body?.includes(BUG_MARKER)); +} + async function enforceBugIssue(context, issue) { const result = validateBugIssue(issue.body ?? ""); @@ -66,6 +74,9 @@ async function closeStaleReproductionIssues(context) { const results = await githubRequest("GET", `/search/issues?q=${query}&per_page=100`); for (const issue of results.items) { + const warningComment = await existingBugWarningComment(context, issue.number); + if (!warningComment) continue; + await upsertIssueComment( context, issue.number, @@ -97,6 +108,9 @@ async function main() { const issue = event.issue; if (!issue || issue.pull_request) return; + const warningComment = await existingBugWarningComment(context, issue.number); + if (event.action !== "opened" && !warningComment) return; + const labels = issue.labels.map((label) => typeof label === "string" ? label : label.name, ); diff --git a/.github/policy/enforce-pr-policy.mjs b/.github/policy/enforce-pr-policy.mjs index 343a68ae..05aec283 100644 --- a/.github/policy/enforce-pr-policy.mjs +++ b/.github/policy/enforce-pr-policy.mjs @@ -41,6 +41,13 @@ async function changedFiles(context, pullNumber) { return files.map((file) => file.filename); } +async function existingWarningComment(context, pullNumber) { + const comments = await paginate( + `/repos/${context.owner}/${context.repo}/issues/${pullNumber}/comments`, + ); + return comments.find((comment) => comment.body?.includes(MARKER)); +} + function failureBody(result) { const missingList = result.missing.map((field) => `- ${field}`).join("\n"); const visualFiles = result.visualFiles.length @@ -60,7 +67,7 @@ function failureBody(result) { ].join("\n"); } -async function enforceOne(context, pr, { closeExpired, failOnInvalid }) { +async function enforceOne(context, pr, { closeExpired, failOnInvalid, warningComment }) { const files = await changedFiles(context, pr.number); const result = validatePullRequest({ body: pr.body ?? "", changedFiles: files }); @@ -78,10 +85,14 @@ async function enforceOne(context, pr, { closeExpired, failOnInvalid }) { labelsToAdd.push(LABELS.screenshots.name); } + warningComment ??= await existingWarningComment(context, pr.number); await addLabels(context, pr.number, labelsToAdd); await upsertIssueComment(context, pr.number, MARKER, failureBody(result)); - const expired = !pr.draft && hoursSince(pr.created_at) >= HOURS_BEFORE_CLOSE; + const expired = + !pr.draft && + warningComment?.created_at && + hoursSince(warningComment.created_at) >= HOURS_BEFORE_CLOSE; if (closeExpired && expired) { await githubRequest("PATCH", `/repos/${context.owner}/${context.repo}/pulls/${pr.number}`, { state: "closed", @@ -119,15 +130,29 @@ async function main() { if (eventName === "schedule" || eventName === "workflow_dispatch") { const pulls = await openPullRequests(context); for (const pr of pulls) { - await enforceOne(context, pr, { closeExpired: true, failOnInvalid: false }); + const warningComment = await existingWarningComment(context, pr.number); + if (!warningComment) continue; + + await enforceOne(context, pr, { + closeExpired: true, + failOnInvalid: false, + warningComment, + }); } return; } const event = eventPayload(); + const warningComment = await existingWarningComment(context, event.pull_request.number); + + if (event.action !== "opened" && !warningComment) { + return; + } + await enforceOne(context, event.pull_request, { closeExpired: true, failOnInvalid: true, + warningComment, }); } diff --git a/.github/policy/github-policy.mjs b/.github/policy/github-policy.mjs index c6b8d48a..149d4d6a 100644 --- a/.github/policy/github-policy.mjs +++ b/.github/policy/github-policy.mjs @@ -1,10 +1,11 @@ const PLACEHOLDER_PATTERNS = [ - /brief description of what this pr does/i, - /add screenshots or video recording/i, - /issue number if applicable/i, - /what you expected to happen/i, - /what actually happened/i, - /steps to reproduce/i, + /^brief description of what this pr does(?: and why it is needed)?\.?$/i, + /^add screenshots or video recording.*$/i, + /^attach screenshots or a video.*$/i, + /^closes #?\(issue number.*$/i, + /^what you expected to happen\.?$/i, + /^what actually happened\.?$/i, + /^steps to reproduce:?$/i, /^\s*(n\/a|none|todo|tbd|\.\.\.)\s*$/i, ];