Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: assign user pr #39

Merged
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
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);
0x4007 marked this conversation as resolved.
Show resolved Hide resolved

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 @@ -552,7 +552,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