forked from DefinitelyTyped/dt-mergebot
/
execute-pr-actions.ts
178 lines (163 loc) · 8.57 KB
/
execute-pr-actions.ts
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
import { LabelName, LabelNames } from "./basic";
import { MutationOptions } from "@apollo/client/core";
import * as schema from "@octokit/graphql-schema/schema";
import { PR_repository_pullRequest } from "./queries/schema/PR";
import { Actions } from "./compute-pr-actions";
import { createMutation, client } from "./graphql-client";
import { getProjectBoardColumns, getLabels } from "./util/cachedQueries";
import { noNullish, flatten } from "./util/util";
import { tagsToDeleteIfNotPosted } from "./comments";
import * as comment from "./util/comment";
import { request } from "https";
// https://github.com/DefinitelyTyped/DefinitelyTyped/projects/5
const ProjectBoardNumber = 5;
export async function executePrActions(actions: Actions, pr: PR_repository_pullRequest, dry?: boolean) {
const botComments: ParsedComment[] = getBotComments(pr);
const mutations = noNullish([
// the mutations are ordered for presentation in the timeline:
// * welcome comment is always first
// * then labels, as a short "here's what I noticed"
// * column changes after that (follow the labels since this is the consequence)
// * state changes next, similar to column changes
// * finally, any other comments (better to see label changes and then a comment that explains what happens now)
...getMutationsForComments(actions, pr.id, botComments, true),
...await getMutationsForLabels(actions, pr),
...await getMutationsForProjectChanges(actions, pr),
...getMutationsForCommentRemovals(actions, botComments),
...getMutationsForChangingPRState(actions, pr),
...getMutationsForComments(actions, pr.id, botComments, false),
]);
const restCalls = getMutationsForReRunningCI(actions);
if (!dry) {
// Perform mutations one at a time
for (const mutation of mutations)
await client.mutate(mutation as MutationOptions<void, { input: schema.AddCommentInput }>);
for (const restCall of restCalls)
await doRestCall(restCall);
}
return [...mutations, ...restCalls];
}
async function getMutationsForLabels(actions: Actions, pr: PR_repository_pullRequest) {
if (!actions.shouldUpdateLabels) return [];
const labels = noNullish(pr.labels?.nodes).map(l => l.name);
const makeMutations = async (pred: (l: LabelName) => boolean, query: keyof schema.Mutation) => {
const labels = LabelNames.filter(pred);
return labels.length === 0 ? null
: createMutation<schema.AddLabelsToLabelableInput & schema.RemoveLabelsFromLabelableInput>(query, {
labelIds: await Promise.all(labels.map(label => getLabelIdByName(label))),
labelableId: pr.id });
};
return Promise.all([
makeMutations((label => !labels.includes(label) && actions.labels.includes(label)), "addLabelsToLabelable"),
makeMutations((label => labels.includes(label) && !actions.labels.includes(label)), "removeLabelsFromLabelable"),
]);
}
async function getMutationsForProjectChanges(actions: Actions, pr: PR_repository_pullRequest) {
if (!actions.projectColumn) return [];
const card = pr.projectCards.nodes?.find(card => card?.project.number === ProjectBoardNumber);
if (actions.projectColumn === "*REMOVE*") {
if (!card || card.column?.name === "Recently Merged") return [];
return [createMutation<schema.DeleteProjectCardInput>("deleteProjectCard", { cardId: card.id })];
}
// Existing card is ok => do nothing
if (card?.column?.name === actions.projectColumn) return [];
const columnId = await getProjectBoardColumnIdByName(actions.projectColumn);
return [card
// Move existing card
? createMutation<schema.MoveProjectCardInput>("moveProjectCard", { cardId: card.id, columnId })
// No existing card => create a new one
: createMutation<schema.AddProjectCardInput>("addProjectCard", { contentId: pr.id, projectColumnId: columnId })];
}
type ParsedComment = { id: string, body: string, tag: string, status: string };
function getBotComments(pr: PR_repository_pullRequest): ParsedComment[] {
return noNullish(
(pr.comments.nodes ?? [])
.filter(comment => comment?.author?.login === "typescript-bot")
.map(c => {
const { id, body } = c!, parsed = comment.parse(body);
return parsed && { id, body, ...parsed };
}));
}
function getMutationsForComments(actions: Actions, prId: string, botComments: ParsedComment[], onlyWelcome: boolean) {
return flatten(actions.responseComments.map(wantedComment => {
if ((wantedComment.tag === "welcome") !== onlyWelcome) return [];
const sameTagComments = botComments.filter(comment => comment.tag === wantedComment.tag);
return sameTagComments.length === 0
? [createMutation<schema.AddCommentInput>("addComment", {
subjectId: prId, body: comment.make(wantedComment) })]
: sameTagComments.map(actualComment =>
(actualComment.status === wantedComment.status) ? null // Comment is up-to-date; skip
: createMutation<schema.UpdateIssueCommentInput>("updateIssueComment", {
id: actualComment.id,
body: comment.make(wantedComment) }));
}));
}
function getMutationsForCommentRemovals(actions: Actions, botComments: ParsedComment[]) {
const ciTagToKeep = actions.responseComments.find(c => c.tag.startsWith("ci-complaint"))?.tag;
const postedTags = actions.responseComments.map(c => c.tag);
return botComments.map(comment => {
const { tag, id } = comment;
const del = () => createMutation<schema.DeleteIssueCommentInput>("deleteIssueComment", { id });
// Remove stale CI 'your build is green' notifications
if (tag.includes("ci-") && tag !== ciTagToKeep) return del();
// tags for comments that should be removed when not included in the actions
if (tagsToDeleteIfNotPosted.includes(tag) && !postedTags.includes(tag)) return del();
return null;
});
}
function getMutationsForChangingPRState(actions: Actions, pr: PR_repository_pullRequest) {
return [
actions.shouldMerge
? createMutation<schema.MergePullRequestInput>("mergePullRequest", {
commitHeadline: `🤖 Merge PR #${pr.number} ${pr.title} by @${pr.author?.login ?? "(ghost)"}`,
expectedHeadOid: pr.headRefOid,
mergeMethod: "SQUASH",
pullRequestId: pr.id,
})
: null,
actions.shouldClose
? createMutation<schema.ClosePullRequestInput>("closePullRequest", { pullRequestId: pr.id })
: null,
];
}
async function getProjectBoardColumnIdByName(name: string): Promise<string> {
const columns = await getProjectBoardColumns();
const res = columns.find(e => e.name === name)?.id;
if (!res) throw new Error(`No project board column named "${name}" exists`);
return res;
}
async function getLabelIdByName(name: string): Promise<string> {
const labels = await getLabels();
const res = labels.find(l => l.name === name)?.id;
if (!res) throw new Error(`No label named "${name}" exists`);
return res;
}
// *** HACK ***
// A GQL mutation of `rerequestCheckSuite` throws an error that it's only
// allowed from a GH app, but a `rerequest` rest call works fine. So do a rest
// call for now, and hopefully GH will have a better way of handling these
// first-time contributors. This whole mess should then turn to a GQL mutation,
// or better, be removed if there's some repo settings to allow test builds
// based on paths or something similar.
type RestMutation = { method: string, op: string };
function doRestCall(call: RestMutation): Promise<void> {
const url = `https://api.github.com/repos/DefinitelyTyped/DefinitelyTyped/${call.op}`;
const headers = {
"accept": "application/vnd.github.v3+json",
"authorization": `token ${process.env.BOT_AUTH_TOKEN}`,
"user-agent": "dt-mergebot"
};
return new Promise((resolve, reject) => {
const req = request(url, { method: call.method, headers }, reply => {
const bad = !reply.statusCode || reply.statusCode < 200 || reply.statusCode >= 300;
if (bad) return reject(`doRestCall failed with a status of ${reply.statusCode}`);
return resolve();
});
req.on("error", reject);
req.end();
});
}
function getMutationsForReRunningCI(actions: Actions) {
return (actions.reRunActionsCheckSuiteIDs || []).map(id =>
({ method: "POST", op: `check-suites/${id}/rerequest` }));
}