xref: /expo/tools/src/GitHub.ts (revision 553b6180)
1import { Octokit, RestEndpointMethodTypes } from '@octokit/rest';
2import parseDiff from 'parse-diff';
3import path from 'path';
4
5import { EXPO_DIR } from './Constants';
6import { GitFileDiff } from './Git';
7
8const octokit = new Octokit({
9  auth: process.env.GITHUB_TOKEN,
10});
11
12const cachedPullRequests = new Map<number, PullRequest>();
13
14// Predefine some params used across almost all requests.
15const owner = 'expo';
16const repo = 'expo';
17
18/**
19 * Returns public informations about the currently authenticated (by GitHub API token) user.
20 */
21export async function getAuthenticatedUserAsync() {
22  const { data } = await octokit.users.getAuthenticated();
23  return data;
24}
25
26/**
27 * Requests for the pull request object.
28 */
29export async function getPullRequestAsync(
30  pull_number: number,
31  cached: boolean = false
32): Promise<PullRequest> {
33  if (cached) {
34    const cachedPullRequest = cachedPullRequests.get(pull_number);
35    if (cachedPullRequest) {
36      return cachedPullRequest;
37    }
38  }
39  const { data } = await octokit.pulls.get({
40    owner,
41    repo,
42    pull_number,
43  });
44  cachedPullRequests.set(pull_number, data);
45  return data;
46}
47
48/**
49 * Requests and parses the diff of the pull request with given number.
50 */
51export async function getPullRequestDiffAsync(
52  pull_number: number,
53  base_path: string = EXPO_DIR
54): Promise<GitFileDiff[]> {
55  const { data } = await octokit.pulls.get({
56    owner,
57    repo,
58    pull_number,
59    headers: {
60      accept: 'application/vnd.github.v3.diff',
61    },
62  });
63
64  // When the custom accept header is provided the returned data
65  // doesn't match declared type (it's a string).
66  const diff = parseDiff(data as unknown as string);
67
68  return diff.map((entry) => {
69    return {
70      ...entry,
71      path: path.join(base_path, (entry.deleted ? entry.from : entry.to)!),
72    };
73  });
74}
75
76/**
77 * Gets a list of reviews left in the pull request with given ID.
78 */
79export async function listPullRequestReviewsAsync(
80  pull_number: number
81): Promise<PullRequestReview[]> {
82  const { data } = await octokit.pulls.listReviews({
83    owner,
84    repo,
85    pull_number,
86  });
87  return data;
88}
89
90/**
91 * Creates pull request review. By default the review is pending which needs to be submitted in order to be visible for other users.
92 * Provide `event` option to create and submit at once.
93 */
94export async function createPullRequestReviewAsync<T>(
95  pull_number: number,
96  options?: T
97): Promise<PullRequestReview> {
98  const { data } = await octokit.pulls.createReview({
99    owner,
100    repo,
101    pull_number,
102    ...options,
103  });
104  return data;
105}
106
107/**
108 * Updates pull request review with a new main comment.
109 */
110export async function updatePullRequestReviewAsync(
111  pull_number: number,
112  review_id: number,
113  body: string
114) {
115  const { data } = await octokit.pulls.updateReview({
116    owner,
117    repo,
118    pull_number,
119    review_id,
120    body,
121  });
122  return data;
123}
124
125/**
126 * Gets a list of comments in review.
127 */
128export async function listPullRequestReviewCommentsAsync(pull_number: number, review_id: number) {
129  const { data } = await octokit.pulls.listReviewComments({
130    owner,
131    repo,
132    pull_number,
133    review_id,
134  });
135  return data;
136}
137
138/**
139 * Deletes a comment left under pull request review.
140 */
141export async function deletePullRequestReviewCommentAsync(comment_id: number) {
142  const { data } = await octokit.pulls.deleteReviewComment({
143    owner,
144    repo,
145    comment_id,
146  });
147  return data;
148}
149
150/**
151 * Deletes all comments from given review.
152 */
153export async function deleteAllPullRequestReviewCommentsAsync(
154  pull_number: number,
155  review_id: number
156) {
157  const comments = await listPullRequestReviewCommentsAsync(pull_number, review_id);
158
159  await Promise.all(
160    comments
161      .filter((comment) => comment.pull_request_review_id === review_id)
162      .map((comment) => deletePullRequestReviewCommentAsync(comment.id))
163  );
164}
165
166/**
167 * Requests given users to review the pull request.
168 * If the user already reviewed the PR, it resets his review state.
169 */
170export async function requestPullRequestReviewersAsync(pull_number: number, reviewers: string[]) {
171  const { data } = await octokit.pulls.requestReviewers({
172    owner,
173    repo,
174    pull_number,
175    reviewers,
176  });
177  return data;
178}
179
180/**
181 * Returns an issue object with given issue number.
182 */
183export async function getIssueAsync(issue_number: number) {
184  const { data } = await octokit.issues.get({
185    owner,
186    repo,
187    issue_number,
188  });
189  return data;
190}
191
192/**
193 * Returns a list of all open issues. Limited to 10 items.
194 */
195export async function listAllOpenIssuesAsync({
196  limit,
197  offset,
198  labels,
199}: {
200  limit?: number;
201  offset?: number;
202  labels?: string;
203} = {}) {
204  const per_page = limit ?? 10;
205  const page = offset ? offset * per_page : 0;
206  const { data } = await octokit.issues.listForRepo({
207    owner,
208    repo,
209    per_page,
210    labels,
211    page,
212    state: 'open',
213  });
214  return data;
215}
216
217/**
218 * Creates an issue comment with given body.
219 */
220export async function createCommentAsync(issue_number: number, body: string) {
221  const { data } = await octokit.issues.createComment({
222    owner,
223    repo,
224    issue_number,
225    body,
226  });
227  return data;
228}
229
230/**
231 * Lists commits in given issue.
232 */
233export async function listCommentsAsync(
234  issue_number: number,
235  options: Partial<ListCommentsOptions>
236) {
237  const { data } = await octokit.issues.listComments({
238    owner,
239    repo,
240    issue_number,
241    ...options,
242  });
243  return data;
244}
245
246/**
247 * Returns a list of issue comments gathered from all pages.
248 */
249export async function listAllCommentsAsync(issue_number: number) {
250  const issue = await getIssueAsync(issue_number);
251  const comments = [] as ListCommentsResponse['data'];
252  const pageSize = 100;
253
254  for (let page = 1, maxPage = Math.ceil(issue.comments / pageSize); page <= maxPage; page++) {
255    const commentsPage = await listCommentsAsync(issue_number, {
256      page,
257      per_page: pageSize,
258    });
259    comments.push(...commentsPage);
260  }
261  return comments;
262}
263
264/**
265 * Deletes an issue comment with given identifier.
266 */
267export async function deleteCommentAsync(comment_id: number) {
268  const { data } = await octokit.issues.deleteComment({
269    owner,
270    repo,
271    comment_id,
272  });
273  return data;
274}
275
276/**
277 * Adds labels to the issue. Throws an error when any of given labels doesn't exist.
278 */
279export async function addIssueLabelsAsync(issue_number: number, labels: string[]) {
280  const { data } = await octokit.issues.addLabels({
281    owner,
282    repo,
283    issue_number,
284    labels,
285  });
286  return data;
287}
288
289/**
290 * Removes single label from the issue.
291 * Throws an error when given label doesn't exist and when the label isn't added.
292 */
293export async function removeIssueLabelAsync(issue_number: number, name: string) {
294  const { data } = await octokit.issues.removeLabel({
295    owner,
296    repo,
297    issue_number,
298    name,
299  });
300  return data;
301}
302
303// Octokit's types are autogenerated and so inconvenient to use if you want to refer to them.
304// We re-export some of them to make it easier.
305export type PullRequestReviewEvent = 'COMMENT' | 'APPROVE' | 'REQUEST_CHANGES';
306export type PullRequest = RestEndpointMethodTypes['pulls']['get']['response']['data'];
307export type PullRequestReview = RestEndpointMethodTypes['pulls']['getReview']['response']['data'];
308export type IssueComment = RestEndpointMethodTypes['issues']['getComment']['response']['data'];
309export type ListCommentsOptions = RestEndpointMethodTypes['issues']['listComments']['parameters'];
310export type ListCommentsResponse = RestEndpointMethodTypes['issues']['listComments']['response'];
311