Jelajahi Sumber

Merge pull request #389 from wsvincent/pr-template-check

Close pull requests that skip the template
Jeff Triplett 2 hari lalu
induk
melakukan
df9a960479

+ 2 - 0
.github/pull_request_template.md

@@ -45,6 +45,8 @@ Please answer the following questions about the project you are submitting. This
    - [ ] I am submitting on behalf of a company
    - [ ] Other (please specify)
 
+   _(If you submit on behalf of a company, read the [commercial products and services](https://github.com/wsvincent/awesome-django/blob/main/contributing.md#commercial-products-and-services) section of the contribution guidelines first.)_
+
 4. **What makes it awesome?**
 
    _(Please provide a brief explanation of why you believe this project is a valuable addition to the Awesome Django list.)_

+ 72 - 0
.github/scripts/check-pr-template.js

@@ -0,0 +1,72 @@
+// Checks that a pull request body has the answers the template asks for.
+// Returns a list of missing items. An empty list means the template is complete.
+// Kept separate from the workflow so it can be run locally:
+//   node .github/scripts/check-pr-template.js < body.md
+
+function section(body, startPattern, endPattern) {
+  const start = body.search(startPattern);
+  if (start === -1) return null;
+  const rest = body.slice(start).replace(startPattern, "");
+  const end = rest.search(endPattern);
+  return end === -1 ? rest : rest.slice(0, end);
+}
+
+function hasAnswer(text) {
+  if (text === null) return false;
+  const cleaned = text
+    .replace(/_\(.*?\)_/gs, "") // italic placeholder from the template
+    .replace(/^\s*-\s*\[[ x]\].*$/gim, "") // checkbox lines are not prose answers
+    .replace(/[-*_\s]/g, "");
+  return cleaned.length > 0;
+}
+
+function hasCheckedBox(text) {
+  return text !== null && /^\s*-\s*\[x\]/im.test(text);
+}
+
+function checkTemplate(body) {
+  const text = (body || "").replace(/\r\n/g, "\n");
+  const missing = [];
+
+  const questions = [
+    { n: 1, label: "How long has the project been maintained?" },
+    { n: 2, label: "How many releases has it had?" },
+    { n: 4, label: "What makes it awesome?" },
+  ];
+
+  const info = section(text, /## Project Information/i, /^----|^## /m);
+  if (!hasAnswer(section(info || "", /\*\*Project Name:\*\*/i, /^\s*2\./m))) {
+    missing.push("Project Information: Project Name");
+  }
+  if (!hasAnswer(section(info || "", /\*\*Project URL:\*\*/i, /^\s*3\./m))) {
+    missing.push("Project Information: Project URL");
+  }
+  if (!hasAnswer(section(info || "", /\*\*Description:\*\*/i, /^----|^## /m))) {
+    missing.push("Project Information: Description");
+  }
+
+  const criteria = section(text, /## Criteria/i, /^## /m) || "";
+  for (const q of questions) {
+    const next = new RegExp(`^\\s*${q.n + 1}\\.\\s+\\*\\*|^----|^## `, "m");
+    const answer = section(criteria, new RegExp(`^\\s*${q.n}\\.\\s+\\*\\*.*?\\*\\*`, "m"), next);
+    if (!hasAnswer(answer)) missing.push(`Criteria ${q.n}: ${q.label}`);
+  }
+  const authorship = section(criteria, /^\s*3\.\s+\*\*.*?\*\*/m, /^\s*4\.\s+\*\*|^----|^## /m);
+  if (!hasCheckedBox(authorship)) missing.push("Criteria 3: Are you the author? (check one box)");
+
+  const disclosure = section(text, /## AI Disclosure/i, /^----|^## /m);
+  if (!hasCheckedBox(disclosure)) missing.push("AI Disclosure (check one box)");
+
+  const company = /^\s*-\s*\[x\].*on behalf of a company/im.test(authorship || "");
+
+  return { missing, company };
+}
+
+module.exports = { checkTemplate };
+
+if (require.main === module) {
+  const body = require("fs").readFileSync(0, "utf8");
+  const result = checkTemplate(body);
+  console.log(JSON.stringify(result, null, 2));
+  process.exitCode = result.missing.length ? 1 : 0;
+}

+ 104 - 0
.github/workflows/pr-template.yml

@@ -0,0 +1,104 @@
+name: Check pull request template
+
+# Runs on pull_request_target so the token can comment, label, and close pull
+# requests that come from forks. Only the base branch is checked out, and the
+# script reads nothing but the pull request body, so the head commit never runs.
+#
+# Run it by hand from the Actions tab to check existing pull requests: give a
+# number to check one, or leave the field empty to check every open pull request.
+on:
+  pull_request_target:
+    types: [opened, edited, reopened]
+  workflow_dispatch:
+    inputs:
+      pull_request:
+        description: "Pull request number (empty = all open pull requests)"
+        required: false
+
+permissions:
+  pull-requests: write
+
+jobs:
+  template:
+    runs-on: ubuntu-latest
+    steps:
+      - uses: actions/checkout@v4
+        with:
+          ref: ${{ github.event.repository.default_branch }}
+          sparse-checkout: .github/scripts
+
+      - uses: actions/github-script@v7
+        env:
+          INPUT_PULL_REQUEST: ${{ github.event.inputs.pull_request }}
+        with:
+          script: |
+            const { checkTemplate } = require("./.github/scripts/check-pr-template.js");
+            const { owner, repo } = context.repo;
+            const SKIP = ["wsvincent", "jefftriplett", "dependabot[bot]"];
+            const LABEL = "needs-template";
+            const MARKER = "<!-- pr-template-check -->";
+
+            async function check(pr) {
+              const issue_number = pr.number;
+              if (SKIP.includes(pr.user.login)) {
+                core.info(`#${issue_number}: skipped (${pr.user.login})`);
+                return;
+              }
+              const { missing, company } = checkTemplate(pr.body);
+              const { data: comments } = await github.rest.issues.listComments({ owner, repo, issue_number, per_page: 100 });
+              const existing = comments.find((c) => c.body && c.body.includes(MARKER));
+
+              async function say(body) {
+                body = `${MARKER}\n${body}`;
+                if (existing) {
+                  await github.rest.issues.updateComment({ owner, repo, comment_id: existing.id, body });
+                } else {
+                  await github.rest.issues.createComment({ owner, repo, issue_number, body });
+                }
+              }
+
+              if (missing.length) {
+                core.info(`#${issue_number}: missing ${missing.length} items`);
+                const list = missing.map((m) => `- ${m}`).join("\n");
+                await say(
+                  `Thanks for the submission! This pull request does not fill out the ` +
+                  `[pull request template](https://github.com/${owner}/${repo}/blob/main/.github/pull_request_template.md), ` +
+                  `so it was closed automatically.\n\nMissing:\n${list}\n\n` +
+                  `Edit the pull request description to add the missing items. ` +
+                  `The check runs again on every edit and reopens the pull request when the template is complete.`
+                );
+                await github.rest.issues.addLabels({ owner, repo, issue_number, labels: [LABEL] });
+                if (pr.state === "open") {
+                  await github.rest.pulls.update({ owner, repo, pull_number: issue_number, state: "closed" });
+                }
+                return;
+              }
+
+              core.info(`#${issue_number}: complete`);
+              const hadLabel = pr.labels.some((l) => l.name === LABEL);
+              if (hadLabel) {
+                await github.rest.issues.removeLabel({ owner, repo, issue_number, name: LABEL }).catch(() => {});
+                if (pr.state === "closed" && !pr.merged_at) {
+                  await github.rest.pulls.update({ owner, repo, pull_number: issue_number, state: "open" });
+                }
+              }
+
+              let note = "The pull request template is complete. Thanks! A maintainer will review it.";
+              if (company) {
+                note +=
+                  `\n\nThis submission is on behalf of a company. Please read the ` +
+                  `[commercial products and services](https://github.com/${owner}/${repo}/blob/main/contributing.md#commercial-products-and-services) ` +
+                  `section of the contribution guidelines.`;
+              }
+              if (existing || company) await say(note);
+            }
+
+            if (context.payload.pull_request) {
+              await check(context.payload.pull_request);
+            } else if (process.env.INPUT_PULL_REQUEST) {
+              const { data: pr } = await github.rest.pulls.get({ owner, repo, pull_number: Number(process.env.INPUT_PULL_REQUEST) });
+              await check(pr);
+            } else {
+              const prs = await github.paginate(github.rest.pulls.list, { owner, repo, state: "open", per_page: 100 });
+              for (const pr of prs) await check(pr);
+            }

+ 10 - 0
contributing.md

@@ -9,3 +9,13 @@ That said, we will review any and all contributions. Here's the process:
 1. Make an individual pull request for each suggestion and include a reason why it is awesome.
 2. To be fair, the order is first-come-first-serve so unless a section is alphabetical, add the item at the end.
 3. If you think something belongs in the wrong category, or think there needs to be a new category, feel free to edit things too.
+
+## Commercial products and services
+
+We list commercial products and hosted services when they are a good fit for Django developers, but the bar is higher than for open source projects.
+
+1. The product must have a clear Django use, such as a hosting platform with documented Django support or a package with a first-party Django integration.
+2. The product must have been available to the public for at least six months.
+3. Tell us in the pull request that you work for the company. We do not accept submissions that hide this.
+4. A listing is not an advertisement. Keep the description to one short, factual sentence with no marketing language.
+5. We do not sell placement in the list. If you want to reach Django developers, consider sponsoring the [Django Software Foundation](https://www.djangoproject.com/fundraising/) or advertising in [Django News](https://django-news.com/).