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

levibostian / new-deployment-tool / 15442294228

04 Jun 2025 12:28PM UTC coverage: 73.591% (+0.4%) from 73.169%
15442294228

Pull #55

github

levibostian
test: write unit tests for get-latest-release step that doesn't use real github api
Pull Request #55: all behavior is a script

129 of 145 branches covered (88.97%)

Branch coverage included in aggregate %.

82 of 100 new or added lines in 10 files covered. (82.0%)

1 existing line in 1 file now uncovered.

785 of 1097 relevant lines covered (71.56%)

11.4 hits per line

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

3.43
/lib/github-api.ts
1
import * as log from "./log.ts"
5✔
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.
40
const getPullRequestStack = async (
×
41
  { owner, repo, startingBranch }: { owner: string; repo: string; startingBranch: string },
×
42
): Promise<GitHubPullRequest[]> => {
43
  // get list of all open pull requests.
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
}
62
`
×
63

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)
×
89
      return true
×
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)
×
95
  if (!startingPullRequest) {
×
96
    throw new Error(
×
97
      `Could not get pull request stack because not able to find pull request for starting branch, ${startingBranch}. This is unexpected.`,
×
98
    )
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 (
×
NEW
122
  { sampleData, owner, repo, processReleases, numberOfResults }: {
×
123
    sampleData?: GitHubRelease[]
124
    owner: string
125
    repo: string
126
    processReleases: (data: GitHubRelease[]) => Promise<boolean>
127
    numberOfResults?: number
128
  },
×
129
): Promise<void> => {
130
  // Gets list of tags that also have a github release made for it.
131
  // If a tag does not have a release, it will not be returned.
132
  // Sorted by latest release first.
133
  // Paging enabled.
134
  const graphqlQuery = `
×
135
query($owner: String!, $repo: String!, $endCursor: String, $numberOfResults: Int!) {
136
  repository(owner: $owner, name: $repo) {
137
    releases(first: $numberOfResults, after: $endCursor) {
138
      nodes {
139
        name # name of github release 
140
        createdAt # "2024-06-06T04:26:30Z"
141
        isDraft # true if release is a draft
142
        tag {
143
          name # tag name 
144
          target {
145
            ... on Commit {
146
              oid # commit sha hash
147
            }
148
          }
149
        }
150
      }
151
      pageInfo {
152
        endCursor
153
        hasNextPage
154
      }
155
    }
156
  }
157
}
158
`
×
159

NEW
160
  if (sampleData) {
×
NEW
161
    await processReleases(sampleData)
×
NEW
162
    return
×
NEW
163
  }
×
164

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

206
      return processReleases(releases)
×
207
    },
×
208
  )
209
}
×
210

211
const getCommitsForBranch = async <T>(
×
NEW
212
  { sampleData, owner, repo, branch, processCommits }: {
×
213
    sampleData?: GitHubCommit[]
214
    owner: string
215
    repo: string
216
    branch: string
217
    processCommits: (data: GitHubCommit[]) => Promise<boolean>
218
  },
×
219
) => {
NEW
220
  if (sampleData) {
×
NEW
221
    await processCommits(sampleData)
×
NEW
222
    return
×
NEW
223
  }
×
224

225
  return await githubApiRequestPaging<GitHubCommitApiResponse[]>(
×
226
    `https://api.github.com/repos/${owner}/${repo}/commits?sha=${branch}&per_page=100`,
×
227
    async (apiResponse) => {
×
228
      return await processCommits(apiResponse.map((response) => {
×
229
        return {
×
230
          sha: response.sha,
×
231
          message: response.commit.message,
×
232
          date: new Date(response.commit.committer.date),
×
233
        }
×
234
      }))
×
235
    },
×
236
  )
237
}
×
238

239
const createGitHubRelease = async (
×
240
  { owner, repo, tagName, commit }: {
×
241
    owner: string
242
    repo: string
243
    tagName: string
244
    commit: GitHubCommit
245
  },
×
246
) => {
247
  await githubApiRequest(
×
248
    `https://api.github.com/repos/${owner}/${repo}/releases`,
×
249
    "POST",
×
250
    {
×
251
      tag_name: tagName,
×
252
      target_commitish: commit.sha,
×
253
      name: tagName,
×
254
      body: "",
×
255
      draft: false,
×
256
      prerelease: false,
×
257
    },
×
258
  )
259
}
×
260

261
// Make a GitHub Rest API request.
262
const githubApiRequest = async <T>(
×
263
  url: string,
×
264
  method: "GET" | "POST" = "GET",
×
265
  body: object | undefined = undefined,
×
266
) => {
267
  const headers = {
×
268
    "Authorization": `Bearer ${Deno.env.get("INPUT_GITHUB_TOKEN")}`,
×
269
    "Accept": "application/vnd.github.v3+json",
×
270
    "Content-Type": "application/json",
×
271
  }
×
272

273
  log.debug(
×
274
    `GitHub API request: ${method}:${url}, headers: ${JSON.stringify(headers)}, body: ${JSON.stringify(body)}`,
×
275
  )
276

277
  const response = await fetch(url, {
×
278
    method,
×
279
    headers,
×
280
    body: body ? JSON.stringify(body) : undefined,
×
281
  })
×
282

283
  if (!response.ok) {
×
284
    throw new Error(
×
285
      `Failed to call github API endpoint: ${url}, given error: ${response.statusText}`,
×
286
    )
287
  }
×
288

289
  const responseJsonBody: T = await response.json()
×
290

291
  return {
×
292
    status: response.status,
×
293
    body: responseJsonBody,
×
294
    headers: response.headers,
×
295
  }
×
296
}
×
297

