Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 14 additions & 3 deletions .github/workflows/daily.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,16 @@ on:
schedule:
- cron: '37 */6 * * *'

permissions:
contents: read
id-token: write

jobs:
build:
runs-on: ubuntu-latest
environment:
name: azure
deployment: false

steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
Expand All @@ -17,8 +24,12 @@ jobs:
- uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8
- run: pnpm install
- run: pnpm run build

# Go through all open PRs and run the bot over them
- uses: azure/login@532459ea530d8321f2fb9bb10d1e0bcf23869a43 # v3.0.0
with:
client-id: ${{ vars.AZURE_CLIENT_ID }}
tenant-id: ${{ vars.AZURE_TENANT_ID }}
subscription-id: ${{ vars.AZURE_SUBSCRIPTION_ID }}
- run: node packages/mergebot/dist/run.js
env:
BOT_AUTH_TOKEN: ${{ secrets.TYPESCRIPT_BOT_TOKEN }}
GITHUB_APP_CLIENT_ID: ${{ vars.TYPESCRIPT_AUTOMATION_GITHUB_APP_CLIENT_ID }}
GITHUB_APP_KEY_VAULT_KEY_ID: ${{ vars.TYPESCRIPT_AUTOMATION_GITHUB_APP_KEY_ID }}
8 changes: 4 additions & 4 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ jobs:
- "1ES.ImageOverride=azure-linux-3"
needs: build
environment:
name: 'Production'
name: azure
permissions:
contents: read
id-token: write
Expand All @@ -72,9 +72,9 @@ jobs:

- uses: azure/login@532459ea530d8321f2fb9bb10d1e0bcf23869a43 # v3.0.0
with:
client-id: ${{ secrets.AZURE_CLIENT_ID }}
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
client-id: ${{ vars.AZURE_CLIENT_ID }}
tenant-id: ${{ vars.AZURE_TENANT_ID }}
subscription-id: ${{ vars.AZURE_SUBSCRIPTION_ID }}

- name: Upload blob
run: az storage blob upload -f ${{ env.FUNCTION_ZIP_NAME }} --account-name ${{ env.STORAGE_ACCOUNT_NAME }} -c ${{ env.STORAGE_CONTAINER_NAME }} -n ${{ env.FUNCTION_ZIP_NAME }} --overwrite true --auth-mode login
Expand Down
3 changes: 1 addition & 2 deletions .github/workflows/update-ts-version-tags.yml
Original file line number Diff line number Diff line change
Expand Up @@ -46,5 +46,4 @@ jobs:
run: node packages/retag/dist/index.js --path ../DefinitelyTyped
env:
NPM_TOKEN: ${{ secrets.NPM_RETAG_TOKEN }}
GH_API_TOKEN: ${{ secrets.GH_API_TOKEN }}


34 changes: 31 additions & 3 deletions .github/workflows/version-or-publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@ on:
- main

permissions:
pull-requests: write
contents: read
id-token: write

concurrency: ${{ github.workflow }}-${{ github.ref }}

Expand All @@ -22,11 +23,14 @@ env:
jobs:
publish:
runs-on: ubuntu-latest
environment:
name: azure
deployment: false
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0
token: ${{ secrets.TYPESCRIPT_BOT_TOKEN }}
persist-credentials: false
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 20
Expand All @@ -35,9 +39,33 @@ jobs:
- run: pnpm install
- run: pnpm build
- run: pnpm test
- uses: azure/login@532459ea530d8321f2fb9bb10d1e0bcf23869a43 # v3.0.0
with:
client-id: ${{ vars.AZURE_CLIENT_ID }}
tenant-id: ${{ vars.AZURE_TENANT_ID }}
subscription-id: ${{ vars.AZURE_SUBSCRIPTION_ID }}
- name: Create GitHub App token
id: app-token
uses: microsoft/create-github-app-token-via-key-vault@5ba0d436e9c3cac52feff4d1f2f66f9698ce4a2d # v1
with:
client-id: ${{ vars.TYPESCRIPT_AUTOMATION_GITHUB_APP_CLIENT_ID }}
key-id: ${{ vars.TYPESCRIPT_AUTOMATION_GITHUB_APP_KEY_ID }}
owner: microsoft
repositories: DefinitelyTyped-tools
permission-contents: write
permission-pull-requests: write
- name: Configure git for GitHub App token
shell: bash
env:
GITHUB_APP_TOKEN: ${{ steps.app-token.outputs.token }}
run: |
set -euo pipefail
basic_auth="$(node -e 'process.stdout.write(Buffer.from("x-access-token:" + process.env.GITHUB_APP_TOKEN).toString("base64"))')"
echo "::add-mask::$basic_auth"
git config --local http.https://github.com/.extraheader "AUTHORIZATION: basic ${basic_auth}"
- uses: changesets/action@63a615b9cd06ba9a3e6d13796c7fbcb080a60a0b # v1.8.0
with:
publish: pnpm ci:publish
env:
GITHUB_TOKEN: ${{ secrets.TYPESCRIPT_BOT_TOKEN }}
GITHUB_TOKEN: ${{ steps.app-token.outputs.token }}
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
2 changes: 1 addition & 1 deletion packages/mergebot/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ There is an Azure function in `PR-Trigger` that receives webhooks; this function

