Compare commits

...

2 Commits

Author SHA1 Message Date
Michel Roegl-Brunner
d4b4880e0d fix(pocketbase-bot): recognize /pocketbase command on any line of a comment
- Job gate uses contains() instead of startsWith() so comments with leading
  text still trigger the bot
- Script scans all lines for the first one starting with /pocketbase, instead
  of only reading line 0
- Command-line detection moved above the permission check so mid-sentence
  mentions exit silently without a "not authorized" reply

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 11:52:47 +02:00
Michel Roegl-Brunner
9dc08aa8c1 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) <noreply@anthropic.com>
2026-06-02 11:43:08 +02:00
4 changed files with 94 additions and 76 deletions

View File

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

View File

@@ -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'

4
.github/workflows/lock-issue.yaml generated vendored
View File

@@ -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;

24
.github/workflows/pocketbase-bot.yml generated vendored
View File

@@ -13,8 +13,8 @@ jobs:
pocketbase-bot:
runs-on: self-hosted
# Only act on /pocketbase commands
if: startsWith(github.event.comment.body, '/pocketbase')
# Act on comments that contain a /pocketbase command line (precise line check happens in-script)
if: contains(github.event.comment.body, '/pocketbase')
steps:
- name: Execute PocketBase bot command
@@ -257,6 +257,22 @@ jobs:
if (!res.ok) console.warn('Could not post comment:', res.body);
}
// ── Locate the command line ────────────────────────────────────────
// Accept /pocketbase at the start of ANY line (leading whitespace ok),
// so the command works even when preceded by other text. Mid-sentence
// mentions and blockquoted ("> ...") examples are ignored.
const commentBody = process.env.COMMENT_BODY || '';
const cmdLine = commentBody
.split('\n')
.map(l => l.trim())
.find(l => l.startsWith('/pocketbase'));
if (!cmdLine) {
console.log('No /pocketbase command line found — ignoring comment.');
process.exit(0);
}
const withoutCmd = cmdLine.replace(/^\/pocketbase\s*/, '').trim();
// ── Permission check ───────────────────────────────────────────────
const association = process.env.ACTOR_ASSOCIATION;
if (association !== 'OWNER' && association !== 'MEMBER') {
@@ -272,10 +288,6 @@ jobs:
await addReaction('eyes');
// ── Parse command ──────────────────────────────────────────────────
const commentBody = process.env.COMMENT_BODY || '';
const lines = commentBody.trim().split('\n');
const firstLine = lines[0].trim();
const withoutCmd = firstLine.replace(/^\/pocketbase\s+/, '').trim();
function extractCodeBlock(body) {
const m = body.match(/```[^\n]*\n([\s\S]*?)```/);