-
Notifications
You must be signed in to change notification settings - Fork 1
/
index.js
269 lines (244 loc) · 7.93 KB
/
index.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
263
264
265
266
267
268
269
/**
* GitHub Action for validating an issue against a given template
*
* @author Joey Takeda (github.com/joeytakeda)
* @date 2021
*
*/
const core = require('@actions/core');
const github = require('@actions/github');
const matter = require('gray-matter');
const marked = require("marked")
const { JSDOM } = require('jsdom');
const token = core.getInput('token');
const issueNumber = core.getInput('issue-number');
const repository = process.env.GITHUB_REPOSITORY;
const repoTokens = repository.split("/");
const owner = repoTokens[0];
const repo = repoTokens[1];
const cfg = {
owner: owner,
repo: repo,
issue_number: issueNumber,
};
const octokit = github.getOctokit(token);
const commenting = core.getInput('comment') === 'true' ? true : false;
/**
* Driver function to initiate the check
* @returns {Promise<boolean>} False is validation errors were found; true if not.
*/
async function go(){
try{
let thisIssue = await octokit.issues.get(cfg);
let errors = await validate(thisIssue.data);
if (errors.length === 0){
core.info(`No validation errors found.`)
return true;
}
core.info(`Found ${errors.length} errors`)
const comment = renderComment(errors);
if (!commenting){
core.info('Commenting turned off, so ending now...')
core.info('I would have said: ');
core.info(comment);
return false;
}
const postedComment = await postComment(comment);
console.log('Posted comment!');
return false;
} catch (e){
console.log(e);
}
}
/**
* Fetches the issue templates specified in the configuration
* @returns {Promise<[]>} An array of template objects
*/
async function getTemplates(){
return new Promise(async (resolve, reject) => {
try{
let templateArray = JSON.parse(core.getInput('templates'));
let promises = templateArray.map(async template => {
let obj = {};
let response = await octokit.repos.getContent({
owner: owner,
repo: repo,
path: '.github/ISSUE_TEMPLATE/' + template,
});
// Parse using the 'grey-matter' to get proper YAML front matter for label checking
let parsed = matter(Buffer.from(response.data.content, 'base64').toString());
parsed.html = await parseDoc(parsed.content);
obj[template] = parsed;
return obj;
});
let results = await Promise.all(promises);
resolve(results);
} catch(e){
console.log(`${e}`);
reject(e);
}
})
}
/**
* POSTs a comment to the issue using the GitHub API
* @param body
* @returns {Promise<unknown>}
*/
async function postComment(body){
return new Promise(async (resolve, reject) => {
try{
const comment = await octokit.issues.createComment(
Object.assign(cfg, {body: body})
);
resolve(comment);
} catch(e){
console.log(`ERROR: ${e}`);
reject(e);
}
});
}
/**
* Creates a markdown comment to be posted to the issue
* @param errors
* @returns {string}
*/
function renderComment(errors){
let preamble = `Hi! It looks like this ${errors[0].name.toLowerCase()} is missing content for the following required field${errors.length > 1 ? 's' : ''}: `;
let list = errors.map(err => ` * ${err.text}`).join("\n");
let suffix = `Please fill out the rest of this template by editing your above comment \
(and sorry if I've wrongly flagged this as incomplete! I'm just an robot :robot:.)`;
return `${preamble}\n\n${list}\n\n${suffix}`;
}
/**
* Parses a markdown text into a HTML fragment
* @param text
* @returns {Promise<HTMLDocument>}
*/
async function parseDoc(text){
return new Promise(async (resolve, reject) => {
try{
let rendered = marked(text);
resolve(JSDOM.fragment(rendered));
} catch(e){
console.log(`ERROR: ${e}`);
reject(e);
}
})
}
/**
* Checks an issue against the template to make sure
* they align
*
* @param data
* @param template
* @return [] An array of errors, which might be empty
*/
async function validate(issue) {
/**
* Maps all of the parsed fields from the template
* to return an array of the fields
* @param frag
* @returns {[]}
*/
const getFields = (frag) => {
let fields = [];
let children = [...frag.children];
for (const el of children){
const isHeading = /^H\d+$/g.test(el.tagName);
const thisId = el.getAttribute('id');
const isRequired = thisId ? thisId.endsWith('required') : false;
const textContent = el.textContent.replace('/[\s\n\t]+/gi',' ').trim();
if (isHeading && isRequired){
let obj = {
id: thisId,
text: textContent,
boilerplate: ""
}
fields.push(obj);
} else if (isHeading && !isRequired){
} else {
if (fields.length > 0){
fields[fields.length -1].boilerplate += " " + textContent;
}
}
}
return fields;
}
/**
* Function to return the object for a singleton object
* @param obj
* @returns {Object}
*/
const flatObj = (obj) => {
return obj[Object.keys(obj)[0]];
}
/**
* Getter for labels in an event object
* @param obj
* @returns {Array}
*/
const getLabels = (obj) => {
return flatObj(obj).data.labels;
};
/**
* Cleans a string for comparison
* @param str
* @returns {string}
*/
const clean = (str) => {
return str.toLowerCase().replace(/[\n\t]+/g,'').trim();
}
const templates = await getTemplates();
const currLabels = issue.labels.map(label => label.name);
return new Promise(async (resolve, reject) => {
try{
// Return the templates that are meant to validate this type of issue
let schemata = currLabels.map(label => {
return templates.filter(template => {
return getLabels(template).includes(label);
});
}).flat();
if (currLabels.length === 0){
reject('No labels set, so no template against which to validate.');
return;
}
if (schemata.length > 1){
reject('More than one template specified.');
return;
}
if (schemata.length === 0){
reject('No schema for this label, so nothing to validate');
return;
}
let schema = flatObj(schemata[0]);
let dataHTML = await parseDoc(issue.body);
let requiredFields = getFields(schema.html);
let completedFields = getFields(dataHTML);
let errors = [];
for (let field of requiredFields){
let fieldId = field.id;
let completed = completedFields.find(c => c.id === fieldId);
// Add the label and name for the validation step
field.label = currLabels[0];
field.name = schema.data.name;
if (!completed){
field.type = 'removed';
errors.push(field);
}
if (completed && clean(field.boilerplate) === clean(completed.boilerplate)){
field.type = 'unchanged';
errors.push(field);
}
}
resolve(errors);
} catch(e){
reject(e);
}
});
}
/**
* Initiate and report.
*/
go().then((status) => {
core.info(`Finished validation. ${status ? ' No errors found.' : ' Errors found.'}`);
})