• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

levibostian / new-deployment-tool / 14242840486

03 Apr 2025 12:22PM UTC coverage: 73.517% (+0.4%) from 73.132%
14242840486

Pull #48

github

web-flow
Merge d7324c618 into f105879dd
Pull Request #48: fix git hooks

110 of 123 branches covered (89.43%)

Branch coverage included in aggregate %.

316 of 423 new or added lines in 15 files covered. (74.7%)

11 existing lines in 4 files now uncovered.

745 of 1040 relevant lines covered (71.63%)

10.37 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

3.57
/lib/github-api.ts
1
import * as log from "./log.ts"
6✔
2

3
export interface GitHubRelease {
4
  tag: {
5
    name: string
6
    commit: {
7
      sha: string
8
    }
9
  }
10
  name: string
11
  created_at: Date
12
}
13

14
export interface GitHubCommit {
15
  sha: string
16
  message: string
17
  date: Date
18
}
19

20
interface GitHubCommitApiResponse {
21
  sha: string
22
  commit: {
23
    message: string
24
    committer: {
25
      date: string
26
    }
27
  }
28
}
29

30
export interface GitHubPullRequest {
31
  prNumber: number
32
  targetBranchName: string
33
  sourceBranchName: string
34
  title: string
35
  description: string
36
}
37

38
// Returns list of all open pull requests that are stacked on top of each other.
39
// Index 0 is the newest pull request.
NEW
40
const getPullRequestStack = async (
×
NEW
41
  { owner, repo, startingBranch }: { owner: string; repo: string; startingBranch: string },
×
42
): Promise<GitHubPullRequest[]> => {
43
  // get list of all open pull requests.
UNCOV
44
  const graphqlQuery = `
×
45
query($owner: String!, $repo: String!, $endCursor: String, $numberOfResults: Int!) {
46
  repository(owner: $owner, name: $repo) {
47
    pullRequests(first: $numberOfResults, states: [OPEN], after: $endCursor, orderBy: {field: CREATED_AT, direction: DESC}) {
48
      nodes {
49
        prNumber: number
50
        targetBranchName: baseRefName
51
        sourceBranchName: headRefName     
52
        title: title
53
        description: body # description in markdown. you can do html or text instead. 
54
      }
55
      pageInfo {        
56
        endCursor
57
        hasNextPage
58
      }
59
    }
60
  }
61
}
NEW
62
`
×
63

NEW
64
  let pullRequests: GitHubPullRequest[] = []
×
65

66
  await githubGraphqlRequestPaging<{
×
67
    data: {
68
      repository: {
69
        pullRequests: {
70
          nodes: {
71
            prNumber: number
72
            targetBranchName: string
73
            sourceBranchName: string
74
            title: string
75
            description: string
76
          }[]
77
          pageInfo: {
78
            endCursor: string
79
            hasNextPage: boolean
80
          }
81
        }
82
      }
83
    }
84
  }>(
85
    graphqlQuery,
×
86
    { owner, repo, numberOfResults: 100 },
×
87
    async (response) => {
×
88
      pullRequests.push(...response.data.repository.pullRequests.nodes)
×
NEW
89
      return true
×
NEW
90
    },
×
91
  )
92

93
  // Takes the list of all pull requests for the repo and puts into a list, starting at the startingBranch PR, and creates the stack. Going from starting to the top of the stack (example: PR 1 -> PR 2 -> PR 3).
94
  const startingPullRequest = pullRequests.find((pr) => pr.sourceBranchName === startingBranch)
×
NEW
95
  if (!startingPullRequest) {
×
NEW
96
    throw new Error(
×
NEW
97
      `Could not get pull request stack because not able to find pull request for starting branch, ${startingBranch}. This is unexpected.`,
×
98
    )
NEW
99
  }
×
100

101
  const prStack: GitHubPullRequest[] = [startingPullRequest]
×
102
  let sourceBranchSearchingFor = startingBranch
×
103

104
  while (true) {
×
105
    const nextPullRequest = pullRequests.find((pr) => pr.targetBranchName === sourceBranchSearchingFor)
×
106

107
    if (!nextPullRequest) {
×
108
      break
×
109
    }
×
110

111
    prStack.push(nextPullRequest)
×
112
    sourceBranchSearchingFor = nextPullRequest.sourceBranchName
×
113
  }
×
114

115
  return prStack
×
116
}
×
117

