diff --git a/.github/DISCUSSION_TEMPLATE/request-script.yml b/.github/DISCUSSION_TEMPLATE/request-script.yml index a2cf93136..5b8896bc7 100644 --- a/.github/DISCUSSION_TEMPLATE/request-script.yml +++ b/.github/DISCUSSION_TEMPLATE/request-script.yml @@ -58,9 +58,9 @@ body: Before continuing, verify that the requested project meets the minimum requirements below. - **Projects with fewer than 1,000 stars are generally not eligible for a script request.** + **Projects with fewer than 1,000 stars, or younger than 6 months, are generally not eligible for a script request.** - Exceptions are only considered where there is a clearly verifiable, significant public adoption signal. + An automated check verifies the star count and project age for GitHub-hosted projects. If the project doesn't meet these requirements, fill in **Justification for an exception** below — without one, the request is closed automatically. With one, it's flagged for manual review instead. Exceptions are rare and require a clearly verifiable, significant public adoption signal. - type: input id: repository-stars @@ -134,6 +134,22 @@ body: - label: I understand that requests outside the project scope may be closed without an extensive explanation. required: true + - type: textarea + id: justification + attributes: + label: Justification for an exception + description: >- + Only needed if the project has fewer than 1,000 stars or is younger than 6 months (e.g. it has no GitHub + presence, so stars don't apply). Explain the significant public adoption signal. Leave blank if the project + meets the normal requirements — requests that don't meet the requirements and have no justification here are + closed automatically. + placeholder: | + Example: + No public GitHub repository, but the project has 50k+ active installs reported by its own telemetry + dashboard and is covered by several established self-hosting publications. + validations: + required: false + - type: markdown attributes: value: | diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 7b1866c18..9713d4f49 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -17,16 +17,6 @@ Fixes # **Tested on:** ---- - -## šŸ¤– AI Assistance (**X** in brackets) - -> If you used an AI tool (GitHub Copilot, Claude, ChatGPT, etc.) to write or generate any scripts in this PR, you **must** confirm compliance below. -> Select exactly one option. - -- [ ] **No AI used** – Scripts were written without AI assistance. -- [ ] **AI was used** – I confirm the scripts were built using [`AGENTS.md`](https://github.com/community-scripts/DevScripts/blob/main/AGENTS.md) and [`.github/agents/pve-script-creator.agent.md`](https://github.com/community-scripts/DevScripts/blob/main/.github/agents/pve-script-creator.agent.md) as guidance, and the output has been reviewed and corrected to match those guidelines. - --- ## šŸ› ļø Type of Change (**X** in brackets) @@ -70,3 +60,13 @@ Guidance: --> + +--- + +## šŸ¤– AI Assistance (**X** in brackets) + +> If you used an AI tool (GitHub Copilot, Claude, ChatGPT, etc.) to write or generate any scripts in this PR, you **must** confirm compliance below. +> Select exactly one option. + +- [ ] **No AI used** – Scripts were written without AI assistance. +- [ ] **AI was used** – I confirm the scripts were built using [`AGENTS.md`](https://github.com/community-scripts/DevScripts/blob/main/AGENTS.md) and [`.github/agents/pve-script-creator.agent.md`](https://github.com/community-scripts/DevScripts/blob/main/.github/agents/pve-script-creator.agent.md) as guidance, and the output has been reviewed and corrected to match those guidelines. diff --git a/.github/workflows/close-invalid-pr-template.yml b/.github/workflows/close-invalid-pr-template.yml index e68caed98..e849ce4ca 100644 --- a/.github/workflows/close-invalid-pr-template.yml +++ b/.github/workflows/close-invalid-pr-template.yml @@ -1,4 +1,4 @@ -name: Close PRs Missing Template +name: Flag PRs Missing Template on: pull_request_target: @@ -14,10 +14,12 @@ jobs: issues: write contents: read steps: - - name: Close PR if it does not follow the PR template + - name: Check PR against the template, comment and label instead of closing uses: actions/github-script@v9 with: script: | + const MARKER = ""; + const LABELS = ["missing pr template", "stale"]; const pr = context.payload.pull_request; const prNumber = pr.number; const author = pr.user.login; @@ -44,11 +46,11 @@ jobs: return; } - const labels = pr.labels.map((label) => label.name); + const prLabels = pr.labels.map((label) => label.name); const skipLabels = ["automated pr", "keep-open"]; - if (skipLabels.some((label) => labels.includes(label))) { - core.info(`PR #${prNumber} has a skip label (${labels.join(", ")}) — skipping template validation.`); + if (skipLabels.some((label) => prLabels.includes(label))) { + core.info(`PR #${prNumber} has a skip label (${prLabels.join(", ")}) — skipping template validation.`); return; } @@ -114,50 +116,52 @@ jobs: failures.push("At least one **Type of Change** checkbox must be checked."); } + const comments = await github.paginate(github.rest.issues.listComments, { + owner, repo, issue_number: prNumber, per_page: 100 + }); + const mine = comments.find(c => c.body && c.body.includes(MARKER)); + if (failures.length === 0) { core.info(`PR #${prNumber} follows the PR template.`); + if (mine && !mine.body.startsWith(MARKER + "\nāœ…")) { + await github.rest.issues.updateComment({ + owner, repo, comment_id: mine.id, + body: MARKER + "\nāœ… **Template check passed.** Thanks for fixing the description — the `stale` mark has been removed.", + }); + } + for (const name of LABELS) { + if (prLabels.includes(name)) { + await github.rest.issues.removeLabel({ owner, repo, issue_number: prNumber, name }); + } + } return; } - core.info(`Closing PR #${prNumber} — template validation failed.`); + core.info(`PR #${prNumber} — template validation failed, flagging instead of closing.`); const templateUrl = "https://github.com/community-scripts/ProxmoxVE/blob/main/.github/pull_request_template.md"; const failureList = failures.map((item) => `- ${item}`).join("\n"); - const comment = [ - `šŸ‘‹ Hi @${author},`, - ``, - `This pull request was closed because it does not follow the [PR template](${templateUrl}).`, - ``, - `Please fix the following and open a new PR (or reopen this one after updating the description):`, - ``, - failureList, - ``, - `> Use the template sections, fill in the description, check all prerequisite boxes, and select at least one type of change.`, - ``, - `Maintainers can add the \`keep-open\` label to exempt a PR from this check.`, - ``, - `Thank you for contributing! šŸ™`, - ].join("\n"); + const text = + MARKER + "\n" + + `šŸ‘‹ Hi @${author},\n\n` + + `This pull request does not follow the [PR template](${templateUrl}) yet:\n\n` + + failureList + "\n\n" + + `**Edit the PR description** to fix it — no new PR needed. This check runs again on every edit and clears itself once everything is in place.\n\n` + + `Until then the PR is marked \`stale\` and will be closed in **7 days**.\n\n` + + `Maintainers can add the \`keep-open\` label to exempt a PR from this check.\n\n` + + `Thank you for contributing! šŸ™`; - await github.rest.issues.createComment({ - owner, - repo, - issue_number: prNumber, - body: comment, - }); + if (mine) { + if (mine.body !== text) { + await github.rest.issues.updateComment({ owner, repo, comment_id: mine.id, body: text }); + } + } else { + await github.rest.issues.createComment({ owner, repo, issue_number: prNumber, body: text }); + } - await github.rest.pulls.update({ - owner, - repo, - pull_number: prNumber, - state: "closed", - }); - - await github.rest.issues.addLabels({ - owner, - repo, - issue_number: prNumber, - labels: ["missing pr template"], - }); + const toAdd = LABELS.filter(name => !prLabels.includes(name)); + if (toAdd.length > 0) { + await github.rest.issues.addLabels({ owner, repo, issue_number: prNumber, labels: toAdd }); + } diff --git a/.github/workflows/request-script-eligibility.yml b/.github/workflows/request-script-eligibility.yml new file mode 100644 index 000000000..147935310 --- /dev/null +++ b/.github/workflows/request-script-eligibility.yml @@ -0,0 +1,141 @@ +name: Request Script Eligibility Check + +# category.slug is the same string GitHub uses in the "new discussion" URL +# (?category=request-script, linked from every repo's issue chooser), so this +# guard matches the live category without needing its numeric id. +on: + discussion: + types: [created, edited, reopened] + +jobs: + check-eligibility: + if: github.repository == 'community-scripts/ProxmoxVE' && github.event.discussion.category.slug == 'request-script' + runs-on: ubuntu-latest + permissions: + discussions: write + issues: write + contents: read + steps: + - name: Verify stars/age, flag or close + uses: actions/github-script@v9 + with: + script: | + const owner = context.repo.owner; + const repo = context.repo.repo; + const discussion = context.payload.discussion; + + if (!discussion || discussion.state === "closed") { + core.info("Discussion missing or already closed — skipping."); + return; + } + + const body = discussion.body || ""; + const lines = body.split("\n"); + const fields = {}; + let current = null; + for (const line of lines) { + const m = line.match(/^###\s+(.+?)\s*$/); + if (m) { + current = m[1].trim(); + fields[current] = []; + } else if (current) { + fields[current].push(line); + } + } + function fieldText(label) { + const raw = (fields[label] || []).join("\n").trim(); + return raw === "_No response_" ? "" : raw; + } + + const sourceRepo = fieldText("Source Repository"); + const justification = fieldText("Justification for an exception"); + + let eligible = false; + let verified = false; + const ghMatch = sourceRepo.match(/^https?:\/\/(?:www\.)?github\.com\/([^\/\s]+)\/([^\/\s#?]+?)(?:\.git)?\/?(?:[#?].*)?$/i); + + if (ghMatch) { + const [, repoOwner, repoName] = ghMatch; + try { + const { data: repoData } = await github.rest.repos.get({ owner: repoOwner, repo: repoName }); + const stars = repoData.stargazers_count; + const createdAt = new Date(repoData.created_at); + const now = new Date(); + const ageMonths = + (now.getFullYear() - createdAt.getFullYear()) * 12 + + (now.getMonth() - createdAt.getMonth()) - + (now.getDate() < createdAt.getDate() ? 1 : 0); + eligible = stars >= 1000 && ageMonths >= 6; + verified = true; + core.info(`Verified ${repoOwner}/${repoName}: ${stars} stars, ${ageMonths} months old, eligible=${eligible}`); + } catch (err) { + core.warning(`Could not verify ${repoOwner}/${repoName}: ${err.message} — treating as unverifiable.`); + } + } else { + core.info("Source Repository is not a github.com URL — cannot auto-verify, treating as unverifiable."); + } + + if (eligible) { + core.info(`Discussion #${discussion.number} meets the requirements — no action.`); + return; + } + + const labels = (discussion.labels || []).map(l => l.name); + const reasonLine = verified + ? "the source repository does not meet the 1,000-star / 6-month minimum adoption requirement" + : "the source repository's star count and age could not be verified automatically"; + + if (justification) { + if (labels.includes("investigation")) { + core.info(`Discussion #${discussion.number} already flagged for investigation — skipping.`); + return; + } + + try { + await github.rest.issues.createLabel({ + owner, repo, name: "investigation", + color: "fbca04", + description: "Below the normal script-request threshold; a justification was given and needs maintainer review.", + }); + } catch (err) { + if (err.status !== 422) throw err; + } + + const labelQuery = await github.graphql( + `query($owner: String!, $repo: String!) { repository(owner: $owner, name: $repo) { label(name: "investigation") { id } } }`, + { owner, repo } + ); + const labelNodeId = labelQuery.repository.label.id; + + await github.graphql( + `mutation($labelableId: ID!, $labelIds: [ID!]!) { addLabelsToLabelable(input: { labelableId: $labelableId, labelIds: $labelIds }) { clientMutationId } }`, + { labelableId: discussion.node_id, labelIds: [labelNodeId] } + ); + + await github.graphql( + `mutation($discussionId: ID!, $body: String!) { addDiscussionComment(input: { discussionId: $discussionId, body: $body }) { comment { id } } }`, + { + discussionId: discussion.node_id, + body: + `Thanks for the justification — ${reasonLine}, so this has been flagged \`investigation\` for a maintainer ` + + `to review manually rather than closed automatically.`, + } + ); + } else { + await github.graphql( + `mutation($discussionId: ID!, $body: String!) { addDiscussionComment(input: { discussionId: $discussionId, body: $body }) { comment { id } } }`, + { + discussionId: discussion.node_id, + body: + `This request doesn't meet the minimum requirements (1,000 stars, project at least 6 months old) — ` + + `${reasonLine}, and no justification was given. Closing automatically.\n\n` + + `If you believe this project qualifies for an exception, edit this discussion to fill in ` + + `**Justification for an exception** and reopen it — it will then be flagged for manual review instead of closed again.`, + } + ); + + await github.graphql( + `mutation($discussionId: ID!) { closeDiscussion(input: { discussionId: $discussionId, reason: OUTDATED }) { discussion { id } } }`, + { discussionId: discussion.node_id } + ); + } diff --git a/.github/workflows/stale_pr_close.yml b/.github/workflows/stale_pr_close.yml index c26e1002c..8f99bbd52 100644 --- a/.github/workflows/stale_pr_close.yml +++ b/.github/workflows/stale_pr_close.yml @@ -22,90 +22,112 @@ jobs: const now = new Date(); const owner = context.repo.owner; const repo = context.repo.repo; - - // --- When stale label is added, comment immediately --- + const TEMPLATE_LABEL = "missing pr template"; + const MAINTAINERS = ["OWNER", "MEMBER", "COLLABORATOR"]; + const days = d => (now - d) / (1000 * 60 * 60 * 24); + const staleNotice = author => + `@${author} This PR has been marked as stale. It will be closed if no new commits are added in 7 days.`; + + // Only fires for a maintainer's label: labels set with GITHUB_TOKEN raise no event. if (context.eventName === "pull_request_target" && context.payload.action === "labeled") { - const label = context.payload.label?.name; - if (label === "stale") { - const author = context.payload.pull_request.user.login; + const pr = context.payload.pull_request; + const labels = (pr.labels || []).map(l => l.name); + if (context.payload.label?.name === "stale" && !labels.includes(TEMPLATE_LABEL)) { await github.rest.issues.createComment({ - owner, - repo, - issue_number: context.payload.pull_request.number, - body: `@${author} This PR has been marked as stale. It will be closed if no new commits are added in 7 days.` + owner, repo, issue_number: pr.number, body: staleNotice(pr.user.login) }); } return; } - - // --- Scheduled run: check all stale PRs --- - const { data: prs } = await github.rest.pulls.list({ - owner, - repo, - state: "open", - per_page: 100 - }); - + + const prs = await github.paginate(github.rest.pulls.list, { owner, repo, state: "open", per_page: 100 }); + for (const pr of prs) { - const hasStale = pr.labels.some(l => l.name === "stale"); - if (!hasStale) continue; - - // Get timeline events to find when stale label was added - const { data: events } = await github.rest.issues.listEvents({ - owner, - repo, - issue_number: pr.number, - per_page: 100 - }); - - // Find the most recent time the stale label was added - const staleLabelEvents = events - .filter(e => e.event === "labeled" && e.label?.name === "stale") - .sort((a, b) => new Date(b.created_at) - new Date(a.created_at)); - - if (staleLabelEvents.length === 0) continue; - - const staleLabelDate = new Date(staleLabelEvents[0].created_at); - const daysSinceStale = (now - staleLabelDate) / (1000 * 60 * 60 * 24); - - // Check for new commits since stale label was added - const { data: commits } = await github.rest.pulls.listCommits({ - owner, - repo, - pull_number: pr.number - }); - - const lastCommitDate = new Date(commits[commits.length - 1].commit.author.date); + const labels = pr.labels.map(l => l.name); const author = pr.user.login; - - // If there are new commits after the stale label, remove it - if (lastCommitDate > staleLabelDate) { - await github.rest.issues.removeLabel({ - owner, - repo, - issue_number: pr.number, - name: "stale" + const hasStale = labels.includes("stale"); + + if (labels.includes("keep-open")) { + if (hasStale) { + await github.rest.issues.removeLabel({ owner, repo, issue_number: pr.number, name: "stale" }); + } + continue; + } + + const comments = await github.paginate(github.rest.issues.listComments, { + owner, repo, issue_number: pr.number, per_page: 100 + }); + const commits = await github.paginate(github.rest.pulls.listCommits, { + owner, repo, pull_number: pr.number, per_page: 100 + }); + const lastCommitDate = commits.length > 0 + ? new Date(commits[commits.length - 1].commit.author.date) + : new Date(pr.created_at); + const humanComments = comments.filter(c => c.user?.type !== "Bot"); + + // ------------------------------------------------------- + // Auto-label PRs with no activity in the last 14 days, + // but ONLY if someone other than the author has engaged. + // ------------------------------------------------------- + if (!hasStale) { + const reviews = await github.paginate(github.rest.pulls.listReviews, { + owner, repo, pull_number: pr.number, per_page: 100 }); + const engaged = + reviews.some(r => r.user?.type !== "Bot" && r.user?.login !== author) || + humanComments.some(c => c.user?.login !== author); + if (!engaged) continue; + + const lastCommentDate = humanComments.length > 0 + ? new Date(humanComments[humanComments.length - 1].created_at) + : null; + const lastActivityDate = lastCommentDate && lastCommentDate > lastCommitDate + ? lastCommentDate + : lastCommitDate; + + if (days(lastActivityDate) > 14) { + await github.rest.issues.addLabels({ owner, repo, issue_number: pr.number, labels: ["stale"] }); + await github.rest.issues.createComment({ owner, repo, issue_number: pr.number, body: staleNotice(author) }); + } + continue; + } + + // ------------------------------------------------------- + // Manage already-stale PRs + // ------------------------------------------------------- + const events = await github.paginate(github.rest.issues.listEvents, { + owner, repo, issue_number: pr.number, per_page: 100 + }); + const staleDates = events + .filter(e => e.event === "labeled" && e.label?.name === "stale") + .map(e => new Date(e.created_at)); + if (staleDates.length === 0) continue; + const staleLabelDate = new Date(Math.max(...staleDates)); + const isTemplateIssue = labels.includes(TEMPLATE_LABEL); + + // A push ends an inactivity mark; a template mark only ends via close-invalid-pr-template.yml's re-check. + if (!isTemplateIssue && lastCommitDate > staleLabelDate) { + await github.rest.issues.removeLabel({ owner, repo, issue_number: pr.number, name: "stale" }); await github.rest.issues.createComment({ - owner, - repo, - issue_number: pr.number, + owner, repo, issue_number: pr.number, body: `@${author} Recent activity detected. Removing stale label.` }); + continue; } - // If 7 days have passed since stale label, close the PR - else if (daysSinceStale > 7) { - await github.rest.pulls.update({ - owner, - repo, - pull_number: pr.number, - state: "closed" - }); - await github.rest.issues.createComment({ - owner, - repo, - issue_number: pr.number, - body: `@${author} Closing stale PR due to inactivity (no commits for 7 days after stale label).` - }); - } + + // A maintainer comment after the mark restarts the 7 days. + const maintainerDates = humanComments + .filter(c => MAINTAINERS.includes(c.author_association) && c.user?.login !== author) + .map(c => new Date(c.created_at)) + .filter(d => d > staleLabelDate); + const clockStart = maintainerDates.length > 0 ? new Date(Math.max(...maintainerDates)) : staleLabelDate; + if (days(clockStart) <= 7) continue; + + await github.rest.issues.createComment({ + owner, repo, issue_number: pr.number, + body: isTemplateIssue + ? `@${author} Closing: the PR template requirements listed above were still missing 7 days later. Once they are in place, please open a new pull request.` + : `@${author} Closing stale PR due to inactivity (no commits for 7 days after stale label).` + }); + await github.rest.pulls.update({ owner, repo, pull_number: pr.number, state: "closed" }); }