- 
                Notifications
    You must be signed in to change notification settings 
- Fork 45
Adds a general shape for discussions support #412
Changes from all commits
d27bcd0
              110cfea
              6cea05d
              6963c7d
              02610f0
              3568e35
              0601b32
              f568381
              05587bd
              d9c88dd
              385d667
              2280f39
              286dc1f
              9a935ff
              File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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" | ||
| } | 
| 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); | ||
| }); | ||
| }); | 
| 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); | ||
|         
                  elibarzilay marked this conversation as resolved.
              Show resolved
            Hide resolved | ||
| } | ||
| 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) { | ||
| There was a problem hiding this comment. Choose a reason for hiding this commentThe 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  There was a problem hiding this comment. Choose a reason for hiding this commentThe 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) There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I didn't see the  There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 }>; | ||
| } | ||
| 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; | ||
| } | 
| 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}]`); | ||
| }; | 
Uh oh!
There was an error while loading. Please reload this page.