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

levibostian / new-deployment-tool / 16025501297

02 Jul 2025 12:45PM UTC coverage: 70.761% (-0.9%) from 71.648%
16025501297

Pull #68

github

web-flow
Merge a8b77f353 into 2f103da89
Pull Request #68: feat: tool is a cli. run it by passing in args.

67 of 85 branches covered (78.82%)

Branch coverage included in aggregate %.

21 of 36 new or added lines in 4 files covered. (58.33%)

2 existing lines in 1 file now uncovered.

584 of 835 relevant lines covered (69.94%)

9.0 hits per line

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

4.12
/lib/github-api.ts
1
import * as log from "./log.ts"
4✔
2
import * as cathy from "npm:cathy"
4✔
3

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

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

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

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

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

65
  let pullRequests: GitHubPullRequest[] = []
×
66

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

94
  // 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).
NEW
95
  const startingPullRequest = pullRequests.find((pr) => pr.prNumber === startingPrNumber)
×
96
  if (!startingPullRequest) {
×
97
    throw new Error(
×
NEW
98
      `Could not get pull request stack because not able to find pull request for starting PR number, ${startingPrNumber}. This is unexpected.`,
×
99
    )
100
  }
×
101

102
  const prStack: GitHubPullRequest[] = [startingPullRequest]
×
NEW
103
  let sourceBranchSearchingFor = startingPullRequest.sourceBranchName
×
104

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

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

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

116
  return prStack
×
117
}
×
118

119
const getCommitsForBranch = async <T>(
×
120
  { sampleData, owner, repo, branch, processCommits }: {
×
121
    sampleData?: GitHubCommit[]
122
    owner: string
123
    repo: string
124
    branch: string
125
    processCommits: (data: GitHubCommit[]) => Promise<boolean>
126
  },
×
127
) => {
128
  if (sampleData) {
×
129
    await processCommits(sampleData)
×
130
    return
×
131
  }
×
132

133
  return await githubApiRequestPaging<GitHubCommitApiResponse[]>(
×
134
    `https://api.github.com/repos/${owner}/${repo}/commits?sha=${branch}&per_page=100`,
×
135
    async (apiResponse) => {
×
136
      return await processCommits(apiResponse.map((response) => {
×
137
        return {
×
138
          sha: response.sha,
×
139
          message: response.commit.message,
×
140
          date: new Date(response.commit.committer.date),
×
141
        }
×
142
      }))
×
143
    },
×
144
  )
145
}
×
146

147
// Make a GitHub Rest API request.
148
const githubApiRequest = async <T>(
×
149
  url: string,
×
150
  method: "GET" | "POST" = "GET",
×
151
  body: object | undefined = undefined,
×
152
) => {
153
  const headers = {
×
154
    "Authorization": `Bearer ${Deno.env.get("INPUT_GITHUB_TOKEN")}`,
×
155
    "Accept": "application/vnd.github.v3+json",
×
156
    "Content-Type": "application/json",
×
157
  }
×
158

159
  log.debug(
×
160
    `GitHub API request: ${method}:${url}, headers: ${JSON.stringify(headers)}, body: ${JSON.stringify(body)}`,
×
161
  )
162

163
  const response = await fetch(url, {
×
164
    method,
×
165
    headers,
×
166
    body: body ? JSON.stringify(body) : undefined,
×
167
  })
×
168

169
  if (!response.ok) {
×
170
    throw new Error(
×
171
      `Failed to call github API endpoint: ${url}, given error: ${response.statusText}`,
×
172
    )
173
  }
×
174

175
  const responseJsonBody: T = await response.json()
×
176

177
  return {
×
178
    status: response.status,
×
179
    body: responseJsonBody,
×
180
    headers: response.headers,
×
181
  }
×
182
}
×
183

184
// Make a GitHub Rest API request that supports paging. Takes in a function that gets called for each page of results.
185
// In that function, return true if you want to get the next page of results.
186
async function githubApiRequestPaging<RESPONSE>(
×
187
  initialUrl: string,
×
188
  processResponse: (data: RESPONSE) => Promise<boolean>,
×
189
): Promise<void> {
190
  let url = initialUrl
×
191
  let getNextPage = true
×
192

193
  while (getNextPage) {
×
194
    const response = await githubApiRequest<RESPONSE>(url)
×
195

196
    getNextPage = await processResponse(response.body)
×
197

198
    // 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.
199
    const linkHeader = response.headers.get("Link")?.match(
×
200
      /<(.*?)>; rel="next"/,
×
201
    )
202
    const nextPageUrl = linkHeader ? linkHeader[1] : undefined
×
203

204
    if (!nextPageUrl) {
×
205
      getNextPage = false
×
206
    } else {
×
207
      url = nextPageUrl
×
208
    }
×
209
  }
×
210
}
×
211

