import { GITHUB_BRANCH_SCHEMA, GITHUB_BRANCH_SUMMARY_SCHEMA, GITHUB_COMBINED_COMMIT_STATUS_SCHEMA, GITHUB_COMMIT_STATUS_SCHEMA, GITHUB_EMPTY_RESPONSE_SCHEMA, GITHUB_REFERENCE_SCHEMA, GITHUB_REPOSITORY_CONTENT_SCHEMA, GITHUB_REPOSITORY_FILE_COMMIT_SCHEMA, GITHUB_REPOSITORY_FILE_DELETION_SCHEMA, GITHUB_REPOSITORY_SCHEMA, GITHUB_REPOSITORY_SUMMARY_SCHEMA, } from "@automate.ax/integration-contracts/github" import * as z from "zod" import { defineAction } from "../../../automation/actions" import { getGitHubApi } from "../lib/api" import { GITHUB_PAGE_INPUT_SCHEMA, GITHUB_REPOSITORY_INPUT_SCHEMA, } from "../lib/input-schemas" import { GITHUB_COMMIT_STATUSES_READ_SCOPE, GITHUB_COMMIT_STATUSES_WRITE_SCOPE, GITHUB_CONTENTS_READ_SCOPE, GITHUB_CONTENTS_WRITE_SCOPE, GITHUB_METADATA_READ_SCOPE, GITHUB_WORKFLOW_FILE_WRITE_REQUIREMENT, } from "../lib/scopes" const GITHUB_COMMIT_AUTHOR_SCHEMA = z.object({ /** Authored or committed timestamp. */ date: z.iso.datetime({ offset: true }).optional(), /** Commit author's email address. */ email: z.email(), /** Commit author's display name. */ name: z.string().min(1), }) const GITHUB_REPOSITORY_DISPATCH_PAYLOAD_SCHEMA = z .record(z.string(), z.json()) .refine((payload) => Object.keys(payload).length <= 10, { error: "Repository dispatch payloads support at most 10 top-level properties.", }) const GITHUB_REPOSITORY_DISPATCH_INPUT_SCHEMA = GITHUB_REPOSITORY_INPUT_SCHEMA.extend({ /** JSON payload delivered with the event. */ clientPayload: GITHUB_REPOSITORY_DISPATCH_PAYLOAD_SCHEMA.optional(), /** Custom event type, up to 100 characters. */ eventType: z.string().min(1).max(100), }).superRefine((input, context) => { if ( input.clientPayload && new TextEncoder().encode(JSON.stringify(input.clientPayload)) .byteLength >= 64 * 1024 ) { context.addIssue({ code: "custom", message: "Repository dispatch payloads must be smaller than 64 KB.", }) } }) const GITHUB_REPOSITORY_FILE_PATH_SCHEMA = z .string() .min(1) .refine((path) => !path.startsWith(".github/workflows/"), { error: "Use a GitHub workflow file action for paths under .github/workflows/.", }) const GITHUB_WORKFLOW_FILE_PATH_SCHEMA = z .string() .regex(/^\.github\/workflows\/[^/]+\.ya?ml$/, { error: "Workflow files must be YAML files directly under .github/workflows/.", }) const GITHUB_REPOSITORY_FILE_WRITE_INPUT_SCHEMA = GITHUB_REPOSITORY_INPUT_SCHEMA.extend({ /** Commit author. Defaults to the authenticated app identity. */ author: GITHUB_COMMIT_AUTHOR_SCHEMA.optional(), /** Branch to update. Defaults to the repository's default branch. */ branch: z.string().min(1).optional(), /** Committer. Defaults to the authenticated app identity. */ committer: GITHUB_COMMIT_AUTHOR_SCHEMA.optional(), /** New file contents encoded as Base64. */ contentBase64: z.base64(), /** Commit message. */ commitMessage: z.string().min(1), /** Repository-relative file path. */ path: z.string().min(1), /** Existing blob SHA. Required when replacing a file. */ sha: z.string().min(1).optional(), }) const GITHUB_REPOSITORY_FILE_DELETE_INPUT_SCHEMA = GITHUB_REPOSITORY_INPUT_SCHEMA.extend({ /** Commit author. Defaults to the authenticated app identity. */ author: GITHUB_COMMIT_AUTHOR_SCHEMA.optional(), /** Branch to update. Defaults to the repository's default branch. */ branch: z.string().min(1).optional(), /** Committer. Defaults to the authenticated app identity. */ committer: GITHUB_COMMIT_AUTHOR_SCHEMA.optional(), /** Commit message. */ commitMessage: z.string().min(1), /** Repository-relative file path. */ path: z.string().min(1), /** Blob SHA of the file being deleted. */ sha: z.string().min(1), }) /** Lists repositories accessible to the selected GitHub App installation. */ export const listGitHubRepositories = defineAction("List GitHub repositories") .describe("Lists repositories accessible to a GitHub App installation.") .account("github") .input(z.object(GITHUB_PAGE_INPUT_SCHEMA)) .output(GITHUB_REPOSITORY_SUMMARY_SCHEMA.array()) .retry({ replaySafety: "safe" }) .handler(async ({ account, input }) => { const { data } = await getGitHubApi( account.secret, ).rest.apps.listReposAccessibleToInstallation({ page: input.page, per_page: input.perPage, }) return data.repositories }) /** Gets repository metadata visible to the installation. */ export const getGitHubRepository = defineAction("Get GitHub repository") .describe("Gets metadata for one GitHub repository.") .account("github", GITHUB_METADATA_READ_SCOPE) .input(GITHUB_REPOSITORY_INPUT_SCHEMA) .output(GITHUB_REPOSITORY_SCHEMA) .retry({ replaySafety: "safe" }) .handler(async ({ account, input }) => { const { data } = await getGitHubApi(account.secret).rest.repos.get({ owner: input.owner, repo: input.repository, }) return data }) /** Gets a file, directory, symlink, or submodule from a repository. */ export const getGitHubRepositoryContent = defineAction( "Get GitHub repository content", ) .describe("Gets repository content at a path and optional Git reference.") .account("github", GITHUB_CONTENTS_READ_SCOPE) .input( GITHUB_REPOSITORY_INPUT_SCHEMA.extend({ /** Repository-relative path. Use an empty string for the root directory. */ path: z.string(), /** Branch, tag, or commit SHA. Defaults to the default branch. */ ref: z.string().min(1).optional(), }), ) .output(GITHUB_REPOSITORY_CONTENT_SCHEMA) .retry({ replaySafety: "safe" }) .handler(async ({ account, input }) => { const { data } = await getGitHubApi(account.secret).rest.repos.getContent({ owner: input.owner, path: input.path, repo: input.repository, ...(input.ref && { ref: input.ref }), }) return data }) /** Creates a repository file or replaces its contents in one commit. */ export const createOrUpdateGitHubRepositoryFile = defineAction( "Create or update GitHub repository file", ) .describe("Creates or replaces one repository file through the Contents API.") .account("github", GITHUB_CONTENTS_WRITE_SCOPE) .input( GITHUB_REPOSITORY_FILE_WRITE_INPUT_SCHEMA.extend({ path: GITHUB_REPOSITORY_FILE_PATH_SCHEMA, }), ) .output(GITHUB_REPOSITORY_FILE_COMMIT_SCHEMA) .retry({ replaySafety: "unsafe" }) .handler(({ account, input }) => createOrUpdateRepositoryFile(account.secret, input), ) /** Creates or replaces one GitHub Actions workflow file in a commit. */ export const createOrUpdateGitHubWorkflowFile = defineAction( "Create or update GitHub workflow file", ) .describe("Creates or replaces one workflow file through the Contents API.") .account("github", GITHUB_WORKFLOW_FILE_WRITE_REQUIREMENT) .input( GITHUB_REPOSITORY_FILE_WRITE_INPUT_SCHEMA.extend({ path: GITHUB_WORKFLOW_FILE_PATH_SCHEMA, }), ) .output(GITHUB_REPOSITORY_FILE_COMMIT_SCHEMA) .retry({ replaySafety: "unsafe" }) .handler(({ account, input }) => createOrUpdateRepositoryFile(account.secret, input), ) /** Deletes one repository file in a commit. */ export const deleteGitHubRepositoryFile = defineAction( "Delete GitHub repository file", ) .describe("Deletes one repository file through the Contents API.") .account("github", GITHUB_CONTENTS_WRITE_SCOPE) .input( GITHUB_REPOSITORY_FILE_DELETE_INPUT_SCHEMA.extend({ path: GITHUB_REPOSITORY_FILE_PATH_SCHEMA, }), ) .output(GITHUB_REPOSITORY_FILE_DELETION_SCHEMA) .retry({ replaySafety: "unsafe" }) .handler(({ account, input }) => deleteRepositoryFile(account.secret, input)) /** Deletes one GitHub Actions workflow file in a commit. */ export const deleteGitHubWorkflowFile = defineAction( "Delete GitHub workflow file", ) .describe("Deletes one workflow file through the Contents API.") .account("github", GITHUB_WORKFLOW_FILE_WRITE_REQUIREMENT) .input( GITHUB_REPOSITORY_FILE_DELETE_INPUT_SCHEMA.extend({ path: GITHUB_WORKFLOW_FILE_PATH_SCHEMA, }), ) .output(GITHUB_REPOSITORY_FILE_DELETION_SCHEMA) .retry({ replaySafety: "unsafe" }) .handler(({ account, input }) => deleteRepositoryFile(account.secret, input)) /** Sends a custom repository dispatch event. */ export const dispatchGitHubRepositoryEvent = defineAction( "Dispatch GitHub repository event", ) .describe("Sends a custom repository_dispatch event to a repository.") .account("github", GITHUB_CONTENTS_WRITE_SCOPE) .input(GITHUB_REPOSITORY_DISPATCH_INPUT_SCHEMA) .output(GITHUB_EMPTY_RESPONSE_SCHEMA) .retry({ replaySafety: "unsafe" }) .handler(async ({ account, input }) => { await getGitHubApi(account.secret).rest.repos.createDispatchEvent({ event_type: input.eventType, owner: input.owner, repo: input.repository, ...(input.clientPayload && { client_payload: input.clientPayload }), }) return {} }) /** Lists branches in one repository. */ export const listGitHubBranches = defineAction("List GitHub branches") .describe("Lists a page of branches in a GitHub repository.") .account("github", GITHUB_CONTENTS_READ_SCOPE) .input( GITHUB_REPOSITORY_INPUT_SCHEMA.extend({ ...GITHUB_PAGE_INPUT_SCHEMA, /** Return only protected or unprotected branches. */ protected: z.boolean().optional(), }), ) .output(GITHUB_BRANCH_SUMMARY_SCHEMA.array()) .retry({ replaySafety: "safe" }) .handler(async ({ account, input }) => { const { data } = await getGitHubApi(account.secret).rest.repos.listBranches( { owner: input.owner, page: input.page, per_page: input.perPage, repo: input.repository, ...(input.protected !== undefined && { protected: input.protected }), }, ) return data }) /** Gets one repository branch. */ export const getGitHubBranch = defineAction("Get GitHub branch") .describe("Gets one branch and its protection metadata.") .account("github", GITHUB_CONTENTS_READ_SCOPE) .input( GITHUB_REPOSITORY_INPUT_SCHEMA.extend({ /** Branch name without the refs/heads prefix. */ branch: z.string().min(1), }), ) .output(GITHUB_BRANCH_SCHEMA) .retry({ replaySafety: "safe" }) .handler(async ({ account, input }) => { const { data } = await getGitHubApi(account.secret).rest.repos.getBranch({ branch: input.branch, owner: input.owner, repo: input.repository, }) return data }) /** Creates a branch at an existing commit SHA. */ export const createGitHubBranch = defineAction("Create GitHub branch") .describe("Creates a branch reference at an existing commit.") .account("github", GITHUB_CONTENTS_WRITE_SCOPE) .input( GITHUB_REPOSITORY_INPUT_SCHEMA.extend({ /** New branch name without the refs/heads prefix. */ branch: z.string().min(1), /** Existing commit SHA for the new branch. */ sha: z.string().min(1), }), ) .output(GITHUB_REFERENCE_SCHEMA) .retry({ replaySafety: "unsafe" }) .handler(async ({ account, input }) => { const { data } = await getGitHubApi(account.secret).rest.git.createRef({ owner: input.owner, ref: `refs/heads/${input.branch}`, repo: input.repository, sha: input.sha, }) return data }) /** Deletes a GitHub branch reference. */ export const deleteGitHubBranch = defineAction("Delete GitHub branch") .describe("Deletes a branch reference from a GitHub repository.") .account("github", GITHUB_CONTENTS_WRITE_SCOPE) .input( GITHUB_REPOSITORY_INPUT_SCHEMA.extend({ /** Branch name without the refs/heads prefix. */ branch: z.string().min(1), }), ) .output(GITHUB_EMPTY_RESPONSE_SCHEMA) .retry({ replaySafety: "unsafe" }) .handler(async ({ account, input }) => { await getGitHubApi(account.secret).rest.git.deleteRef({ owner: input.owner, ref: `heads/${input.branch}`, repo: input.repository, }) return {} }) /** Gets the combined commit status for a ref. */ export const getGitHubCombinedCommitStatus = defineAction( "Get GitHub combined commit status", ) .describe("Gets the latest combined commit status for a ref.") .account("github", GITHUB_COMMIT_STATUSES_READ_SCOPE) .input( GITHUB_REPOSITORY_INPUT_SCHEMA.extend({ ...GITHUB_PAGE_INPUT_SCHEMA, /** Commit SHA, branch name, or tag name. */ ref: z.string().min(1), }), ) .output(GITHUB_COMBINED_COMMIT_STATUS_SCHEMA) .retry({ replaySafety: "safe" }) .handler(async ({ account, input }) => { const { data } = await getGitHubApi( account.secret, ).rest.repos.getCombinedStatusForRef({ owner: input.owner, page: input.page, per_page: input.perPage, ref: input.ref, repo: input.repository, }) return data }) /** Creates a commit status for one SHA and context. */ export const createGitHubCommitStatus = defineAction( "Create GitHub commit status", ) .describe("Creates a status for one commit SHA and context.") .account("github", GITHUB_COMMIT_STATUSES_WRITE_SCOPE) .input( GITHUB_REPOSITORY_INPUT_SCHEMA.extend({ /** Status context, such as ci/automate. */ context: z.string().min(1).prefault("default"), /** Short status description. */ description: z.string().max(140).optional(), /** Commit SHA. */ sha: z.string().min(1), /** New status state. */ state: z.enum(["error", "failure", "pending", "success"]), /** URL with details about this status. */ targetUrl: z.url().optional(), }), ) .output(GITHUB_COMMIT_STATUS_SCHEMA) .retry({ replaySafety: "unsafe" }) .handler(async ({ account, input }) => { const { data } = await getGitHubApi( account.secret, ).rest.repos.createCommitStatus({ context: input.context, owner: input.owner, repo: input.repository, sha: input.sha, state: input.state, ...(input.description !== undefined && { description: input.description, }), ...(input.targetUrl && { target_url: input.targetUrl }), }) return data }) /** * Writes one file through GitHub's repository Contents API. * * @param secret - Runtime GitHub account secret. * @param input - Validated file and commit input. */ async function createOrUpdateRepositoryFile( secret: Record, input: z.infer, ) { const { data } = await getGitHubApi( secret, ).rest.repos.createOrUpdateFileContents({ content: input.contentBase64, message: input.commitMessage, owner: input.owner, path: input.path, repo: input.repository, ...(input.author && { author: input.author }), ...(input.branch && { branch: input.branch }), ...(input.committer && { committer: input.committer }), ...(input.sha && { sha: input.sha }), }) return data } /** * Deletes one file through GitHub's repository Contents API. * * @param secret - Runtime GitHub account secret. * @param input - Validated file and commit input. */ async function deleteRepositoryFile( secret: Record, input: z.infer, ) { const { data } = await getGitHubApi(secret).rest.repos.deleteFile({ message: input.commitMessage, owner: input.owner, path: input.path, repo: input.repository, sha: input.sha, ...(input.author && { author: input.author }), ...(input.branch && { branch: input.branch }), ...(input.committer && { committer: input.committer }), }) return data }