-
Notifications
You must be signed in to change notification settings - Fork 419
165 lines (149 loc) · 7.41 KB
/
Copy pathredirect-pull-requests.yml
File metadata and controls
165 lines (149 loc) · 7.41 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
name: Redirect Pull Requests
on:
pull_request_target:
types: [opened]
permissions:
pull-requests: write
jobs:
redirect:
runs-on: ubuntu-latest
steps:
- name: Check org membership and redirect
uses: actions/github-script@v7
with:
script: |
const pr = context.payload.pull_request;
const author = pr.user.login;
// Allow PRs from trusted automation bots (e.g., repo sync)
const allowedBots = ['foundry-samples-repo-sync[bot]'];
if (allowedBots.includes(author)) {
console.log(`Skipping redirect for allowed bot: ${author}`);
return;
}
// Classify the PR author as internal (Microsoft) vs external using a
// cascade of signals. The GITHUB_TOKEN is an *installation* token, not
// a user identity in the 'microsoft' or 'microsoft-foundry' orgs, so
// the org-membership checks below can only confirm *public* members.
// Most Microsoft employees default to private membership, so we also
// fall back to a username pattern and a public-profile heuristic.
// Contributors with no public Microsoft signal anywhere will still be
// misclassified as external; the external-tier message below carries a
// universal caveat pointing self-aware internal contributors at the
// private staging repo, so that failure mode is self-correcting.
async function classifyAuthor(login) {
// Signal 1: microsoft-foundry org membership (public members only).
try {
const res = await github.rest.orgs.checkMembershipForUser({
org: 'microsoft-foundry',
username: login,
});
if (res.status === 204) return 'microsoft-foundry org member (public)';
} catch {}
// Signal 2: direct collaborator on this repo (team-based access is
// typically not visible to GITHUB_TOKEN here).
try {
const res = await github.rest.repos.checkCollaborator({
owner: context.repo.owner,
repo: context.repo.repo,
username: login,
});
if (res.status === 204) return 'repo collaborator';
} catch {}
// Signal 3: microsoft org membership (public members only).
try {
const res = await github.rest.orgs.checkMembershipForUser({
org: 'microsoft',
username: login,
});
if (res.status === 204) return 'microsoft org member (public)';
} catch {}
// Signal 4: username pattern. Matches 'ms', 'msft', or 'microsoft'
// as a whole token bounded by start/end/'-'/'_'. Catches handles like
// 'aprilk-ms', 'mitsha-microsoft', 'brandom-msft' without false-
// positiving 'cosmos', 'awesome', etc.
if (/(^|[-_])(ms|msft|microsoft)([-_]|$)/i.test(login)) {
return 'username pattern';
}
// Signal 5: public profile heuristic. Strict regex on `email`, plus
// a normalized whole-string match on `company` against a small allow
// list. We deliberately do NOT scan `bio` — phrases like
// 'ex-Microsoft' or 'Microsoft MVP' would produce false positives.
try {
const { data: profile } = await github.rest.users.getByUsername({ username: login });
const email = (profile.email || '').trim();
if (/@([a-z0-9-]+\.)?microsoft\.com$/i.test(email)) {
return 'profile email (@microsoft.com)';
}
const normalizedCompany = (profile.company || '')
.trim()
.toLowerCase()
.replace(/^@/, '')
.replace(/[.,]+$/, '');
const acceptedCompanies = new Set([
'microsoft',
'microsoft corporation',
'microsoft corp',
'msft',
]);
if (acceptedCompanies.has(normalizedCompany)) {
return 'profile company';
}
} catch {}
return null;
}
const matchedSignal = await classifyAuthor(author);
const isInternal = matchedSignal !== null;
console.log(`Author: ${author}, isInternal: ${isInternal}, signal: ${matchedSignal || 'none'}`);
let body;
if (isInternal) {
body = [
`👋 Thanks for your contribution, @${author}!`,
'',
'This repository is read-only. If you are contributing on behalf of Microsoft, please submit your PR to the private staging repository instead:',
'',
'👉 **[foundry-samples-pr](https://github.com/microsoft-foundry/foundry-samples-pr)**',
'',
'See [CONTRIBUTING.md](https://github.com/microsoft-foundry/foundry-samples/blob/main/CONTRIBUTING.md) for full instructions.',
].join('\n');
} else {
body = [
`👋 Thanks for your interest in contributing, @${author}!`,
'',
'This repository does not accept pull requests directly. If you\'d like to report a bug, suggest an improvement, or propose a new sample, please **[open an issue](https://github.com/microsoft-foundry/foundry-samples/issues/new)** instead.',
'',
'_If you are a Microsoft-internal contributor, please submit your PR through **[foundry-samples-pr](https://github.com/microsoft-foundry/foundry-samples-pr)** instead._',
'',
'See [CONTRIBUTING.md](https://github.com/microsoft-foundry/foundry-samples/blob/main/CONTRIBUTING.md) for more details.',
].join('\n');
}
// Skip if the bot already commented (idempotent on re-runs). We
// match on the staging-repo slug "microsoft-foundry/foundry-samples-pr",
// which both the internal- and external-tier messages above include
// and is structurally specific to this workflow — generic phrases
// like "This repository" can collide with unrelated bot comments
// and silently suppress the redirect.
const comments = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: pr.number,
});
const alreadyCommented = comments.data.some(c =>
c.user.login === 'github-actions[bot]' &&
c.body.includes('microsoft-foundry/foundry-samples-pr')
);
if (alreadyCommented) {
console.log('Bot already commented on this PR, skipping.');
return;
}
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: pr.number,
body,
});
await github.rest.pulls.update({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: pr.number,
state: 'closed',
});