diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..bc58447 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1 @@ +* @blairg23 diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..2dae3c1 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,5 @@ +blank_issues_enabled: false +contact_links: + - name: Security report + url: https://github.com/blairg23/cuequeue/security/advisories/new + about: Please privately disclose security issues through GitHub Security Advisories. diff --git a/.github/ISSUE_TEMPLATE/epic.md b/.github/ISSUE_TEMPLATE/epic.md new file mode 100644 index 0000000..555bb75 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/epic.md @@ -0,0 +1,47 @@ +--- +name: Epic +about: Track a multi-ticket initiative +title: "" +labels: ["epic", "needs-triage"] +assignees: [] +--- + +## 🧾 Title + + +## 🧠 Summary + + +## πŸ“¦ Scope + + +## 🚫 Out of Scope + + +## βœ… Acceptance Criteria + +- [ ] +- [ ] +- [ ] + +## πŸ—‚οΈ Milestones / Phases + +- [ ] Phase 1 +- [ ] Phase 2 + +## πŸ§ͺ Testing / Validation + +- [ ] + +## πŸ”— Child Tickets + +- [ ] + +## 🧰 Notes / Links + + +## βœ… Addendum: Bugs Found/Fixed + + +## ⏳ Addendum: Pending + diff --git a/.github/ISSUE_TEMPLATE/ticket.md b/.github/ISSUE_TEMPLATE/ticket.md new file mode 100644 index 0000000..7e9dac6 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/ticket.md @@ -0,0 +1,41 @@ +--- +name: Ticket +about: Describe one implementation task +title: "" +labels: ["needs-triage"] +assignees: [] +--- + +## 🧾 Title + + +## 🧠 Summary + + +## πŸ“¦ Scope + + +## 🚫 Out of Scope + + +## βœ… Acceptance Criteria + +- [ ] +- [ ] +- [ ] + +## πŸ§ͺ Testing / Validation + +- [ ] + +## 🧰 Implementation Notes + + +## 🧰 Notes / Links + + +## βœ… Addendum: Bugs Found/Fixed + + +## ⏳ Addendum: Pending + diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..72ba916 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,36 @@ +## 🧾 Title + + +## 🧠 Description + +This pull request [summarize purpose here]. + +## 🧩 Changes Included + +- [ ] Added/updated documentation +- [ ] Implemented new feature or enhancement +- [ ] Refactored existing code +- [ ] Improved error handling or logging +- [ ] Updated environment configuration +- [ ] Other (specify) + +## 🎯 Purpose + +This PR aims to [describe the goal or intended outcome]. + +## πŸ”— Related Issues / Epics + +- **Epic:** # +- **Issue:** # +- Closes # + +## πŸ§ͺ Testing + +1. [Step one of testing] +2. [Step two] +3. [Expected result] + +## 🧰 Developer Notes + +- [ ] Verify environment variables are configured properly +- [ ] Ensure code runs under both local and CI/CD environments diff --git a/.github/workflows/validate-issue.yml b/.github/workflows/validate-issue.yml new file mode 100644 index 0000000..8ab4291 --- /dev/null +++ b/.github/workflows/validate-issue.yml @@ -0,0 +1,74 @@ +name: Validate issue body format + +on: + issues: + types: [opened, edited] + +jobs: + validate: + runs-on: ubuntu-latest + permissions: + issues: write + + steps: + - name: Check body and flag if malformed + uses: actions/github-script@v9 + with: + script: | + const body = context.payload.issue.body || ""; + const number = context.payload.issue.number; + + const required = ["## 🧾 Title", "## 🧠 Summary"]; + const missing = required.filter(h => !body.includes(h)); + + if (missing.length === 0) { + core.info(`Issue #${number} body looks good.`); + // Remove needs-format if it was added by a previous failed check + try { + await github.rest.issues.removeLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: number, + name: "needs-format", + }); + core.info(`Removed needs-format label from issue #${number}.`); + } catch (_) {} + return; + } + + // Create needs-format label if it does not exist yet (422 = already exists) + try { + await github.rest.issues.createLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + name: "needs-format", + color: "e11d48", + description: "Issue body does not match the required template format", + }); + } catch (_) {} + + await github.rest.issues.addLabels({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: number, + labels: ["needs-format"], + }); + + // Relative markdown links in a comment resolve against the comment's + // own URL (.../issues/), not the repo root, so a plain relative + // path 404s instead of opening the template. Build an absolute blob + // URL against the actual default branch instead (see #299). + const ref = context.payload.repository.default_branch; + const repoBlobUrl = `https://github.com/${context.repo.owner}/${context.repo.repo}/blob/${ref}`; + + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: number, + body: [ + "This issue is missing required template sections:", + ...missing.map(h => `- \`${h}\``), + "", + `Please update the body to match the [epic template](${repoBlobUrl}/.github/ISSUE_TEMPLATE/epic.md) or [ticket template](${repoBlobUrl}/.github/ISSUE_TEMPLATE/ticket.md).`, + ].join("\n"), + }); diff --git a/.github/workflows/validate-pr-sop.yml b/.github/workflows/validate-pr-sop.yml new file mode 100644 index 0000000..66b1ada --- /dev/null +++ b/.github/workflows/validate-pr-sop.yml @@ -0,0 +1,178 @@ +name: Validate PR review thread SOP + +on: + pull_request: + types: [opened, synchronize, reopened] + pull_request_review: + types: [submitted] + # Replying to a review thread is a new review comment, so this re-triggers + # the check after the "reply" SOP step. Resolving a thread and reacting + # +1 have no corresponding Actions trigger event (GitHub does not expose + # pull_request_review_thread's resolved/unresolved action, or comment + # reactions, to workflow `on:` triggers) -- `repo-scaffold pr rerun + # --failed-only` after finishing those steps remains the way to refresh + # the check without a new commit or review submission. + pull_request_review_comment: + types: [created] + workflow_dispatch: + inputs: + pr_number: + description: PR number to check + required: true + type: number + +jobs: + check-sop: + runs-on: ubuntu-latest + permissions: + pull-requests: read + + steps: + - name: Check review threads for SOP compliance + uses: actions/github-script@v9 + with: + script: | + const prNumber = + context.payload.pull_request?.number || + parseInt(context.payload.inputs?.pr_number || "0"); + + if (!prNumber) { + core.setFailed("Could not determine PR number from event payload."); + return; + } + + // The SOP's prescribed reply form is "Fixed in . ." + // (see AGENTS.md Review thread SOP step 2) -- anchor to that phrase + // instead of matching a bare hex-looking token anywhere in the + // comment, which false-positives on ordinary words built entirely + // from hex letters (e.g. "defaced"). See #263 (original bug) and + // the follow-up review on #307 (this anchoring fix). + const REPLY_RE = /\bfixed in\s+[0-9a-f]{7,40}\b/i; + + // A thread's own comments connection can in principle exceed one + // page too, same as the outer reviewThreads connection -- fetch + // whatever's left with a follow-up query scoped to that thread. + async function fetchRemainingComments(threadId, after) { + const comments = []; + while (true) { + const data = await github.graphql(` + query($threadId: ID!, $after: String) { + node(id: $threadId) { + ... on PullRequestReviewThread { + comments(first: 50, after: $after) { + pageInfo { hasNextPage endCursor } + nodes { + databaseId + body + reactionGroups { + content + reactors { totalCount } + } + } + } + } + } + } + `, { threadId, after }); + const page = data.node.comments; + comments.push(...page.nodes); + if (!page.pageInfo.hasNextPage) break; + after = page.pageInfo.endCursor; + } + return comments; + } + + const threads = []; + let after = null; + while (true) { + const data = await github.graphql(` + query($owner: String!, $repo: String!, $number: Int!, $after: String) { + repository(owner: $owner, name: $repo) { + pullRequest(number: $number) { + reviewThreads(first: 50, after: $after) { + pageInfo { hasNextPage endCursor } + nodes { + id + isResolved + comments(first: 50) { + pageInfo { hasNextPage endCursor } + nodes { + databaseId + body + reactionGroups { + content + reactors { totalCount } + } + } + } + } + } + } + } + } + `, { + owner: context.repo.owner, + repo: context.repo.repo, + number: prNumber, + after, + }); + + const page = data.repository.pullRequest.reviewThreads; + threads.push(...page.nodes); + if (!page.pageInfo.hasNextPage) break; + after = page.pageInfo.endCursor; + } + + if (threads.length === 0) { + core.info("No review threads found. SOP check passed."); + return; + } + + const failing = []; + for (const thread of threads) { + let comments = thread.comments.nodes; + if (thread.comments.pageInfo.hasNextPage) { + comments = comments.concat( + await fetchRemainingComments(thread.id, thread.comments.pageInfo.endCursor) + ); + } + + const hasReply = comments.slice(1).some(c => REPLY_RE.test(c.body || "")); + const isResolved = thread.isResolved; + const firstComment = comments[0] || {}; + // reactionGroups gives an aggregate count per reaction type in + // one shot -- no pagination needed, unlike the raw `reactions` + // connection this replaced (see #303). + const reactionGroups = firstComment.reactionGroups || []; + const hasPlusOne = reactionGroups.some( + g => g.content === "THUMBS_UP" && g.reactors.totalCount > 0 + ); + + if (!hasReply || !isResolved || !hasPlusOne) { + const missing = []; + if (!hasReply) missing.push("reply"); + if (!isResolved) missing.push("resolved"); + if (!hasPlusOne) missing.push("reaction(+1)"); + failing.push({ + threadId: thread.id, + commentId: firstComment.databaseId, + missing, + }); + } + } + + const total = threads.length; + const passed = total - failing.length; + core.info(`${passed}/${total} threads SOP-compliant.`); + + if (failing.length > 0) { + for (const f of failing) { + core.error( + `Thread ${f.threadId} (comment #${f.commentId}) missing: ${f.missing.join(", ")}` + ); + } + core.setFailed( + `${failing.length} review thread(s) not SOP-compliant. ` + + "SOP: push fix -> reply with commit hash -> resolve thread -> react +1." + ); + } diff --git a/.github/workflows/validate-pr.yml b/.github/workflows/validate-pr.yml new file mode 100644 index 0000000..64be0d5 --- /dev/null +++ b/.github/workflows/validate-pr.yml @@ -0,0 +1,111 @@ +name: Validate PR body format + +on: + pull_request_target: + types: [opened, edited, synchronize, reopened] + +jobs: + validate-pr: + runs-on: ubuntu-latest + permissions: + pull-requests: write + issues: write + + steps: + - name: Check PR body and flag if malformed + uses: actions/github-script@v9 + with: + script: | + const number = context.payload.pull_request.number; + const author = context.payload.pull_request.user?.login || ""; + const MISSING_SECTIONS_MARKER = "This PR is missing required body sections:"; + + const removeStaleLabel = async () => { + try { + await github.rest.issues.removeLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: number, + name: "needs-format", + }); + core.info(`Removed needs-format label from PR #${number}.`); + } catch (_) {} + }; + + if (author === "dependabot[bot]") { + core.info(`PR #${number} is from Dependabot; skipping body-format check.`); + // Clear any needs-format label left over from before this skip existed. + await removeStaleLabel(); + return; + } + + const body = context.payload.pull_request.body || ""; + const required = ["## 🧾 Title"]; + const missing = required.filter(h => !body.includes(h)); + + if (missing.length === 0) { + core.info(`PR #${number} body looks good.`); + await removeStaleLabel(); + return; + } + + // Create needs-format label if it does not exist yet (422 = already + // exists). Any other failure (e.g. a permissions regression) is + // surfaced instead of swallowed, so it shows up in the Actions log + // rather than silently preventing the label -- and therefore the + // explanatory comment below -- from ever landing (see #302). + try { + await github.rest.issues.createLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + name: "needs-format", + color: "e11d48", + description: "Issue body does not match the required template format", + }); + } catch (err) { + if (err.status !== 422) { + core.warning(`Failed to create needs-format label: ${err.message}`); + } + } + + await github.rest.issues.addLabels({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: number, + labels: ["needs-format"], + }); + + // Avoid posting a duplicate notice on every push while the PR stays + // malformed -- only comment once, when the notice isn't already + // there (see #301). + const existingComments = await github.paginate(github.rest.issues.listComments, { + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: number, + }); + const alreadyNotified = existingComments.some( + c => c.user?.type === "Bot" && (c.body || "").includes(MISSING_SECTIONS_MARKER) + ); + if (alreadyNotified) { + core.info(`PR #${number} already has the missing-sections notice; skipping duplicate comment.`); + return; + } + + // Relative markdown links in a comment resolve against the comment's + // own URL (.../pull/), not the repo root, so a plain relative path + // 404s instead of opening the template. Build an absolute blob URL + // against the actual default branch instead (see #299). + const ref = context.payload.repository.default_branch; + const templateUrl = `https://github.com/${context.repo.owner}/${context.repo.repo}/blob/${ref}/.github/pull_request_template.md`; + + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: number, + body: [ + MISSING_SECTIONS_MARKER, + ...missing.map(h => `- \`${h}\``), + "", + `Please update the PR description to match the [pull request template](${templateUrl}).`, + ].join("\n"), + });