Skip to content

Commit

Permalink
Merge pull request #109 from ubiquity-os-marketplace/development
Browse files Browse the repository at this point in the history
Merge development into main
  • Loading branch information
gentlementlegen authored Dec 17, 2024
2 parents b5bb568 + 393a601 commit 183406e
Show file tree
Hide file tree
Showing 10 changed files with 275 additions and 72 deletions.
10 changes: 4 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,11 +30,7 @@ To configure your Ubiquity Kernel to run this plugin, add the following to the `

```yml
- plugin: http://localhost:4000 # or the URL where the plugin is hosted
name: start-stop
id: start-stop-command
description: "Allows a user to start/stop a task without negative XP impact"
command: "\/start|\/stop"
example: "/start" # or "/stop"
with:
reviewDelayTolerance: "3 Days"
taskStaleTimeoutDuration: "30 Days"
Expand All @@ -45,7 +41,9 @@ To configure your Ubiquity Kernel to run this plugin, add the following to the `
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)"]
requiredLabelsToStart:
- name: "Priority: 5 (Emergency)"
roles: ["admin", "collaborator"]
```
# Testing
Expand All @@ -55,5 +53,5 @@ To configure your Ubiquity Kernel to run this plugin, add the following to the `
To run the Jest test suite, run the following command:
```bash
yarn test
bun run test
```
2 changes: 1 addition & 1 deletion dist/index.js

Large diffs are not rendered by default.

66 changes: 64 additions & 2 deletions manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,22 +35,33 @@
"properties": {
"reviewDelayTolerance": {
"default": "1 Day",
"description": "How long shall the wait be for a reviewer to take action?",
"examples": ["1 Day", "5 Days"],
"description": "When considering a user for a task: if they have existing PRs with no reviews, how long should we wait before 'increasing' their assignable task limit?",
"type": "string"
},
"taskStaleTimeoutDuration": {
"default": "30 Days",
"examples": ["1 Day", "5 Days"],
"description": "When displaying the '/start' response, how long should we wait before considering a task 'stale' and provide a warning?",
"type": "string"
},
"startRequiresWallet": {
"default": true,
"description": "If true, users must set their wallet address with the /wallet command before they can start tasks.",
"type": "boolean"
},
"maxConcurrentTasks": {
"default": {
"member": 10,
"contributor": 2
},
"description": "The maximum number of tasks a user can have assigned to them at once, based on their role.",
"examples": [
{
"member": 5,
"contributor": 1
}
],
"type": "object",
"patternProperties": {
"^(.*)$": {
Expand All @@ -60,6 +71,8 @@
},
"assignedIssueScope": {
"default": "org",
"description": "When considering a user for a task: should we consider their assigned issues at the org, repo, or network level?",
"examples": ["org", "repo", "network"],
"anyOf": [
{
"const": "org",
Expand All @@ -77,11 +90,17 @@
},
"emptyWalletText": {
"default": "Please set your wallet address with the /wallet command first and try again.",
"description": "a message to display when a user tries to start a task without setting their wallet address.",
"type": "string"
},
"rolesWithReviewAuthority": {
"default": ["OWNER", "ADMIN", "MEMBER", "COLLABORATOR"],
"uniqueItems": true,
"description": "When considering a user for a task: which roles should be considered as having review authority? All others are ignored.",
"examples": [
["OWNER", "ADMIN"],
["MEMBER", "COLLABORATOR"]
],
"type": "array",
"items": {
"anyOf": [
Expand All @@ -106,9 +125,52 @@
},
"requiredLabelsToStart": {
"default": [],
"description": "If set, a task must have at least one of these labels to be started.",
"examples": [["Priority: 5 (Emergency)"], ["Good First Issue"]],
"type": "array",
"items": {
"type": "string"
"type": "object",
"properties": {
"name": {
"description": "The name of the required labels to start the task.",
"type": "string"
},
"roles": {
"description": "The list of allowed roles to start the task with the given label.",
"uniqueItems": true,
"default": ["admin", "member", "collaborator", "contributor", "owner", "billing_manager"],
"type": "array",
"items": {
"anyOf": [
{
"const": "admin",
"type": "string"
},
{
"const": "member",
"type": "string"
},
{
"const": "collaborator",
"type": "string"
},
{
"const": "contributor",
"type": "string"
},
{
"const": "owner",
"type": "string"
},
{
"const": "billing_manager",
"type": "string"
}
]
}
}
},
"required": ["name"]
}
}
}
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@
},
"lint-staged": {
"*.ts": [
"yarn prettier --write",
"prettier --write",
"eslint --fix"
],
"src/**.{ts,json}": [
Expand Down
55 changes: 40 additions & 15 deletions src/handlers/shared/start.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,33 +9,58 @@ import { getUserRoleAndTaskLimit } from "./get-user-task-limit-and-role";
import structuredMetadata from "./structured-metadata";
import { assignTableComment } from "./table";

async function checkRequirements(context: Context, issue: Context<"issue_comment.created">["payload"]["issue"], login: string) {
const {
config: { requiredLabelsToStart },
logger,
} = context;
const issueLabels = issue.labels.map((label) => label.name.toLowerCase());
const userAssociation = await getUserRoleAndTaskLimit(context, login);

if (requiredLabelsToStart.length) {
const currentLabelConfiguration = requiredLabelsToStart.find((label) =>
issueLabels.some((issueLabel) => label.name.toLowerCase() === issueLabel.toLowerCase())
);
if (!currentLabelConfiguration) {
// If we didn't find the label in the allowed list, then the user cannot start this task.
throw logger.error(
`This task does not reflect a business priority at the moment. You may start tasks with one of the following labels: ${requiredLabelsToStart.map((label) => label.name).join(", ")}`,
{
requiredLabelsToStart,
issueLabels,
issue: issue.html_url,
}
);
} else if (!currentLabelConfiguration.roles.includes(userAssociation.role.toLowerCase() as (typeof currentLabelConfiguration.roles)[number])) {
// If we found the label in the allowed list, but the user role does not match the allowed roles, then the user cannot start this task.
throw logger.error(
`You do not have the adequate role to start this task (your role is: ${userAssociation.role}). Allowed roles are: ${currentLabelConfiguration.roles.join(", ")}.`,
{
currentLabelConfiguration,
issueLabels,
issue: issue.html_url,
userAssociation,
}
);
}
}
}

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 { 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. You may start tasks with one of the following labels: ${requiredLabelsToStart.join(", ")}`,
{
requiredLabelsToStart,
issueLabels,
issue: issue.html_url,
}
);
}
const { taskStaleTimeoutDuration } = config;

