Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 34 additions & 17 deletions packages/cdkConstructs/src/stacks/deleteUnusedStacks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ export async function deleteUnusedPrStacks(
const cloudFormationClient = new CloudFormationClient({})
const route53Client = new Route53Client({})
const {hostedZoneId, cnameRecords} = await getHostedZoneInfo(route53Client, hostedZoneName)
const pullRequestStateCache = new Map<string, string>()

console.log("checking cloudformation stacks")

Expand All @@ -94,7 +95,7 @@ export async function deleteUnusedPrStacks(
}

const stackName = stack.StackName
if (!(await isClosedPullRequest(stackName, baseStackName, repoName))) {
if (!(await isClosedPullRequest(stackName, baseStackName, repoName, pullRequestStateCache))) {
continue
}

Expand Down Expand Up @@ -188,34 +189,50 @@ async function getHostedZoneInfo(
return {hostedZoneId, cnameRecords}
}

async function isClosedPullRequest(stackName: string, baseStackName: string, repoName: string): Promise<boolean> {
const match = new RegExp(String.raw`^${baseStackName}-pr-(?<pullRequestId>\d+)(-sandbox)?$`).exec(stackName)
async function isClosedPullRequest(
stackName: string,
baseStackName: string,
repoName: string,
pullRequestStateCache: Map<string, string>
): Promise<boolean> {
const match = new RegExp(String.raw`^${baseStackName}-pr-(?<pullRequestId>\d+)(-[\w-]+)?$`).exec(stackName)
Comment thread
MatthewPopat-NHS marked this conversation as resolved.
if (!match?.groups?.pullRequestId) {
return false
}

const pullRequestId = match.groups.pullRequestId
console.log(`Checking pull request id ${pullRequestId}`)
const url = `https://api.github.com/repos/NHSDigital/${repoName}/pulls/${pullRequestId}`
let pullRequestState = pullRequestStateCache.get(pullRequestId)
if (pullRequestState === undefined) {

const headers: Record<string, string> = {
Accept: "application/vnd.github+json",
Authorization: `Bearer ${process.env.GITHUB_TOKEN}`
}
console.log(`Checking pull request id ${pullRequestId}`)
const url = `https://api.github.com/repos/NHSDigital/${repoName}/pulls/${pullRequestId}`

const response = await fetch(url, {headers})
if (!response.ok) {
console.log(`Failed to fetch PR ${pullRequestId}: ${response.status} ${await response.text()}`)
return false
const headers: Record<string, string> = {
Accept: "application/vnd.github+json",
Authorization: `Bearer ${process.env.GITHUB_TOKEN}`
}

const response = await fetch(url, {headers})
if (!response.ok) {
console.log(`Failed to fetch PR ${pullRequestId}: ${response.status} ${await response.text()}`)
// To avoid accidentally deleting stacks due to transient API failures, we treat errors as non-closed state
// and do not cache the result, allowing another fetch attempt later in this run if needed.
return false
}

const data = (await response.json()) as {state?: string}
pullRequestState = data.state
if (pullRequestState) {
pullRequestStateCache.set(pullRequestId, pullRequestState)
}
}

const data = (await response.json()) as {state?: string}
if (data.state !== "closed") {
console.log(`not going to delete stack ${stackName} as PR state is ${data.state}`)
if (pullRequestState !== "closed") {
console.log(`not going to delete stack ${stackName} as PR state is ${pullRequestState}`)
return false
}

console.log(`** going to delete stack ${stackName} as PR state is ${data.state} **`)
console.log(`** going to delete stack ${stackName} as PR state is ${pullRequestState} **`)
return true
}

Expand Down
45 changes: 45 additions & 0 deletions packages/cdkConstructs/tests/stacks/deleteUnusedStacks.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -858,6 +858,51 @@ describe("stack deletion", () => {
})
})

test("deletes PR stacks with multiple suffixes", async () => {
const now = new Date()
const twoDaysAgo = new Date(now.getTime() - 2 * 24 * 60 * 60 * 1000)

mockListStacksSend.mockReturnValue({
StackSummaries: [
{
StackName: `${baseStackName}-pr-123-sandbox`,
StackStatus: "CREATE_COMPLETE",
CreationTime: twoDaysAgo
},
{
StackName: `${baseStackName}-pr-123-front-door`,
StackStatus: "CREATE_COMPLETE",
CreationTime: twoDaysAgo
},
{
StackName: `${baseStackName}-pr-123-stateful`,
StackStatus: "CREATE_COMPLETE",
CreationTime: twoDaysAgo
}
]
})

mockGetPRState.mockImplementation((url: string) => {
if (url.endsWith("/repos/NHSDigital/eps-cdk-utils/pulls/123")) {
return "closed"
}
throw new Error(`Unexpected URL: ${url}`)
})

const promise = deleteUnusedPrStacks(baseStackName, repoName, hostedZoneName)
await vi.runAllTimersAsync()
await promise

// One delete stack call per PR stack
expect(mockDeleteStackSend).toHaveBeenCalledTimes(3)
expect(mockDeleteStackSend).toHaveBeenCalledWith({StackName: `${baseStackName}-pr-123-sandbox`})
expect(mockDeleteStackSend).toHaveBeenCalledWith({StackName: `${baseStackName}-pr-123-front-door`})
expect(mockDeleteStackSend).toHaveBeenCalledWith({StackName: `${baseStackName}-pr-123-stateful`})

// PR state should only be fetched once per PR, not once per stack
expect(mockGetPRState).toHaveBeenCalledTimes(1)
})

test("does not delete open PR stacks", async () => {
const now = new Date()
const twoDaysAgo = new Date(now.getTime() - 2 * 24 * 60 * 60 * 1000)
Expand Down