Skip to content

.NET SDK: Reflect updated PubSub message handler for streaming subscriptions #315

.NET SDK: Reflect updated PubSub message handler for streaming subscriptions

.NET SDK: Reflect updated PubSub message handler for streaming subscriptions #315

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, labeled, unlabeled]
paths:
- 'sdkdocs/**'
# Token scopes needed: label, review, and merge for repo actions.
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:
# 1) Check team membership with an org-scoped token
- name: Check SDK team membership
id: teamcheck
uses: actions/github-script@v7
with:
github-token: ${{ secrets.ORG_READ_MEMBERS_TOKEN }} # PAT read:org or fine-grained Members: Read
script: |
const { owner, repo } = context.repo;
const pr = context.payload.pull_request;
const number = pr.number;
// --- Mapping: directory prefixes -> team slug + label ---
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 action = context.payload.action;
// Helper: verify a user is an active member of a team
async function checkMembership(teamSlug, username) {
const membership = await github.rest.teams.getMembershipForUserInOrg({
org: owner, team_slug: teamSlug, username
});
return membership.data.state === 'active';
}
// Helper: verify all PR files fall within a single mapping's prefixes
async function resolveMapping(number) {
const files = await github.paginate(
github.rest.pulls.listFiles,
{ owner, repo, pull_number: number, per_page: 100 }
);
if (files.length === 0) return null;
let mapping = null;
for (const f of files) {
const matched = MAPPINGS.find(m => m.prefixes.some(p => f.filename.startsWith(p)));
if (!matched) return null;
if (!mapping) mapping = matched;
else if (mapping !== matched) return null;
}
return mapping;
}
if (action === 'labeled') {
// --- Maintainer-approved flow ---
// A maintainer adds an automerge label to a PR they didn't necessarily author.
// Validate: label is a known automerge label, adder is in that team, PR only
// touches that SDK's directory.
const addedLabel = context.payload.label.name;
const mapping = MAPPINGS.find(m => m.label === addedLabel);
if (!mapping) {
core.info(`Label "${addedLabel}" is not a recognized automerge label; skipping.`);
core.setOutput('eligible', 'false');
return;
}
const labelAdder = context.payload.sender.login;
try {
const active = await checkMembership(mapping.teamSlug, labelAdder);
if (!active) {
core.info(`User ${labelAdder} is not active in team ${mapping.teamSlug}; removing label.`);
core.setOutput('eligible', 'false');
core.setOutput('remove_label', addedLabel);
return;
}
} catch (err) {
core.info(`Membership check failed for ${labelAdder} in team ${mapping.teamSlug}: ${err.status} ${err.message}`);
core.setOutput('eligible', 'false');
core.setOutput('remove_label', addedLabel);
return;
}
const resolvedMapping = await resolveMapping(number);
if (!resolvedMapping || resolvedMapping !== mapping) {
core.info('PR touches files outside the labeled SDK directory or multiple directories; removing label.');
core.setOutput('eligible', 'false');
core.setOutput('remove_label', addedLabel);
return;
}
core.info(`Maintainer ${labelAdder} approved merge via label for ${mapping.label}.`);
core.setOutput('eligible', 'true');
core.setOutput('label', mapping.label);
core.setOutput('teamSlug', mapping.teamSlug);
core.setOutput('lang', (mapping.label.split(': ')[1] || 'sdk'));
} else {
// --- Author-is-maintainer flow ---
// PR author must themselves be a maintainer of the single SDK directory the PR touches.
const username = pr.user.login;
const currentMapping = await resolveMapping(number);
if (!currentMapping) {
core.info('PR is not eligible: no changed files, outside mapped paths, or touches multiple SDK directories.');
core.setOutput('eligible', 'false');
return;
}
try {
const active = await checkMembership(currentMapping.teamSlug, username);
if (!active) {
core.info(`User ${username} is not active in team ${currentMapping.teamSlug}.`);
core.setOutput('eligible', 'false');
return;
}
} catch (err) {
core.info(`Membership check failed or user not in team ${currentMapping.teamSlug}: ${err.status} ${err.message}`);
core.setOutput('eligible', 'false');
return;
}
core.setOutput('eligible', 'true');
core.setOutput('label', currentMapping.label);
core.setOutput('teamSlug', currentMapping.teamSlug);
core.setOutput('lang', (currentMapping.label.split(': ')[1] || 'sdk'));
}
# 2) Remove unauthorized automerge labels (uses default repo-scoped token)
- name: Remove unauthorized automerge label
if: steps.teamcheck.outputs.remove_label != ''
uses: actions/github-script@v7
with:
script: |
const { owner, repo } = context.repo;
const number = context.payload.pull_request.number;
const labelName = '${{ steps.teamcheck.outputs.remove_label }}';
try {
await github.rest.issues.removeLabel({ owner, repo, issue_number: number, name: labelName });
core.info(`Removed unauthorized label "${labelName}" from PR #${number}.`);
} catch (e) {
if (e.status !== 404) throw e; // 404 = already removed, ignore
}
# 3) If eligible, label, approve and merge with the default repo-scoped token
- name: Label, auto-approve & merge
if: steps.teamcheck.outputs.eligible == 'true'
uses: actions/github-script@v7
with:
script: |
const { owner, repo } = context.repo;
const number = context.payload.pull_request.number;
const labelName = '${{ steps.teamcheck.outputs.label }}';
const lang = '${{ steps.teamcheck.outputs.lang }}';
const mergeMethod = process.env.MERGE_METHOD || 'squash';
// Ensure label exists then add it
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: '6A9286',
description: 'Auto-merged language-specific SDK docs'
});
} else {
throw e;
}
}
await github.rest.issues.addLabels({ owner, repo, issue_number: number, labels: [labelName] });
// Auto-approve
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}`);
}
// Block if "do-not-merge" label is present
const currentLabels = context.payload.pull_request.labels.map(l => l.name.toLowerCase());
if (currentLabels.includes('do-not-merge')) {
core.info('PR has "do-not-merge" label; skipping merge until it is removed.');
return;
}
// Poll mergeability
const wait = ms => new Promise(r => setTimeout(r, ms));
for (let i = 0; i < 12; i++) {
const pr = await github.rest.pulls.get({ owner, repo, pull_number: number });
if (pr.data.mergeable && ['clean','unstable'].includes(pr.data.mergeable_state)) break;
await wait(5000);
}
// Merge
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})`
});