Skip to content
This repository was archived by the owner on Jul 1, 2024. It is now read-only.
Merged
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
20 changes: 20 additions & 0 deletions Discussions-Trigger/function.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
{
"bindings": [
{
"authLevel": "function",
"type": "httpTrigger",
"direction": "in",
"name": "req",
"methods": [
"get",
"post"
]
},
{
"type": "http",
"direction": "out",
"name": "res"
}
],
"scriptFile": "../bin/discussions-trigger.js"
}
15 changes: 15 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,8 @@ set BOT_AUTH_TOKEN=xxxxxxxxxxxxxxxxxxxxxxxxxxxx
export BOT_AUTH_TOKEN=xxxxxxxxxxxxxxxxxxxxxxxxxxxx
```

Then to run locally you'll need to install the [Azure Functions cli](https://docs.microsoft.com/en-us/azure/azure-functions/functions-run-local?tabs=macos%2Ccsharp%2Cbash).

# Development

```sh
Expand Down Expand Up @@ -123,3 +125,16 @@ npm run update-all-fixtures

Be careful with this, because PRs may now be in a different state e.g. it's now merged and it used to be a specific
weird state.

## Running with real webhooks

You need a tool like [ngrok](https://ngrok.com) to expose a URL from the [webhooks section](https://github.com/DefinitelyTyped/DefinitelyTyped/settings/hooks/new) on DT.

Start two terminal sessions with:

- `yarn watch` (for TypeScript changes)
- `yarn start` (for the app)

Then start a third with your localhost router like ngrok:

- `ngrok http 7071`
2 changes: 1 addition & 1 deletion apollo.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ module.exports = {
process.env["BOT_AUTH_TOKEN"] ||
process.env["AUTH_TOKEN"]
}`,
accept: "application/vnd.github.starfox-preview+json",
accept: "application/vnd.github.starfox-preview+json, application/vnd.github.bane-preview+json",
},
}
}
Expand Down
28 changes: 28 additions & 0 deletions src/_tests/discussions.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
/// <reference types="jest" />
import {canHandleRequest, extractNPMReference} from "../discussions-trigger";

describe(canHandleRequest, () => {
const eventActions = [
["discussion", "created", true],
["discussion", "edited", true],
["discussion", "updated", false],
["pull_request", "created", false]
] as const;

test.concurrent.each(eventActions)("(%s, %s) is %s", async (event, action, expected) => {
expect(canHandleRequest(event, action)).toEqual(expected);
});
});

describe(extractNPMReference, () => {
const eventActions = [
["[node] my thingy", "node"],
["OK [react]", "react"],
["I think [@typescript/twoslash] need improving ", "@typescript/twoslash"],
["[@types/node] needs X", "node"],
] as const;

test.concurrent.each(eventActions)("(%s, %s) is %s", async (title, result) => {
expect(extractNPMReference({ title })).toEqual(result);
});
});
177 changes: 177 additions & 0 deletions src/discussions-trigger.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
import { HttpRequest, Context } from "@azure/functions";
import fetch from "node-fetch";
import { gql } from "@apollo/client/core";
import { Discussion, DiscussionWebhook } from "./types/discussions";
import { createMutation, client } from "./graphql-client";
import { reply } from "./util/reply";
import { httpLog, shouldRunRequest } from "./util/verify";
import { txt } from "./util/util";
import { getOwnersOfPackage } from "./pr-info";
import { fetchFile } from "./util/fetchFile";

export async function run(context: Context, req: HttpRequest) {
httpLog(context, req);

if (!(await shouldRunRequest(req, canHandleRequest))) {
reply(context, 204, "Can't handle this request");
}

const { body, headers } = req;
return handleTrigger({ event: headers["x-github-event"]!, action: body.action, body }, context);
}

export const canHandleRequest = (event: string, action: string) => {
const name = "discussion";
const actions = ["created", "edited"];
return event == name && actions.includes(action);
};

const handleTrigger = (info: { event: string; action: string; body: DiscussionWebhook }, context: Context) => {
const categoryID = info.body.discussion.category.slug;
if (categoryID === "issues-with-a-types-package") {
return pingAuthorsAndSetUpDiscussion(info.body.discussion);
} else if (categoryID === "request-a-new-types-package" && info.action === "created") {
return updateDiscordWithRequest(info.body.discussion);
}
return reply(context, 204, "Can't handle this specific request");
};

export function extractNPMReference(discussion: { title: string }) {
const title = discussion.title;
if (title.includes("[") && title.includes("]")) {
const full = title.split("[")[1]!.split("]")[0];
return full!.replace("@types/", "");
}
return undefined;
}

const couldNotFindMessage = txt`
|Hi, we could not find a reference to the types you are talking about in this discussion.
|Please edit the title to include the name on npm inside square brackets.
|
|E.g.
|- \`"[@typescript/vfs] Does not x, y"\`
|- \`"Missing x inside [node]"\`
|- \`"[express] Broken support for template types"\`
`;

const errorsGettingOwners = (str: string) => txt`
|Hi, we could not find [${str}] in DefinitelyTyped, is there possibly a typo?
`;

const couldNotFindOwners = (str: string) => txt`
|Hi, we had an issue getting the owners for [${str}] - please raise an issue on DefinitelyTyped/dt-mergebot if this
`;


const gotAReferenceMessage = (module: string, owners: string[]) => txt`
|Thanks for the discussion about "${module}", some useful links for everyone:
|
| - [npm](https://www.npmjs.com/package/${module})
| - [DT](https://github.com/DefinitelyTyped/DefinitelyTyped/blob/master/types/${module})
| - [Related discussions](https://github.com/DefinitelyTyped/DefinitelyTyped/issues?q=is%3Aopen+is%3Aissue+label%3A%22Pkg%3A+${module}%22/)
|
|Pinging the DT module owners: ${owners.join(", ")}.
`;


async function pingAuthorsAndSetUpDiscussion(discussion: Discussion) {
const aboutNPMRef = extractNPMReference(discussion);
if (!aboutNPMRef) {
// Could not find a types reference
await updateOrCreateMainComment(discussion, couldNotFindMessage);
} else {
const owners = await getOwnersOfPackage(aboutNPMRef, "master", fetchFile);
if (owners instanceof Error) {
await updateOrCreateMainComment(discussion, errorsGettingOwners(aboutNPMRef));
} else if (!owners) {
await updateOrCreateMainComment(discussion, couldNotFindOwners(aboutNPMRef));
} else {
const message = gotAReferenceMessage(aboutNPMRef, owners);
await updateOrCreateMainComment(discussion, message);
await addLabel(discussion, "Pkg: " + aboutNPMRef, `Discussions related to ${aboutNPMRef}`);
}
}
}

async function updateDiscordWithRequest(discussion: Discussion) {
const discordWebhookAddress = process.env.DT_MODULE_REQ_DISCORD_WEBHOOK;
if (!discordWebhookAddress) throw new Error("DT_MODULE_REQ_DISCORD_WEBHOOK not set in ENV");

// https://birdie0.github.io/discord-webhooks-guide/discord_webhook.html
const webhook = { content: `New DT Module requested:`, embeds: [ { title: discussion.title, url: discussion.html_url } ] };
await fetch(discordWebhookAddress, { method: "POST", body: JSON.stringify(webhook), headers: { "content-type": "application/json" } });
}


async function updateOrCreateMainComment(discussion: Discussion, message: string) {
const discussionComments = await getCommentsForDiscussionNumber(discussion.number);
const previousComment = discussionComments.find(c => c.author.login === "typescript-bot");
if (previousComment) {
await client.mutate(createMutation<any>("updateDiscussionComment" as any, { body: message, commentId: previousComment.id }));
} else {
await client.mutate(createMutation<any>("addDiscussionComment" as any, { body: message, discussionId: discussion.node_id }));
}
}

async function addLabel(discussion: Discussion, labelName: string, description?: string) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wouldn't it upset GH if we end up with up to thousands of labels?

But more importantly, it looks like they're project-global, so having these massive number of labels is going to make dealing with labels pretty difficult. The least that this should do is use some prefix for these things, otherwise I can post a message with a [Critical] and confuse people and code...

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, I've opted for "Pkg: " - I'd like something a bit further in the alphabet if you can thing of something better though? (emoji/non-ascii aren't counted in the sorting for labels)

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I didn't see the Pkg: change, so leaving this open. Also: I'm still worried about the first point: we have thousands of packages, and therefore we'd end up with thousands of labels, and I'm not sure how things would look like if/when we have that.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, over a long enough timeframe we could - I doubt the long-tail of DT modules worth writing a discussion about is that long though. To my knowledge (and some googling) there is no limit to the amount of issues a repo can have, and given they all have the same prefix - removing them from the repo is a pretty trivial script if it gets out of hand

const existingLabel = await getLabelByName(labelName);
let labelID = null;
if (existingLabel.label && existingLabel.label.name === labelName) {
labelID = existingLabel.label.id;
} else {
const color = "eeeeee";
const newLabel = await client.mutate(createMutation("createLabel" as any, { name: labelName, repositoryId: existingLabel.repoID, color, description })) as any;
labelID = newLabel.data.label.id;
}
await client.mutate(createMutation<any>("addLabelsToLabelable" as any, { labelableId: discussion.node_id, labelIds: [labelID] }));
}

async function getLabelByName(name: string) {
const info = await client.query({
query: gql`
query GetLabel($name: String!) {
repository(name: "DefinitelyTyped", owner: "DefinitelyTyped") {
id
name
labels(query: $name, first: 1) {
nodes {
id
name
}
}
}
}`,
variables: { name },
fetchPolicy: "no-cache",
});

const label: { id: string, name: string } | undefined = info.data.repository.labels.nodes[0];
return { repoID: info.data.repository.id, label };
}

async function getCommentsForDiscussionNumber(number: number) {
const info = await client.query({
query: gql`
query GetDiscussionComments($discussionNumber: Int!) {
repository(name: "DefinitelyTyped", owner: "DefinitelyTyped") {
name
discussion(number: $discussionNumber) {
comments(first: 100, orderBy: { field: UPDATED_AT, direction: DESC }) {
nodes {
author {
login
}
id
body
}
}
}
}
}`,
variables: { discussionNumber: number },
fetchPolicy: "no-cache",
});

return info.data.repository.discussion.comments.nodes as Array<{ author: { login: string}, body: string, id: string }>;
}
2 changes: 1 addition & 1 deletion src/graphql-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ export function createMutation<T>(name: keyof schema.Mutation, input: T): Mutati
function getAuthToken() {
if (process.env.JEST_WORKER_ID) return "FAKE_TOKEN";

const result = process.env["BOT_AUTH_TOKEN"] || process.env["AUTH_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 either BOT_AUTH_TOKEN or AUTH_TOKEN to a valid auth token");
}
Expand Down
2 changes: 1 addition & 1 deletion src/pr-info.ts
Original file line number Diff line number Diff line change
Expand Up @@ -487,7 +487,7 @@ function downloadsToPopularityLevel(monthlyDownloads: number): PopularityLevel {
: "Well-liked by everyone";
}

async function getOwnersOfPackage(packageName: string, version: string, fetchFile: typeof defaultFetchFile): Promise<string[] | null | Error> {
export async function getOwnersOfPackage(packageName: string, version: string, fetchFile: typeof defaultFetchFile): Promise<string[] | null | Error> {
const indexDts = `${version}:types/${packageName}/index.d.ts`;
const indexDtsContent = await fetchFile(indexDts, 10240); // grab at most 10k
if (indexDtsContent === undefined) return null;
Expand Down
26 changes: 7 additions & 19 deletions src/pr-trigger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@ import { mergeCodeOwnersOnGreen } from "./side-effects/merge-codeowner-prs";
import { runQueryToGetPRMetadataForSHA1 } from "./queries/SHA1-to-PR-query";
import { HttpRequest, Context } from "@azure/functions";
import { createEventHandler, EmitterWebhookEvent } from "@octokit/webhooks";
import { verify } from "@octokit/webhooks-methods";
import { reply } from "./util/reply";
import { httpLog, shouldRunRequest } from "./util/verify";

const eventNames = [
"check_suite.completed",
Expand All @@ -29,31 +30,18 @@ const eventNames = [
// see https://github.com/octokit/webhooks.js/issues/491, and get rid of this when fixed
const eventNamesSillyCopy = [...eventNames];

const reply = (context: Context, status: number, body: string) => {
context.res = { status, body };
context.log.info(`${body} [${status}]`);
};

class IgnoredBecause {
constructor(public reason: string) { }
}

export async function httpTrigger(context: Context, req: HttpRequest) {
const isDev = process.env.AZURE_FUNCTIONS_ENVIRONMENT === "Development";
const secret = process.env.GITHUB_WEBHOOK_SECRET;
const { headers, body, rawBody } = req, githubId = headers["x-github-delivery"];
httpLog(context, req);
const { headers, body } = req, githubId = headers["x-github-delivery"];
const evName = headers["x-github-event"], evAction = body.action;

context.log(`>>> HTTP Trigger [${
evName}.${evAction
}; gh: ${githubId
}; az: ${context.invocationId
}; node: ${process.version}]`);

// For process.env.GITHUB_WEBHOOK_SECRET see
// https://ms.portal.azure.com/#blade/WebsitesExtension/FunctionsIFrameBlade/id/%2Fsubscriptions%2F57bfeeed-c34a-4ffd-a06b-ccff27ac91b8%2FresourceGroups%2Fdtmergebot%2Fproviders%2FMicrosoft.Web%2Fsites%2FDTMergeBot
if (!isDev && !(await verify(secret!, rawBody, headers["x-hub-signature-256"]!)))
return reply(context, 500, "This webhook did not come from GitHub");
if (!(await shouldRunRequest(req))) {
reply(context, 204, "Can't handle this request");
}

if (evName === "check_run" && evAction === "completed") {
context.log(`>>>>>> name: ${body?.check_run?.name}, sha: ${body?.check_run?.head_sha}`);
Expand Down
42 changes: 42 additions & 0 deletions src/types/discussions.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
// Generated from the JSON response because it's not in the upstream tooling

export interface DiscussionWebhook {
action: string;
discussion: Discussion;
repository: any;
sender: any;
}

export interface Discussion {
repository_url: string;
category: Category;
answer_html_url: null;
answer_chosen_at: null;
answer_chosen_by: null;
html_url: string;
id: number;
node_id: string;
number: number;
title: string;
user: Sender;
state: string;
locked: boolean;
comments: number;
created_at: Date;
updated_at: Date;
author_association: string;
active_lock_reason: null;
body: string;
}

export interface Category {
id: number;
repository_id: number;
emoji: string;
name: string;
description: string;
created_at: Date;
updated_at: Date;
slug: string;
is_answerable: boolean;
}
6 changes: 6 additions & 0 deletions src/util/reply.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { Context } from "@azure/functions";

export const reply = (context: Context, status: number, body: string) => {
context.res = { status, body };
context.log.info(`${body} [${status}]`);
};
Loading