298
// Make a GitHub Rest API request that supports paging. Takes in a function that gets called for each page of results.
299
// In that function, return true if you want to get the next page of results.
300
async function githubApiRequestPaging<RESPONSE>(
×
301
  initialUrl: string,
×
302
  processResponse: (data: RESPONSE) => Promise<boolean>,
×
303
): Promise<void> {
304
  let url = initialUrl
×
305
  let getNextPage = true
×
306

307
  while (getNextPage) {
×
308
    const response = await githubApiRequest<RESPONSE>(url)
×
309

310
    getNextPage = await processResponse(response.body)
×
311

312
    // 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.
313
    const linkHeader = response.headers.get("Link")?.match(
×
314
      /<(.*?)>; rel="next"/,
×
315
    )
316
    const nextPageUrl = linkHeader ? linkHeader[1] : undefined
×
317

318
    if (!nextPageUrl) {
×
319
      getNextPage = false
×
320
    } else {
×
321
      url = nextPageUrl
×
322
    }
×
323
  }
×
324
}
×
325

326
/*
327

328
  // Make a GitHub GraphQL API request.
329

330
  Example:
331
  // const QUERY = `
332
  // query($owner: String!, $name: String!) {
333
  //   repository(owner: $owner, name: $name) {
334
  //     releases(first: 100, orderBy: {field: CREATED_AT, direction: DESC}) {
335
  //       nodes {
336
  //         name
337
  //         createdAt
338
  //         tag {
339
  //           name
340
  //           target {
341
  //             ... on Commit {
342
  //               oid
343
  //             }
344
  //           }
345
  //         }
346
  //       }
347
  //     }
348
  //   }
349
  // }
350
  // `;
351
  // const variables = {
352
  //   owner: 'REPO_OWNER', // Replace with the repository owner
353
  //   name: 'REPO_NAME'    // Replace with the repository name
354
  // };
355
*/
356
const githubGraphqlRequest = async <T>(query: string, variables: object) => {
×
357
  const headers = {
×
358
    "Authorization": `Bearer ${Deno.env.get("INPUT_GITHUB_TOKEN")}`,
×
359
    "Content-Type": "application/json",
×
360
  }
×
361

362
  const body = JSON.stringify({
×
363
    query,
×
364
    variables,
×
365
  })
×
366

367
  log.debug(
×
368
    `GitHub graphql request: headers: ${JSON.stringify(headers)}, body: ${body}`,
×
369
  )
370

371
  const response = await fetch("https://api.github.com/graphql", {
×
372
    method: "POST",
×
373
    headers,
×
374
    body,
×
375
  })
×
376

377
  if (!response.ok) {
×
378
    throw new Error(
×
379
      `Failed to call github graphql api. Given error: ${response.statusText}`,
×
380
    )
381
  }
×
382

383
  const responseJsonBody: T = await response.json()
×
384

385
  log.debug(`GitHub graphql response: ${JSON.stringify(responseJsonBody)}`)
×
386

387
  return {
×
388
    status: response.status,
×
389
    body: responseJsonBody,
×
390
    headers: response.headers,
×
391
  }
×
392
}
×
393

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

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

418
    for (const key in obj) {
×
419
      if (key === "pageInfo") {
×
420
        return obj[key] as { hasNextPage: boolean; endCursor: string }
×
421
      } else {
×
422
        return findPageInfo(obj[key])
×
423
      }
×
424
    }
×
425

426
    throw new Error(
×
427
      "pageInfo object not found in response. Did you forget to add pageInfo to your graphql query?",
×
428
    )
429
  }
×
430

431
  let getNextPage = true
×
432
  while (getNextPage) {
×
433
    const response = await githubGraphqlRequest<RESPONSE>(query, variables)
×
434

435
    getNextPage = await processResponse(response.body)
×
436

437
    const pageInfo = findPageInfo(response.body)
×
438

439
    log.debug(`pageInfo: ${JSON.stringify(pageInfo)}`)
×
440

441
    if (!pageInfo.hasNextPage) {
×
442
      getNextPage = false
×
443
    } else {
×
444
      variables["endCursor"] = pageInfo.endCursor
×
445
    }
×
446
  }
×
447
}
×
448

449
export interface GitHubApi {
450
  getTagsWithGitHubReleases: typeof getTagsWithGitHubReleases
451
  getCommitsForBranch: typeof getCommitsForBranch
452
  createGitHubRelease: typeof createGitHubRelease
453
  getPullRequestStack: typeof getPullRequestStack
454
}
455

456
export const GitHubApiImpl: GitHubApi = {
5✔
457
  getTagsWithGitHubReleases,
5✔
458
  getCommitsForBranch,
5✔
459
  createGitHubRelease,
5✔
460
  getPullRequestStack,
5✔
461
}
5✔
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