diff --git a/.github/workflows/dapr-maintainer-merge.yml b/.github/workflows/dapr-maintainer-merge.yml new file mode 100644 index 00000000000..e6cfe77d66e --- /dev/null +++ b/.github/workflows/dapr-maintainer-merge.yml @@ -0,0 +1,196 @@ + +name: Auto-approve & merge SDK docs PRs (per directory/team) + +# Run on PRs (including forks) but act with repo-level permissions. +# We DO NOT check out PR code; we only read PR metadata via the API. +on: + pull_request_target: + types: [opened, synchronize, reopened, ready_for_review, edited] + +# Token scopes needed: label, review, and merge. +permissions: + contents: write # required to merge + pull-requests: write # required to approve/merge + issues: write # required to create/add labels + +jobs: + sdk-docs-automerge: + runs-on: ubuntu-latest + # Ignore drafts + if: ${{ github.event.pull_request.draft == false }} + + steps: + - name: Evaluate PR for SDK docs eligibility & add label + id: check + uses: actions/github-script@v7 + with: + # Optional: override org/merge method/colors via env + # env: + # ORG: dapr + # MERGE_METHOD: squash + # LABEL_COLOR_DEFAULT: '6A9286' + script: | + const { owner, repo } = context.repo; + const pr = context.payload.pull_request; + const number = pr.number; + + // --- Mapping: directory prefixes -> team slug + label --- + // Each entry can have multiple prefixes for the same SDK if needed. + const MAPPINGS = [ + { label: 'automerge: dotnet', teamSlug: 'maintainers-dotnet-sdk', prefixes: ['sdkdocs/dotnet/'] }, + { label: 'automerge: go', teamSlug: 'maintainers-go-sdk', prefixes: ['sdkdocs/go/'] }, + { label: 'automerge: java', teamSlug: 'maintainers-java-sdk', prefixes: ['sdkdocs/java/content/en/'] }, + { label: 'automerge: js', teamSlug: 'maintainers-js-sdk', prefixes: ['sdkdocs/js/'] }, + { label: 'automerge: php', teamSlug: 'maintainers-php-sdk', prefixes: ['sdkdocs/php/'] }, + { label: 'automerge: python', teamSlug: 'maintainers-python-sdk', prefixes: ['sdkdocs/python/'] }, + { label: 'automerge: rust', teamSlug: 'maintainers-rust-sdk', prefixes: ['sdkdocs/rust/content/en/'] }, + ]; + + const org = owner; + const defaultLabelColor = '6A9286'; + const username = pr.user.login; + + // 1) List changed files + const files = await github.paginate( + github.rest.pulls.listFiles, + { owner, repo, pull_number: number, per_page: 100 } + ); + + if (files.length === 0) { + core.info('No files changed in PR; skipping.'); + core.setOutput('eligible', 'false'); + return; + } + + // 2) Determine which single SDK mapping the PR targets + // - All files must match ONE mapping's prefixes + // - If files touch multiple mappings or outside any mapping, skip + + let currentMapping = null; // holds the mapping object we've locked onto + let ineligible = false; + + for (const f of files) { + const path = f.filename; + + // find the first mapping whose prefixes match this file + let matched = null; + for (const m of MAPPINGS) { + if (m.prefixes.some(p => path.startsWith(p))) { + matched = m; + break; + } + } + + // if no mapping matched, we can stop: not eligible + if (!matched) { + ineligible = true; + break; + } + + // if we haven't locked onto a mapping yet, set it now + if (!currentMapping) { + currentMapping = matched; + } else if (currentMapping !== matched) { + // different SDK mapping from the one already selected => not eligible + ineligible = true; + break; + } + } + + if (ineligible || !currentMapping) { + core.info('PR is not eligible: outside mapped paths or touches multiple SDK directories.'); + core.setOutput('eligible', 'false'); + return; + } + + const mapping = currentMapping; + const labelName = mapping.label; + const teamSlug = mapping.teamSlug; + const lang = mapping.label.split(': ')[1] || 'sdk'; + + // 3) Verify author is active in the corresponding team + // teams.getMembershipForUserInOrg: GET /orgs/{org}/teams/{team_slug}/memberships/{username} + // Requires team visibility to the token. [3](https://docs.github.com/rest/teams/members) + try { + const membership = await github.rest.teams.getMembershipForUserInOrg({ + org, + team_slug: teamSlug, + username + }); + if (membership.data.state !== 'active') { + core.info(`User ${username} is not active in team ${teamSlug}.`); + core.setOutput('eligible', 'false'); + return; + } + } catch (err) { + if (err.status === 404) { + core.info(`User ${username} is not a member of team ${teamSlug}.`); + core.setOutput('eligible', 'false'); + return; + } + throw err; + } + + // 4) Ensure label exists; then add it to the PR + try { + await github.rest.issues.getLabel({ owner, repo, name: labelName }); + } catch (e) { + if (e.status === 404) { + await github.rest.issues.createLabel({ + owner, repo, name: labelName, color: defaultLabelColor, + description: 'Auto-merged language-specific SDK docs' + }); + } else { + throw e; + } + } + await github.rest.issues.addLabels({ + owner, repo, issue_number: number, labels: [labelName] + }); + + // 5) Expose mapping for next step + core.setOutput('eligible', 'true'); + core.setOutput('label', labelName); + core.setOutput('teamSlug', teamSlug); + core.setOutput('lang', lang); + + - name: Auto-approve & merge (only if eligible) + if: steps.check.outputs.eligible == 'true' + uses: actions/github-script@v7 + with: + script: | + const { owner, repo } = context.repo; + const number = context.payload.pull_request.number; + const lang = core.getInput('lang') || '${{ steps.check.outputs.lang }}'; + const mergeMethod = process.env.MERGE_METHOD || 'squash'; + + // 6) Auto-approve review + try { + await github.rest.pulls.createReview({ + owner, repo, pull_number: number, + event: 'APPROVE', + body: `Auto-approval: ${lang} SDK docs` + }); + } catch (e) { + core.warning(`Failed to create review: ${e.message}`); + } + + // 7) Poll until PR is mergeable (clean/unstable) + const wait = ms => new Promise(r => setTimeout(r, ms)); + let attempt = 0; + while (attempt < 12) { // up to ~60s + attempt++; + const pr = await github.rest.pulls.get({ owner, repo, pull_number: number }); + const state = pr.data.mergeable_state; + core.info(`mergeable=${pr.data.mergeable}, mergeable_state=${state}`); + if (pr.data.mergeable && (state === 'clean' || state === 'unstable')) break; + await wait(5000); + } + + // 8) Merge the PR + await github.rest.pulls.merge({ + owner, repo, pull_number: number, + merge_method: mergeMethod, + commit_title: `${lang}: ${context.payload.pull_request.title}`, + commit_message: `Auto-merged by SDK maintainer merge bot (${lang})` + });