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