diff --git a/.github/workflows/invoke-fern-bot-upgrade-generators.yml b/.github/workflows/invoke-fern-bot-upgrade-generators.yml new file mode 100644 index 0000000000..817e30bda6 --- /dev/null +++ b/.github/workflows/invoke-fern-bot-upgrade-generators.yml @@ -0,0 +1,61 @@ +name: Invoke FernBot - updateGeneratorVersions + +on: + workflow_dispatch: + inputs: + environment: + type: choice + description: Which environment to run the workflow in + options: + - production + - development + repo: + description: "The repo to run the action against (of the form `owner/repo_name`), if omitted runs on all repos the app is installed on" + type: string + +jobs: + invoke_dev: + if: ${{ github.event.inputs.environment == 'development' }} + runs-on: ubuntu-latest + env: + SERVERLESS_ACCESS_KEY: ${{ secrets.SERVERLESS_ACCESS_KEY }} + GITHUB_APP_LOGIN_NAME: ${{ secrets.FERN_BOT_DEV_GITHUB_APP_LOGIN_NAME }} + GITHUB_APP_LOGIN_ID: ${{ secrets.FERN_BOT_DEV_GITHUB_APP_LOGIN_ID }} + GITHUB_APP_ID: ${{ secrets.FERN_BOT_DEV_GITHUB_APP_ID }} + GITHUB_APP_PRIVATE_KEY: ${{ secrets.FERN_BOT_DEV_GITHUB_APP_PRIVATE_KEY }} + GITHUB_APP_CLIENT_ID: ${{ secrets.FERN_BOT_DEV_GITHUB_APP_CLIENT_ID }} + GITHUB_APP_CLIENT_SECRET: ${{ secrets.FERN_BOT_DEV_GITHUB_APP_CLIENT_SECRET }} + GITHUB_APP_WEBHOOK_SECRET: ${{ secrets.FERN_BOT_DEV_GITHUB_APP_WEBHOOK_SECRET }} + CO_API_KEY: ${{ secrets.DEV_CO_API_KEY }} + REPO_TO_RUN_ON: ${{ github.event.inputs.repo }} + steps: + - uses: actions/checkout@v4 + - name: 📥 Install + uses: ./.github/actions/install + - name: 🚀 serverless deploy + run: | + pnpm --filter "@fern-platform/fern-bot" install + pnpm --filter "@fern-platform/fern-bot" invoke local --function updateGeneratorVersions --stage development + + invoke_prod: + if: ${{ github.event.inputs.environment == 'production' }} + runs-on: ubuntu-latest + env: + SERVERLESS_ACCESS_KEY: ${{ secrets.SERVERLESS_ACCESS_KEY }} + GITHUB_APP_LOGIN_NAME: ${{ secrets.FERN_BOT_PROD_GITHUB_APP_LOGIN_NAME }} + GITHUB_APP_LOGIN_ID: ${{ secrets.FERN_BOT_PROD_GITHUB_APP_LOGIN_ID }} + GITHUB_APP_ID: ${{ secrets.FERN_BOT_PROD_GITHUB_APP_ID }} + GITHUB_APP_PRIVATE_KEY: ${{ secrets.FERN_BOT_PROD_GITHUB_APP_PRIVATE_KEY }} + GITHUB_APP_CLIENT_ID: ${{ secrets.FERN_BOT_PROD_GITHUB_APP_CLIENT_ID }} + GITHUB_APP_CLIENT_SECRET: ${{ secrets.FERN_BOT_PROD_GITHUB_APP_CLIENT_SECRET }} + GITHUB_APP_WEBHOOK_SECRET: ${{ secrets.FERN_BOT_PROD_GITHUB_APP_WEBHOOK_SECRET }} + CO_API_KEY: ${{ secrets.PROD_CO_API_KEY }} + REPO_TO_RUN_ON: ${{ github.event.inputs.repo }} + steps: + - uses: actions/checkout@v4 + - name: 📥 Install + uses: ./.github/actions/install + - name: 🚀 serverless deploy + run: | + pnpm --filter "@fern-platform/fern-bot" install + pnpm --filter "@fern-platform/fern-bot" invoke local --function updateGeneratorVersions --stage production diff --git a/servers/fern-bot/serverless.yml b/servers/fern-bot/serverless.yml index 2d25b88388..d70aaaa062 100644 --- a/servers/fern-bot/serverless.yml +++ b/servers/fern-bot/serverless.yml @@ -17,6 +17,7 @@ provider: GITHUB_APP_CLIENT_SECRET: ${env:GITHUB_APP_CLIENT_SECRET, 'placeholder'} GITHUB_APP_WEBHOOK_SECRET: ${env:GITHUB_APP_WEBHOOK_SECRET, 'placeholder'} CO_API_KEY: ${env:CO_API_KEY, 'placeholder'} + VENUS_URL: ${env:VENUS_URL, 'placeholder'} REPO_TO_RUN_ON: ${env:REPO_TO_RUN_ON, 'OMIT'} functions: updateOpenApiSpec: @@ -29,6 +30,14 @@ functions: - schedule: rate: cron(0 0 * * ? *) enabled: true + updateGeneratorVersions: + handler: "src/functions/generator-updates/updateGeneratorVersions.handler" + layers: + - arn:aws:lambda:us-east-1:553035198032:layer:git-lambda2:8 + events: + - schedule: + rate: cron(0 1 * * MON *) + enabled: true custom: esbuild: minify: true diff --git a/servers/fern-bot/src/functions/generator-updates/actions/updateGeneratorVersions.ts b/servers/fern-bot/src/functions/generator-updates/actions/updateGeneratorVersions.ts new file mode 100644 index 0000000000..af9e3a01a2 --- /dev/null +++ b/servers/fern-bot/src/functions/generator-updates/actions/updateGeneratorVersions.ts @@ -0,0 +1,95 @@ +import { Env } from "@libs/env"; +import { execFernCli } from "@libs/fern"; +import { setupGithubApp } from "@libs/github/octokit"; +import { + DEFAULT_REMOTE_NAME, + cloneRepo, + configureGit, + createOrUpdatePullRequest, + getOrUpdateBranch, + type Repository, +} from "@libs/github/utilities"; +import { App, Octokit } from "octokit"; + +const GENERATOR_UPDATE_BRANCH = "fern/update-generators"; + +async function updateGeneratorVersionInternal( + octokit: Octokit, + repository: Repository, + fernBotLoginName: string, + fernBotLoginId: string, +): Promise { + const [git, fullRepoPath] = await configureGit(repository); + console.log(`Cloning repo: ${repository.clone_url} to ${fullRepoPath}`); + await cloneRepo(git, repository, octokit, fernBotLoginName, fernBotLoginId); + + const originDefaultBranch = `${DEFAULT_REMOTE_NAME}/${repository.default_branch}`; + await getOrUpdateBranch(git, originDefaultBranch, GENERATOR_UPDATE_BRANCH); + + try { + // Run API update command which will pull the new spec from the specified + // origin and write it to disk we can then commit it to github from there. + await execFernCli("upgrade", fullRepoPath); + await execFernCli("generator upgrade", fullRepoPath); + } catch (error) { + return; + } + + console.log("Checking for changes to commit and push"); + if (!(await git.status()).isClean()) { + console.log("Changes detected, committing and pushing"); + // Add + commit files + await git.add(["-A"]); + await git.commit("(chore): upgrade generator versions to latest"); + + // Push the changes + await git.push([ + "--force-with-lease", + DEFAULT_REMOTE_NAME, + `${GENERATOR_UPDATE_BRANCH}:refs/heads/${GENERATOR_UPDATE_BRANCH}`, + ]); + + // Open a PR + await createOrUpdatePullRequest( + octokit, + { + title: ":herb: :sparkles: [Scheduled] Upgrade SDK Generator Versions", + base: "main", + // TODO: This should really pull from the changelogs the generators maintain in the Fern repo + // at the least the CLI should output the versions of the generators it's upgrading, so we can display that + body: `## Automated Upgrade PR + +
+ +--- + +This Pull Request has been auto-generated as part of Fern's release process.`, + }, + repository.full_name, + repository.full_name, + GENERATOR_UPDATE_BRANCH, + ); + } +} + +export async function updateGeneratorVersionsInternal(env: Env): Promise { + const app: App = setupGithubApp(env); + + if (env.REPO_TO_RUN_ON !== undefined) { + console.log("REPO_TO_RUN_ON has been specified, only running on:", env.REPO_TO_RUN_ON); + } + await app.eachRepository(async (installation) => { + if (env.REPO_TO_RUN_ON !== undefined && installation.repository.full_name !== env.REPO_TO_RUN_ON) { + return; + } else if (env.REPO_TO_RUN_ON !== undefined) { + console.log("REPO_TO_RUN_ON has been found, running logic."); + } + console.log("Encountered installation", installation.repository.full_name); + await updateGeneratorVersionInternal( + installation.octokit, + installation.repository, + env.GITHUB_APP_LOGIN_NAME, + env.GITHUB_APP_LOGIN_ID, + ); + }); +} diff --git a/servers/fern-bot/src/functions/generator-updates/updateGeneratorVersions.ts b/servers/fern-bot/src/functions/generator-updates/updateGeneratorVersions.ts new file mode 100644 index 0000000000..82f2a16e68 --- /dev/null +++ b/servers/fern-bot/src/functions/generator-updates/updateGeneratorVersions.ts @@ -0,0 +1,12 @@ +import { evaluateEnv } from "@libs/env"; +import { handlerWrapper } from "@libs/handler-wrapper"; +import { updateGeneratorVersionsInternal } from "./actions/updateGeneratorVersions"; + +const updateGeneratorVersions = async (_event: unknown) => { + console.debug("Beginning scheduled run of `updateGeneratorVersions`, received event:", _event); + const env = evaluateEnv(); + console.debug("Environment evaluated, continuing to actual action execution."); + return updateGeneratorVersionsInternal(env); +}; + +export const handler = handlerWrapper(updateGeneratorVersions); diff --git a/servers/fern-bot/src/functions/oas-cron/actions/updateOpenApiSpecs.ts b/servers/fern-bot/src/functions/oas-cron/actions/updateOpenApiSpecs.ts index fd08038cfb..5cdbdade90 100644 --- a/servers/fern-bot/src/functions/oas-cron/actions/updateOpenApiSpecs.ts +++ b/servers/fern-bot/src/functions/oas-cron/actions/updateOpenApiSpecs.ts @@ -1,15 +1,16 @@ -import { AbsoluteFilePath, doesPathExist } from "@fern-api/fs-utils"; import { generateChangelog, generateCommitMessage } from "@libs/cohere"; import { Env } from "@libs/env"; +import { execFernCli } from "@libs/fern"; +import { setupGithubApp } from "@libs/github/octokit"; +import { + DEFAULT_REMOTE_NAME, + cloneRepo, + configureGit, + createOrUpdatePullRequest, + getOrUpdateBranch, +} from "@libs/github/utilities"; import { components } from "@octokit/openapi-types"; -import { mkdir } from "fs/promises"; import { App, Octokit } from "octokit"; -import * as path from "path"; -import simpleGit from "simple-git"; -import tmp from "tmp-promise"; -import { execFernCli } from "../../../libs/fern"; -import { setupGithubApp } from "../github/octokit"; -import { createOrUpdatePullRequest } from "../github/utilities"; const OPENAPI_UPDATE_BRANCH = "fern/update-api-specs"; type Repository = components["schemas"]["repository"]; @@ -20,43 +21,12 @@ async function updateOpenApiSpecInternal( fernBotLoginName: string, fernBotLoginId: string, ): Promise { - const tmpDir = await tmp.dir(); - const fullRepoPath = AbsoluteFilePath.of(path.join(tmpDir.path, repository.id.toString(), repository.name)); - - const branchRemoteName = "origin"; - const originDefaultBranch = `${branchRemoteName}/${repository.default_branch}`; - + const [git, fullRepoPath] = await configureGit(repository); console.log(`Cloning repo: ${repository.clone_url} to ${fullRepoPath}`); - if (!(await doesPathExist(fullRepoPath))) { - await mkdir(fullRepoPath, { recursive: true }); - } + await cloneRepo(git, repository, octokit, fernBotLoginName, fernBotLoginId); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const installationToken = ((await octokit.auth({ type: "installation" })) as any).token; - const git = simpleGit(fullRepoPath); - - const authedCloneUrl = repository.clone_url.replace("https://", `https://x-access-token:${installationToken}@`); - // Clone the repo to fullRepoPath and update the branch - await git.clone(authedCloneUrl, "."); - // Configure git to show the app as the committer - await git.addConfig("user.name", fernBotLoginName); - await git.addConfig("user.email", `${fernBotLoginId}+${fernBotLoginName}@users.noreply.github.com`); - try { - // If you can fetch the branch, checkout the branch - await git.fetch(branchRemoteName, OPENAPI_UPDATE_BRANCH); - console.log("Branch exists, checking out"); - await git.checkout(OPENAPI_UPDATE_BRANCH); - // Merge the default branch into this branch to update it - // prefer the default branch changes - // - // TODO: we could honestly probably just delete the branch and recreate it - // my concern with that is if there are more changes we decide to make in other actions - // to the same branch that are not OpenAPI related, that we'd lose if we deleted and reupdated the spec. - await git.merge(["-X", "theirs", originDefaultBranch]); - } catch (e) { - console.log("Branch does not exist, create and checkout"); - await git.checkoutBranch(OPENAPI_UPDATE_BRANCH, branchRemoteName); - } + const originDefaultBranch = `${DEFAULT_REMOTE_NAME}/${repository.default_branch}`; + await getOrUpdateBranch(git, originDefaultBranch, OPENAPI_UPDATE_BRANCH); try { // Run API update command which will pull the new spec from the specified @@ -77,7 +47,7 @@ async function updateOpenApiSpecInternal( // Push the changes await git.push([ "--force-with-lease", - branchRemoteName, + DEFAULT_REMOTE_NAME, `${OPENAPI_UPDATE_BRANCH}:refs/heads/${OPENAPI_UPDATE_BRANCH}`, ]); diff --git a/servers/fern-bot/src/functions/oas-cron/github/utilities.ts b/servers/fern-bot/src/functions/oas-cron/github/utilities.ts deleted file mode 100644 index 0d39568ab6..0000000000 --- a/servers/fern-bot/src/functions/oas-cron/github/utilities.ts +++ /dev/null @@ -1,89 +0,0 @@ -import { Octokit } from "octokit"; - -interface CreatePRRequest { - title: string; - body: string; - base: string; - draft?: boolean; -} - -interface RepoMetadata { - owner: string; - repo: string; -} - -export async function createOrUpdatePullRequest( - octokit: Octokit, - inputs: CreatePRRequest, - baseRepository: string, - headRepository: string, - branchName: string, -): Promise { - const [headOwner] = headRepository.split("/"); - const headBranch = `${headOwner}:${branchName}`; - - // Try to create the pull request - try { - console.log("Attempting creation of pull request"); - const { data: pull } = await octokit.rest.pulls.create({ - ...parseRepository(baseRepository), - title: inputs.title, - head: headBranch, - head_repo: headRepository, - base: inputs.base, - body: inputs.body, - draft: inputs.draft, - }); - console.log( - `Created pull request #${pull.number} (${headBranch} => ${inputs.base}), with info ${JSON.stringify({ - number: pull.number, - html_url: pull.html_url, - created: true, - })}`, - ); - } catch (e) { - if (getErrorMessage(e).includes("A pull request already exists for")) { - console.error(`A pull request already exists for ${headBranch}`); - } else { - throw e; - } - } - - // Update the pull request that exists for this branch and base - console.log("Fetching existing pull request"); - const { data: pulls } = await octokit.rest.pulls.list({ - ...parseRepository(baseRepository), - state: "open", - head: headBranch, - base: inputs.base, - }); - console.log("Attempting update of pull request"); - const { data: pull } = await octokit.rest.pulls.update({ - ...parseRepository(baseRepository), - pull_number: pulls[0].number, - title: inputs.title, - body: inputs.body, - }); - console.log( - `Updated pull request #${pull.number} (${headBranch} => ${inputs.base}) with information ${JSON.stringify({ - number: pull.number, - html_url: pull.html_url, - created: false, - })}`, - ); -} - -function parseRepository(repository: string): RepoMetadata { - const [owner, repo] = repository.split("/"); - return { - owner, - repo, - }; -} - -function getErrorMessage(error: unknown) { - if (error instanceof Error) { - return error.message; - } - return String(error); -} diff --git a/servers/fern-bot/src/functions/oas-cron/mock.json b/servers/fern-bot/src/functions/oas-cron/mock.json deleted file mode 100644 index 6b1c818c96..0000000000 --- a/servers/fern-bot/src/functions/oas-cron/mock.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "headers": { - "Content-Type": "application/json" - }, - "body": "{\"name\": \"Frederic\"}" -} diff --git a/servers/fern-bot/src/functions/oas-cron/schema.ts b/servers/fern-bot/src/functions/oas-cron/schema.ts deleted file mode 100644 index 78e8dfee91..0000000000 --- a/servers/fern-bot/src/functions/oas-cron/schema.ts +++ /dev/null @@ -1,7 +0,0 @@ -export default { - type: "object", - properties: { - name: { type: "string" }, - }, - required: ["name"], -} as const; diff --git a/servers/fern-bot/src/functions/oas-cron/github/octokit.ts b/servers/fern-bot/src/libs/github/octokit.ts similarity index 100% rename from servers/fern-bot/src/functions/oas-cron/github/octokit.ts rename to servers/fern-bot/src/libs/github/octokit.ts diff --git a/servers/fern-bot/src/functions/oas-cron/github/octokitHooks.ts b/servers/fern-bot/src/libs/github/octokitHooks.ts similarity index 100% rename from servers/fern-bot/src/functions/oas-cron/github/octokitHooks.ts rename to servers/fern-bot/src/libs/github/octokitHooks.ts diff --git a/servers/fern-bot/src/libs/github/utilities.ts b/servers/fern-bot/src/libs/github/utilities.ts new file mode 100644 index 0000000000..ec683148c2 --- /dev/null +++ b/servers/fern-bot/src/libs/github/utilities.ts @@ -0,0 +1,148 @@ +import { AbsoluteFilePath, doesPathExist } from "@fern-api/fs-utils"; +import { components } from "@octokit/openapi-types"; +import { mkdir } from "fs/promises"; +import { Octokit } from "octokit"; +import * as path from "path"; +import simpleGit, { SimpleGit } from "simple-git"; +import tmp from "tmp-promise"; + +interface CreatePRRequest { + title: string; + body: string; + base: string; + draft?: boolean; +} + +interface RepoMetadata { + owner: string; + repo: string; +} + +export async function createOrUpdatePullRequest( + octokit: Octokit, + inputs: CreatePRRequest, + baseRepository: string, + headRepository: string, + branchName: string, +): Promise { + const [headOwner] = headRepository.split("/"); + const headBranch = `${headOwner}:${branchName}`; + + // Try to create the pull request + try { + console.log("Attempting creation of pull request"); + const { data: pull } = await octokit.rest.pulls.create({ + ...parseRepository(baseRepository), + title: inputs.title, + head: headBranch, + head_repo: headRepository, + base: inputs.base, + body: inputs.body, + draft: inputs.draft, + }); + console.log( + `Created pull request #${pull.number} (${headBranch} => ${inputs.base}), with info ${JSON.stringify({ + number: pull.number, + html_url: pull.html_url, + created: true, + })}`, + ); + } catch (e) { + if (getErrorMessage(e).includes("A pull request already exists for")) { + console.error(`A pull request already exists for ${headBranch}`); + } else { + throw e; + } + } + + // Update the pull request that exists for this branch and base + console.log("Fetching existing pull request"); + const { data: pulls } = await octokit.rest.pulls.list({ + ...parseRepository(baseRepository), + state: "open", + head: headBranch, + base: inputs.base, + }); + console.log("Attempting update of pull request"); + const { data: pull } = await octokit.rest.pulls.update({ + ...parseRepository(baseRepository), + pull_number: pulls[0].number, + title: inputs.title, + body: inputs.body, + }); + console.log( + `Updated pull request #${pull.number} (${headBranch} => ${inputs.base}) with information ${JSON.stringify({ + number: pull.number, + html_url: pull.html_url, + created: false, + })}`, + ); +} + +function parseRepository(repository: string): RepoMetadata { + const [owner, repo] = repository.split("/"); + return { + owner, + repo, + }; +} + +function getErrorMessage(error: unknown) { + if (error instanceof Error) { + return error.message; + } + return String(error); +} + +export const DEFAULT_REMOTE_NAME = "origin"; +export type Repository = components["schemas"]["repository"]; + +export async function configureGit(repository: Repository): Promise<[SimpleGit, string]> { + const tmpDir = await tmp.dir(); + const fullRepoPath = AbsoluteFilePath.of(path.join(tmpDir.path, repository.id.toString(), repository.name)); + if (!(await doesPathExist(fullRepoPath))) { + await mkdir(fullRepoPath, { recursive: true }); + } + return [simpleGit(fullRepoPath), fullRepoPath]; +} + +export async function cloneRepo( + git: SimpleGit, + repository: Repository, + octokit: Octokit, + fernBotLoginName: string, + fernBotLoginId: string, +): Promise { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const installationToken = ((await octokit.auth({ type: "installation" })) as any).token; + + const authedCloneUrl = repository.clone_url.replace("https://", `https://x-access-token:${installationToken}@`); + // Clone the repo to fullRepoPath and update the branch + await git.clone(authedCloneUrl, "."); + // Configure git to show the app as the committer + await git.addConfig("user.name", fernBotLoginName); + await git.addConfig("user.email", `${fernBotLoginId}+${fernBotLoginName}@users.noreply.github.com`); +} + +export async function getOrUpdateBranch( + git: SimpleGit, + defaultBranchName: string, + branchToCheckoutName: string, +): Promise { + try { + // If you can fetch the branch, checkout the branch + await git.fetch(DEFAULT_REMOTE_NAME, branchToCheckoutName); + console.log("Branch exists, checking out"); + await git.checkout(branchToCheckoutName); + // Merge the default branch into this branch to update it + // prefer the default branch changes + // + // TODO: we could honestly probably just delete the branch and recreate it + // my concern with that is if there are more changes we decide to make in other actions + // to the same branch that are not OpenAPI related, that we'd lose if we deleted and reupdated the spec. + await git.merge(["-X", "theirs", defaultBranchName]); + } catch (e) { + console.log("Branch does not exist, create and checkout"); + await git.checkoutBranch(branchToCheckoutName, defaultBranchName); + } +}