-
Notifications
You must be signed in to change notification settings - Fork 17
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
* Starts github app utilities * Fix bulid errors
- Loading branch information
Showing
13 changed files
with
526 additions
and
17 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,7 @@ | ||
{ | ||
"extends": ["../../.eslintrc.js"], | ||
"root": false, | ||
"parserOptions": { | ||
"project": ["./ops/github-app-utils/tsconfig.json"] | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,4 @@ | ||
# oso Github App Utils | ||
|
||
Some tools for creating a CLI envoked github app that can be installed on a | ||
repository or organization. Used generally for OSO github workflow automation. |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,43 @@ | ||
{ | ||
"name": "@opensource-observer/ops-github-app-utils", | ||
"version": "0.0.1", | ||
"description": "Utilities for running a github app", | ||
"author": "Kariba Labs", | ||
"license": "Apache-2.0", | ||
"private": false, | ||
"main": "./dist/src/index.js", | ||
"types": "./dist/src/index.d.ts", | ||
"type": "module", | ||
"repository": { | ||
"type": "git", | ||
"url": "git+https://github.com/opensource-observer/oso.git" | ||
}, | ||
"engines": { | ||
"node": ">=20" | ||
}, | ||
"scripts": { | ||
"build": "tsc", | ||
"lint": "tsc --noEmit && pnpm lint:eslint && pnpm lint:prettier", | ||
"lint:eslint": "eslint --ignore-path ../../.gitignore --max-warnings 0 .", | ||
"lint:prettier": "prettier --ignore-path ../../.gitignore --log-level warn --check **/*.{js,jsx,ts,tsx,sol,md,json}", | ||
"tools": "node --loader ts-node/esm src/cli.ts" | ||
}, | ||
"keywords": [], | ||
"devDependencies": { | ||
"@types/node": "^20.11.17", | ||
"dotenv": "^16.4.1", | ||
"ts-node": "^10.9.1", | ||
"typescript": "^5.3.3" | ||
}, | ||
"dependencies": { | ||
"@types/libsodium-wrappers": "^0.7.13", | ||
"@types/yargs": "^17.0.32", | ||
"chalk": "^5.3.0", | ||
"libsodium-wrappers": "^0.7.13", | ||
"octokit": "^3.1.0", | ||
"ts-dedent": "^2.2.0", | ||
"winston": "^3.11.0", | ||
"yaml": "^2.3.1", | ||
"yargs": "^17.7.2" | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,35 @@ | ||
import { App, Octokit } from "octokit"; | ||
|
||
export interface Repo { | ||
name: string; | ||
owner: string; | ||
} | ||
|
||
export async function getRepoPermissions( | ||
octo: Octokit, | ||
repo: Repo, | ||
login: string, | ||
) { | ||
const res = await octo.rest.repos.getCollaboratorPermissionLevel({ | ||
owner: repo.owner, | ||
repo: repo.name, | ||
username: login, | ||
}); | ||
return res.data.permission; | ||
} | ||
|
||
export async function getOctokitFor( | ||
app: App, | ||
repo: Repo, | ||
): Promise<Octokit | void> { | ||
for await (const { installation } of app.eachInstallation.iterator()) { | ||
for await (const { octokit, repository } of app.eachRepository.iterator({ | ||
installationId: installation.id, | ||
})) { | ||
if (repository.full_name === `${repo.owner}/${repo.name}`) { | ||
return octokit; | ||
} | ||
} | ||
} | ||
throw new Error("invalid repo for this github app"); | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,51 @@ | ||
import { Octokit } from "octokit"; | ||
|
||
export enum CheckStatus { | ||
Queued = "queued", | ||
InProgress = "in_progress", | ||
Completed = "completed", | ||
// These are documented at github but don't actually work | ||
//Waiting = "waiting", | ||
//Requested = "requested", | ||
//Pending = "pending", | ||
} | ||
|
||
export enum CheckConclusion { | ||
ActionRequired = "action_required", | ||
Cancelled = "cancelled", | ||
Failure = "failure", | ||
Neutral = "neutral", | ||
Success = "success", | ||
Skipped = "skipped", | ||
Stale = "stale", | ||
TimedOut = "timed_out", | ||
} | ||
|
||
export type CheckOutput = { | ||
title: string; | ||
summary: string; | ||
}; | ||
|
||
type CheckRequest = { | ||
name: string; | ||
head_sha: string; | ||
status: string; | ||
conclusion?: string; | ||
output: CheckOutput; | ||
}; | ||
|
||
export async function setCheckStatus( | ||
gh: Octokit, | ||
owner: string, | ||
repo: string, | ||
request: CheckRequest, | ||
): Promise<any> { | ||
if (request.status == CheckStatus.Completed && !request.conclusion) { | ||
throw new Error("Completed check requires conclusion"); | ||
} | ||
return await gh.request("POST /repos/{owner}/{repo}/check-runs", { | ||
owner: owner, | ||
repo: repo, | ||
data: request, | ||
}); | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,147 @@ | ||
import yargs from "yargs"; | ||
import { ArgumentsCamelCase } from "yargs"; | ||
import { hideBin } from "yargs/helpers"; | ||
import { App, Octokit } from "octokit"; | ||
|
||
import { logger } from "./logger.js"; | ||
import { handleError } from "./error.js"; | ||
import dotenv from "dotenv"; | ||
import { Repo, getOctokitFor } from "./app.js"; | ||
import { CheckStatus, setCheckStatus, CheckConclusion } from "./checks.js"; | ||
|
||
dotenv.config(); | ||
|
||
interface BaseArgs { | ||
githubAppPrivateKey: string; | ||
githubAppId: string; | ||
repo: Repo; | ||
app: App; | ||
} | ||
|
||
type BeforeClientArgs = ArgumentsCamelCase<{ | ||
"github-app-private-key": unknown; | ||
"github-app-id": unknown; | ||
}>; | ||
|
||
interface InitializePRCheck extends BaseArgs { | ||
sha: string; | ||
login: string; | ||
checkName: string; | ||
} | ||
|
||
async function getRepoPermissions(octo: Octokit, repo: Repo, login: string) { | ||
const res = await octo.rest.repos.getCollaboratorPermissionLevel({ | ||
owner: repo.owner, | ||
repo: repo.name, | ||
username: login, | ||
}); | ||
return res.data.permission; | ||
} | ||
|
||
async function initializePrCheck(args: InitializePRCheck) { | ||
logger.info({ | ||
message: "initializing an admin only PR check", | ||
repo: args.repo, | ||
sha: args.sha, | ||
login: args.login, | ||
checkName: args.checkName, | ||
}); | ||
|
||
const app = args.app; | ||
const octo = await getOctokitFor(app, args.repo); | ||
if (!octo) { | ||
throw new Error("No repo found"); | ||
} | ||
|
||
const permissions = await getRepoPermissions(octo, args.repo, args.login); | ||
|
||
// If this user has write then we can show this as being queued | ||
if (["admin", "write"].indexOf(permissions) !== -1) { | ||
await setCheckStatus(octo, args.repo.owner, args.repo.name, { | ||
name: args.checkName, | ||
head_sha: args.sha, | ||
status: CheckStatus.Queued, | ||
output: { | ||
title: "Test deployment queued", | ||
summary: "Test deployment queued", | ||
}, | ||
}); | ||
} else { | ||
// The user is not a writer. Show that this needs to be approved. | ||
await setCheckStatus(octo, args.repo.owner, args.repo.name, { | ||
name: "test-deploy", | ||
head_sha: args.sha, | ||
status: CheckStatus.Completed, | ||
conclusion: CheckConclusion.ActionRequired, | ||
output: { | ||
title: "Deployment approval required to deploy", | ||
summary: | ||
"Deployment pproval required to deploy. A valid user must comment `/test-deploy ${sha}` on the PR.", | ||
}, | ||
}); | ||
} | ||
} | ||
|
||
export function appCli() { | ||
const cli = yargs(hideBin(process.argv)) | ||
.positional("repo", { | ||
type: "string", | ||
description: "The repo in the style owner/repo_name", | ||
}) | ||
.coerce("repo", (v: string): Repo => { | ||
const splitName = v.split("/"); | ||
if (splitName.length !== 2) { | ||
throw new Error("Repo name must be an owner/repo_name pair"); | ||
} | ||
return { | ||
owner: splitName[0], | ||
name: splitName[1], | ||
}; | ||
}) | ||
.option("github-app-private-key", { | ||
description: "The private key for the github app", | ||
type: "string", | ||
demandOption: true, | ||
}) | ||
.option("github-app-id", { | ||
description: "The private key for the github app", | ||
type: "string", | ||
demandOption: true, | ||
}) | ||
.middleware(async (args: BeforeClientArgs) => { | ||
const buf = Buffer.from(args.githubAppPrivateKey as string, "base64"); // Ta-da | ||
|
||
const app = new App({ | ||
appId: args.githubAppId as string, | ||
privateKey: buf.toString("utf-8"), | ||
}); | ||
args.app = app; | ||
|
||
const { data } = await app.octokit.request("/app"); | ||
logger.debug(`Authenticated as ${data.name}`); | ||
}) | ||
.command<InitializePRCheck>( | ||
"initialize-check <repo> <sha> <login> <check-name>", | ||
"subcommand for initializing a check", | ||
(yags) => { | ||
yags.positional("sha", { | ||
type: "string", | ||
description: "The sha for the check to initialize", | ||
}); | ||
yags.positional("login", { | ||
type: "string", | ||
description: "The login for the PR", | ||
}); | ||
yags.positional("check-name", { | ||
type: "string", | ||
description: "The name for the check to initialize", | ||
}); | ||
}, | ||
(args) => handleError(initializePrCheck(args)), | ||
) | ||
.demandCommand() | ||
.strict() | ||
.help("h") | ||
.alias("h", "help"); | ||
return cli; | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,85 @@ | ||
import { Repo, getOctokitFor, getRepoPermissions } from "./app.js"; | ||
import { logger } from "./logger.js"; | ||
import { App } from "octokit"; | ||
import { NoRepoError } from "./error.js"; | ||
|
||
export interface CommentCommand { | ||
repo: Repo; | ||
commentId: number; | ||
user: { | ||
login?: string | null; | ||
permissions?: string | null; | ||
}; | ||
command: string; | ||
body: string; | ||
} | ||
|
||
export class NoUserError extends Error {} | ||
|
||
export class NoCommandError extends Error {} | ||
|
||
export async function parseCommentForCommand( | ||
app: App, | ||
repo: Repo, | ||
commentId: number, | ||
): Promise<CommentCommand> { | ||
logger.info({ | ||
message: "checking for a /deploy-test message", | ||
repo: repo, | ||
commentId: commentId, | ||
}); | ||
|
||
const octo = await getOctokitFor(app, repo); | ||
if (!octo) { | ||
throw new NoRepoError("No repo found"); | ||
} | ||
|
||
const comment = await octo.rest.issues.getComment({ | ||
repo: repo.name, | ||
owner: repo.owner, | ||
comment_id: commentId, | ||
}); | ||
logger.info("gathered the comment", { | ||
commentId: comment.data.id, | ||
authorAssosication: comment.data.author_association, | ||
author: comment.data.user?.login, | ||
}); | ||
|
||
const login = comment.data.user?.login; | ||
let permissions: string | null = null; | ||
if (login) { | ||
// Get the author's permissions | ||
permissions = await getRepoPermissions(octo, repo, login); | ||
|
||
logger.info({ | ||
message: "gathered the commentor's permissions", | ||
login: login, | ||
permissions: permissions, | ||
}); | ||
} | ||
|
||
const body = comment.data.body || ""; | ||
const match = body.match(/^\/([a-z-]+)\s+(.*)$/); | ||
if (!match) { | ||
logger.error("command not found"); | ||
throw new NoCommandError("No command error"); | ||
} | ||
if (match.length < 2) { | ||
logger.error({ | ||
message: "proper command not found", | ||
matches: match, | ||
}); | ||
throw new NoCommandError("No command error"); | ||
} | ||
|
||
return { | ||
repo: repo, | ||
commentId: commentId, | ||
body: comment.data.body || "", | ||
user: { | ||
login: login, | ||
permissions: permissions, | ||
}, | ||
command: match[1], | ||
}; | ||
} |
Oops, something went wrong.