Skip to content
This repository has been archived by the owner on Jul 1, 2024. It is now read-only.

Commit

Permalink
Adds a general shape for discussions support (#412)
Browse files Browse the repository at this point in the history
* Adds a general shape for discussions support

* WIP

* Get green

* Feedback WIP

* Adds a general shape for discussions support

* WIP

* Get green

* Feedback WIP

* Some fixes:

* `discussions-trigger`: added some uses of `txt` to make it more
  readable and other minor reformatting for the gql queries

* Call to `verifyIsFromGitHub` should be negated

* `verifyIsFromGitHub` doesn't need to be exported

* Pings owners etc

* Reuse repo ID from query

* Feedback

Co-authored-by: Eli Barzilay <eli@barzilay.org>
  • Loading branch information
orta and elibarzilay authored Aug 16, 2021
1 parent fac6976 commit 9ed27cd
Show file tree
Hide file tree
Showing 11 changed files with 341 additions and 22 deletions.
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) {
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

0 comments on commit 9ed27cd

Please sign in to comment.