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