summaryrefslogtreecommitdiffhomepage
path: root/.github/workflows/code-owner-approval.yml
blob: 256f626d3fc3b442fff9230bc0c6a802020e5ed4 (plain)
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
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
---
name: Code Owner Approval
description: Ensure that someone from each team that owns code changed in the PR has approved the PR
on:
  # Trigger on both changes to the PR content and new/changed reviews. Both of these can affect
  # whether the PR has the required approvals.
  pull_request:
  pull_request_review:

concurrency:
  group: ${{ github.workflow }}-${{ github.event.pull_request.number }}
  cancel-in-progress: true

jobs:
  check-code-owner-approvals:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      pull-requests: read
      # Required to write a custom commit status with createCommitStatus
      statuses: write
    steps:
      - uses: actions/checkout@v5

      - name: Check code owner approvals
        uses: actions/github-script@v8
        with:
          # Requires a token with read access to the "Members" scope under organization,
          # and read access to the "Pull request" scope under the repository.
          # and write access to "Commit statuses" under the repository.
          github-token: ${{ secrets.CODE_OWNERSHIP_CI_TOKEN }}
          script: |
            const fs = require('fs');
            const path = require('path');

            // Helper function to set a custom commit status. This is not the status
            // of this workflow run, but rather a custom status.
            // This is required since the `pull_request` and `pull_request_review` events
            // trigger independently and create separate statuses, that can be incompatible.
            // Instead we report both of these into a custom status, that is set as required
            // in the branch protection rules.
            async function setCommitStatus(state, description) {
              await github.rest.repos.createCommitStatus({
                owner: context.repo.owner,
                repo: context.repo.repo,
                sha: context.payload.pull_request.head.sha,
                state: state,
                context: 'Code Owner Approval',
                description: description
              });
            }

            // Returns an array of file paths changed in the PR
            async function getChangedFiles() {
              const changedFiles = await github.rest.pulls.listFiles({
                owner: context.repo.owner,
                repo: context.repo.repo,
                pull_number: context.issue.number
              });

              return changedFiles.data.map(file => file.filename);
            }

            // Returns a list of usernames who approved the PR (based on their latest review)
            async function getApprovers() {
              const reviews = await github.rest.pulls.listReviews({
                owner: context.repo.owner,
                repo: context.repo.repo,
                pull_number: context.issue.number
              });

              const latestReviews = new Map();

              for (const review of reviews.data) {
                const currentLatest = latestReviews.get(review.user.id);

                // Keep the most recent review (higher ID = more recent)
                if (!currentLatest || review.id > currentLatest.id) {
                  latestReviews.set(review.user.id, review);
                }
              }

              // Filter to only approved reviews
              const approvers = [];
              for (const [userId, review] of latestReviews) {
                if (review.state === 'APPROVED') {
                  approvers.push(review.user.login);
                }
              }

              return approvers;
            }

            // Put all logic in a try-catch, so we can set the commit status
            // to "error" if something fails.
            try {
              // Set status to pending at the start, clearing any previous state
              await setCommitStatus('pending', 'Checking code owner approvals...');

              const changedFiles = await getChangedFiles();
              console.log('[DEBUG] Files changed in this PR:', changedFiles);

              // Load team ownership mapping
              const codeOwnerships = JSON.parse(fs.readFileSync('code-owners.json'));

              // The set of teams owning code changed in this PR
              const affectedTeams = new Set();

              for (const [team, patterns] of Object.entries(codeOwnerships)) {
                console.log(`[DEBUG] Team: ${team}, Ownership patterns:`, [...patterns]);

                // List all files in the repository matching this owner's patterns
                const globber = await glob.create(patterns.join('\n'));
                const matches = await globber.glob();

                // Convert absolute paths to relative paths
                const ownedFiles = matches.map(match =>
                  path.relative(process.env.GITHUB_WORKSPACE, match)
                );

                for (const changedFile of changedFiles) {
                  if (ownedFiles.includes(changedFile)) {
                    affectedTeams.add(team);
                    console.log(`[DEBUG] File ${changedFile} is owned by ${team}`);
                  }
                }
              }

              if (affectedTeams.size === 0) {
                console.log('✅ No code owner for any changed file');
                await setCommitStatus('success', 'No changes require code owner approval');
                return;
              }

              console.log(`👥 This PR needs approval from: ${[...affectedTeams].join(', ')}`);

              // Set of teams that have approved this PR
              const approvedTeams = new Set();

              // Get array of github usernames that have approved the PR
              const approvers = await getApprovers();

              // PR author automatically counts as an approver. A code owner changing
              // their own code does not need extra code owner approval. They still
              // need the code reviewed, but that's not part of code ownership approval.
              const prAuthor = context.payload.pull_request.user.login;
              if (!approvers.includes(prAuthor)) {
                approvers.push(prAuthor);
              }
              console.log(`👍 PR approved by the following accounts: ${approvers.join(', ')}`);

              for (const approver of approvers) {
                for (const team of affectedTeams) {
                  try {
                    await github.rest.teams.getMembershipForUserInOrg({
                      org: context.repo.owner,
                      team_slug: team,
                      username: approver
                    });
                    approvedTeams.add(team);
                    console.log(`[DEBUG] ${approver} is member of team '${team}' - approval counted`);
                  } catch (e) {
                    console.log(`[DEBUG] ${approver} is not member of team '${team} (${e})`);
                  }
                }
              }

              console.log('👍 Teams that have approved this PR:', [...approvedTeams].join(', '));

              const missingApprovals = [...affectedTeams].filter(t => !approvedTeams.has(t));

              if (missingApprovals.length > 0) {
                console.log(`❌ Missing approvals from: ${missingApprovals.join(', ')}`);
                await setCommitStatus('failure', `Missing approvals from: ${missingApprovals.join(', ')}`);
              } else {
                console.log('✅ All code owners approved this change!');
                await setCommitStatus('success', `All code owners have approved: ${[...affectedTeams].join(', ')}`);
              }
            } catch (error) {
              console.error('Error checking code owner approvals:', error);
              await setCommitStatus('error', 'Error checking approvals');
              throw error;
            }