Skip to content

Commit

Permalink
Merge pull request #39 from gentlementlegen/feat/assign-user-pr
Browse files Browse the repository at this point in the history
  • Loading branch information
0x4007 authored Sep 9, 2024
2 parents d6c40e9 + 243dc32 commit 6e43a1b
Show file tree
Hide file tree
Showing 15 changed files with 184 additions and 28 deletions.
6 changes: 6 additions & 0 deletions graphql.config.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
schema:
- https://api.github.com/graphql:
headers:
Authorization: Bearer ${GITHUB_TOKEN}
documents: src/**.ts
projects: {}
2 changes: 1 addition & 1 deletion manifest.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "Start | Stop",
"description": "Assign or un-assign yourself from an issue.",
"ubiquity:listeners": ["issue_comment.created", "issues.assigned"],
"ubiquity:listeners": ["issue_comment.created", "issues.assigned", "pull_request.opened"],
"commands": {
"start": {
"ubiquity:example": "/start",
Expand Down
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@
"open-source"
],
"dependencies": {
"@octokit/graphql-schema": "15.25.0",
"@octokit/plugin-paginate-graphql": "5.2.2",
"@octokit/rest": "20.1.1",
"@octokit/webhooks": "13.2.7",
"@sinclair/typebox": "^0.32.5",
Expand Down
8 changes: 7 additions & 1 deletion src/handlers/shared/check-assignments.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
import { Context } from "../../types";
import { Context, isContextCommentCreated } from "../../types";
import { getOwnerRepoFromHtmlUrl } from "../../utils/issue";

async function getUserStopComments(context: Context, username: string): Promise<number> {
if (!isContextCommentCreated(context)) {
throw new Error("The context does not contain an issue.");
}
const { payload, octokit, logger } = context;
const { number, html_url } = payload.issue;
const { owner, repo } = getOwnerRepoFromHtmlUrl(html_url);
Expand Down Expand Up @@ -52,6 +55,9 @@ export async function hasUserBeenUnassigned(context: Context, username: string):
}

async function getAssignmentEvents(context: Context) {
if (!isContextCommentCreated(context)) {
throw new Error("The context does not contain an issue.");
}
const { repository, issue } = context.payload;
try {
const data = await context.octokit.paginate(context.octokit.issues.listEventsForTimeline, {
Expand Down
6 changes: 3 additions & 3 deletions src/handlers/shared/generate-assignment-comment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,12 @@ export const options: Intl.DateTimeFormatOptions = {
timeZoneName: "short",
};

export function getDeadline(issue: Context["payload"]["issue"]): string | null {
if (!issue?.labels) {
export function getDeadline(labels: Context<"issue_comment.created">["payload"]["issue"]["labels"] | undefined | null): string | null {
if (!labels?.length) {
throw new Error("No labels are set.");
}
const startTime = new Date().getTime();
const duration: number = calculateDurations(issue.labels).shift() ?? 0;
const duration: number = calculateDurations(labels).shift() ?? 0;
if (!duration) return null;
const endTime = new Date(startTime + duration * 1000);
return endTime.toLocaleString("en-US", options);
Expand Down
9 changes: 7 additions & 2 deletions src/handlers/shared/start.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,12 @@ import { generateAssignmentComment, getDeadline } from "./generate-assignment-co
import structuredMetadata from "./structured-metadata";
import { assignTableComment } from "./table";

export async function start(context: Context, issue: Context["payload"]["issue"], sender: Context["payload"]["sender"], teammates: string[]): Promise<Result> {
export async function start(
context: Context,
issue: Context<"issue_comment.created">["payload"]["issue"],
sender: Context["payload"]["sender"],
teammates: string[]
): Promise<Result> {
const { logger, config } = context;
const { maxConcurrentTasks, taskStaleTimeoutDuration } = config;

Expand Down Expand Up @@ -82,7 +87,7 @@ export async function start(context: Context, issue: Context["payload"]["issue"]
throw new Error(logger.error("No price label is set to calculate the duration", { issueNumber: issue.number }).logMessage.raw);
}

const deadline = getDeadline(issue);
const deadline = getDeadline(labels);
const toAssignIds = await fetchUserIds(context, toAssign);

const assignmentComment = await generateAssignmentComment(context, issue.created_at, issue.number, sender.id, deadline);
Expand Down
7 changes: 6 additions & 1 deletion src/handlers/shared/stop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,12 @@ import { Assignee, Context, Sender } from "../../types";
import { addCommentToIssue, closePullRequestForAnIssue } from "../../utils/issue";
import { HttpStatusCode, Result } from "../result-types";

export async function stop(context: Context, issue: Context["payload"]["issue"], sender: Sender, repo: Context["payload"]["repository"]): Promise<Result> {
export async function stop(
context: Context,
issue: Context<"issue_comment.created">["payload"]["issue"],
sender: Sender,
repo: Context["payload"]["repository"]
): Promise<Result> {
const { logger } = context;
const issueNumber = issue.number;

Expand Down
58 changes: 54 additions & 4 deletions src/handlers/user-start-stop.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { Context, isContextCommentCreated } from "../types";
import { addCommentToIssue } from "../utils/issue";
import { Repository } from "@octokit/graphql-schema";
import { Context, isContextCommentCreated, Label } from "../types";
import { QUERY_CLOSING_ISSUE_REFERENCES } from "../utils/get-closing-issue-references";
import { addCommentToIssue, getOwnerRepoFromHtmlUrl } from "../utils/issue";
import { HttpStatusCode, Result } from "./result-types";
import { getDeadline } from "./shared/generate-assignment-comment";
import { start } from "./shared/start";
Expand All @@ -26,10 +28,10 @@ export async function userStartStop(context: Context): Promise<Result> {
return { status: HttpStatusCode.NOT_MODIFIED };
}

export async function userSelfAssign(context: Context): Promise<Result> {
export async function userSelfAssign(context: Context<"issues.assigned">): Promise<Result> {
const { payload } = context;
const { issue } = payload;
const deadline = getDeadline(issue);
const deadline = getDeadline(issue.labels);

if (!deadline) {
context.logger.debug("Skipping deadline posting message because no deadline has been set.");
Expand All @@ -41,3 +43,51 @@ export async function userSelfAssign(context: Context): Promise<Result> {
await addCommentToIssue(context, `${users} the deadline is at ${deadline}`);
return { status: HttpStatusCode.OK };
}

export async function userPullRequest(context: Context<"pull_request.opened">): Promise<Result> {
const { payload } = context;
const { pull_request } = payload;
const { owner, repo } = getOwnerRepoFromHtmlUrl(pull_request.html_url);
const linkedIssues = await context.octokit.graphql.paginate<{ repository: Repository }>(QUERY_CLOSING_ISSUE_REFERENCES, {
owner,
repo,
issue_number: pull_request.number,
});
const issues = linkedIssues.repository.pullRequest?.closingIssuesReferences?.nodes;
if (!issues) {
context.logger.info("No linked issues were found, nothing to do.");
return { status: HttpStatusCode.NOT_MODIFIED };
}
for (const issue of issues) {
if (issue && !issue.assignees.nodes?.length) {
const labels =
issue.labels?.nodes?.reduce<Label[]>((acc, curr) => {
if (curr) {
acc.push({
...curr,
id: Number(curr.id),
node_id: curr.id,
default: true,
description: curr.description ?? null,
});
}
return acc;
}, []) ?? [];
const deadline = getDeadline(labels);
if (!deadline) {
context.logger.debug("Skipping deadline posting message because no deadline has been set.");
return { status: HttpStatusCode.NOT_MODIFIED };
} else {
const issueWithComment: Context<"issue_comment.created">["payload"]["issue"] = {
...issue,
assignees: issue.assignees.nodes as Context<"issue_comment.created">["payload"]["issue"]["assignees"],
labels,
html_url: issue.url,
} as unknown as Context<"issue_comment.created">["payload"]["issue"];
context.payload = Object.assign({ issue: issueWithComment }, context.payload);
return await start(context, issueWithComment, payload.sender, []);
}
}
}
return { status: HttpStatusCode.NOT_MODIFIED };
}
10 changes: 7 additions & 3 deletions src/plugin.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
import { paginateGraphQL } from "@octokit/plugin-paginate-graphql";
import { Octokit } from "@octokit/rest";
import { createClient } from "@supabase/supabase-js";
import { LogReturn, Logs } from "@ubiquity-dao/ubiquibot-logger";
import { createAdapters } from "./adapters";
import { userSelfAssign, userStartStop } from "./handlers/user-start-stop";
import { userPullRequest, userSelfAssign, userStartStop } from "./handlers/user-start-stop";
import { Context, Env, PluginInputs } from "./types";
import { addCommentToIssue } from "./utils/issue";

export async function startStopTask(inputs: PluginInputs, env: Env) {
const octokit = new Octokit({ auth: inputs.authToken });
const customOctokit = Octokit.plugin(paginateGraphQL);
const octokit = new customOctokit({ auth: inputs.authToken });
const supabase = createClient(env.SUPABASE_URL, env.SUPABASE_KEY);

const context: Context = {
Expand All @@ -27,7 +29,9 @@ export async function startStopTask(inputs: PluginInputs, env: Env) {
case "issue_comment.created":
return await userStartStop(context);
case "issues.assigned":
return await userSelfAssign(context);
return await userSelfAssign(context as Context<"issues.assigned">);
case "pull_request.opened":
return await userPullRequest(context as Context<"pull_request.opened">);
default:
context.logger.error(`Unsupported event: ${context.eventName}`);
}
Expand Down
5 changes: 3 additions & 2 deletions src/types/context.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import { paginateGraphQLInterface } from "@octokit/plugin-paginate-graphql";
import { EmitterWebhookEvent as WebhookEvent, EmitterWebhookEventName as WebhookEventName } from "@octokit/webhooks";
import { Octokit } from "@octokit/rest";
import { StartStopSettings } from "./plugin-input";
import { createAdapters } from "../adapters";
import { Env } from "./env";
import { Logs } from "@ubiquity-dao/ubiquibot-logger";

export type SupportedEventsU = "issue_comment.created" | "issues.assigned";
export type SupportedEventsU = "issue_comment.created" | "issues.assigned" | "pull_request.opened";

export type SupportedEvents = {
[K in SupportedEventsU]: K extends WebhookEventName ? WebhookEvent<K> : never;
Expand All @@ -18,7 +19,7 @@ export function isContextCommentCreated(context: Context): context is Context<"i
export interface Context<T extends SupportedEventsU = SupportedEventsU, TU extends SupportedEvents[T] = SupportedEvents[T]> {
eventName: T;
payload: TU["payload"];
octokit: InstanceType<typeof Octokit>;
octokit: InstanceType<typeof Octokit> & paginateGraphQLInterface;
adapters: ReturnType<typeof createAdapters>;
config: StartStopSettings;
env: Env;
Expand Down
33 changes: 33 additions & 0 deletions src/utils/get-closing-issue-references.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
export const QUERY_CLOSING_ISSUE_REFERENCES = /* GraphQL */ `
query closingIssueReferences($owner: String!, $repo: String!, $issue_number: Int!, $cursor: String) {
repository(owner: $owner, name: $repo) {
pullRequest(number: $issue_number) {
id
closingIssuesReferences(first: 10, after: $cursor) {
nodes {
id
url
number
labels(first: 100) {
nodes {
id
name
description
}
}
assignees(first: 100) {
nodes {
id
login
}
}
}
pageInfo {
hasNextPage
endCursor
}
}
}
}
}
`;
13 changes: 9 additions & 4 deletions src/utils/issue.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import ms from "ms";
import { Context } from "../types/context";
import { Issue, GitHubIssueSearch, Review } from "../types/payload";
import { Context, isContextCommentCreated } from "../types/context";
import { GitHubIssueSearch, Issue, Review } from "../types/payload";
import { getLinkedPullRequests, GetLinkedResults } from "./get-linked-prs";

