From 9dc08aa8c17e84d8f7662e8a861341a9317683b0 Mon Sep 17 00:00:00 2001 From: Michel Roegl-Brunner Date: Tue, 2 Jun 2026 11:43:08 +0200 Subject: [PATCH] ci(workflows): harden new-script close, slug-match VED issue close, 7-day lock - close-new-script-prs: trigger on added script file OR label, exempt by author_association (OWNER/MEMBER/COLLABORATOR) instead of team API - close_issue_in_dev: match VED issues by derived slug, close all matches - lock-issue: lock closed issues after 7 days instead of 3 Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/close-new-script-prs.yml | 71 ++++++++++------------ .github/workflows/close_issue_in_dev.yaml | 71 +++++++++++++--------- .github/workflows/lock-issue.yaml | 4 +- 3 files changed, 76 insertions(+), 70 deletions(-) diff --git a/.github/workflows/close-new-script-prs.yml b/.github/workflows/close-new-script-prs.yml index 9690687e2..d3541e21b 100644 --- a/.github/workflows/close-new-script-prs.yml +++ b/.github/workflows/close-new-script-prs.yml @@ -3,7 +3,7 @@ name: Close Unauthorized New Script PRs on: pull_request_target: branches: ["main"] - types: [opened, labeled] + types: [opened, labeled, reopened, synchronize] jobs: check-new-script: @@ -24,13 +24,6 @@ jobs: const owner = context.repo.owner; const repo = context.repo.repo; - // --- Only act on PRs with the "new script" label --- - const labels = pr.labels.map(l => l.name); - if (!labels.includes("new script")) { - core.info(`PR #${prNumber} does not have "new script" label — skipping.`); - return; - } - // --- Allow our bots --- const allowedBots = [ "push-app-to-main[bot]", @@ -42,38 +35,40 @@ jobs: return; } - // --- Check if author is a member of the contributor team --- - const teamSlug = "contributor"; - let isMember = false; - - try { - const { status } = await github.rest.teams.getMembershipForUserInOrg({ - org: owner, - team_slug: teamSlug, - username: author, - }); - // status 200 means the user is a member (active or pending) - isMember = true; - } catch (error) { - if (error.status === 404) { - isMember = false; - } else { - core.warning(`Could not check team membership for ${author}: ${error.message}`); - // Fallback: check org membership - try { - await github.rest.orgs.checkMembershipForUser({ - org: owner, - username: author, - }); - isMember = true; - } catch { - isMember = false; - } - } + // --- Exempt contributors via author_association --- + // OWNER/MEMBER/COLLABORATOR are trusted; CONTRIBUTOR ("has merged before") + // and NONE are not — their new-script PRs are still closed. + const association = pr.author_association; + const exempt = ["OWNER", "MEMBER", "COLLABORATOR"]; + if (exempt.includes(association)) { + core.info(`PR #${prNumber} by ${association} "${author}" — skipping.`); + return; } - if (isMember) { - core.info(`PR #${prNumber} by contributor "${author}" — skipping.`); + // --- Detect a new-script PR: "new script" label OR a newly-added + // script file under ct/ install/ turnkey/ vm/ (mirrors + // autolabeler-config.json). Removes the label-timing dependency. --- + const labels = pr.labels.map(l => l.name); + const hasNewScriptLabel = labels.includes("new script"); + + const scriptPrefixes = ["ct/", "install/", "turnkey/", "vm/"]; + let hasAddedScriptFile = false; + try { + const files = await github.paginate(github.rest.pulls.listFiles, { + owner, + repo, + pull_number: prNumber, + per_page: 100, + }); + hasAddedScriptFile = files.some( + f => f.status === "added" && scriptPrefixes.some(p => f.filename.startsWith(p)) + ); + } catch (error) { + core.warning(`Could not list files for PR #${prNumber}: ${error.message}`); + } + + if (!hasNewScriptLabel && !hasAddedScriptFile) { + core.info(`PR #${prNumber} is not a new-script submission (no label, no added script file) — skipping.`); return; } diff --git a/.github/workflows/close_issue_in_dev.yaml b/.github/workflows/close_issue_in_dev.yaml index d61ca0e0a..3d82ad5f1 100644 --- a/.github/workflows/close_issue_in_dev.yaml +++ b/.github/workflows/close_issue_in_dev.yaml @@ -56,46 +56,57 @@ jobs: echo "$slugs" > pocketbase_slugs.txt echo "count=$(echo $slugs | wc -w)" >> "$GITHUB_OUTPUT" - - name: Search for Issues with Similar Titles + - name: Find matching issues in ProxmoxVED by slug id: find_issue env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | - issues=$(gh issue list --repo community-scripts/ProxmoxVED --json number,title --jq '.[] | {number, title}') - - best_match_score=0 - best_match_number=0 - - for issue in $(echo "$issues" | jq -r '. | @base64'); do - _jq() { - echo ${issue} | base64 --decode | jq -r ${1} - } - - issue_title=$(_jq '.title' | tr '[:upper:]' '[:lower:]' | sed 's/ //g' | sed 's/-//g') - issue_number=$(_jq '.number') - - match_score=$(echo "$title" | grep -o "$issue_title" | wc -l) - - if [ "$match_score" -gt "$best_match_score" ]; then - best_match_score=$match_score - best_match_number=$issue_number - fi - done - - if [ "$best_match_number" != "0" ]; then - echo "issue_number=$best_match_number" >> $GITHUB_ENV - else - echo "No matching issue found." + if [[ ! -s pocketbase_slugs.txt ]]; then + echo "No slugs derived from PR — nothing to match." + echo "issue_numbers=" >> "$GITHUB_OUTPUT" exit 0 fi + slugs=$(cat pocketbase_slugs.txt) - - name: Comment on the Best-Matching Issue and Close It - if: env.issue_number != '' + # Normalize: lowercase, strip spaces and hyphens (same shape as the slug derivation) + norm() { echo "$1" | tr '[:upper:]' '[:lower:]' | sed 's/[[:space:]]//g; s/-//g'; } + + issues=$(gh issue list --repo community-scripts/ProxmoxVED --state open --limit 1000 --json number,title,body) + + matched="" + for slug in $slugs; do + nslug=$(norm "$slug") + [[ -z "$nslug" ]] && continue + while IFS= read -r row; do + num=$(echo "$row" | jq -r '.number') + ntitle=$(norm "$(echo "$row" | jq -r '.title')") + body=$(echo "$row" | jq -r '.body // ""' | tr '[:upper:]' '[:lower:]') + # Match when the issue title contains the slug, or the body mentions it verbatim + if [[ "$ntitle" == *"$nslug"* ]] || [[ "$body" == *"$slug"* ]]; then + matched="$matched $num" + fi + done < <(echo "$issues" | jq -c '.[]') + done + + matched=$(echo $matched | xargs -n1 2>/dev/null | sort -un | tr '\n' ' ') + if [[ -z "$matched" ]]; then + echo "No matching ProxmoxVED issues found for slugs: $slugs" + echo "issue_numbers=" >> "$GITHUB_OUTPUT" + exit 0 + fi + echo "Matched ProxmoxVED issues: $matched" + echo "issue_numbers=$matched" >> "$GITHUB_OUTPUT" + + - name: Comment on and close matching ProxmoxVED issues + if: steps.find_issue.outputs.issue_numbers != '' env: GH_TOKEN: ${{ secrets.PAT_MICHEL }} run: | - gh issue comment $issue_number --repo community-scripts/ProxmoxVED --body "Merged with #${{ github.event.pull_request.number }} in ProxmoxVE" - gh issue close $issue_number --repo community-scripts/ProxmoxVED + for issue_number in ${{ steps.find_issue.outputs.issue_numbers }}; do + echo "Closing ProxmoxVED issue #$issue_number" + gh issue comment "$issue_number" --repo community-scripts/ProxmoxVED --body "Merged with #${{ github.event.pull_request.number }} in ProxmoxVE" + gh issue close "$issue_number" --repo community-scripts/ProxmoxVED + done - name: Set is_dev to false in PocketBase if: steps.get_slugs.outputs.count != '0' diff --git a/.github/workflows/lock-issue.yaml b/.github/workflows/lock-issue.yaml index 6aa2e49c6..7d97ed720 100644 --- a/.github/workflows/lock-issue.yaml +++ b/.github/workflows/lock-issue.yaml @@ -17,7 +17,7 @@ jobs: uses: actions/github-script@v7 with: script: | - const daysBeforeLock = 3; + const daysBeforeLock = 7; const lockDate = new Date(); lockDate.setDate(lockDate.getDate() - daysBeforeLock); @@ -28,7 +28,7 @@ jobs: /dependabot/i ]; - // Search for closed, unlocked issues older than 3 days (paginated, oldest first) + // Search for closed, unlocked issues older than 7 days (paginated, oldest first) let page = 1; let totalLocked = 0;