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