You _probably_ don't need to do this. Use test to validate any change inside the src dir against integration tests.

However, you need to have a GitHub API access key in either: `DT_BOT_AUTH_TOKEN`, `BOT_AUTH_TOKEN` or `AUTH_TOKEN`.
However, you need to have a GitHub API access key in either: `DT_BOT_AUTH_TOKEN`, `BOT_AUTH_TOKEN` or `AUTH_TOKEN`. These token env vars are for local development; production uses GitHub App tokens minted through Azure Key Vault with `GITHUB_APP_CLIENT_ID` and `GITHUB_APP_KEY_VAULT_KEY_ID`.
Ask Ryan for the bot's auth token (TypeScript team members: Look in the team OneNote).
Don't run the bot under your own auth token as this will generate a bunch of spam from duplicate comments.

Expand Down
2 changes: 2 additions & 0 deletions packages/mergebot/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@
},
"dependencies": {
"@apollo/client": "^4.1.6",
"@azure/identity": "^4.13.0",
"@azure/keyvault-keys": "^4.10.0",
"@azure/functions": "^4.11.2",
"@definitelytyped/old-header-parser": "npm:@definitelytyped/header-parser@0.0.178",
"@definitelytyped/utils": "workspace:*",
Expand Down
31 changes: 14 additions & 17 deletions packages/mergebot/src/execute-pr-actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,10 @@ import type { PrQuery } from "./queries/schema/graphql";
import { Actions } from "./compute-pr-actions";
import { createMutation, client } from "./graphql-client";
import { getProjectBoardColumns, getLabels } from "./util/cachedQueries";
import { noNullish, flatten } from "./util/util";
import { noNullish, flatten, isTypeScriptBot } from "./util/util";
import { tagsToDeleteIfNotPosted } from "./comments";
import * as comment from "./util/comment";
import { request } from "https";
import { getGitHubAuthToken } from "./github-auth";
import { assertDefined } from "@definitelytyped/utils";

type PR_repository_pullRequest = NonNullable<NonNullable<PrQuery["repository"]>["pullRequest"]>;
Expand Down Expand Up @@ -153,7 +153,7 @@ interface ParsedComment {
function getBotComments(pr: PR_repository_pullRequest): ParsedComment[] {
return noNullish(
(pr.comments.nodes ?? [])
.filter((comment) => comment?.author?.login === "typescript-bot")
.filter((comment) => isTypeScriptBot(comment?.author?.login))
.map((c) => {
const { id, body } = c!,
parsed = comment.parse(body);
Expand Down Expand Up @@ -236,22 +236,19 @@ interface RestMutation {
op: string;
}

function doRestCall(call: RestMutation): Promise<void> {
async function doRestCall(call: RestMutation): Promise<void> {
const token = await getGitHubAuthToken();
const url = `https://api.github.com/repos/DefinitelyTyped/DefinitelyTyped/${call.op}`;
const headers = {
accept: "application/vnd.github.v3+json",
authorization: `token ${process.env.BOT_AUTH_TOKEN}`,
"user-agent": "mergebot",
};
return new Promise((resolve, reject) => {
const req = request(url, { method: call.method, headers }, (reply) => {
const bad = !reply.statusCode || reply.statusCode < 200 || reply.statusCode >= 300;
if (bad) return reject(`doRestCall failed with a status of ${reply.statusCode}`);
return resolve();
});
req.on("error", reject);
req.end();
const response = await fetch(url, {
method: call.method,
headers: {
accept: "application/vnd.github.v3+json",
authorization: `token ${token}`,
},
});
if (!response.ok) {
throw new Error(`doRestCall failed with a status of ${response.status}`);
}
}

function getMutationsForReRunningCI(actions: Actions) {
Expand Down
3 changes: 2 additions & 1 deletion packages/mergebot/src/functions/api.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { app, HttpRequest, HttpResponseInit, InvocationContext } from "@azure/functions";
import { getPRInfo } from "../queries/pr-query";
import { isTypeScriptBot } from "../util/util";
const headers = {
"Content-Type": "text/json",
"Access-Control-Allow-Methods": "GET",
Expand All @@ -23,7 +24,7 @@ export async function httpTrigger(request: HttpRequest, context: InvocationConte
if (!prInfo) return notFound("No PR metadata");

const welcomeComment = prInfo.comments.nodes!.find(
(c) => c && c.author?.login === "typescript-bot" && c.body.endsWith("<!--typescript_bot_welcome-->"),
(c) => c && isTypeScriptBot(c.author?.login) && c.body.endsWith("<!--typescript_bot_welcome-->"),
);
if (!welcomeComment || !welcomeComment.body || !welcomeComment.body.includes("```json"))
return notFound("PR comment with JSON not found");
Expand Down
6 changes: 3 additions & 3 deletions packages/mergebot/src/functions/discussions-trigger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { createMutation, client } from "../graphql-client";
import { getLabelByName, getCommentsForDiscussionNumber } from "../queries/discussion-queries";
import { reply } from "../util/reply";
import { httpLog, shouldRunRequest } from "../util/verify";
import { txt } from "../util/util";
import { isTypeScriptBot, txt } from "../util/util";
import { getOwnersOfPackage } from "../pr-info";
import { fetchFile } from "../util/fetchFile";

Expand Down Expand Up @@ -88,7 +88,7 @@ async function pingAuthorsAndSetUpDiscussion(discussion: Discussion) {
const message = gotAReferenceMessage(aboutNPMRef, owners);
await updateOrCreateMainComment(discussion, message);
// Only create a label once we've confirmed the package actually exists on DT --
// otherwise an unprivileged user could make typescript-bot create arbitrarily-named
// otherwise an unprivileged user could make the TypeScript bot create arbitrarily-named
// repository labels by editing the discussion title.
await addLabel(discussion, "Pkg: " + aboutNPMRef, `Discussions related to ${aboutNPMRef}`);
}
Expand All @@ -115,7 +115,7 @@ async function updateDiscordWithRequest(discussion: Discussion) {

async function updateOrCreateMainComment(discussion: Discussion, message: string) {
const discussionComments = await getCommentsForDiscussionNumber(discussion.number);
const previousComment = discussionComments.find((c) => c?.author?.login === "typescript-bot");
const previousComment = discussionComments.find((c) => isTypeScriptBot(c?.author?.login));
if (previousComment) {
await client.mutate(
createMutation<any>("updateDiscussionComment" as any, { body: message, commentId: previousComment.id }),
Expand Down
5 changes: 3 additions & 2 deletions packages/mergebot/src/functions/pr-trigger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import type {
PullRequestReviewEvent,
} from "@octokit/webhooks-types";
import { runQueryToGetPRForCardId } from "../queries/card-id-to-pr-query";
import { isTypeScriptBot } from "../util/util";

app.http("PR-Trigger", { methods: ["GET", "POST"], authLevel: "anonymous", handler: httpTrigger });
const eventNames = [
Expand Down Expand Up @@ -81,8 +82,8 @@ async function httpTrigger(req: HttpRequest, context: InvocationContext) {
const handleTrigger = async (context: InvocationContext, event: PrEvent) => {
const fullName = event.name + "." + event.payload.action;
context.log(`Handling event: ${fullName}`);
if (event.payload.sender.login === "typescript-bot" && fullName !== "check_suite.completed")
return reply(context, 200, "Skipped webhook because it was triggered by typescript-bot");
if (isTypeScriptBot(event.payload.sender.login) && fullName !== "check_suite.completed")
return reply(context, 200, "Skipped webhook because it was triggered by the TypeScript bot");

// Allow the bot to run side-effects that are not the 'core' function
// of the review cycle, but are related to keeping DT running smoothly
Expand Down
77 changes: 77 additions & 0 deletions packages/mergebot/src/github-auth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import { DefaultAzureCredential } from "@azure/identity";
import { CryptographyClient } from "@azure/keyvault-keys";
import { createGitHubAppAuth } from "./util/github-app-auth";

type PermissionLevel = "read" | "write" | "admin";
type GitHubAppAuth = ReturnType<typeof createGitHubAppAuth>;

let githubAuth: GitHubAppAuth | undefined;

function explicitToken() {
if (process.env.JEST_WORKER_ID) return "FAKE_TOKEN";
return process.env.BOT_AUTH_TOKEN || process.env.DT_BOT_AUTH_TOKEN || process.env.AUTH_TOKEN;
}

function requiredEnv(name: string) {
const value = process.env[name];
if (!value) {
throw new Error(`${name} must be set`);
}
return value;
}

function getGitHubAuth() {
if (!githubAuth) {
const cryptographyClient = new CryptographyClient(
requiredEnv("GITHUB_APP_KEY_VAULT_KEY_ID"),
new DefaultAzureCredential(),
);
githubAuth = createGitHubAppAuth({
appClientId: requiredEnv("GITHUB_APP_CLIENT_ID"),
signer: async (signingInput) => {
const signature = await cryptographyClient.signData("RS256", Buffer.from(signingInput));
return Buffer.from(signature.result).toString("base64url");
},
defaultOwner: process.env.GITHUB_APP_INSTALLATION_OWNER || "DefinitelyTyped",
});
}
return githubAuth;
}

function permissions() {
const raw = process.env.GITHUB_APP_PERMISSIONS;
if (!raw) {
return undefined;
}
const parsed: unknown = JSON.parse(raw);
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
throw new Error("GITHUB_APP_PERMISSIONS must be a JSON object");
}
const result: Record<string, PermissionLevel> = {};
for (const [permission, level] of Object.entries(parsed)) {
if (level !== "read" && level !== "write" && level !== "admin") {
throw new Error(`Invalid GitHub App permission level for ${permission}: ${level}`);
}
result[permission] = level;
}
return result;
}

export async function getGitHubAuthToken() {
const token = explicitToken();
if (token) {
return token.trim();
}

return getGitHubAuth().getToken({
repositories: [process.env.GITHUB_APP_INSTALLATION_REPO || "DefinitelyTyped"],
permissions: permissions() ?? {
checks: "write",
contents: "read",
discussions: "write",
issues: "write",
organization_projects: "write",
pull_requests: "write",
},
});
}
29 changes: 14 additions & 15 deletions packages/mergebot/src/graphql-client.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,24 @@
import { ApolloClient, gql, HttpLink, InMemoryCache, MutationOptions, TypedDocumentNode } from "@apollo/client/core";
import { print } from "graphql";
import * as schema from "@octokit/graphql-schema";
import { getGitHubAuthToken } from "./github-auth";

const uri = "https://api.github.com/graphql";
const headers = {
authorization: `Bearer ${getAuthToken()}`,
accept: "application/vnd.github.starfox-preview+json, application/vnd.github.bane-preview+json",
};
const accept = "application/vnd.github.starfox-preview+json, application/vnd.github.bane-preview+json";

const cache = new InMemoryCache();
const link = new HttpLink({ uri, headers, fetch });
const link = new HttpLink({
uri,
fetch: async (input, init) =>
fetch(input, {
...init,
headers: {
...init?.headers,
authorization: `Bearer ${await getGitHubAuthToken()}`,
accept,
},
}),
});

export const client = new ApolloClient({ cache, link });

Expand All @@ -29,13 +38,3 @@ export function createMutation<T>(
};
return { mutation, variables: { input } };
}

function getAuthToken() {
if (process.env.JEST_WORKER_ID) return "FAKE_TOKEN";

const result = process.env.BOT_AUTH_TOKEN || process.env.AUTH_TOKEN || process.env.DT_BOT_AUTH_TOKEN;
if (typeof result !== "string") {
throw new Error("Set BOT_AUTH_TOKEN or AUTH_TOKEN to a valid auth token");
}
return result.trim();
}
Loading
Loading