118
// Get a list of github releases.
119
// The github rest api does not return the git tag (and commit sha) for a release. You would need to also call the tags endpoint and match the tag name to the release name (painful).
120
// But I found you can use the github graphql api to get this information in 1 call.
121
const getTagsWithGitHubReleases = async (
×
122
  { owner, repo, processReleases, numberOfResults }: {
×
123
    owner: string
124
    repo: string
125
    processReleases: (data: GitHubRelease[]) => Promise<boolean>
126
    numberOfResults?: number
UNCOV
127
  },
×
128
): Promise<void> => {
129
  // Gets list of tags that also have a github release made for it.
130
  // If a tag does not have a release, it will not be returned.
131
  // Sorted by latest release first.
132
  // Paging enabled.
133
  const graphqlQuery = `
×
134
query($owner: String!, $repo: String!, $endCursor: String, $numberOfResults: Int!) {
135
  repository(owner: $owner, name: $repo) {
136
    releases(first: $numberOfResults, after: $endCursor) {
137
      nodes {
138
        name # name of github release 
139
        createdAt # "2024-06-06T04:26:30Z"
140
        isDraft # true if release is a draft
141
        tag {
142
          name # tag name 
143
          target {
144
            ... on Commit {
145
              oid # commit sha hash
146
            }
147
          }
148
        }
149
      }
150
      pageInfo {
151
        endCursor
152
        hasNextPage
153
      }
154
    }
155
  }
156
}
NEW
157
`
×
158

159
  await githubGraphqlRequestPaging<{
×
160
    data: {
161
      repository: {
162
        releases: {
163
          nodes: {
164
            name: string
165
            createdAt: string
166
            isDraft: boolean
167
            tag: {
168
              name: string
169
              target: {
170
                oid: string
171
              }
172
            }
173
          }[]
174
          pageInfo: {
175
            endCursor: string
176
            hasNextPage: boolean
177
          }
178
        }
179
      }
180
    }
181
  }>(
182
    graphqlQuery,
×
183
    { owner, repo, numberOfResults: numberOfResults || 100 },
×
184
    (response) => {
×
185
      const releases: GitHubRelease[] = response.data.repository.releases.nodes
×
186
        .filter((release) => !release.isDraft) // only look at releases that are not drafts
×
187
        .map((release) => {
×
188
          return {
×
189
            tag: {
×
190
              name: release.tag.name,
×
191
              commit: {
×
192
                sha: release.tag.target.oid,
×
193
              },
×
194
            },
×
195
            name: release.name,
×
196
            created_at: new Date(release.createdAt),
×
NEW
197
          }
×
NEW
198
        })
×
199

NEW
200
      return processReleases(releases)
×
201
    },
×
202
  )
NEW
203
}
×
204

205
const getCommitsForBranch = async <T>(
×
206
  { owner, repo, branch, processCommits }: {
×
207
    owner: string
208
    repo: string
209
    branch: string
210
    processCommits: (data: GitHubCommit[]) => Promise<boolean>
UNCOV
211
  },
×
212
) => {
213
  return await githubApiRequestPaging<GitHubCommitApiResponse[]>(
×
214
    `https://api.github.com/repos/${owner}/${repo}/commits?sha=${branch}&per_page=100`,
×
215
    async (apiResponse) => {
×
216
      return await processCommits(apiResponse.map((response) => {
×
217
        return {
×
218
          sha: response.sha,
×
219
          message: response.commit.message,
×
220
          date: new Date(response.commit.committer.date),
×
NEW
221
        }
×
NEW
222
      }))
×
UNCOV
223
    },
×
224
  )
NEW
225
}
×
226

