xref: /expo/tools/src/GitHub.ts (revision d3014462)
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 * Creates an issue comment with given body.
194 */
195export async function createCommentAsync(issue_number: number, body: string) {
196  const { data } = await octokit.issues.createComment({
197    owner,
198    repo,
199    issue_number,
200    body,
201  });
202  return data;
203}
204
205/**
206 * Lists commits in given issue.
207 */
208export async function listCommentsAsync(
209  issue_number: number,
210  options: Partial<ListCommentsOptions>
211) {
212  const { data } = await octokit.issues.listComments({
213    owner,
214    repo,
215    issue_number,
216    ...options,
217  });
218  return data;
219}
220
221/**
222 * Returns a list of issue comments gathered from all pages.
223 */
224export async function listAllCommentsAsync(issue_number: number) {
225  const issue = await getIssueAsync(issue_number);
226  const comments = [] as ListCommentsResponse['data'];
227  const pageSize = 100;
228
229  for (let page = 1, maxPage = Math.ceil(issue.comments / pageSize); page <= maxPage; page++) {
230    const commentsPage = await listCommentsAsync(issue_number, {
231      page,
232      per_page: pageSize,
233    });
234    comments.push(...commentsPage);
235  }
236  return comments;
237}
238
239/**
240 * Deletes an issue comment with given identifier.
241 */
242export async function deleteCommentAsync(comment_id: number) {
243  const { data } = await octokit.issues.deleteComment({
244    owner,
245    repo,
246    comment_id,
247  });
248  return data;
249}
250
251/**
252 * Adds labels to the issue. Throws an error when any of given labels doesn't exist.
253 */
254export async function addIssueLabelsAsync(issue_number: number, labels: string[]) {
255  const { data } = await octokit.issues.addLabels({
256    owner,
257    repo,
258    issue_number,
259    labels,
260  });
261  return data;
262}
263
264/**
265 * Removes single label from the issue.
266 * Throws an error when given label doesn't exist and when the label isn't added.
267 */
268export async function removeIssueLabelAsync(issue_number: number, name: string) {
269  const { data } = await octokit.issues.removeLabel({
270    owner,
271    repo,
272    issue_number,
273    name,
274  });
275  return data;
276}
277
278// Octokit's types are autogenerated and so inconvenient to use if you want to refer to them.
279// We re-export some of them to make it easier.
280export type PullRequestReviewEvent = 'COMMENT' | 'APPROVE' | 'REQUEST_CHANGES';
281export type PullRequest = RestEndpointMethodTypes['pulls']['get']['response']['data'];
282export type PullRequestReview = RestEndpointMethodTypes['pulls']['getReview']['response']['data'];
283export type IssueComment = RestEndpointMethodTypes['issues']['getComment']['response']['data'];
284export type ListCommentsOptions = RestEndpointMethodTypes['issues']['listComments']['parameters'];
285export type ListCommentsResponse = RestEndpointMethodTypes['issues']['listComments']['response'];
286