-
Notifications
You must be signed in to change notification settings - Fork 48
/
Copy pathmonitor-specs.js
262 lines (236 loc) · 8.11 KB
/
monitor-specs.js
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
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
'use strict';
/**
* The monitor-specs script loops through the list of open issues in the
* browser-specs repository that have a "new spec" label, checks those that
* have not been reviewed for a while, and adds a comment and "review" label to
* those that seems worth reviewing again because an update was detected since
* last review.
*
* The last time that an issue was reviewed is the last time that the "review"
* label was removed, which the script retrieves thanks through the GraphQL
* endpoint.
*
* To report the list of issues that need a review (without updating the
* issues), run:
* node src/monitor-specs.js
*
* To report the list of issues that need a review **and** also update the
* issues to add a comment/label, run:
* node src/monitor-specs.js --update
*/
import sendGraphQLQuery from "./graphql.js";
import splitIssueBodyIntoSections from "./split-issue-body.js";
import loadJSON from "./load-json.js";
const config = await loadJSON("config.json");
const githubToken = config?.GITHUB_TOKEN ?? process.env.GITHUB_TOKEN;
/**
* The list of specs that are already known is derived from open and closed
* issues in the browser-specs repository.
*/
const BROWSER_SPECS_REPO = {
owner: "w3c",
name: "browser-specs"
};
/**
* Script does not update GitHub issues by default
*/
const updateGitHubIssues =
(process.argv[2] === "--update") ||
(process.argv[2] === "-u");
/**
* Retrieve the list of specs and repositories that should not be reported
* because we're already aware of them and their treatment is still pending or
* we explicitly don't want to add them to browser-specs.
*/
async function fetchIssuesToReview() {
let list = [];
// Retrieve the list of open issues that have a "new spec" label and,
// for each of them, the last "unlabeled" events.
// Notes:
// - Issues that have a "review" label get skipped for now. By definition,
// a review is already pending for them. If this script is run every couple
// of months, there should not be any issue in that category though...
// - The code assumes that we won't ever set more than 10 different labels on
// a single issue and that we'll find a "review" label removal within the
// last 5 "unlabeled" events. That seems more than enough for now.
let hasNextPage = true;
let endCursor = "";
while (hasNextPage) {
const response = await sendGraphQLQuery(`query {
organization(login: "${BROWSER_SPECS_REPO.owner}") {
repository(name: "${BROWSER_SPECS_REPO.name}") {
issues(
states: OPEN,
labels: "new spec",
first: 100
${endCursor ? ', after: "' + endCursor + '"' : ''}
) {
pageInfo {
endCursor
hasNextPage
}
nodes {
id
number
title
body
createdAt
labels(first: 10) {
nodes {
name
}
}
timelineItems(last: 5, itemTypes: UNLABELED_EVENT) {
nodes {
... on UnlabeledEvent {
label {
name
}
createdAt
}
}
}
}
}
}
}
}`, githubToken);
if (!response?.data?.organization?.repository?.issues) {
console.log(JSON.stringify(response, null, 2));
throw new Error(`GraphQL error, could not retrieve the list of issues`);
}
const issues = response.data.organization.repository.issues;
list.push(...issues.nodes
.filter(issue => !issue.labels.nodes.find(label => label.name === "review"))
);
hasNextPage = issues.pageInfo.hasNextPage;
endCursor = issues.pageInfo.endCursor;
}
return list;
}
/**
* Set a label on a GitHub issue
*/
const labelIds = {};
async function setIssueLabel(issue, label) {
if (!labelIds[label]) {
// Retrieve the label ID from GitHub if we don't know anything about it yet
const labelResponse = await sendGraphQLQuery(`query {
organization(login: "${BROWSER_SPECS_REPO.owner}") {
repository(name: "${BROWSER_SPECS_REPO.name}") {
label(name: "${label}") {
id
}
}
}
}`, githubToken);
if (!labelResponse?.data?.organization?.repository?.label?.id) {
console.log(JSON.stringify(labelResponse, null, 2));
throw new Error(`GraphQL error, could not retrieve the "${label}" label`);
}
labelIds[label] = labelResponse.data.organization.repository.label.id;
}
// Set the label on the issue
const response = await sendGraphQLQuery(`mutation {
addLabelsToLabelable(input: {
labelableId: "${issue.id}"
labelIds: ["${labelIds[label]}"]
clientMutationId: "mutatis mutandis"
}) {
labelable {
... on Issue {
id
}
}
}
}`, githubToken);
if (!response?.data?.addLabelsToLabelable?.labelable?.id) {
console.log(JSON.stringify(response, null, 2));
throw new Error(`GraphQL error, could not add "${label}" label to issue #${session.number}`);
}
}
/**
* Add the "review" label to the given issue, along with a comment
*/
let reviewLabelId = null;
async function flagIssueForReview(issue, comment) {
if (comment) {
// Using a variable to avoid having to deal with comment escaping issues
const commentResponse = await sendGraphQLQuery(`
mutation($comment: AddCommentInput!) {
addComment(input: $comment) {
subject {
id
}
}
}`, {
comment: {
subjectId: issue.id,
body: comment,
clientMutationId: "mutatis mutandis"
}
},
githubToken);
if (!commentResponse?.data?.addComment?.subject?.id) {
console.log(JSON.stringify(commentResponse, null, 2));
throw new Error(`GraphQL error, could not add comment to issue #${issue.number}`);
}
}
await setIssueLabel(issue, "review");
}
fetchIssuesToReview().then(async issues => {
const issuesToReview = [];
for (const issue of issues) {
const lastReviewedEvent = issue.timelineItems.nodes.find(event =>
event.label.name === "review");
issue.lastReviewed = (new Date(lastReviewedEvent ?
lastReviewedEvent.createdAt :
issue.createdAt))
.toJSON()
.slice(0, 10);
const sections = splitIssueBodyIntoSections(issue.body);
const urlSection = sections.find(section => section.title === 'URL');
if (!urlSection) {
console.warn(`- ${issue.title} (#${issue.number}) does not follow the expected issue format`);
if (updateGitHubIssues) {
await setIssueLabel(issue, "invalid");
}
continue;
}
// Retrieve the spec and check the last-modified HTTP header
const response = await fetch(urlSection.value);
const { headers } = response;
// The CSS drafts use a proprietary header to expose the real last
// modification date
issue.lastRevised = (new Date(headers.get('Last-Revised') ?
headers.get('Last-Revised') :
headers.get('Last-Modified')))
.toJSON()
.slice(0, 10);
if (issue.lastRevised > issue.lastReviewed) {
issuesToReview.push(issue);
}
// We don't need the response's body, but not reading it means Node will keep
// the network request in memory, which prevents the CLI from returning until
// a timeout occurs.
await response.arrayBuffer();
}
if (issuesToReview.length === 0) {
console.log('No candidate spec to review');
return;
}
console.log('Candidate specs to review:');
console.log(issuesToReview
.map(issue => `- ${issue.title} (#${issue.number}) updated on ${issue.lastRevised} (last reviewed on ${issue.lastReviewed})`)
.join('\n')
);
if (!updateGitHubIssues) {
return;
}
console.log('Mark GitHub issues as needing a review...');
for (const issue of issues) {
const comment = `The specification was updated on **${issue.lastRevised}** (last reviewed on ${issue.lastReviewed}).`;
await flagIssueForReview(issue, comment);
}
console.log('Mark GitHub issues as needing a review... done');
});