name: Close Matching Issue on PR Merge on: pull_request: types: - closed jobs: close_issue: if: github.event.pull_request.merged == true && github.repository == 'community-scripts/ProxmoxVE' runs-on: self-hosted steps: - name: Checkout target repo (merge commit) uses: actions/checkout@v4 with: repository: community-scripts/ProxmoxVE ref: ${{ github.event.pull_request.merge_commit_sha }} token: ${{ secrets.GITHUB_TOKEN }} - name: Extract and Process PR Title id: extract_title run: | title=$(echo "${{ github.event.pull_request.title }}" | sed 's/^New Script://g' | tr '[:upper:]' '[:lower:]' | sed 's/ //g' | sed 's/-//g') echo "Processed Title: $title" echo "title=$title" >> $GITHUB_ENV - name: Get slugs from merged PR id: get_slugs env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | pr_files=$(gh pr view ${{ github.event.pull_request.number }} --repo community-scripts/ProxmoxVE --json files -q '.files[].path' 2>/dev/null || true) slugs="" for path in $pr_files; do [[ -f "$path" ]] || continue if [[ "$path" == frontend/public/json/*.json ]]; then s=$(jq -r '.slug // empty' "$path" 2>/dev/null) [[ -n "$s" ]] && slugs="$slugs $s" elif [[ "$path" == ct/*.sh ]] || [[ "$path" == install/*.sh ]] || [[ "$path" == tools/*.sh ]] || [[ "$path" == turnkey/*.sh ]] || [[ "$path" == vm/*.sh ]]; then base=$(basename "$path" .sh) if [[ "$path" == install/* && "$base" == *-install ]]; then s="${base%-install}" else s="$base" fi [[ -n "$s" ]] && slugs="$slugs $s" fi done slugs=$(echo $slugs | xargs -n1 | sort -u | tr '\n' ' ') if [[ -z "$slugs" && -n "$title" ]]; then slugs="$title" fi if [[ -z "$slugs" ]]; then echo "count=0" >> "$GITHUB_OUTPUT" exit 0 fi echo "$slugs" > pocketbase_slugs.txt echo "count=$(echo $slugs | wc -w)" >> "$GITHUB_OUTPUT" - name: Find matching issues in ProxmoxVED by slug id: find_issue env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | 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) # 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: | 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' env: POCKETBASE_URL: ${{ secrets.POCKETBASE_URL }} POCKETBASE_COLLECTION: ${{ secrets.POCKETBASE_COLLECTION }} POCKETBASE_ADMIN_EMAIL: ${{ secrets.POCKETBASE_ADMIN_EMAIL }} POCKETBASE_ADMIN_PASSWORD: ${{ secrets.POCKETBASE_ADMIN_PASSWORD }} PR_URL: ${{ github.server_url }}/${{ github.repository }}/pull/${{ github.event.pull_request.number }} run: | node << 'ENDSCRIPT' (async function() { const fs = require('fs'); const https = require('https'); const http = require('http'); const url = require('url'); function request(fullUrl, opts, redirectsLeft) { if (redirectsLeft === undefined) redirectsLeft = 5; return new Promise(function(resolve, reject) { const u = url.parse(fullUrl); const isHttps = u.protocol === 'https:'; const body = opts.body; const options = { hostname: u.hostname, port: u.port || (isHttps ? 443 : 80), path: u.path, method: opts.method || 'GET', headers: opts.headers || {} }; if (body) options.headers['Content-Length'] = Buffer.byteLength(body); const lib = isHttps ? https : http; const req = lib.request(options, function(res) { // Follow redirects (301/302/307/308) if ([301, 302, 307, 308].indexOf(res.statusCode) !== -1 && res.headers.location && redirectsLeft > 0) { res.resume(); const nextUrl = url.resolve(fullUrl, res.headers.location); // For 301/302, browsers historically downgrade to GET; preserve method for 307/308. const nextOpts = Object.assign({}, opts); if (res.statusCode === 301 || res.statusCode === 302) { nextOpts.method = 'GET'; delete nextOpts.body; } resolve(request(nextUrl, nextOpts, redirectsLeft - 1)); return; } let data = ''; res.on('data', function(chunk) { data += chunk; }); res.on('end', function() { resolve({ ok: res.statusCode >= 200 && res.statusCode < 300, statusCode: res.statusCode, body: data }); }); }); req.on('error', reject); if (body) req.write(body); req.end(); }); } const raw = process.env.POCKETBASE_URL.replace(/\/$/, ''); const apiBase = /\/api$/i.test(raw) ? raw : raw + '/api'; const coll = process.env.POCKETBASE_COLLECTION; const slugsText = fs.readFileSync('pocketbase_slugs.txt', 'utf8').trim(); const slugs = slugsText ? slugsText.split(/\s+/).filter(Boolean) : []; if (slugs.length === 0) { console.log('No slugs to update.'); return; } const authUrl = apiBase + '/collections/users/auth-with-password'; const authBody = JSON.stringify({ identity: process.env.POCKETBASE_ADMIN_EMAIL, password: process.env.POCKETBASE_ADMIN_PASSWORD }); const authRes = await request(authUrl, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: authBody }); if (!authRes.ok) { throw new Error('Auth failed: ' + authRes.body); } const token = JSON.parse(authRes.body).token; const recordsUrl = apiBase + '/collections/' + encodeURIComponent(coll) + '/records'; const prUrl = process.env.PR_URL || ''; for (const slug of slugs) { const filter = "(slug='" + slug.replace(/'/g, "''") + "')"; const listRes = await request(recordsUrl + '?filter=' + encodeURIComponent(filter) + '&perPage=1', { headers: { 'Authorization': token } }); const list = JSON.parse(listRes.body); const record = list.items && list.items[0]; if (!record) { console.log('Slug not in DB, skipping: ' + slug); continue; } const patchRes = await request(recordsUrl + '/' + record.id, { method: 'PATCH', headers: { 'Authorization': token, 'Content-Type': 'application/json' }, body: JSON.stringify({ name: record.name || record.slug, last_update_commit: prUrl, is_dev: false }) }); if (!patchRes.ok) { console.warn('PATCH failed for slug ' + slug + ': ' + patchRes.body); continue; } console.log('Set is_dev=false for slug: ' + slug); } console.log('Done.'); })().catch(e => { console.error(e); process.exit(1); }); ENDSCRIPT shell: bash