if (!sender) {
throw logger.error(`Skipping '/start' since there is no sender in the context.`);
}

await checkRequirements(context, issue, sender.login);

// is it a child issue?
if (issue.body && isParentIssue(issue.body)) {
await addCommentToIssue(
Expand Down
117 changes: 86 additions & 31 deletions src/types/plugin-input.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { StaticDecode, Type as T } from "@sinclair/typebox";
import { StaticDecode, TLiteral, Type as T, Union } from "@sinclair/typebox";

export enum AssignedIssueScope {
ORG = "org",
Expand All @@ -13,40 +13,95 @@ export enum Role {
COLLABORATOR = "COLLABORATOR",
}

const rolesWithReviewAuthority = T.Array(T.Enum(Role), { default: [Role.OWNER, Role.ADMIN, Role.MEMBER, Role.COLLABORATOR], uniqueItems: true });

function maxConcurrentTasks() {
return T.Transform(T.Record(T.String(), T.Integer(), { default: { member: 10, contributor: 2 } }))
.Decode((obj) => {
// normalize the role keys to lowercase
obj = Object.keys(obj).reduce(
(acc, key) => {
acc[key.toLowerCase()] = obj[key];
return acc;
},
{} as Record<string, number>
);

// If admin is omitted, defaults to infinity
if (!obj["admin"]) {
obj["admin"] = Infinity;
}

return obj;
})
.Encode((value) => value);
const rolesWithReviewAuthority = T.Array(T.Enum(Role), {
default: [Role.OWNER, Role.ADMIN, Role.MEMBER, Role.COLLABORATOR],
uniqueItems: true,
description: "When considering a user for a task: which roles should be considered as having review authority? All others are ignored.",
examples: [
[Role.OWNER, Role.ADMIN],
[Role.MEMBER, Role.COLLABORATOR],
],
});

const maxConcurrentTasks = T.Transform(
T.Record(T.String(), T.Integer(), {
default: { member: 10, contributor: 2 },
description: "The maximum number of tasks a user can have assigned to them at once, based on their role.",
examples: [{ member: 5, contributor: 1 }],
})
)
.Decode((obj) => {
// normalize the role keys to lowercase
obj = Object.keys(obj).reduce(
(acc, key) => {
acc[key.toLowerCase()] = obj[key];
return acc;
},
{} as Record<string, number>
);

// If admin is omitted, defaults to infinity
if (!obj["admin"]) {
obj["admin"] = Infinity;
}

return obj;
})
.Encode((value) => value);

type IntoStringLiteralUnion<T> = { [K in keyof T]: T[K] extends string ? TLiteral<T[K]> : never };

export function stringLiteralUnion<T extends string[]>(values: [...T]): Union<IntoStringLiteralUnion<T>> {
const literals = values.map((value) => T.Literal(value));
return T.Union(literals) as Union<IntoStringLiteralUnion<T>>;
}

const roles = stringLiteralUnion(["admin", "member", "collaborator", "contributor", "owner", "billing_manager"]);

const requiredLabel = T.Object({
name: T.String({ description: "The name of the required labels to start the task." }),
roles: T.Array(roles, {
description: "The list of allowed roles to start the task with the given label.",
uniqueItems: true,
default: ["admin", "member", "collaborator", "contributor", "owner", "billing_manager"],
}),
});

export const pluginSettingsSchema = T.Object(
{
reviewDelayTolerance: T.String({ default: "1 Day", description: "How long shall the wait be for a reviewer to take action?" }),
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: rolesWithReviewAuthority,
requiredLabelsToStart: T.Array(T.String(), { default: [] }),
reviewDelayTolerance: T.String({
default: "1 Day",
examples: ["1 Day", "5 Days"],
description:
"When considering a user for a task: if they have existing PRs with no reviews, how long should we wait before 'increasing' their assignable task limit?",
}),
taskStaleTimeoutDuration: T.String({
default: "30 Days",
examples: ["1 Day", "5 Days"],
description: "When displaying the '/start' response, how long should we wait before considering a task 'stale' and provide a warning?",
}),
startRequiresWallet: T.Boolean({
default: true,
description: "If true, users must set their wallet address with the /wallet command before they can start tasks.",
}),
maxConcurrentTasks: maxConcurrentTasks,
assignedIssueScope: T.Enum(AssignedIssueScope, {
default: AssignedIssueScope.ORG,
description: "When considering a user for a task: should we consider their assigned issues at the org, repo, or network level?",
examples: [AssignedIssueScope.ORG, AssignedIssueScope.REPO, AssignedIssueScope.NETWORK],
}),
emptyWalletText: T.String({
default: "Please set your wallet address with the /wallet command first and try again.",
description: "a message to display when a user tries to start a task without setting their wallet address.",
}),
rolesWithReviewAuthority: T.Transform(rolesWithReviewAuthority)
.Decode((value) => value.map((role) => role.toUpperCase()))
.Encode((value) => value.map((role) => Role[role as keyof typeof Role])),
requiredLabelsToStart: T.Array(requiredLabel, {
default: [],
description: "If set, a task must have at least one of these labels to be started.",
examples: [["Priority: 5 (Emergency)"], ["Good First Issue"]],
}),
},
{
default: {},
Expand Down
23 changes: 22 additions & 1 deletion tests/__mocks__/valid-configuration.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,5 +10,26 @@
"assignedIssueScope": "org",
"emptyWalletText": "Please set your wallet address with the /wallet command first and try again.",
"rolesWithReviewAuthority": ["OWNER", "ADMIN", "MEMBER"],
"requiredLabelsToStart": ["Priority: 1 (Normal)", "Priority: 2 (Medium)", "Priority: 3 (High)", "Priority: 4 (Urgent)", "Priority: 5 (Emergency)"]
"requiredLabelsToStart": [
{
"name": "Priority: 1 (Normal)",
"roles": ["admin", "member", "contributor", "owner", "billing_manager"]
},
{
"name": "Priority: 2 (Medium)",
"roles": ["admin", "member", "contributor", "owner", "billing_manager"]
},
{
"name": "Priority: 3 (High)",
"roles": ["admin", "member", "contributor", "owner", "billing_manager"]
},
{
"name": "Priority: 4 (Urgent)",
"roles": ["admin", "member", "contributor", "owner", "billing_manager"]
},
{
"name": "Priority: 5 (Emergency)",
"roles": ["admin", "member", "contributor", "owner", "billing_manager"]
}
]
}
Loading

0 comments on commit 183406e

Please sign in to comment.