Repository navigation
Expand file tree
/
Copy pathgithub.js
More file actions
167 lines (151 loc) · 4.98 KB
/
Copy pathgithub.js
File metadata and controls
167 lines (151 loc) · 4.98 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
166
167
const GITHUB_GRAPHQL_URL = "https://api.github.com/graphql";
const PENDING_REVIEWS_QUERY = `
query($owner: String!, $name: String!) {
repository(owner: $owner, name: $name) {
pullRequests(states: OPEN, first: 100) {
nodes {
number
title
url
isDraft
authorAssociation
author { login __typename }
reviewRequests(first: 20) {
nodes {
requestedReviewer {
... on User { login }
}
}
}
timelineItems(itemTypes: [REVIEW_REQUESTED_EVENT], first: 100) {
nodes {
... on ReviewRequestedEvent {
createdAt
requestedReviewer {
... on User { login }
}
}
}
}
}
}
}
}
`;
async function fetchRepoPRs(org, repo, token) {
const response = await fetch(GITHUB_GRAPHQL_URL, {
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
query: PENDING_REVIEWS_QUERY,
variables: { owner: org, name: repo },
}),
});
if (!response.ok) {
throw new Error(`GitHub GraphQL error: ${response.status}`);
}
const payload = await response.json();
if (payload.errors) {
throw new Error(
`GitHub GraphQL error: ${JSON.stringify(payload.errors)}`
);
}
return payload.data.repository.pullRequests.nodes;
}
function mostRecentRequestedAt(timelineNodes, reviewer) {
const times = timelineNodes
.filter(
(n) => n.requestedReviewer && n.requestedReviewer.login === reviewer
)
.map((n) => n.createdAt)
.sort();
return times.length ? times[times.length - 1] : null;
}
// GraphQL types bot accounts as Bot, but the same accounts show up as a plain
// login suffixed with "[bot]" elsewhere, so check both.
function isBotAuthor(author) {
if (!author) return false;
return author.__typename === "Bot" || /\[bot\]$/.test(author.login || "");
}
// GitHub reports MEMBER/OWNER for members of the org that owns the repo and
// COLLABORATOR for anyone else granted push access; everyone else is outside
// the org.
const COWORKER_ASSOCIATIONS = new Set(["MEMBER", "OWNER", "COLLABORATOR"]);
function authorCategory(pr, teamHandles) {
const login = pr.author ? pr.author.login : null;
if (login && teamHandles.has(login)) return "coworker";
if (isBotAuthor(pr.author)) return "bot";
if (COWORKER_ASSOCIATIONS.has(pr.authorAssociation)) return "coworker";
return "external";
}
const CATEGORY_RANK = { coworker: 0, bot: 1, external: 2 };
// Coworkers' PRs first, then bots, then external contributors; within a group
// the longest-waiting review comes first.
function comparePendingReviews(a, b) {
const byCategory =
CATEGORY_RANK[a.authorCategory] - CATEGORY_RANK[b.authorCategory];
if (byCategory !== 0) return byCategory;
return new Date(a.requestedAt) - new Date(b.requestedAt);
}
// A repo the token cannot see — a private repo when the workflow runs on the
// default repo-scoped GITHUB_TOKEN — fails the same way a transient error does:
// its PRs just go missing. Emit an Actions annotation so the run summary shows
// it instead of burying it in the step log.
function warnRepoFetchFailed(repo, message) {
const text = `Failed to fetch reviews for ${repo}: ${message}`;
console.warn(process.env.GITHUB_ACTIONS ? `::warning::${text}` : text);
}
async function getPendingReviews(config, token) {
const { org, repos, teamMembers } = config;
const teamHandles = new Set(teamMembers.map((m) => m.github));
const result = {};
for (const member of teamMembers) {
result[member.github] = [];
}
const repoResults = await Promise.all(
repos.map((repo) =>
fetchRepoPRs(org, repo, token)
.then((nodes) => ({ repo, nodes }))
.catch((err) => {
warnRepoFetchFailed(repo, err.message);
return { repo, nodes: [] };
})
)
);
for (const { repo, nodes } of repoResults) {
for (const pr of nodes) {
if (pr.isDraft) continue;
const requestedReviewers = (pr.reviewRequests.nodes || [])
.map((r) => r.requestedReviewer && r.requestedReviewer.login)
.filter((login) => login && teamHandles.has(login));
for (const reviewer of requestedReviewers) {
const requestedAt = mostRecentRequestedAt(
pr.timelineItems.nodes || [],
reviewer
);
if (requestedAt && result[reviewer]) {
result[reviewer].push({
number: pr.number,
title: pr.title,
url: pr.url,
repo,
author: pr.author ? pr.author.login : null,
authorCategory: authorCategory(pr, teamHandles),
reviewer,
requestedAt,
});
}
}
}
}
for (const reviews of Object.values(result)) {
reviews.sort(comparePendingReviews);
}
return result;
}
module.exports = {
getPendingReviews,
};