212
/*
213

214
  // Make a GitHub GraphQL API request.
215

216
  Example:
217
  // const QUERY = `
218
  // query($owner: String!, $name: String!) {
219
  //   repository(owner: $owner, name: $name) {
220
  //     releases(first: 100, orderBy: {field: CREATED_AT, direction: DESC}) {
221
  //       nodes {
222
  //         name
223
  //         createdAt
224
  //         tag {
225
  //           name
226
  //           target {
227
  //             ... on Commit {
228
  //               oid
229
  //             }
230
  //           }
231
  //         }
232
  //       }
233
  //     }
234
  //   }
235
  // }
236
  // `;
237
  // const variables = {
238
  //   owner: 'REPO_OWNER', // Replace with the repository owner
239
  //   name: 'REPO_NAME'    // Replace with the repository name
240
  // };
241
*/
242
const githubGraphqlRequest = async <T>(query: string, variables: object) => {
×
243
  const headers = {
×
244
    "Authorization": `Bearer ${Deno.env.get("INPUT_GITHUB_TOKEN")}`,
×
245
    "Content-Type": "application/json",
×
246
  }
×
247

248
  const body = JSON.stringify({
×
249
    query,
×
250
    variables,
×
251
  })
×
252

253
  log.debug(
×
254
    `GitHub graphql request: headers: ${JSON.stringify(headers)}, body: ${body}`,
×
255
  )
256

257
  const response = await fetch("https://api.github.com/graphql", {
×
258
    method: "POST",
×
259
    headers,
×
260
    body,
×
261
  })
×
262

263
  if (!response.ok) {
×
264
    throw new Error(
×
265
      `Failed to call github graphql api. Given error: ${response.statusText}`,
×
266
    )
267
  }
×
268

269
  const responseJsonBody: T = await response.json()
×
270

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

273
  return {
×
274
    status: response.status,
×
275
    body: responseJsonBody,
×
276
    headers: response.headers,
×
277
  }
×
278
}
×
279

280
// Make a GitHub GraphQL API request that supports paging. Takes in a function that gets called for each page of results.
281
// Assumptions when using this function:
282
// 1. Query must contain a pageInfo object:
283
// pageInfo {
284
//  endCursor
285
//  hasNextPage
286
// }
287
// 2. Query contains a variable called endCursor that is used to page through results.
288
// See: https://docs.github.com/en/graphql/guides/using-pagination-in-the-graphql-api to learn more about these assumptions.
289
async function githubGraphqlRequestPaging<RESPONSE>(
×
290
  query: string,
×
291
  variables: { [key: string]: string | number },
×
292
  processResponse: (data: RESPONSE) => Promise<boolean>,
×
293
): Promise<void> {
UNCOV
294
  function findPageInfo(
×
295
    // deno-lint-ignore no-explicit-any
UNCOV
296
    _obj: any,
×
297
  ): { hasNextPage: boolean; endCursor: string } {
298
    // Create a shallow copy of the object to avoid modifying the original
299
    const obj = { ..._obj }
×
300

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

304
    for (const key in obj) {
×
305
      if (key === "pageInfo") {
×
306
        return obj[key] as { hasNextPage: boolean; endCursor: string }
×
307
      } else {
×
308
        return findPageInfo(obj[key])
×
309
      }
×
310
    }
×
311

312
    throw new Error(
×
313
      "pageInfo object not found in response. Did you forget to add pageInfo to your graphql query?",
×
314
    )
315
  }
×
316

317
  let getNextPage = true
×
318
  while (getNextPage) {
×
319
    const response = await githubGraphqlRequest<RESPONSE>(query, variables)
×
320

321
    getNextPage = await processResponse(response.body)
×
322

323
    const pageInfo = findPageInfo(response.body)
×
324

325
    log.debug(`pageInfo: ${JSON.stringify(pageInfo)}`)
×
326

327
    if (!pageInfo.hasNextPage) {
×
328
      getNextPage = false
×
329
    } else {
×
330
      variables["endCursor"] = pageInfo.endCursor
×
331
    }
×
332
  }
×
333
}
×
334

NEW
335
const postStatusUpdateOnPullRequest = async ({ message, owner, repo, prNumber, ciBuildId }: {
×
336
  message: string
337
  owner: string
338
  repo: string
339
  prNumber: number
340
  ciBuildId: string
NEW
341
}) => {
×
NEW
342
  await cathy.speak(message, {
×
NEW
343
    githubToken: Deno.env.get("INPUT_GITHUB_TOKEN")!,
×
NEW
344
    githubRepo: `${owner}/${repo}`,
×
NEW
345
    githubIssue: prNumber,
×
NEW
346
    updateExisting: true,
×
NEW
347
    appendToExisting: true,
×
NEW
348
    updateID: ["new-deployment-tool-deploy-run-output", `new-deployment-tool-deploy-run-output-${ciBuildId}`],
×
NEW
349
  })
×
NEW
350
}
×
351

352
export interface GitHubApi {
353
  getCommitsForBranch: typeof getCommitsForBranch
354
  getPullRequestStack: typeof getPullRequestStack
355
  postStatusUpdateOnPullRequest: typeof postStatusUpdateOnPullRequest
356
}
357

358
export const GitHubApiImpl: GitHubApi = {
4✔
359
  getCommitsForBranch,
4✔
360
  getPullRequestStack,
4✔
361
  postStatusUpdateOnPullRequest,
4✔
362
}
4✔
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