1+ name : PR workflow
2+
3+ permissions :
4+ contents : read # for git operations
5+ pull-requests : write # to request reviewers
6+ issues : write # to create/update PR comments
7+
8+ on :
9+ pull_request :
10+ types : [opened, synchronize, ready_for_review] # include ready_for_review for draft PRs
11+
12+ jobs :
13+ pr-automation :
14+ name : " PR Check"
15+ runs-on : ubuntu-latest
16+
17+ steps :
18+ # Step 1: Get changed files
19+ - name : Get changed files
20+ id : files
21+ uses : tj-actions/changed-files@v44
22+
23+ # Step 2: Assign, label, request reviewers, and enforce approvals
24+ - name : Assign, label, request reviewers, enforce approvals
25+ uses : actions/github-script@v7
26+ with :
27+ script : |
28+ const owner = context.repo.owner;
29+ const repo = context.repo.repo;
30+ const prNumber = context.payload.pull_request.number;
31+ const author = context.payload.pull_request.user.login;
32+
33+ if (context.payload.pull_request.draft) {
34+ console.log("PR is a draft — skipping workflow steps.");
35+ return;
36+ }
37+
38+ const changedFiles = "${{ steps.files.outputs.all_changed_files }}".split(" ").filter(f => f);
39+
40+ // -------------------
41+ // CONFIGURATION
42+ // -------------------
43+ const rules = {
44+ frontend: { path: "frontend/", label: "frontend", lead: ["MaddieWright"], team: [] },
45+ backend: { path: "backend/", label: "backend", lead: ["HeisSteve"], team: [] },
46+ infra: { path: ".github/", label: "infra", lead: ["HeisSteve", "MaddieWright" ], team: [] }
47+ };
48+
49+ const labelsToAdd = new Set();
50+ const reviewersToAdd = new Set();
51+
52+ const leadsToCheck = new Set();
53+ const teamToCheck = new Set();
54+
55+
56+ // Determine applicable rules
57+ for (const file of changedFiles) {
58+ for (const rule of Object.values(rules)) {
59+ if (!file.startsWith(rule.path)) continue;
60+
61+ labelsToAdd.add(rule.label);
62+
63+ // Normalize leads to an array
64+ const leads = Array.isArray(rule.lead) ? rule.lead : [rule.lead];
65+
66+ // Add leads for approval & reviewers
67+ leads.forEach(u => {
68+ leadsToCheck.add(u);
69+ reviewersToAdd.add(u);
70+ });
71+
72+ // Add team members for approval & reviewers
73+ rule.team.forEach(u => {
74+ teamToCheck.add(u);
75+ reviewersToAdd.add(u);
76+ });
77+ }
78+ }
79+
80+ // Assign PR author
81+ await github.rest.issues.addAssignees({
82+ owner,
83+ repo,
84+ issue_number: prNumber,
85+ assignees: [author]
86+ });
87+
88+ // Get existing labels
89+ const existingLabels = await github.rest.issues.listLabelsOnIssue({
90+ owner,
91+ repo,
92+ issue_number: prNumber
93+ });
94+
95+ const managedLabels = Object.values(rules).map(r => r.label);
96+ const existingManagedLabels = existingLabels.data
97+ .map(l => l.name)
98+ .filter(l => managedLabels.includes(l));
99+
100+ // Remove labels that no longer apply
101+ for (const label of existingManagedLabels) {
102+ if (!labelsToAdd.has(label)) {
103+ await github.rest.issues.removeLabel({
104+ owner,
105+ repo,
106+ issue_number: prNumber,
107+ name: label
108+ });
109+ }
110+ }
111+
112+ // Add labels
113+ if (labelsToAdd.size > 0) {
114+ await github.rest.issues.addLabels({
115+ owner,
116+ repo,
117+ issue_number: prNumber,
118+ labels: [...labelsToAdd]
119+ });
120+ }
121+
122+ // Remove PR author from reviewers
123+ if (!leadsToCheck.has(author)) {
124+ reviewersToAdd.delete(author);
125+ }
126+
127+ // Request reviewers
128+ if (reviewersToAdd.size > 0) {
129+ await github.rest.pulls.requestReviewers({
130+ owner,
131+ repo,
132+ pull_number: prNumber,
133+ reviewers: [...reviewersToAdd]
134+ });
135+ }
136+
137+
138+ // -------------------
139+ // CUSTOM APPROVAL CHECK WITH SINGLE COMMENT
140+ // -------------------
141+ const { data : reviews } = await github.rest.pulls.listReviews({
142+ owner,
143+ repo,
144+ pull_number : prNumber
145+ });
146+
147+ // Set of users who approved
148+ const approvedUsers = new Set(
149+ reviews
150+ .filter(r => r.state === "APPROVED")
151+ .map(r => r.user.login)
152+ );
153+
154+ // Check if at least one lead approved (include author if they are a lead)
155+ const leadApproved = [...leadsToCheck].some(u => approvedUsers.has(u));
156+
157+ // Count how many team members approved (exclude PR author)
158+ let teamApprovals = 0;
159+ for (const member of teamToCheck) {
160+ if (approvedUsers.has(member) && member !== author) teamApprovals++;
161+ }
162+
163+ // Determine if approval rules are satisfied
164+ const approvalSatisfied = leadApproved || teamApprovals >= 2;
165+
166+
167+ // Prepare the comment body with a timestamp and identifier
168+ const now = new Date().toISOString();
169+ const commentBody = approvalSatisfied
170+ ? `✅ PR approval conditions satisfied (1 lead OR 2 team members approved).\n[Last checked : ${now}] <!-- PR_APPROVAL_CHECK -->`
171+ : `❌ PR approval conditions NOT satisfied. At least 1 team lead OR 2 team members must approve before merging.\n[Last checked : ${now}] <!-- PR_APPROVAL_CHECK -->`;
172+
173+ const { data : comments } = await github.rest.issues.listComments({
174+ owner,
175+ repo,
176+ issue_number : prNumber
177+ });
178+
179+ const existingComment = comments.find(c => c.body.includes("<!-- PR_APPROVAL_CHECK -->"));
180+
181+ if (existingComment) {
182+ // Update the existing comment
183+ await github.rest.issues.updateComment({
184+ owner,
185+ repo,
186+ comment_id : existingComment.id,
187+ body : commentBody
188+ });
189+ } else {
190+ // Create a new comment
191+ await github.rest.issues.createComment({
192+ owner,
193+ repo,
194+ issue_number : prNumber,
195+ body : commentBody
196+ });
197+ }
198+
199+ // Fail workflow if approvals are not satisfied
200+ if (!approvalSatisfied) {
201+ process.exit(1);
202+ }
0 commit comments