227
const createGitHubRelease = async (
×
228
  { owner, repo, tagName, commit }: {
×
229
    owner: string
230
    repo: string
231
    tagName: string
232
    commit: GitHubCommit
UNCOV
233
  },
×
234
) => {
235
  await githubApiRequest(
×
236
    `https://api.github.com/repos/${owner}/${repo}/releases`,
×
237
    "POST",
×
238
    {
×
239
      tag_name: tagName,
×
240
      target_commitish: commit.sha,
×
241
      name: tagName,
×
242
      body: "",
×
243
      draft: false,
×
244
      prerelease: false,
×
245
    },
×
246
  )
NEW
247
}
×
248

249
// Make a GitHub Rest API request.
250
const githubApiRequest = async <T>(
×
251
  url: string,
×
252
  method: "GET" | "POST" = "GET",
×
253
  body: object | undefined = undefined,
×
254
) => {
255
  const headers = {
×
256
    "Authorization": `Bearer ${Deno.env.get("INPUT_GITHUB_TOKEN")}`,
×
257
    "Accept": "application/vnd.github.v3+json",
×
258
    "Content-Type": "application/json",
×
NEW
259
  }
×
260

261
  log.debug(
×
NEW
262
    `GitHub API request: ${method}:${url}, headers: ${JSON.stringify(headers)}, body: ${JSON.stringify(body)}`,
×
263
  )
264

265
  const response = await fetch(url, {
×
266
    method,
×
267
    headers,
×
268
    body: body ? JSON.stringify(body) : undefined,
×
NEW
269
  })
×
270

271
  if (!response.ok) {
×
272
    throw new Error(
×
273
      `Failed to call github API endpoint: ${url}, given error: ${response.statusText}`,
×
274
    )
275
  }
×
276

NEW
277
  const responseJsonBody: T = await response.json()
×
278

279
  return {
×
280
    status: response.status,
×
281
    body: responseJsonBody,
×
282
    headers: response.headers,
×
NEW
283
  }
×
NEW
284
}
×
285

286
// Make a GitHub Rest API request that supports paging. Takes in a function that gets called for each page of results.
287
// In that function, return true if you want to get the next page of results.
288
async function githubApiRequestPaging<RESPONSE>(
×
289
  initialUrl: string,
×
290
  processResponse: (data: RESPONSE) => Promise<boolean>,
×
291
): Promise<void> {
NEW
292
  let url = initialUrl
×
NEW
293
  let getNextPage = true
×
294

295
  while (getNextPage) {
×
NEW
296
    const response = await githubApiRequest<RESPONSE>(url)
×
297

NEW
298
    getNextPage = await processResponse(response.body)
×
299

300
    // for propagation, add nextLink to responseJsonBody. It's the URL that should be used in the next HTTP request to get the next page of results.
301
    const linkHeader = response.headers.get("Link")?.match(
×
302
      /<(.*?)>; rel="next"/,
×
303
    )
NEW
304
    const nextPageUrl = linkHeader ? linkHeader[1] : undefined
×
305

306
    if (!nextPageUrl) {
×
NEW
307
      getNextPage = false
×
308
    } else {
×
NEW
309
      url = nextPageUrl
×
310
    }
×
311
  }
×
312
}
×
313

