diff --git a/README.md b/README.md index 18bf34b4..59689d97 100644 --- a/README.md +++ b/README.md @@ -45,6 +45,7 @@ To configure your Ubiquibot to run this plugin, add the following to the `.ubiqu 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 diff --git a/manifest.json b/manifest.json index 6fde9465..bdce4313 100644 --- a/manifest.json +++ b/manifest.json @@ -78,6 +78,13 @@ "items": { "type": "string" } + }, + "requiredLabelsToStart": { + "default": [], + "type": "array", + "items": { + "type": "string" + } } }, "required": [ @@ -87,7 +94,8 @@ "maxConcurrentTasks", "assignedIssueScope", "emptyWalletText", - "rolesWithReviewAuthority" + "rolesWithReviewAuthority", + "requiredLabelsToStart" ] } } \ No newline at end of file diff --git a/src/handlers/shared/start.ts b/src/handlers/shared/start.ts index ce2b5bc0..f8629c06 100644 --- a/src/handlers/shared/start.ts +++ b/src/handlers/shared/start.ts @@ -16,7 +16,18 @@ export async function start( teammates: string[] ): Promise { 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.`); diff --git a/src/handlers/user-start-stop.ts b/src/handlers/user-start-stop.ts index b5017c94..28567b89 100644 --- a/src/handlers/user-start-stop.ts +++ b/src/handlers/user-start-stop.ts @@ -11,8 +11,7 @@ export async function userStartStop(context: Context): Promise { 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("@") diff --git a/src/types/plugin-input.ts b/src/types/plugin-input.ts index fe0398f3..fef6718c 100644 --- a/src/types/plugin-input.ts +++ b/src/types/plugin-input.ts @@ -52,6 +52,7 @@ export const pluginSettingsSchema = T.Object( 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: {}, diff --git a/tests/__mocks__/issue-template.ts b/tests/__mocks__/issue-template.ts index c8f7dab1..b4c48b73 100644 --- a/tests/__mocks__/issue-template.ts +++ b/tests/__mocks__/issue-template.ts @@ -51,6 +51,9 @@ export default { { name: "Time: 1h", }, + { + name: "Priority: 1 (Normal)", + }, ], body: "body", }; diff --git a/tests/__mocks__/valid-configuration.json b/tests/__mocks__/valid-configuration.json index b67e4e8c..a3e8bc1d 100644 --- a/tests/__mocks__/valid-configuration.json +++ b/tests/__mocks__/valid-configuration.json @@ -9,5 +9,6 @@ }, "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)"] } diff --git a/tests/configuration.test.ts b/tests/configuration.test.ts index 99342de4..634b4967 100644 --- a/tests/configuration.test.ts +++ b/tests/configuration.test.ts @@ -2,6 +2,8 @@ import { Value } from "@sinclair/typebox/value"; import { AssignedIssueScope, PluginSettings, pluginSettingsSchema } from "../src/types"; import cfg from "./__mocks__/valid-configuration.json"; +const PRIORITY_LABELS = ["Priority: 1 (Normal)", "Priority: 2 (Medium)", "Priority: 3 (High)", "Priority: 4 (Urgent)", "Priority: 5 (Emergency)"]; + describe("Configuration tests", () => { it("Should decode the configuration", () => { const settings = Value.Default(pluginSettingsSchema, { @@ -12,11 +14,14 @@ describe("Configuration tests", () => { emptyWalletText: "Please set your wallet address with the /wallet command first and try again.", maxConcurrentTasks: { admin: 20, member: 10, contributor: 2 }, rolesWithReviewAuthority: ["OWNER", "ADMIN", "MEMBER"], + requiredLabelsToStart: PRIORITY_LABELS, }) as PluginSettings; expect(settings).toEqual(cfg); }); it("Should default the admin to infinity if missing from config when decoded", () => { - const settings = Value.Default(pluginSettingsSchema, {}) as PluginSettings; + const settings = Value.Default(pluginSettingsSchema, { + requiredLabelsToStart: PRIORITY_LABELS, + }) as PluginSettings; const decodedSettings = Value.Decode(pluginSettingsSchema, settings); expect(decodedSettings.maxConcurrentTasks["admin"]).toEqual(Infinity); }); @@ -24,6 +29,7 @@ describe("Configuration tests", () => { it("Should normalize maxConcurrentTasks role keys to lowercase when decoded", () => { const settings = Value.Default(pluginSettingsSchema, { maxConcurrentTasks: { ADMIN: 20, memBER: 10, CONTRIBUTOR: 2 }, + requiredLabelsToStart: PRIORITY_LABELS, }) as PluginSettings; const decodedSettings = Value.Decode(pluginSettingsSchema, settings); expect(decodedSettings.maxConcurrentTasks).toEqual({ admin: 20, member: 10, contributor: 2 }); diff --git a/tests/main.test.ts b/tests/main.test.ts index 0ae2f5f4..25451d21 100644 --- a/tests/main.test.ts +++ b/tests/main.test.ts @@ -19,6 +19,8 @@ type PayloadSender = Context["payload"]["sender"]; const octokit = jest.requireActual("@octokit/rest"); const TEST_REPO = "ubiquity/test-repo"; +const PRIORITY_ONE = "Priority: 1 (Normal)"; +const PRIORITY_LABELS = [PRIORITY_ONE, "Priority: 2 (Medium)", "Priority: 3 (High)", "Priority: 4 (Urgent)", "Priority: 5 (Emergency)"]; beforeAll(() => { server.listen(); @@ -276,6 +278,23 @@ describe("User start/stop", () => { expect(errorDetails).toContain("Invalid BOT_USER_ID"); } }); + + test("Should not allow a user to start if no requiredLabelToStart exists", async () => { + const issue = db.issue.findFirst({ where: { id: { equals: 7 } } }) as unknown as Issue; + const sender = db.users.findFirst({ where: { id: { equals: 1 } } }) as unknown as PayloadSender; + + const context = createContext(issue, sender, "/start", "1", false, [ + "Priority: 3 (High)", + "Priority: 4 (Urgent)", + "Priority: 5 (Emergency)", + ]) as Context<"issue_comment.created">; + + context.adapters = createAdapters(getSupabase(), context); + + await expect(userStartStop(context)).rejects.toMatchObject({ + logMessage: { raw: "This task does not reflect a business priority at the moment and cannot be started. This will be reassessed in the coming weeks." }, + }); + }); }); async function setupTests() { @@ -324,7 +343,11 @@ async function setupTests() { node_id: "MDU6SXNzdWUy", title: "Third issue", number: 3, - labels: [], + labels: [ + { + name: PRIORITY_ONE, + }, + ], body: "Third issue body", owner: "ubiquity", }); @@ -361,6 +384,28 @@ async function setupTests() { assignees: [], }); + db.issue.create({ + ...issueTemplate, + id: 7, + node_id: "MDU6SXNzdWUg", + title: "Seventh issue", + number: 7, + body: "Seventh issue body", + owner: "ubiquity", + assignees: [], + labels: [ + { + name: "Price: 200 USD", + }, + { + name: "Time: 1h", + }, + { + name: PRIORITY_ONE, + }, + ], + }); + db.pull.create({ id: 1, html_url: "https://github.com/ubiquity/test-repo/pull/1", @@ -595,7 +640,7 @@ function createIssuesForMaxAssignment(n: number, userId: number) { for (let i = 0; i < n; i++) { db.issue.create({ ...issueTemplate, - id: i + 7, + id: i + 8, assignee: user, }); } @@ -612,7 +657,8 @@ export function createContext( sender: Record | undefined, body = "/start", appId: string | null = "1", - startRequiresWallet = false + startRequiresWallet = false, + requiredLabelsToStart: string[] = PRIORITY_LABELS ): Context { return { adapters: {} as ReturnType, @@ -634,6 +680,7 @@ export function createContext( assignedIssueScope: AssignedIssueScope.ORG, emptyWalletText: "Please set your wallet address with the /wallet command first and try again.", rolesWithReviewAuthority: ["ADMIN", "OWNER", "MEMBER"], + requiredLabelsToStart, }, octokit: new octokit.Octokit(), eventName: "issue_comment.created" as SupportedEventsU,