Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
157 changes: 115 additions & 42 deletions .github/workflows/dapr-maintainer-merge.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
# 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]
types: [opened, synchronize, reopened, ready_for_review, edited, labeled, unlabeled]
paths:
- 'sdkdocs/**'

Expand Down Expand Up @@ -43,59 +43,125 @@ jobs:
{ label: 'automerge: rust', teamSlug: 'maintainers-rust-sdk', prefixes: ['sdkdocs/rust/content/en/'] },
];

const username = pr.user.login;
const action = context.payload.action;

// 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;
// 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';
}

// 2) Determine which single SDK mapping the PR targets
let currentMapping = null, ineligible = false;
for (const f of files) {
const matched = MAPPINGS.find(m => m.prefixes.some(p => f.filename.startsWith(p)));
if (!matched) { ineligible = true; break; }
if (!currentMapping) currentMapping = matched;
else if (currentMapping !== matched) { ineligible = true; break; }
// 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 (ineligible || !currentMapping) {
core.info('PR is not eligible: outside mapped paths or touches multiple SDK directories.');
core.setOutput('eligible', 'false');
return;
}
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);

// 3) Verify author is active in the corresponding team (org-scoped token)
try {
const membership = await github.rest.teams.getMembershipForUserInOrg({
org: owner,
team_slug: currentMapping.teamSlug,
username
});
if (membership.data.state !== 'active') {
core.info(`User ${username} is not active in team ${currentMapping.teamSlug}.`);
if (!mapping) {
core.info(`Label "${addedLabel}" is not a recognized automerge label; skipping.`);
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;

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'));
}

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
}

# 2) If eligible, label, approve and merge with the default repo-scoped token
# 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
Expand Down Expand Up @@ -133,6 +199,13 @@ jobs:
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++) {
Expand Down
Loading