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