export function isParentIssue(body: string) {
Expand All @@ -21,12 +21,17 @@ export async function getAssignedIssues(context: Context, username: string): Pro
}

export async function addCommentToIssue(context: Context, message: string | null) {
const { payload, logger } = context;
if (!message) {
logger.error("Message is not defined");
context.logger.error("Message is not defined");
return;
}

if (!isContextCommentCreated(context)) {
context.logger.error("Cannot post without a referenced issue.");
return;
}
const { payload } = context;

try {
await context.octokit.rest.issues.createComment({
owner: payload.repository.owner.login,
Expand Down
8 changes: 4 additions & 4 deletions src/utils/shared.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
import ms from "ms";
import { Label } from "../types";
import { Context } from "../types";

export function calculateDurations(labels: Label[]): number[] {
export function calculateDurations(labels: Context<"issue_comment.created">["payload"]["issue"]["labels"]): number[] {
// from shortest to longest
const durations: number[] = [];

labels.forEach((label: Label) => {
const matches = label.name.match(/<(\d+)\s*(\w+)/);
labels.forEach((label) => {
const matches = label?.name.match(/<(\d+)\s*(\w+)/);
if (matches && matches.length >= 3) {
const number = parseInt(matches[1]);
const unit = matches[2];
Expand Down
4 changes: 2 additions & 2 deletions tests/main.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import dotenv from "dotenv";
import { Logs, cleanLogString } from "@ubiquity-dao/ubiquibot-logger";
dotenv.config();

type Issue = Context["payload"]["issue"];
type Issue = Context<"issue_comment.created">["payload"]["issue"];
type PayloadSender = Context["payload"]["sender"];

const octokit = jest.requireActual("@octokit/rest");
Expand Down Expand Up @@ -567,7 +567,7 @@ function createContext(
return {
adapters: {} as ReturnType<typeof createAdapters>,
payload: {
issue: issue as unknown as Context["payload"]["issue"],
issue: issue as unknown as Context<"issue_comment.created">["payload"]["issue"],
sender: sender as unknown as Context["payload"]["sender"],
repository: db.repo.findFirst({ where: { id: { equals: 1 } } }) as unknown as Context["payload"]["repository"],
comment: { body } as unknown as Context<"issue_comment.created">["payload"]["comment"],
Expand Down
Loading

0 comments on commit 6e43a1b

Please sign in to comment.