Procházet zdrojové kódy

:robot: Close pull requests that skip the template

Add a workflow that checks the pull request body for the template answers.
When items are missing it comments, adds the needs-template label, and
closes the pull request. An edit that completes the template reopens it.
Maintainers and Dependabot are skipped.

Also add a commercial products section to contributing.md and point to it
from the template and from the bot comment.
Jeff Triplett před 2 dny
rodič
revize
7ba82ea3fa

+ 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;
+}

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

@@ -0,0 +1,80 @@
+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.
+on:
+  pull_request_target:
+    types: [opened, edited, reopened]
+
+permissions:
+  pull-requests: write
+
+jobs:
+  template:
+    if: >-
+      github.event.pull_request.user.login != 'wsvincent' &&
+      github.event.pull_request.user.login != 'jefftriplett' &&
+      github.event.pull_request.user.login != 'dependabot[bot]'
+    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
+        with:
+          script: |
+            const { checkTemplate } = require("./.github/scripts/check-pr-template.js");
+            const pr = context.payload.pull_request;
+            const { owner, repo } = context.repo;
+            const issue_number = pr.number;
+            const LABEL = "needs-template";
+            const MARKER = "<!-- pr-template-check -->";
+            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) {
+              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;
+            }
+
+            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);

+ 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/).