Skip to content

Commit

Permalink
Merge pull request #81 from ubiquity-os-marketplace/development
Browse files Browse the repository at this point in the history
Merge development into main
  • Loading branch information
rndquu authored Nov 8, 2024
2 parents 0e22d5d + 844cac7 commit 9e57a33
Show file tree
Hide file tree
Showing 15 changed files with 741 additions and 568 deletions.
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,8 +42,10 @@ To configure your Ubiquibot to run this plugin, add the following to the `.ubiqu
member: 5
contributor: 3
startRequiresWallet: true # default is true
assignedIssueScope: "org" # or "org" or "network". Default is org
emptyWalletText: "Please set your wallet address with the /wallet command first and try again."
rolesWithReviewAuthority: ["MEMBER", "OWNER"]
requiredLabelsToStart: ["Priority: 5 (Emergency)"]
```
# Testing
Expand Down
28 changes: 27 additions & 1 deletion manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,23 @@
}
}
},
"assignedIssueScope": {
"default": "org",
"anyOf": [
{
"const": "org",
"type": "string"
},
{
"const": "repo",
"type": "string"
},
{
"const": "network",
"type": "string"
}
]
},
"emptyWalletText": {
"default": "Please set your wallet address with the /wallet command first and try again.",
"type": "string"
Expand All @@ -61,15 +78,24 @@
"items": {
"type": "string"
}
},
"requiredLabelsToStart": {
"default": [],
"type": "array",
"items": {
"type": "string"
}
}
},
"required": [
"reviewDelayTolerance",
"taskStaleTimeoutDuration",
"startRequiresWallet",
"maxConcurrentTasks",
"assignedIssueScope",
"emptyWalletText",
"rolesWithReviewAuthority"
"rolesWithReviewAuthority",
"requiredLabelsToStart"
]
}
}
63 changes: 54 additions & 9 deletions src/handlers/shared/start.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Context, ISSUE_TYPE, Label } from "../../types";
import { AssignedIssue, Context, ISSUE_TYPE, Label } from "../../types";
import { isUserCollaborator } from "../../utils/get-user-association";
import { addAssignees, addCommentToIssue, getAssignedIssues, getAvailableOpenedPullRequests, getTimeValue, isParentIssue } from "../../utils/issue";
import { HttpStatusCode, Result } from "../result-types";
Expand All @@ -16,7 +16,18 @@ export async function start(
teammates: string[]
): Promise<Result> {
const { logger, config } = context;
const { taskStaleTimeoutDuration } = config;
const { taskStaleTimeoutDuration, requiredLabelsToStart } = config;

const issueLabels = issue.labels.map((label) => label.name);

if (requiredLabelsToStart.length && !requiredLabelsToStart.some((label) => issueLabels.includes(label))) {
// The "Priority" label must reflect a business priority, not a development one.
throw logger.error("This task does not reflect a business priority at the moment and cannot be started. This will be reassessed in the coming weeks.", {
requiredLabelsToStart,
issueLabels,
issue: issue.html_url,
});
}

if (!sender) {
throw logger.error(`Skipping '/start' since there is no sender in the context.`);
Expand Down Expand Up @@ -64,23 +75,51 @@ export async function start(
teammates.push(sender.login);

const toAssign = [];
let assignedIssues: AssignedIssue[] = [];
// check max assigned issues
for (const user of teammates) {
if (await handleTaskLimitChecks(user, context, logger, sender.login)) {
const { isWithinLimit, issues } = await handleTaskLimitChecks(user, context, logger, sender.login);
if (isWithinLimit) {
toAssign.push(user);
} else {
issues.forEach((issue) => {
assignedIssues = assignedIssues.concat({
title: issue.title,
html_url: issue.html_url,
});
});
}
}

let error: string | null = null;

if (toAssign.length === 0 && teammates.length > 1) {
error = "All teammates have reached their max task limit. Please close out some tasks before assigning new ones.";
throw logger.error(error, { issueNumber: issue.number });
} else if (toAssign.length === 0) {
error = "You have reached your max task limit. Please close out some tasks before assigning new ones.";
}
let issues = "";
const urlPattern = /https:\/\/(github.com\/(\S+)\/(\S+)\/issues\/(\d+))/;
assignedIssues.forEach((el) => {
const match = el.html_url.match(urlPattern);
if (match) {
issues = issues.concat(`- ###### [${match[2]}/${match[3]} - ${el.title} #${match[4]}](https://www.${match[1]})\n`);
} else {
issues = issues.concat(`- ###### [${el.title}](${el.html_url})\n`);
}
});

if (error) {
throw logger.error(error, { issueNumber: issue.number });
await addCommentToIssue(
context,
`
> [!WARNING]
> ${error}
${issues}
`
);
return { content: error, status: HttpStatusCode.NOT_MODIFIED };
}

const labels = issue.labels ?? [];
Expand Down Expand Up @@ -168,12 +207,18 @@ async function handleTaskLimitChecks(username: string, context: Context, logger:
limit,
});

return false;
return {
isWithinLimit: false,
issues: assignedIssues,
};
}

if (await hasUserBeenUnassigned(context, username)) {
throw logger.error(`${username} you were previously unassigned from this task. You cannot be reassigned.`, { username });
}

return true;
return {
isWithinLimit: true,
issues: [],
};
}
3 changes: 1 addition & 2 deletions src/handlers/user-start-stop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,7 @@ export async function userStartStop(context: Context): Promise<Result> {
if (!isIssueCommentEvent(context)) {
return { status: HttpStatusCode.NOT_MODIFIED };
}
const { payload } = context;
const { issue, comment, sender, repository } = payload;
const { issue, comment, sender, repository } = context.payload;
const slashCommand = comment.body.trim().split(" ")[0].replace("/", "");
const teamMates = comment.body
.split("@")
Expand Down
5 changes: 5 additions & 0 deletions src/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { createAdapters } from "./adapters";
import { userPullRequest, userSelfAssign, userStartStop, userUnassigned } from "./handlers/user-start-stop";
import { Context, Env, PluginInputs } from "./types";
import { addCommentToIssue } from "./utils/issue";
import { listOrganizations } from "./utils/list-organizations";

export async function startStopTask(inputs: PluginInputs, env: Env) {
const customOctokit = Octokit.plugin(paginateGraphQL);
Expand All @@ -16,6 +17,7 @@ export async function startStopTask(inputs: PluginInputs, env: Env) {
eventName: inputs.eventName,
payload: inputs.eventPayload,
config: inputs.settings,
organizations: [],
octokit,
env,
logger: new Logs("info"),
Expand All @@ -25,6 +27,9 @@ export async function startStopTask(inputs: PluginInputs, env: Env) {
context.adapters = createAdapters(supabase, context);

try {
const organizations = await listOrganizations(context);
context.organizations = organizations;

switch (context.eventName) {
case "issue_comment.created":
return await userStartStop(context);
Expand Down
1 change: 1 addition & 0 deletions src/types/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ export interface Context<T extends SupportedEventsU = SupportedEventsU, TU exten
octokit: InstanceType<typeof Octokit> & paginateGraphQLInterface;
adapters: ReturnType<typeof createAdapters>;
config: PluginSettings;
organizations: string[];
env: Env;
logger: Logs;
}
6 changes: 6 additions & 0 deletions src/types/payload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,12 @@ export type TimelineEventResponse = RestEndpointMethodTypes["issues"]["listEvent
export type TimelineEvents = RestEndpointMethodTypes["issues"]["listEventsForTimeline"]["response"]["data"][0];
export type Assignee = Issue["assignee"];
export type GitHubIssueSearch = RestEndpointMethodTypes["search"]["issuesAndPullRequests"]["response"]["data"];
export type RepoIssues = RestEndpointMethodTypes["issues"]["listForRepo"]["response"]["data"];

export type AssignedIssue = {
title: string;
html_url: string;
};

export type Sender = { login: string; id: number };

Expand Down
8 changes: 8 additions & 0 deletions src/types/plugin-input.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,12 @@ export interface PluginInputs<T extends SupportedEventsU = SupportedEventsU, TU
ref: string;
}

export enum AssignedIssueScope {
ORG = "org",
REPO = "repo",
NETWORK = "network",
}

const rolesWithReviewAuthority = T.Array(T.String(), { default: ["COLLABORATOR", "OWNER", "MEMBER", "ADMIN"] });

function maxConcurrentTasks() {
Expand Down Expand Up @@ -41,10 +47,12 @@ export const pluginSettingsSchema = T.Object(
taskStaleTimeoutDuration: T.String({ default: "30 Days" }),
startRequiresWallet: T.Boolean({ default: true }),
maxConcurrentTasks: maxConcurrentTasks(),
assignedIssueScope: T.Enum(AssignedIssueScope, { default: AssignedIssueScope.ORG }),
emptyWalletText: T.String({ default: "Please set your wallet address with the /wallet command first and try again." }),
rolesWithReviewAuthority: T.Transform(rolesWithReviewAuthority)
.Decode((value) => value.map((role) => role.toUpperCase()))
.Encode((value) => value.map((role) => role.toUpperCase())),
requiredLabelsToStart: T.Array(T.String(), { default: [] }),
},
{
default: {},
Expand Down
47 changes: 30 additions & 17 deletions src/utils/issue.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,31 +2,36 @@ import { RestEndpointMethodTypes } from "@octokit/rest";
import { Endpoints } from "@octokit/types";
import ms from "ms";
import { Context } from "../types/context";
import { GitHubIssueSearch, Review } from "../types/payload";
import { GitHubIssueSearch, RepoIssues, Review } from "../types/payload";
import { getLinkedPullRequests, GetLinkedResults } from "./get-linked-prs";
import { getAllPullRequestsFallback, getAssignedIssuesFallback } from "./get-pull-requests-fallback";
import { AssignedIssueScope } from "../types";

export function isParentIssue(body: string) {
const parentPattern = /-\s+\[( |x)\]\s+#\d+/;
return body.match(parentPattern);
}

export async function getAssignedIssues(context: Context, username: string) {
const payload = context.payload;
export async function getAssignedIssues(context: Context, username: string): Promise<GitHubIssueSearch["items"] | RepoIssues> {
let repoOrgQuery = "";
if (context.config.assignedIssueScope === AssignedIssueScope.REPO) {
repoOrgQuery = `repo:${context.payload.repository.full_name}`;
} else {
context.organizations.forEach((org) => {
repoOrgQuery += `org:${org} `;
});
}

try {
return await context.octokit
.paginate(context.octokit.rest.search.issuesAndPullRequests, {
q: `org:${payload.repository.owner.login} assignee:${username} is:open is:issue`,
per_page: 100,
order: "desc",
sort: "created",
})
.then((issues) =>
issues.filter((issue) => {
return issue.state === "open" && (issue.assignee?.login === username || issue.assignees?.some((assignee) => assignee.login === username));
})
);
const issues = await context.octokit.paginate(context.octokit.rest.search.issuesAndPullRequests, {
q: `${repoOrgQuery} is:open is:issue assignee:${username}`,
per_page: 100,
order: "desc",
sort: "created",
});
return issues.filter((issue) => {
return issue.assignee?.login === username || issue.assignees?.some((assignee) => assignee.login === username);
});
} catch (err) {
context.logger.info("Will try re-fetching assigned issues...", { error: err as Error });
return getAssignedIssuesFallback(context, username);
Expand Down Expand Up @@ -174,9 +179,17 @@ export async function addAssignees(context: Context, issueNo: number, assignees:
}

async function getAllPullRequests(context: Context, state: Endpoints["GET /repos/{owner}/{repo}/pulls"]["parameters"]["state"] = "open", username: string) {
const { payload } = context;
let repoOrgQuery = "";
if (context.config.assignedIssueScope === AssignedIssueScope.REPO) {
repoOrgQuery = `repo:${context.payload.repository.full_name}`;
} else {
context.organizations.forEach((org) => {
repoOrgQuery += `org:${org} `;
});
}

const query: RestEndpointMethodTypes["search"]["issuesAndPullRequests"]["parameters"] = {
q: `org:${payload.repository.owner.login} author:${username} state:${state}`,
q: `${repoOrgQuery} author:${username} state:${state} is:pr`,
per_page: 100,
order: "desc",
sort: "created",
Expand Down
38 changes: 38 additions & 0 deletions src/utils/list-organizations.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { AssignedIssueScope, Context, GitHubIssueSearch } from "../types";

export async function listOrganizations(context: Context): Promise<string[]> {
const {
config: { assignedIssueScope },
logger,
payload,
} = context;

if (assignedIssueScope === AssignedIssueScope.REPO || assignedIssueScope === AssignedIssueScope.ORG) {
return [payload.repository.owner.login];
} else if (assignedIssueScope === AssignedIssueScope.NETWORK) {
const orgsSet: Set<string> = new Set();
const urlPattern = /https:\/\/github\.com\/(\S+)\/\S+\/issues\/\d+/;

const url = "https://raw.githubusercontent.com/ubiquity/devpool-directory/refs/heads/__STORAGE__/devpool-issues.json";
const response = await fetch(url);
if (!response.ok) {
if (response.status === 404) {
throw logger.error(`Error 404: unable to fetch file devpool-issues.json ${url}`);
} else {
throw logger.error("Error fetching file devpool-issues.json.", { status: response.status });
}
}

const devpoolIssues: GitHubIssueSearch["items"] = await response.json();
devpoolIssues.forEach((issue) => {
const match = issue.html_url.match(urlPattern);
if (match) {
orgsSet.add(match[1]);
}
});

return [...orgsSet];
}

throw new Error("Unknown assignedIssueScope value. Supported values: ['org', 'repo', 'network']");
}
3 changes: 3 additions & 0 deletions tests/__mocks__/issue-template.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,9 @@ export default {
{
name: "Time: 1h",
},
{
name: "Priority: 1 (Normal)",
},
],
body: "body",
};
4 changes: 3 additions & 1 deletion tests/__mocks__/valid-configuration.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
"member": 10,
"contributor": 2
},
"assignedIssueScope": "org",
"emptyWalletText": "Please set your wallet address with the /wallet command first and try again.",
"rolesWithReviewAuthority": ["OWNER", "ADMIN", "MEMBER"]
"rolesWithReviewAuthority": ["OWNER", "ADMIN", "MEMBER"],
"requiredLabelsToStart": ["Priority: 1 (Normal)", "Priority: 2 (Medium)", "Priority: 3 (High)", "Priority: 4 (Urgent)", "Priority: 5 (Emergency)"]
}
Loading

0 comments on commit 9e57a33

Please sign in to comment.