Repository: DefinitelyTyped/dt-mergebot
Pull request #412 (2021-06-04 16:00:29)

Adds a general shape for discussions support
Adds a new function route for discussions on the DT repo.

Main features:
- When you ask for help on a dt module then it requires you have `[module-name]` in the title somewhere, then it pings everyone and sets a label on the discussion for you. If you don't it asks you for info. If you don't it asks for it.

Some things that this means:

 - We will have categorized discussions which haven't been hooked up via a label
 - People can keep see overall what's going on with a dep via the labels 
 - It doesn't rely on folks doing the work to find the right people to ping, which doesn't seem to happen too often

Future: When you post a new request for a dt module, it pings the discord


orta: OK, I think this is pretty ready to go 
elibarzilay: I did some tweaks (main one is negating the result of the verify function), and marked all of the resolved comments as such.

I could do some of these too, but you said that you did them so I'm guessing that maybe you forgot to push some more changes, and if I'll fix these I'll create a conflict nightmare for you -- so left them as-is.

orta: Alright, I'm gonna nurse this deploy

Patch:
diff --git a/Discussions-Trigger/function.json b/Discussions-Trigger/function.json
new file mode 100644
index 0000000..17bc3cf
--- /dev/null
+++ b/Discussions-Trigger/function.json
@@ -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"
+}
diff --git a/README.md b/README.md
index 5607292..83773ae 100644
--- a/README.md
+++ b/README.md
@@ -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
@@ -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`
\ No newline at end of file
diff --git a/apollo.config.js b/apollo.config.js
index d663b02..2ee42a0 100644
--- a/apollo.config.js
+++ b/apollo.config.js
@@ -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",
             },
         }
     }
diff --git a/src/discussions-trigger.ts b/src/discussions-trigger.ts
new file mode 100644
index 0000000..7d2cf5e
--- /dev/null
+++ b/src/discussions-trigger.ts
@@ -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) {
+    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 }>;
+}
diff --git a/src/graphql-client.ts b/src/graphql-client.ts
index 8be4cde..a34af74 100644
--- a/src/graphql-client.ts
+++ b/src/graphql-client.ts
@@ -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");
     }
diff --git a/src/pr-info.ts b/src/pr-info.ts
index 7e1a7f5..2d57a0f 100644
--- a/src/pr-info.ts
+++ b/src/pr-info.ts
@@ -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;
diff --git a/src/pr-trigger.ts b/src/pr-trigger.ts
index a557da7..dbbf068 100644
--- a/src/pr-trigger.ts
+++ b/src/pr-trigger.ts
@@ -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",
@@ -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}`);
diff --git a/src/types/discussions.d.ts b/src/types/discussions.d.ts
new file mode 100644
index 0000000..2385c99
--- /dev/null
+++ b/src/types/discussions.d.ts
@@ -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;
+}
diff --git a/src/util/reply.ts b/src/util/reply.ts
new file mode 100644
index 0000000..e592ba1
--- /dev/null
+++ b/src/util/reply.ts
@@ -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}]`);
+};
diff --git a/src/util/verify.ts b/src/util/verify.ts
new file mode 100644
index 0000000..dfb414c
--- /dev/null
+++ b/src/util/verify.ts
@@ -0,0 +1,43 @@
+import { verify } from "@octokit/webhooks-methods";
+import { Context, HttpRequest } from "@azure/functions";
+
+export async function httpLog(context: Context, req: HttpRequest) {
+    const { headers, body } = req;
+    const githubId = headers["x-github-delivery"];
+    const event = headers["x-github-event"]!;
+    const action = body.action;
+    context.log(`>>> HTTP Trigger ${context.executionContext.functionName} [${
+                  event}.${action
+                  }; gh: ${githubId
+                  }; az: ${context.invocationId
+                  }; node: ${process.version}]`);
+}
+
+export async function shouldRunRequest(req: HttpRequest, canHandleRequest?: (event: string, action: string) => boolean) {
+    const isDev = process.env.AZURE_FUNCTIONS_ENVIRONMENT === "Development";
+    const { headers, body } = req;
+
+    const event = headers["x-github-event"]!;
+    const action = body.action;
+
+    // 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 verifyIsFromGitHub(req))
+        return false;
+
+    // Optional function for early bailing if it returns false
+    if (canHandleRequest && !canHandleRequest(event, action))
+        return false;
+
+    return true;
+}
+
+
+export async function verifyIsFromGitHub(req: HttpRequest) {
+    const secret = process.env.GITHUB_WEBHOOK_SECRET;
+
+    // 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
+    return await verify(secret!, req.rawBody, req.headers["x-hub-signature-256"]!);
+}
+


Test patch:
diff --git a/src/_tests/discussions.test.ts b/src/_tests/discussions.test.ts
new file mode 100644
index 0000000..f781e97
--- /dev/null
+++ b/src/_tests/discussions.test.ts
@@ -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);
+    });
+});