diff --git a/.github/APPROVED_CONTRIBUTORS b/.github/APPROVED_CONTRIBUTORS new file mode 100644 index 0000000..ba2edd6 --- /dev/null +++ b/.github/APPROVED_CONTRIBUTORS @@ -0,0 +1,9 @@ +# GitHub handles approved to bypass contribution auto-close +# Format: +# capability: +# issue future issues stay open +# pr future issues and PRs stay open +# Maintainers add people by replying `lgtmi` or `lgtm` on an issue +# (.github/workflows/approve-contributor.yml); editing this file by hand works too. + +fbl100 pr diff --git a/.github/ISSUE_TEMPLATE/bug.yml b/.github/ISSUE_TEMPLATE/bug.yml new file mode 100644 index 0000000..32009be --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug.yml @@ -0,0 +1,51 @@ +name: Bug report +description: Report something that's broken +labels: ["bug"] +body: + - type: markdown + attributes: + value: | + **Before you start:** read [CONTRIBUTING.md](https://github.com/saphid/frame-control/blob/main/CONTRIBUTING.md). + + Issues from new contributors are auto-closed by default. A maintainer reviews them and reopens worthwhile ones. The [website feedback form](https://frame-control.pages.dev/feedback/) skips that queue. + + Keep this short. If it doesn't fit on one screen, it's too long. Write in your own voice. + + - type: textarea + id: description + attributes: + label: What happened? + description: Be specific. Include error messages and the last lines of the server log (Frame → Show Server Log). + validations: + required: true + + - type: textarea + id: repro + attributes: + label: Steps to reproduce + description: Minimal steps to trigger the bug. + validations: + required: false + + - type: textarea + id: expected + attributes: + label: Expected behavior + validations: + required: false + + - type: input + id: version + attributes: + label: Frame Control version + description: e.g. v0.3.1 + validations: + required: false + + - type: input + id: os + attributes: + label: Computer and SteamOS build + description: e.g. Windows 11, SteamOS 20260922.6101926 (Steam Settings → System) + validations: + required: false diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..af55e89 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,5 @@ +blank_issues_enabled: false +contact_links: + - name: Feedback form (no GitHub account needed, skips the queue) + url: https://frame-control.pages.dev/feedback/ + about: Bugs, ideas and questions from the website become issues here without being auto-closed. diff --git a/.github/ISSUE_TEMPLATE/idea.yml b/.github/ISSUE_TEMPLATE/idea.yml new file mode 100644 index 0000000..9f95ac7 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/idea.yml @@ -0,0 +1,36 @@ +name: Idea or contribution proposal +description: Propose a change or feature (required for new contributors before opening a PR) +labels: ["enhancement"] +body: + - type: markdown + attributes: + value: | + **Before you start:** read [CONTRIBUTING.md](https://github.com/saphid/frame-control/blob/main/CONTRIBUTING.md). + + Issues from new contributors are auto-closed by default. A maintainer reviews them and reopens worthwhile ones. + + Keep this short. If it doesn't fit on one screen, it's too long. Write in your own voice. + + - type: textarea + id: what + attributes: + label: What do you want to change? + description: Be specific and concise. + validations: + required: true + + - type: textarea + id: why + attributes: + label: Why? + description: What problem does this solve? + validations: + required: true + + - type: textarea + id: how + attributes: + label: How? (optional) + description: Brief technical approach, and whether you'd like to implement it yourself. + validations: + required: false diff --git a/.github/workflows/approve-contributor.yml b/.github/workflows/approve-contributor.yml new file mode 100644 index 0000000..9cf74ec --- /dev/null +++ b/.github/workflows/approve-contributor.yml @@ -0,0 +1,238 @@ +# Contributor gate adapted from badlogic/pi-mono (MIT) at 6f7551516b84. +# See CONTRIBUTING.md for how it works. + +name: Approve Contributor + +on: + issue_comment: + types: [created] + +jobs: + approve: + # Only maintainers' comments that might approve someone join the queue, and + # they run one at a time so two lgtm replies can't race on APPROVED_CONTRIBUTORS. + # (The script below still checks for write access.) + if: >- + contains(github.event.comment.body, 'lgtm') && + contains(fromJSON('["OWNER", "MEMBER", "COLLABORATOR"]'), github.event.comment.author_association) + concurrency: + group: approve-contributor + cancel-in-progress: false + queue: max + runs-on: ubuntu-latest + permissions: + contents: write + issues: write + pull-requests: write + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.event.repository.default_branch }} + + - name: Update contributor approval + id: update + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + script: | + const fs = require('fs'); + + const APPROVED_FILE = '.github/APPROVED_CONTRIBUTORS'; + const VALID_CAPABILITIES = new Set(['issue', 'pr']); + const issueAuthor = context.payload.issue.user.login; + const commenter = context.payload.comment.user.login; + const commentBody = (context.payload.comment.body || '').trim(); + + const approvalAtStartPattern = /^[\s.]*(?:@[A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?(?:\s*,\s*|[.:]\s*|\s+))*(lgtmi|lgtm)(?=$|[\s]|[^\p{L}\p{N}_\s])/iu; + const approvalAtEndPattern = /(?:^|[\s.])(lgtmi|lgtm)\s*(?:[^\p{L}\p{N}_\s])?\s*$/iu; + const approvalMatch = commentBody.match(approvalAtStartPattern) ?? commentBody.match(approvalAtEndPattern); + + if (!approvalMatch) { + console.log('Comment does not start or end with lgtm or lgtmi'); + core.setOutput('status', 'skipped'); + return; + } + + const targetCapability = approvalMatch[1].toLowerCase() === 'lgtmi' ? 'issue' : 'pr'; + + try { + const { data: permissionLevel } = await github.rest.repos.getCollaboratorPermissionLevel({ + owner: context.repo.owner, + repo: context.repo.repo, + username: commenter, + }); + + if (!['admin', 'maintain', 'write'].includes(permissionLevel.permission)) { + console.log(`${commenter} does not have write access`); + core.setOutput('status', 'skipped'); + return; + } + } catch { + console.log(`${commenter} does not have collaborator access`); + core.setOutput('status', 'skipped'); + return; + } + + function parseMentionedUsers(body) { + const users = []; + const seenUsers = new Set(); + const mentionPattern = /(^|[^A-Za-z0-9_])@([A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?)(?![A-Za-z0-9-]|\/)/g; + + for (const match of body.matchAll(mentionPattern)) { + const username = match[2]; + const normalizedUser = username.toLowerCase(); + if (seenUsers.has(normalizedUser)) { + continue; + } + seenUsers.add(normalizedUser); + users.push(username); + } + + return users; + } + + function parseApprovedUsers(content) { + const lines = content.split('\n'); + const entries = []; + const users = new Map(); + + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith('#')) { + entries.push({ type: 'other', line }); + continue; + } + + const parts = trimmed.split(/\s+/); + if (parts.length !== 2) { + console.log(`Skipping malformed line: ${line}`); + entries.push({ type: 'other', line }); + continue; + } + + const [username, capability] = parts; + const normalizedCapability = capability.toLowerCase(); + if (!VALID_CAPABILITIES.has(normalizedCapability)) { + console.log(`Skipping line with invalid capability: ${line}`); + entries.push({ type: 'other', line }); + continue; + } + + const normalizedUser = username.toLowerCase(); + const entry = { type: 'user', username, normalizedUser, capability: normalizedCapability }; + entries.push(entry); + users.set(normalizedUser, entry); + } + + return { entries, users }; + } + + function stringifyApprovedUsers(entries) { + const normalizedEntries = [...entries]; + + while (normalizedEntries.length > 0) { + const lastEntry = normalizedEntries[normalizedEntries.length - 1]; + if (lastEntry.type !== 'other' || lastEntry.line.trim() !== '') { + break; + } + normalizedEntries.pop(); + } + + return `${normalizedEntries + .map((entry) => (entry.type === 'user' ? `${entry.username} ${entry.capability}` : entry.line)) + .join('\n')}\n`; + } + + const content = fs.readFileSync(APPROVED_FILE, 'utf8'); + const { entries, users } = parseApprovedUsers(content); + const mentionedUsers = parseMentionedUsers(commentBody); + const approvalTargets = mentionedUsers.length > 0 ? mentionedUsers : [issueAuthor]; + const changedTargets = []; + const alreadyTargets = []; + + for (const username of approvalTargets) { + const normalizedUser = username.toLowerCase(); + const existingEntry = users.get(normalizedUser); + const existingCapability = existingEntry?.capability ?? null; + + if (existingCapability === 'pr' || existingCapability === targetCapability) { + alreadyTargets.push(existingEntry?.username ?? username); + console.log(`${username} is already approved for ${existingCapability}`); + continue; + } + + if (existingEntry) { + existingEntry.capability = targetCapability; + changedTargets.push(existingEntry.username); + } else { + const entry = { type: 'user', username, normalizedUser, capability: targetCapability }; + entries.push(entry); + users.set(normalizedUser, entry); + changedTargets.push(username); + } + + console.log(`Set ${username} capability to ${targetCapability}`); + } + + core.setOutput('capability', targetCapability); + core.setOutput('changed_targets', JSON.stringify(changedTargets)); + core.setOutput('already_targets', JSON.stringify(alreadyTargets)); + + if (changedTargets.length === 0) { + core.setOutput('status', 'already'); + return; + } + + fs.writeFileSync(APPROVED_FILE, stringifyApprovedUsers(entries)); + core.setOutput('status', 'changed'); + + - name: Commit and push + if: steps.update.outputs.status == 'changed' + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git add .github/APPROVED_CONTRIBUTORS + git diff --staged --quiet || git commit -m "chore: approve contributors from issue #${{ github.event.issue.number }}" + # main may have moved since checkout; replay the approval on top of it. + git pull --rebase origin "${{ github.event.repository.default_branch }}" + git push + + - name: Comment on issue + if: steps.update.outputs.status == 'changed' || steps.update.outputs.status == 'already' + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + CAPABILITY: ${{ steps.update.outputs.capability }} + CHANGED_TARGETS: ${{ steps.update.outputs.changed_targets }} + ALREADY_TARGETS: ${{ steps.update.outputs.already_targets }} + with: + script: | + const capability = process.env.CAPABILITY; + const changedTargets = JSON.parse(process.env.CHANGED_TARGETS || '[]'); + const alreadyTargets = JSON.parse(process.env.ALREADY_TARGETS || '[]'); + const defaultBranch = context.payload.repository.default_branch; + const formatTargets = (targets) => targets.map((target) => `@${target}`).join(', '); + const bodyLines = []; + + if (changedTargets.length > 0) { + if (capability === 'issue') { + bodyLines.push(`${formatTargets(changedTargets)} approved for issues. Future issues will not be auto-closed. PRs still require \`lgtm\` at the start of a maintainer reply (optionally after one or more \`@username\` mentions) or at the end.`); + } else { + bodyLines.push(`${formatTargets(changedTargets)} approved for issues and PRs. Future issues and PRs will not be auto-closed.`); + } + } + + if (alreadyTargets.length > 0) { + const verb = alreadyTargets.length === 1 ? 'is' : 'are'; + bodyLines.push(`${formatTargets(alreadyTargets)} ${verb} already approved.`); + } + + bodyLines.push('', `See [CONTRIBUTING.md](https://github.com/${context.repo.owner}/${context.repo.repo}/blob/${defaultBranch}/CONTRIBUTING.md).`); + const body = bodyLines.join('\n'); + + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + body, + }); + diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index 654da64..9239b5d 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -35,6 +35,8 @@ jobs: run: python -m unittest discover -s tests -v - name: App syntax run: node --check app/main.js && node --check app/build/make-icon.js && node --check app/build/fetch-deps.js && node --check app/preload.js && node --check app/install-link.js + - name: Website + run: node --test site/test/*.test.mjs && node --check site/public/js/site.js && node --check site/public/js/feedback.js # The server runs on each desktop OS the app ships for, on the Python version # the app bundles (app/build/fetch-deps.js) and, on Ubuntu, a newer one. diff --git a/.github/workflows/issue-gate.yml b/.github/workflows/issue-gate.yml new file mode 100644 index 0000000..8695373 --- /dev/null +++ b/.github/workflows/issue-gate.yml @@ -0,0 +1,134 @@ +# Contributor gate adapted from badlogic/pi-mono (MIT) at 6f7551516b84. +# See CONTRIBUTING.md for how it works. + +name: Issue Gate + +on: + issues: + types: [opened] + +jobs: + check-contributor: + runs-on: ubuntu-latest + permissions: + contents: read + issues: write + steps: + - name: Check issue author + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + script: | + const APPROVED_FILE = '.github/APPROVED_CONTRIBUTORS'; + const VALID_CAPABILITIES = new Set(['issue', 'pr']); + const TRUSTED_BOT_AUTHORS = new Set(['dependabot[bot]', 'sentry[bot]', 'claude[bot]']); + const issueAuthor = context.payload.issue.user.login; + const defaultBranch = context.payload.repository.default_branch; + const isBotAuthor = issueAuthor.endsWith('[bot]'); + + if (TRUSTED_BOT_AUTHORS.has(issueAuthor)) { + console.log(`Skipping trusted bot: ${issueAuthor}`); + return; + } + + async function getPermission(username) { + try { + const { data: permissionLevel } = await github.rest.repos.getCollaboratorPermissionLevel({ + owner: context.repo.owner, + repo: context.repo.repo, + username, + }); + return permissionLevel.permission; + } catch { + return null; + } + } + + async function getTextFile(path) { + const { data: fileContent } = await github.rest.repos.getContent({ + owner: context.repo.owner, + repo: context.repo.repo, + path, + ref: defaultBranch, + }); + + if (!('content' in fileContent) || typeof fileContent.content !== 'string') { + throw new Error(`Expected file content for ${path}`); + } + + return Buffer.from(fileContent.content, 'base64').toString('utf8'); + } + + function parseApprovedUsers(content) { + const users = new Map(); + + for (const rawLine of content.split('\n')) { + const line = rawLine.trim(); + if (!line || line.startsWith('#')) continue; + + const parts = line.split(/\s+/); + if (parts.length !== 2) { + console.log(`Skipping malformed line: ${rawLine}`); + continue; + } + + const [username, capability] = parts; + const normalizedCapability = capability.toLowerCase(); + if (!VALID_CAPABILITIES.has(normalizedCapability)) { + console.log(`Skipping line with invalid capability: ${rawLine}`); + continue; + } + + users.set(username.toLowerCase(), normalizedCapability); + } + + return users; + } + + const permission = await getPermission(issueAuthor); + if (!isBotAuthor && ['admin', 'maintain', 'write'].includes(permission)) { + console.log(`${issueAuthor} is a collaborator with ${permission} access`); + return; + } + + const approvedContent = await getTextFile(APPROVED_FILE); + const approvedUsers = parseApprovedUsers(approvedContent); + const capability = approvedUsers.get(issueAuthor.toLowerCase()); + + if (!isBotAuthor && (capability === 'issue' || capability === 'pr')) { + console.log(`${issueAuthor} is approved for ${capability}`); + return; + } + + const message = [ + 'This issue was auto-closed. All issues from new contributors are auto-closed by default.', + '', + `Maintainers review auto-closed issues regularly and reopen worthwhile ones. Issues that do not meet the quality bar in [CONTRIBUTING.md](https://github.com/${context.repo.owner}/${context.repo.repo}/blob/${defaultBranch}/CONTRIBUTING.md) will not be reopened or receive a reply.`, + '', + 'Just want to report a bug or share an idea? The [website feedback form](https://frame-control.pages.dev/feedback/) skips this queue.', + '', + 'If a maintainer replies `lgtmi` on one of your issues, your future issues will stay open. If a maintainer replies `lgtm`, your future issues and PRs will stay open. The command must be at the start of the reply (optionally after one or more `@username` mentions) or at the end.', + '', + `See [CONTRIBUTING.md](https://github.com/${context.repo.owner}/${context.repo.repo}/blob/${defaultBranch}/CONTRIBUTING.md).`, + ].join('\n'); + + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + body: message, + }); + + await github.rest.issues.addLabels({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + labels: ['untriaged'], + }); + + await github.rest.issues.update({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + state: 'closed', + state_reason: 'not_planned', + }); diff --git a/.github/workflows/issue-triage-labels.yml b/.github/workflows/issue-triage-labels.yml new file mode 100644 index 0000000..0ce3f50 --- /dev/null +++ b/.github/workflows/issue-triage-labels.yml @@ -0,0 +1,145 @@ +# Contributor gate adapted from badlogic/pi-mono (MIT) at 6f7551516b84. +# See CONTRIBUTING.md for how it works. + +name: Issue Triage Labels + +on: + issues: + types: [reopened, labeled] + +jobs: + update-labels: + runs-on: ubuntu-latest + permissions: + issues: write + steps: + - name: Update triage labels + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + script: | + const UNTRIAGED_LABEL = 'untriaged'; + const NO_ACTION_LABEL = 'no-action'; + const LAST_READ_LABEL = 'last-read'; + const TO_DISCUSS_LABEL = 'to-discuss'; + const INPROGRESS_LABEL = 'inprogress'; + + function issueHasLabel(issue, labelName) { + return (issue.labels ?? []).some((label) => label.name === labelName); + } + + async function removeLabelIfPresent(issueNumber, issue, labelName) { + if (!issueHasLabel(issue, labelName)) { + console.log(`Issue #${issueNumber} does not have ${labelName}`); + return; + } + + try { + await github.rest.issues.removeLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issueNumber, + name: labelName, + }); + console.log(`Removed ${labelName} from #${issueNumber}`); + } catch (error) { + if (error.status === 404) { + console.log(`Label ${labelName} was already absent from #${issueNumber}`); + return; + } + throw error; + } + } + + if (context.payload.action === 'reopened') { + await removeLabelIfPresent(context.issue.number, context.payload.issue, UNTRIAGED_LABEL); + await removeLabelIfPresent(context.issue.number, context.payload.issue, NO_ACTION_LABEL); + return; + } + + if (context.payload.action === 'labeled' && context.payload.label?.name === NO_ACTION_LABEL) { + await removeLabelIfPresent(context.issue.number, context.payload.issue, UNTRIAGED_LABEL); + return; + } + + if (context.payload.action !== 'labeled' || context.payload.label?.name !== LAST_READ_LABEL) { + console.log('Not a last-read label event'); + return; + } + + const currentIssueNumber = context.issue.number; + const lastReadIssues = await github.paginate(github.rest.issues.listForRepo, { + owner: context.repo.owner, + repo: context.repo.repo, + state: 'all', + labels: LAST_READ_LABEL, + per_page: 100, + }); + + const previousIssueNumbers = lastReadIssues + .filter((issue) => !issue.pull_request) + .map((issue) => issue.number) + .filter((issueNumber) => issueNumber !== currentIssueNumber); + + if (previousIssueNumbers.length === 0) { + console.log('No previous last-read issue found'); + return; + } + + const previousIssueNumber = Math.max(...previousIssueNumbers); + if (currentIssueNumber <= previousIssueNumber) { + console.log( + `Last-read was added to old issue #${currentIssueNumber}; latest last-read is #${previousIssueNumber}`, + ); + return; + } + + const untriagedIssues = await github.paginate(github.rest.issues.listForRepo, { + owner: context.repo.owner, + repo: context.repo.repo, + state: 'all', + labels: UNTRIAGED_LABEL, + per_page: 100, + }); + + const issuesToMark = untriagedIssues + .filter((issue) => !issue.pull_request) + .filter((issue) => issue.number >= previousIssueNumber && issue.number <= currentIssueNumber) + .sort((a, b) => a.number - b.number); + + if (issuesToMark.length === 0) { + console.log(`No untriaged issues found from #${previousIssueNumber} to #${currentIssueNumber}`); + return; + } + + for (const issue of issuesToMark) { + if (issueHasLabel(issue, TO_DISCUSS_LABEL)) { + console.log(`Skipped ${NO_ACTION_LABEL} for #${issue.number} because it has ${TO_DISCUSS_LABEL}`); + } else { + await github.rest.issues.addLabels({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issue.number, + labels: [NO_ACTION_LABEL], + }); + console.log(`Added ${NO_ACTION_LABEL} to #${issue.number}`); + } + + await github.rest.issues.update({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issue.number, + state: 'closed', + state_reason: 'not_planned', + }); + console.log(`Closed #${issue.number} as not planned`); + + await removeLabelIfPresent(issue.number, issue, INPROGRESS_LABEL); + + await github.rest.issues.removeLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issue.number, + name: UNTRIAGED_LABEL, + }); + console.log(`Removed ${UNTRIAGED_LABEL} from #${issue.number}`); + } diff --git a/.github/workflows/pr-gate.yml b/.github/workflows/pr-gate.yml new file mode 100644 index 0000000..b5674c2 --- /dev/null +++ b/.github/workflows/pr-gate.yml @@ -0,0 +1,131 @@ +# Contributor gate adapted from badlogic/pi-mono (MIT) at 6f7551516b84. +# See CONTRIBUTING.md for how it works. + +name: PR Gate + +on: + pull_request_target: + types: [opened] + +jobs: + check-contributor: + runs-on: ubuntu-latest + permissions: + contents: read + issues: write + pull-requests: write + steps: + - name: Check if contributor is approved + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + script: | + const APPROVED_FILE = '.github/APPROVED_CONTRIBUTORS'; + const VALID_CAPABILITIES = new Set(['issue', 'pr']); + const TRUSTED_BOT_AUTHORS = new Set(['dependabot[bot]', 'sentry[bot]', 'claude[bot]']); + const prAuthor = context.payload.pull_request.user.login; + const defaultBranch = context.payload.repository.default_branch; + const isBotAuthor = prAuthor.endsWith('[bot]'); + + if (TRUSTED_BOT_AUTHORS.has(prAuthor)) { + console.log(`Skipping trusted bot: ${prAuthor}`); + return; + } + + async function getPermission(username) { + try { + const { data: permissionLevel } = await github.rest.repos.getCollaboratorPermissionLevel({ + owner: context.repo.owner, + repo: context.repo.repo, + username, + }); + return permissionLevel.permission; + } catch { + return null; + } + } + + async function getTextFile(path) { + const { data: fileContent } = await github.rest.repos.getContent({ + owner: context.repo.owner, + repo: context.repo.repo, + path, + ref: defaultBranch, + }); + + if (!('content' in fileContent) || typeof fileContent.content !== 'string') { + throw new Error(`Expected file content for ${path}`); + } + + return Buffer.from(fileContent.content, 'base64').toString('utf8'); + } + + function parseApprovedUsers(content) { + const users = new Map(); + + for (const rawLine of content.split('\n')) { + const line = rawLine.trim(); + if (!line || line.startsWith('#')) continue; + + const parts = line.split(/\s+/); + if (parts.length !== 2) { + console.log(`Skipping malformed line: ${rawLine}`); + continue; + } + + const [username, capability] = parts; + const normalizedCapability = capability.toLowerCase(); + if (!VALID_CAPABILITIES.has(normalizedCapability)) { + console.log(`Skipping line with invalid capability: ${rawLine}`); + continue; + } + + users.set(username.toLowerCase(), normalizedCapability); + } + + return users; + } + + async function closePullRequest(message) { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.payload.pull_request.number, + body: message, + }); + + await github.rest.pulls.update({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: context.payload.pull_request.number, + state: 'closed', + }); + } + + const permission = await getPermission(prAuthor); + if (!isBotAuthor && ['admin', 'maintain', 'write'].includes(permission)) { + console.log(`${prAuthor} is a collaborator with ${permission} access`); + return; + } + + const approvedContent = await getTextFile(APPROVED_FILE); + const approvedUsers = parseApprovedUsers(approvedContent); + const capability = approvedUsers.get(prAuthor.toLowerCase()); + + if (!isBotAuthor && capability === 'pr') { + console.log(`${prAuthor} is approved for PRs`); + return; + } + + console.log(`${prAuthor} is not approved, closing PR`); + + const message = [ + 'This PR was auto-closed. Only contributors approved with `lgtm` can open PRs. Open an issue first and ask a maintainer for approval.', + '', + `Maintainers review auto-closed issues regularly. Issues that do not meet the quality bar in [CONTRIBUTING.md](https://github.com/${context.repo.owner}/${context.repo.repo}/blob/${defaultBranch}/CONTRIBUTING.md) will not be reopened or receive a reply.`, + '', + 'If a maintainer replies `lgtmi`, your future issues will stay open. If a maintainer replies `lgtm`, your future issues and PRs will stay open. The command must be at the start of the reply (optionally after one or more `@username` mentions) or at the end.', + '', + `See [CONTRIBUTING.md](https://github.com/${context.repo.owner}/${context.repo.repo}/blob/${defaultBranch}/CONTRIBUTING.md).`, + ].join('\n'); + + await closePullRequest(message); diff --git a/.github/workflows/remove-inprogress-on-close.yml b/.github/workflows/remove-inprogress-on-close.yml new file mode 100644 index 0000000..b1e9454 --- /dev/null +++ b/.github/workflows/remove-inprogress-on-close.yml @@ -0,0 +1,34 @@ +# Contributor gate adapted from badlogic/pi-mono (MIT) at 6f7551516b84. +# See CONTRIBUTING.md for how it works. + +name: Remove In Progress Label On Close + +on: + issues: + types: [closed] + +jobs: + remove-label: + runs-on: ubuntu-latest + permissions: + issues: write + steps: + - name: Remove inprogress label + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + script: | + const labelName = 'inprogress'; + const labels = context.payload.issue.labels ?? []; + const hasLabel = labels.some((label) => label.name === labelName); + + if (!hasLabel) { + console.log(`Issue does not have ${labelName} label`); + return; + } + + await github.rest.issues.removeLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + name: labelName, + }); diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..f42d9fb --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,71 @@ +# Contributing to Frame Control + +This guide exists to save both sides time. The process is borrowed from +[pi](https://github.com/badlogic/pi-mono/blob/main/CONTRIBUTING.md). + +## Just want to report something? + +Use the [feedback form](https://frame-control.pages.dev/feedback/). It needs no +GitHub account, and what you send becomes an issue here that stays open. + +## The One Rule + +**You must understand your code.** If you can't explain what your change does +and how it interacts with the rest of the app, your PR will be closed. + +Using AI to write code is fine. Submitting AI-generated slop you don't +understand is not. + +## Contribution gate + +Issues and PRs opened on GitHub by new contributors are auto-closed by default. +A maintainer reviews auto-closed issues regularly and reopens worthwhile ones. +Issues that don't meet the quality bar below won't be reopened or get a reply. + +Approval happens through maintainer replies on issues: + +- `lgtmi`: your future issues won't be auto-closed +- `lgtm`: your future issues and PRs won't be auto-closed + +The word must be at the start of the reply (optionally after one or more +`@username` mentions) or at the end. Only `lgtm` lets you open PRs. Approved +people are listed in [`.github/APPROVED_CONTRIBUTORS`](.github/APPROVED_CONTRIBUTORS). + +## Quality bar for issues + +Use one of the issue templates, and keep it short, concrete and worth reading. + +- If it doesn't fit on one screen, it's too long. +- Write in your own voice. If you must use an LLM, say so in a clearly labelled + follow-up comment. +- State the bug or request clearly, and why it matters. +- For bugs, include your OS, your SteamOS build (Steam Settings → System), and + the server log (**Frame → Show Server Log** in the app). +- If you want to implement the change yourself, say so. + +## Before opening a PR + +Don't open a PR until a maintainer has approved you with `lgtm`. Open an +[idea or contribution proposal](https://github.com/saphid/frame-control/issues/new?template=idea.yml) +first. + +Then check your change: + +```sh +python3 -m unittest discover -s tests # server tests; no headset needed +node --test site/test/*.test.mjs # website feedback function +``` + +Say what you tested, and whether you tried it on a real Steam Frame. + +## Blocking + +If you ignore this document twice, or spam the tracker with agent-generated +issues, your GitHub account will be blocked from the repo. + +## Why auto-close? + +This is a hobby project with one maintainer. Auto-closing is a buffer against +burnout and tracker spam: issues get reviewed on the maintainer's schedule, and +the good ones are reopened. Short, concrete, reproducible reports and thoughtful +contributions are welcome. diff --git a/README.md b/README.md index d9b4681..75167b0 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ See what the headset sees, install games and Android apps, move files and text a [![Checks](https://img.shields.io/github/actions/workflow/status/saphid/steam-frame/checks.yml?branch=main&label=checks)](https://github.com/saphid/steam-frame/actions/workflows/checks.yml) [![License: MIT](https://img.shields.io/badge/license-MIT-66c0f4)](LICENSE) -[**Download**](#install) · [Trailer](#trailer) · [Features](#features) · [Set up the headset](#set-up-the-headset) · [Feedback](#feedback) · [Docs](#going-further) +[**Website**](https://frame-control.pages.dev) · [**Download**](#install) · [Trailer](#trailer) · [Features](#features) · [Set up the headset](#set-up-the-headset) · [Feedback](#feedback) · [Docs](#going-further)
@@ -172,13 +172,17 @@ entry to `~/.ssh/config` and keys at `~/.ssh/id_ed25519_frame` and ## Feedback This is a first public test, so reports are really useful, especially from -Windows and Linux. Please [open an issue](https://github.com/saphid/steam-frame/issues/new) -with: +Windows and Linux. The quickest way is the +[feedback form](https://frame-control.pages.dev/feedback/): no GitHub account +needed, and it opens an issue here. Please include: - what you tried and what happened - your computer's OS and your SteamOS build (Steam Settings → System) - the server log: **Frame → Show Server Log** in the app +Issues and PRs opened directly on GitHub by new contributors are auto-closed +until a maintainer approves them; see [CONTRIBUTING.md](CONTRIBUTING.md). + ## Going further This repo also holds the scripts behind the app and field notes on how the diff --git a/site/.gitignore b/site/.gitignore new file mode 100644 index 0000000..a933f10 --- /dev/null +++ b/site/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +.wrangler/ +.dev.vars diff --git a/site/README.md b/site/README.md new file mode 100644 index 0000000..1180f4e --- /dev/null +++ b/site/README.md @@ -0,0 +1,35 @@ +# Website + +The Frame Control website, , on Cloudflare Pages. + +- `public/`: static pages. `/` is the landing page, `/feedback/` the feedback form, `/privacy/` the privacy note. +- `functions/api/feedback.js`: `POST /api/feedback`, which turns the form into a GitHub issue labelled `feedback`. +- `lib/feedback.js`: validation and issue formatting, tested by `test/feedback.test.mjs`. +- `public/js/site.js`: settings, including the Ko-fi page name for the donate buttons. + +## Feedback → GitHub issues + +The function needs a `GITHUB_TOKEN` secret: a fine-grained token with **Issues: read and write** on +`saphid/frame-control` only. Issues are opened as the token's owner, so they pass the contributor gate +(`.github/workflows/issue-gate.yml`) and stay open. Without the token the form answers 503 and offers a +prefilled GitHub issue instead. + +```sh +cd site +npx wrangler pages secret put GITHUB_TOKEN --project-name frame-control +``` + +Spam protection: a hidden honeypot field, a 3-second minimum fill time, 5 submissions per hour per IP +(a salted hash, kept in the `FEEDBACK_RL` KV namespace for about an hour), and 100 a day in total. +User text has `@mentions` and `#123` references broken so nobody gets pinged. + +## Run and deploy + +```sh +cd site +node --test test/*.test.mjs +npx wrangler pages dev --port 8788 # local; put GITHUB_TOKEN/GITHUB_REPO in .dev.vars to test issues +npx wrangler pages deploy --branch main # production +``` + +Point `GITHUB_REPO` in `.dev.vars` at a scratch repo when testing locally so test issues don't land on the real tracker. diff --git a/site/functions/api/feedback.js b/site/functions/api/feedback.js new file mode 100644 index 0000000..67a29f7 --- /dev/null +++ b/site/functions/api/feedback.js @@ -0,0 +1,86 @@ +// POST /api/feedback: turns the website's feedback form into a GitHub issue. +// +// Environment (Cloudflare Pages → Settings → Variables and Secrets): +// GITHUB_TOKEN secret. Fine-grained token with Issues: read and write on GITHUB_REPO only. +// GITHUB_REPO owner/name, e.g. saphid/frame-control (wrangler.toml sets it). +// FEEDBACK_RL KV namespace binding for rate limits (optional; without it there is no limit). + +import { buildIssue, hashIp, validate } from "../../lib/feedback.js"; + +const PER_IP_PER_HOUR = 5; +const TOTAL_PER_DAY = 100; + +const json = (status, data) => + new Response(JSON.stringify(data), { + status, + headers: { "content-type": "application/json; charset=utf-8", "cache-control": "no-store" }, + }); + +async function overLimit(kv, key, limit, ttl) { + const count = Number(await kv.get(key)) || 0; + if (count >= limit) return true; + await kv.put(key, String(count + 1), { expirationTtl: ttl }); + return false; +} + +export async function onRequestPost({ request, env }) { + if (!env.GITHUB_TOKEN || !env.GITHUB_REPO) { + return json(503, { error: "Feedback isn't connected to GitHub yet. Use the GitHub link instead." }); + } + + const origin = request.headers.get("origin"); + if (origin && new URL(origin).host !== new URL(request.url).host) { + return json(403, { error: "Send feedback from the website's form." }); + } + + let input; + try { + input = await request.json(); + } catch { + return json(400, { error: "Send the form as JSON." }); + } + + const checked = validate(input); + // Bots get a success-shaped answer so they don't learn what tripped them. + if (checked.spam) return json(200, { ok: true }); + if (checked.error) return json(400, { error: checked.error }); + + // Best effort: KV is eventually consistent, so bursts can slip past, and a + // storage error lets the feedback through rather than losing it. + if (env.FEEDBACK_RL) try { + const ip = request.headers.get("cf-connecting-ip") || "unknown"; + const hour = Math.floor(Date.now() / 3600e3); + const day = Math.floor(Date.now() / 86400e3); + // Salted with the secret token, so the stored hashes can't be reversed by trying every IP. + const who = await hashIp(ip, env.GITHUB_TOKEN); + if (await overLimit(env.FEEDBACK_RL, `ip:${who}:${hour}`, PER_IP_PER_HOUR, 3900)) { + return json(429, { error: "That's a lot of feedback in one hour. Try again later, or use GitHub." }); + } + if (await overLimit(env.FEEDBACK_RL, `day:${day}`, TOTAL_PER_DAY, 90000)) { + return json(429, { error: "The form has had a busy day. Try again tomorrow, or use GitHub." }); + } + } catch (err) { + console.log(`Rate limit check failed: ${err}`); + } + + const res = await fetch(`https://api.github.com/repos/${env.GITHUB_REPO}/issues`, { + method: "POST", + headers: { + authorization: `Bearer ${env.GITHUB_TOKEN}`, + accept: "application/vnd.github+json", + "x-github-api-version": "2022-11-28", + "user-agent": "frame-control-website", + "content-type": "application/json", + }, + body: JSON.stringify(buildIssue(checked.value)), + }); + + if (!res.ok) { + console.log(`GitHub answered ${res.status}: ${(await res.text()).slice(0, 500)}`); + return json(502, { error: "GitHub didn't accept it just now. Try again, or use the GitHub link." }); + } + const issue = await res.json(); + return json(201, { ok: true, number: issue.number, url: issue.html_url }); +} + +export const onRequest = () => json(405, { error: "POST only." }); diff --git a/site/lib/feedback.js b/site/lib/feedback.js new file mode 100644 index 0000000..698575d --- /dev/null +++ b/site/lib/feedback.js @@ -0,0 +1,102 @@ +// Feedback form → GitHub issue. Pure functions, so tests can run them without +// Cloudflare or GitHub (site/test/feedback.test.mjs). + +export const KINDS = { + bug: { label: "bug", title: "Bug report" }, + idea: { label: "enhancement", title: "Idea" }, + question: { label: "question", title: "Question" }, + other: { label: null, title: "Other feedback" }, +}; + +export const LIMITS = { title: [5, 120], message: [10, 5000], field: 120 }; + +// Anyone who fills the form in under this many milliseconds is a script. +export const MIN_FILL_MS = 3000; + +const GITHUB_LOGIN = /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?$/; + +const oneLine = (value, max) => String(value ?? "").replace(/\s+/g, " ").trim().slice(0, max); + +// Mentions in someone else's text would ping strangers, and issue references +// (#1, owner/repo#1, GH-1, github.com links) would add backlinks to other +// people's issues, so break them all with a zero-width space. Escaping & first +// stops @ and # from turning back into @ and # when GitHub renders, +// escaping < keeps out raw HTML such as an unclosed