314
/*
315

316
  // Make a GitHub GraphQL API request.
317

318
  Example:
319
  // const QUERY = `
320
  // query($owner: String!, $name: String!) {
321
  //   repository(owner: $owner, name: $name) {
322
  //     releases(first: 100, orderBy: {field: CREATED_AT, direction: DESC}) {
323
  //       nodes {
324
  //         name
325
  //         createdAt
326
  //         tag {
327
  //           name
328
  //           target {
329
  //             ... on Commit {
330
  //               oid
331
  //             }
332
  //           }
333
  //         }
334
  //       }
335
  //     }
336
  //   }
337
  // }
338
  // `;
339
  // const variables = {
340
  //   owner: 'REPO_OWNER', // Replace with the repository owner
341
  //   name: 'REPO_NAME'    // Replace with the repository name
342
  // };
343
*/
344
const githubGraphqlRequest = async <T>(query: string, variables: object) => {
×
345
  const headers = {
×
346
    "Authorization": `Bearer ${Deno.env.get("INPUT_GITHUB_TOKEN")}`,
×
347
    "Content-Type": "application/json",
×
NEW
348
  }
×
349

350
  const body = JSON.stringify({
×
351
    query,
×
352
    variables,
×
NEW
353
  })
×
354

355
  log.debug(
×
NEW
356
    `GitHub graphql request: headers: ${JSON.stringify(headers)}, body: ${body}`,
×
357
  )
358

359
  const response = await fetch("https://api.github.com/graphql", {
×
360
    method: "POST",
×
361
    headers,
×
362
    body,
×
NEW
363
  })
×
364

365
  if (!response.ok) {
×
366
    throw new Error(
×
367
      `Failed to call github graphql api. Given error: ${response.statusText}`,
×
368
    )
369
  }
×
370

NEW
371
  const responseJsonBody: T = await response.json()
×
372

NEW
373
  log.debug(`GitHub graphql response: ${JSON.stringify(responseJsonBody)}`)
×
374

375
  return {
×
376
    status: response.status,
×
377
    body: responseJsonBody,
×
378
    headers: response.headers,
×
NEW
379
  }
×
NEW
380
}
×
381

382
// Make a GitHub GraphQL API request that supports paging. Takes in a function that gets called for each page of results.
383
// Assumptions when using this function:
384
// 1. Query must contain a pageInfo object:
385
// pageInfo {
386
//  endCursor
387
//  hasNextPage
388
// }
389
// 2. Query contains a variable called endCursor that is used to page through results.
390
// See: https://docs.github.com/en/graphql/guides/using-pagination-in-the-graphql-api to learn more about these assumptions.
391
async function githubGraphqlRequestPaging<RESPONSE>(
×
392
  query: string,
×
393
  variables: { [key: string]: string | number },
×
394
  processResponse: (data: RESPONSE) => Promise<boolean>,
×
395
): Promise<void> {
396
  // deno-lint-ignore no-explicit-any
397
  function findPageInfo(
×
398
    _obj: any,
×
399
  ): { hasNextPage: boolean; endCursor: string } {
400
    // Create a shallow copy of the object to avoid modifying the original
NEW
401
    const obj = { ..._obj }
×
402

403
    // nodes is the JSON response. It could be a really big object. Do not perform recursion on it, so let's delete it.
NEW
404
    delete obj["nodes"]
×
405

406
    for (const key in obj) {
×
407
      if (key === "pageInfo") {
×
NEW
408
        return obj[key] as { hasNextPage: boolean; endCursor: string }
×
409
      } else {
×
NEW
410
        return findPageInfo(obj[key])
×
411
      }
×
412
    }
×
413

414
    throw new Error(
×
415
      "pageInfo object not found in response. Did you forget to add pageInfo to your graphql query?",
×
416
    )
417
  }
×
418

NEW
419
  let getNextPage = true
×
420
  while (getNextPage) {
×
NEW
421
    const response = await githubGraphqlRequest<RESPONSE>(query, variables)
×
422

NEW
423
    getNextPage = await processResponse(response.body)
×
424

NEW
425
    const pageInfo = findPageInfo(response.body)
×
426

NEW
427
    log.debug(`pageInfo: ${JSON.stringify(pageInfo)}`)
×
428

429
    if (!pageInfo.hasNextPage) {
×
NEW
430
      getNextPage = false
×
431
    } else {
×
NEW
432
      variables["endCursor"] = pageInfo.endCursor
×
433
    }
×
434
  }
×
435
}
×
436

437
export interface GitHubApi {
438
  getTagsWithGitHubReleases: typeof getTagsWithGitHubReleases
439
  getCommitsForBranch: typeof getCommitsForBranch
440
  createGitHubRelease: typeof createGitHubRelease
441
  getPullRequestStack: typeof getPullRequestStack
442
}
443

444
export const GitHubApiImpl: GitHubApi = {
6✔
445
  getTagsWithGitHubReleases,
6✔
446
  getCommitsForBranch,
6✔
447
  createGitHubRelease,
6✔
448
  getPullRequestStack,
6✔
449
}
6✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc