Skip to content

Commit

Permalink
Starts github app utilities (#1149)
Browse files Browse the repository at this point in the history
* Starts github app utilities

* Fix bulid errors
  • Loading branch information
ravenac95 authored Apr 2, 2024
1 parent d592776 commit 9ba6536
Show file tree
Hide file tree
Showing 13 changed files with 526 additions and 17 deletions.
7 changes: 7 additions & 0 deletions ops/github-app-utils/.eslintrc.json
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"]
}
}
4 changes: 4 additions & 0 deletions ops/github-app-utils/README.md
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.
43 changes: 43 additions & 0 deletions ops/github-app-utils/package.json
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"
}
}
35 changes: 35 additions & 0 deletions ops/github-app-utils/src/app.ts
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");
}
51 changes: 51 additions & 0 deletions ops/github-app-utils/src/checks.ts
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,
});
}
147 changes: 147 additions & 0 deletions ops/github-app-utils/src/cli.ts
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;
}
85 changes: 85 additions & 0 deletions ops/github-app-utils/src/commands.ts
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],
};
}
Loading

0 comments on commit 9ba6536

Please sign in to comment.