2026-01-28 11:17:43 +00:00
|
|
|
import { Request, Response } from "express"
|
2026-04-13 16:03:05 +00:00
|
|
|
import { AuthorizedUserReq } from "../types.js"
|
2026-01-28 11:17:43 +00:00
|
|
|
import { userNickname } from "../auth0-mgmt.js"
|
|
|
|
|
import { getInstallationOctokitByOwner, isUserCollaborator } from "../github/github-utils.js"
|
|
|
|
|
import { githubApp } from "../github/github-app.js"
|
|
|
|
|
import { logger } from "../utils/logger.js"
|
|
|
|
|
import {
|
|
|
|
|
missingRequiredFields,
|
|
|
|
|
validationFailure,
|
|
|
|
|
unauthorized,
|
|
|
|
|
githubInstallationError,
|
|
|
|
|
githubNotCollaborator,
|
|
|
|
|
githubPrNotFound,
|
|
|
|
|
forbidden,
|
|
|
|
|
internalServerError,
|
|
|
|
|
} from "../exceptions/index.js"
|
|
|
|
|
|
|
|
|
|
// Dependencies interface for easier testing
|
|
|
|
|
export interface ClosePrDependencies {
|
|
|
|
|
userNickname: typeof userNickname
|
|
|
|
|
getInstallationOctokitByOwner: typeof getInstallationOctokitByOwner
|
|
|
|
|
isUserCollaborator: typeof isUserCollaborator
|
|
|
|
|
githubApp: typeof githubApp
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Default dependencies
|
|
|
|
|
let dependencies: ClosePrDependencies = {
|
|
|
|
|
userNickname,
|
|
|
|
|
getInstallationOctokitByOwner,
|
|
|
|
|
isUserCollaborator,
|
|
|
|
|
githubApp,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// For testing - allow dependency injection
|
|
|
|
|
export function setClosePrDependencies(deps: Partial<ClosePrDependencies>) {
|
|
|
|
|
dependencies = { ...dependencies, ...deps }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function resetClosePrDependencies() {
|
|
|
|
|
dependencies = {
|
|
|
|
|
userNickname,
|
|
|
|
|
getInstallationOctokitByOwner,
|
|
|
|
|
isUserCollaborator,
|
|
|
|
|
githubApp,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Close a PR that was created by Codeflash
|
|
|
|
|
*
|
|
|
|
|
* This endpoint allows closing PRs that were raised by mistake on client repositories.
|
|
|
|
|
* It verifies that:
|
|
|
|
|
* 1. The user is authenticated and is a collaborator on the repo
|
|
|
|
|
* 2. The PR was created by the Codeflash bot (safety check)
|
|
|
|
|
* 3. The PR exists and is open
|
|
|
|
|
*/
|
|
|
|
|
export async function closePr(req: Request, res: Response): Promise<void> {
|
|
|
|
|
const { owner, repo, pr_number, comment } = req.body
|
|
|
|
|
|
|
|
|
|
// Validate required fields
|
|
|
|
|
if (!owner || !repo || !pr_number) {
|
|
|
|
|
throw missingRequiredFields("owner, repo, pr_number")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const ownerStr = String(owner).trim()
|
|
|
|
|
const repoStr = String(repo).trim()
|
|
|
|
|
const prNumber = Number(pr_number)
|
|
|
|
|
|
|
|
|
|
if (ownerStr === "" || repoStr === "") {
|
|
|
|
|
throw validationFailure("owner and repo cannot be empty")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (isNaN(prNumber) || prNumber <= 0) {
|
|
|
|
|
throw validationFailure("pr_number must be a positive integer")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
// Verify user is authenticated
|
|
|
|
|
const nickname = await dependencies.userNickname((req as AuthorizedUserReq).userId)
|
|
|
|
|
if (nickname == null) {
|
|
|
|
|
throw unauthorized("")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Get installation octokit
|
|
|
|
|
const installationOctokit = await dependencies.getInstallationOctokitByOwner(
|
|
|
|
|
dependencies.githubApp,
|
|
|
|
|
ownerStr,
|
|
|
|
|
repoStr,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
if (installationOctokit instanceof Error) {
|
|
|
|
|
throw githubInstallationError(installationOctokit.message)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
//Verify user is a collaborator
|
|
|
|
|
const isCollaborator = await dependencies.isUserCollaborator(
|
|
|
|
|
installationOctokit,
|
|
|
|
|
ownerStr,
|
|
|
|
|
repoStr,
|
|
|
|
|
nickname,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
if (!isCollaborator) {
|
|
|
|
|
throw githubNotCollaborator(`${ownerStr}/${repoStr}`)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Get the PR to verify it exists and was created by Codeflash
|
|
|
|
|
let pr
|
|
|
|
|
try {
|
|
|
|
|
const prResponse = await installationOctokit.rest.pulls.get({
|
|
|
|
|
owner: ownerStr,
|
|
|
|
|
repo: repoStr,
|
|
|
|
|
pull_number: prNumber,
|
|
|
|
|
})
|
|
|
|
|
pr = prResponse.data
|
|
|
|
|
} catch (error: any) {
|
|
|
|
|
if (error.status === 404) {
|
|
|
|
|
throw githubPrNotFound(`${ownerStr}/${repoStr}#${prNumber}`)
|
|
|
|
|
}
|
|
|
|
|
throw error
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Safety check: Only allow closing PRs created by the Codeflash bot
|
|
|
|
|
const isCodeflashBot = pr.user?.type === "Bot" && pr.user?.login?.includes("codeflash")
|
|
|
|
|
if (!isCodeflashBot) {
|
|
|
|
|
throw forbidden(
|
|
|
|
|
`Cannot close PR #${prNumber} - it was not created by Codeflash. ` +
|
|
|
|
|
`PR was created by ${pr.user?.login} (${pr.user?.type})`,
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Check if PR is already closed
|
|
|
|
|
if (pr.state === "closed") {
|
|
|
|
|
logger.info("PR is already closed", req, {
|
|
|
|
|
repo: `${ownerStr}/${repoStr}`,
|
|
|
|
|
prNumber,
|
|
|
|
|
})
|
|
|
|
|
res.json({
|
|
|
|
|
success: true,
|
|
|
|
|
message: `PR #${prNumber} is already closed`,
|
|
|
|
|
pr_url: pr.html_url,
|
|
|
|
|
state: pr.state,
|
|
|
|
|
})
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Add a comment if provided
|
|
|
|
|
const closeComment =
|
|
|
|
|
comment || `This PR has been manually closed by ${nickname} via Codeflash admin API.`
|
|
|
|
|
|
|
|
|
|
await installationOctokit.rest.issues.createComment({
|
|
|
|
|
owner: ownerStr,
|
|
|
|
|
repo: repoStr,
|
|
|
|
|
issue_number: prNumber,
|
|
|
|
|
body: closeComment,
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// Close the PR
|
|
|
|
|
await installationOctokit.rest.pulls.update({
|
|
|
|
|
owner: ownerStr,
|
|
|
|
|
repo: repoStr,
|
|
|
|
|
pull_number: prNumber,
|
|
|
|
|
state: "closed",
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
logger.info("Successfully closed PR", req, {
|
|
|
|
|
repo: `${ownerStr}/${repoStr}`,
|
|
|
|
|
prNumber,
|
|
|
|
|
closedBy: nickname,
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
res.json({
|
|
|
|
|
success: true,
|
|
|
|
|
message: `Successfully closed PR #${prNumber}`,
|
|
|
|
|
pr_url: pr.html_url,
|
|
|
|
|
closed_by: nickname,
|
|
|
|
|
})
|
|
|
|
|
} catch (error: any) {
|
|
|
|
|
// Re-throw AppExceptions
|
|
|
|
|
if (error && typeof error === "object" && "getHttpStatus" in error) {
|
|
|
|
|
throw error
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
logger.errorWithSentry(
|
|
|
|
|
"Error closing PR",
|
|
|
|
|
req,
|
|
|
|
|
{ repo: `${ownerStr}/${repoStr}`, prNumber },
|
|
|
|
|
error,
|
|
|
|
|
)
|
|
|
|
|
throw internalServerError("Error closing PR")
|
|
|
|
|
}
|
|
|
|
|
}
|