Skip to content

Commit

Permalink
Merge branch 'ubiquibot:development' into fix/approved-roles
Browse files Browse the repository at this point in the history
  • Loading branch information
Keyrxng authored Sep 16, 2024
2 parents 4c29657 + e9b7bd6 commit 0820f92
Show file tree
Hide file tree
Showing 30 changed files with 482 additions and 144 deletions.
1 change: 1 addition & 0 deletions .dev.vars.example
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
SUPABASE_URL=
SUPABASE_KEY=
BOT_USER_ID=
4 changes: 2 additions & 2 deletions .github/workflows/worker-deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -41,11 +41,11 @@ jobs:
secrets: |
SUPABASE_URL
SUPABASE_KEY
APP_ID
BOT_USER_ID
env:
SUPABASE_URL: ${{ secrets.SUPABASE_URL }}
SUPABASE_KEY: ${{ secrets.SUPABASE_KEY }}
APP_ID: ${{ secrets.APP_ID }}
BOT_USER_ID: ${{ secrets.BOT_USER_ID }}

- name: Write Deployment URL to Summary
run: |
Expand Down
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,9 @@ To configure your Ubiquibot to run this plugin, add the following to the `.ubiqu
with:
reviewDelayTolerance: "3 Days"
taskStaleTimeoutDuration: "30 Days"
maxConcurrentTasks: 3
maxConcurrentTasks: # Default concurrent task limits per role.
member: 5
contributor: 3
startRequiresWallet: true # default is true
emptyWalletText: "Please set your wallet address with the /wallet command first and try again."
rolesWithReviewAuthority: ["MEMBER", "OWNER"]
Expand Down
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"],
"ubiquity:listeners": ["issue_comment.created", "issues.assigned", "issues.unassigned", "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
4 changes: 1 addition & 3 deletions src/adapters/supabase/helpers/user.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,8 @@ export class User extends Super {
if ((error && !data) || !data.wallets?.address) {
this.context.logger.error("No wallet address found", { userId, issueNumber });
if (this.context.config.startRequiresWallet) {
await addCommentToIssue(this.context, this.context.config.emptyWalletText);
await addCommentToIssue(this.context, this.context.logger.error(this.context.config.emptyWalletText, { userId, issueNumber }).logMessage.diff);
throw new Error("No wallet address found");
} else {
await addCommentToIssue(this.context, this.context.config.emptyWalletText);
}
} else {
this.context.logger.info("Successfully fetched wallet", { userId, address: data.wallets?.address });
Expand Down
10 changes: 10 additions & 0 deletions src/handlers/result-types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
export enum HttpStatusCode {
OK = 200,
NOT_MODIFIED = 304,
}

export interface Result {
status: HttpStatusCode;
content?: string;
reason?: string;
}
12 changes: 9 additions & 3 deletions src/handlers/shared/check-assignments.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@ import { Context } from "../../types";
import { getOwnerRepoFromHtmlUrl } from "../../utils/issue";

async function getUserStopComments(context: Context, username: string): Promise<number> {
if (!("issue" in context.payload)) {
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 All @@ -21,7 +24,7 @@ async function getUserStopComments(context: Context, username: string): Promise<

export async function hasUserBeenUnassigned(context: Context, username: string): Promise<boolean> {
const {
env: { APP_ID },
env: { BOT_USER_ID },
} = context;
const events = await getAssignmentEvents(context);
const userAssignments = events.filter((event) => event.assignee === username);
Expand All @@ -33,9 +36,9 @@ export async function hasUserBeenUnassigned(context: Context, username: string):
const unassignedEvents = userAssignments.filter((event) => event.event === "unassigned");
// all bot unassignments (/stop, disqualification, etc)
// TODO: task-xp-guard: will also prevent future assignments so we need to add a comment tracker we can use here
const botUnassigned = unassignedEvents.filter((event) => event.actorId === APP_ID);
const botUnassigned = unassignedEvents.filter((event) => event.actorId === BOT_USER_ID);
// UI assignment
const adminUnassigned = unassignedEvents.filter((event) => event.actor !== username && event.actorId !== APP_ID);
const adminUnassigned = unassignedEvents.filter((event) => event.actor !== username && event.actorId !== BOT_USER_ID);
// UI assignment
const userUnassigned = unassignedEvents.filter((event) => event.actor === username);
const userStopComments = await getUserStopComments(context, username);
Expand All @@ -52,6 +55,9 @@ export async function hasUserBeenUnassigned(context: Context, username: string):
}

async function getAssignmentEvents(context: Context) {
if (!("issue" in context.payload)) {
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
24 changes: 16 additions & 8 deletions src/handlers/shared/generate-assignment-comment.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { Context } from "../../types/context";
import { Context } from "../../types";
import { calculateDurations } from "../../utils/shared";

const options: Intl.DateTimeFormatOptions = {
export const options: Intl.DateTimeFormatOptions = {
weekday: "short",
month: "short",
day: "numeric",
Expand All @@ -10,16 +11,23 @@ const options: Intl.DateTimeFormatOptions = {
timeZoneName: "short",
};

export async function generateAssignmentComment(context: Context, issueCreatedAt: string, issueNumber: number, senderId: number, duration: number) {
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(labels).shift() ?? 0;
if (!duration) return null;
const endTime = new Date(startTime + duration * 1000);
return endTime.toLocaleString("en-US", options);
}

export async function generateAssignmentComment(context: Context, issueCreatedAt: string, issueNumber: number, senderId: number, deadline: string | null) {
const startTime = new Date().getTime();
let endTime: null | Date = null;
let deadline: null | string = null;
endTime = new Date(startTime + duration * 1000);
deadline = endTime.toLocaleString("en-US", options);

return {
daysElapsedSinceTaskCreation: Math.floor((startTime - new Date(issueCreatedAt).getTime()) / 1000 / 60 / 60 / 24),
deadline: duration > 0 ? deadline : null,
deadline: deadline ?? null,
registeredWallet:
(await context.adapters.supabase.user.getWalletByUserId(senderId, issueNumber)) ||
"Register your wallet address using the following slash command: `/wallet 0x0000...0000`",
Expand Down
37 changes: 37 additions & 0 deletions src/handlers/shared/get-user-task-limit-and-role.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { Context } from "../../types";

interface MatchingUserProps {
role: string;
limit: number;
}

export async function getUserRoleAndTaskLimit(context: Context, user: string): Promise<MatchingUserProps> {
const orgLogin = context.payload.organization?.login;
const { config, logger } = context;
const { maxConcurrentTasks } = config;

const smallestTask = Object.entries(maxConcurrentTasks).reduce((minTask, [role, limit]) => (limit < minTask.limit ? { role, limit } : minTask), {
role: "",
limit: Infinity,
} as MatchingUserProps);

try {
// Validate the organization login
if (typeof orgLogin !== "string" || orgLogin.trim() === "") {
throw new Error("Invalid organization name");
}

const response = await context.octokit.orgs.getMembershipForUser({
org: orgLogin,
username: user,
});

const role = response.data.role.toLowerCase();
const limit = maxConcurrentTasks[role];

return limit ? { role, limit } : smallestTask;
} catch (err) {
logger.error("Could not get user role", { err });
return smallestTask;
}
}
36 changes: 21 additions & 15 deletions src/handlers/shared/start.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,21 @@
import { Context, ISSUE_TYPE, Label } from "../../types";
import { isParentIssue, getAvailableOpenedPullRequests, getAssignedIssues, addAssignees, addCommentToIssue, getTimeValue } from "../../utils/issue";
import { calculateDurations } from "../../utils/shared";
import { checkTaskStale } from "./check-task-stale";
import { hasUserBeenUnassigned } from "./check-assignments";
import { generateAssignmentComment } from "./generate-assignment-comment";
import { getUserRoleAndTaskLimit } from "./get-user-task-limit-and-role";
import { HttpStatusCode, Result } from "../result-types";
import { generateAssignmentComment, getDeadline } from "./generate-assignment-comment";
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[]) {
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;
const { taskStaleTimeoutDuration } = config;

// is it a child issue?
if (issue.body && isParentIssue(issue.body)) {
Expand Down Expand Up @@ -57,7 +63,7 @@ export async function start(context: Context, issue: Context["payload"]["issue"]
const toAssign = [];
// check max assigned issues
for (const user of teammates) {
if (await handleTaskLimitChecks(user, context, maxConcurrentTasks, logger, sender.login)) {
if (await handleTaskLimitChecks(user, context, logger, sender.login)) {
toAssign.push(user);
}
}
Expand All @@ -75,17 +81,17 @@ export async function start(context: Context, issue: Context["payload"]["issue"]
}

// get labels
const labels = issue.labels;
const labels = issue.labels ?? [];
const priceLabel = labels.find((label: Label) => label.name.startsWith("Price: "));

if (!priceLabel) {
throw new Error(logger.error("No price label is set to calculate the duration", { issueNumber: issue.number }).logMessage.raw);
}

const duration: number = calculateDurations(labels).shift() ?? 0;
const deadline = getDeadline(labels);
const toAssignIds = await fetchUserIds(context, toAssign);

const assignmentComment = await generateAssignmentComment(context, issue.created_at, issue.number, sender.id, duration);
const assignmentComment = await generateAssignmentComment(context, issue.created_at, issue.number, sender.id, deadline);
const logMessage = logger.info("Task assigned successfully", {
taskDeadline: assignmentComment.deadline,
taskAssignees: toAssignIds,
Expand Down Expand Up @@ -113,7 +119,7 @@ export async function start(context: Context, issue: Context["payload"]["issue"]
].join("\n") as string
);

return { output: "Task assigned successfully" };
return { content: "Task assigned successfully", status: HttpStatusCode.OK };
}

async function fetchUserIds(context: Context, username: string[]) {
Expand All @@ -134,19 +140,19 @@ async function fetchUserIds(context: Context, username: string[]) {
return ids;
}

async function handleTaskLimitChecks(username: string, context: Context, maxConcurrentTasks: number, logger: Context["logger"], sender: string) {
async function handleTaskLimitChecks(username: string, context: Context, logger: Context["logger"], sender: string) {
const openedPullRequests = await getAvailableOpenedPullRequests(context, username);
const assignedIssues = await getAssignedIssues(context, username);
const { limit } = await getUserRoleAndTaskLimit(context, username);

// check for max and enforce max

if (Math.abs(assignedIssues.length - openedPullRequests.length) >= maxConcurrentTasks) {
const log = logger.error(username === sender ? "You have reached your max task limit" : `${username} has reached their max task limit`, {
if (Math.abs(assignedIssues.length - openedPullRequests.length) >= limit) {
logger.error(username === sender ? "You have reached your max task limit" : `${username} has reached their max task limit`, {
assignedIssues: assignedIssues.length,
openedPullRequests: openedPullRequests.length,
maxConcurrentTasks,
limit,
});
await addCommentToIssue(context, log?.logMessage.diff as string);

return false;
}

Expand Down
20 changes: 10 additions & 10 deletions src/handlers/shared/stop.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,13 @@
import { Assignee, Context, Sender } from "../../types";
import { addCommentToIssue, closePullRequestForAnIssue } from "../../utils/issue";

export async function stop(context: Context, issue: Context["payload"]["issue"], sender: Sender, repo: Context["payload"]["repository"]) {
import { closePullRequestForAnIssue } from "../../utils/issue";
import { HttpStatusCode, Result } from "../result-types";

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 Expand Up @@ -41,11 +47,5 @@ export async function stop(context: Context, issue: Context["payload"]["issue"],
);
}

const unassignedLog = logger.info("You have been unassigned from the task", {
issueNumber,
user: userToUnassign.login,
});

await addCommentToIssue(context, unassignedLog?.logMessage.diff as string);
return { output: "Task unassigned successfully" };
return { content: "Task unassigned successfully", status: HttpStatusCode.OK };
}
2 changes: 1 addition & 1 deletion src/handlers/shared/table.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ export function assignTableComment({ taskDeadline, registeredWallet, isTaskStale
${taskStaleWarning}
${deadlineWarning}
<tr>
<td>Registered Wallet</td>
<td>Beneficiary</td>
<td>${registeredWallet}</td>
</tr>
</table>
Expand Down
Loading

0 comments on commit 0820f92

Please sign in to comment.