diff --git a/bun.lockb b/bun.lockb index ed3749a8..bc47ff67 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/manifest.json b/manifest.json index 6c21ad89..a77752b0 100644 --- a/manifest.json +++ b/manifest.json @@ -35,6 +35,7 @@ "properties": { "reviewDelayTolerance": { "default": "1 Day", + "description": "How long shall the wait be for a reviewer to take action?", "type": "string" }, "taskStaleTimeoutDuration": { diff --git a/package.json b/package.json index aaa12fd7..b262ef3b 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "@ubiquity-os/command-start-stop", "version": "1.0.0", "description": "Enables the assignment and graceful unassignment of tasks to contributors.", - "main": "src/worker.ts", + "main": "src/index.ts", "author": "Ubiquity DAO", "license": "MIT", "engines": { @@ -35,6 +35,7 @@ "@ubiquity-os/plugin-sdk": "^1.1.0", "@ubiquity-os/ubiquity-os-logger": "^1.3.2", "dotenv": "^16.4.4", + "hono": "^4.6.12", "ms": "^2.1.3" }, "devDependencies": { diff --git a/src/handlers/shared/start.ts b/src/handlers/shared/start.ts index 4159c669..d0c7b1a0 100644 --- a/src/handlers/shared/start.ts +++ b/src/handlers/shared/start.ts @@ -1,6 +1,6 @@ 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 { addAssignees, addCommentToIssue, getAssignedIssues, getPendingOpenedPullRequests, getTimeValue, isParentIssue } from "../../utils/issue"; import { HttpStatusCode, Result } from "../result-types"; import { hasUserBeenUnassigned } from "./check-assignments"; import { checkTaskStale } from "./check-task-stale"; @@ -198,7 +198,7 @@ async function fetchUserIds(context: Context, username: string[]) { } async function handleTaskLimitChecks(username: string, context: Context, logger: Context["logger"], sender: string) { - const openedPullRequests = await getAvailableOpenedPullRequests(context, username); + const openedPullRequests = await getPendingOpenedPullRequests(context, username); const assignedIssues = await getAssignedIssues(context, username); const { limit } = await getUserRoleAndTaskLimit(context, username); diff --git a/src/worker.ts b/src/index.ts similarity index 94% rename from src/worker.ts rename to src/index.ts index a8a66434..e11b302e 100644 --- a/src/worker.ts +++ b/src/index.ts @@ -1,14 +1,14 @@ import { createPlugin } from "@ubiquity-os/plugin-sdk"; +import { Manifest } from "@ubiquity-os/plugin-sdk/manifest"; +import { LogLevel } from "@ubiquity-os/ubiquity-os-logger"; import type { ExecutionContext } from "hono"; +import manifest from "../manifest.json"; import { createAdapters } from "./adapters"; +import { startStopTask } from "./plugin"; +import { Command } from "./types/command"; import { SupportedEvents } from "./types/context"; import { Env, envSchema } from "./types/env"; import { PluginSettings, pluginSettingsSchema } from "./types/plugin-input"; -import manifest from "../manifest.json"; -import { Command } from "./types/command"; -import { startStopTask } from "./plugin"; -import { Manifest } from "@ubiquity-os/plugin-sdk/manifest"; -import { LogLevel } from "@ubiquity-os/ubiquity-os-logger"; export default { async fetch(request: Request, env: Env, executionCtx?: ExecutionContext) { @@ -27,6 +27,7 @@ export default { settingsSchema: pluginSettingsSchema, logLevel: env.LOG_LEVEL as LogLevel, kernelPublicKey: env.KERNEL_PUBLIC_KEY, + bypassSignatureVerification: process.env.NODE_ENV === "local", } ).fetch(request, env, executionCtx); }, diff --git a/src/plugin.ts b/src/plugin.ts index 43083397..7773d3a2 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -1,14 +1,13 @@ +import { createClient } from "@supabase/supabase-js"; +import { createAdapters } from "./adapters"; +import { HttpStatusCode } from "./handlers/result-types"; import { commandHandler, userPullRequest, userSelfAssign, userStartStop, userUnassigned } from "./handlers/user-start-stop"; import { Context } from "./types"; import { listOrganizations } from "./utils/list-organizations"; -import { HttpStatusCode } from "./handlers/result-types"; -import { createAdapters } from "./adapters"; -import { createClient } from "@supabase/supabase-js"; export async function startStopTask(context: Context) { context.adapters = createAdapters(createClient(context.env.SUPABASE_URL, context.env.SUPABASE_KEY), context as Context); - const organizations = await listOrganizations(context); - context.organizations = organizations; + context.organizations = await listOrganizations(context); if (context.command) { return await commandHandler(context); diff --git a/src/types/plugin-input.ts b/src/types/plugin-input.ts index 581674f4..428fc721 100644 --- a/src/types/plugin-input.ts +++ b/src/types/plugin-input.ts @@ -39,7 +39,7 @@ function maxConcurrentTasks() { export const pluginSettingsSchema = T.Object( { - reviewDelayTolerance: T.String({ default: "1 Day" }), + 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(), diff --git a/src/utils/issue.ts b/src/utils/issue.ts index 5d34a903..754ef119 100644 --- a/src/utils/issue.ts +++ b/src/utils/issue.ts @@ -1,11 +1,11 @@ import { RestEndpointMethodTypes } from "@octokit/plugin-rest-endpoint-methods"; import { Endpoints } from "@octokit/types"; import ms from "ms"; +import { AssignedIssueScope, Role } from "../types"; import { Context } from "../types/context"; import { GitHubIssueSearch, RepoIssues, Review } from "../types/payload"; import { getLinkedPullRequests, GetLinkedResults } from "./get-linked-prs"; import { getAllPullRequestsFallback, getAssignedIssuesFallback } from "./get-pull-requests-fallback"; -import { AssignedIssueScope, Role } from "../types"; export function isParentIssue(body: string) { const parentPattern = /-\s+\[( |x)\]\s+#\d+/; @@ -248,7 +248,62 @@ export function getOwnerRepoFromHtmlUrl(url: string) { }; } -export async function getAvailableOpenedPullRequests(context: Context, username: string) { +async function getReviewByUser(context: Context, pullRequest: Awaited>[0]) { + const { owner, repo } = getOwnerRepoFromHtmlUrl(pullRequest.html_url); + const reviews = (await getAllPullRequestReviews(context, pullRequest.number, owner, repo)).sort((a, b) => { + if (!a?.submitted_at || !b?.submitted_at) { + return 0; + } + return new Date(b.submitted_at).getTime() - new Date(a.submitted_at).getTime(); + }); + const latestReviewsByUser: Map = new Map(); + for (const review of reviews) { + const isReviewRequestedForUser = + "requested_reviewers" in pullRequest && pullRequest.requested_reviewers && pullRequest.requested_reviewers.some((o) => o.id === review.user?.id); + if (!isReviewRequestedForUser && review.user?.id && !latestReviewsByUser.has(review.user?.id)) { + latestReviewsByUser.set(review.user?.id, review); + } + } + + return latestReviewsByUser; +} + +async function shouldSkipPullRequest( + context: Context, + pullRequest: Awaited>[0], + reviews: Awaited>, + { owner, repo, issueNumber }: { owner: string; repo: string; issueNumber: number }, + reviewDelayTolerance: string +) { + const timeline = await context.octokit.paginate(context.octokit.rest.issues.listEventsForTimeline, { + owner, + repo, + issue_number: issueNumber, + }); + const reviewEvent = timeline.filter((o) => o.event === "review_requested").pop(); + const referenceTime = reviewEvent && "created_at" in reviewEvent ? new Date(reviewEvent.created_at).getTime() : new Date(pullRequest.created_at).getTime(); + + // If no reviews exist, check time reference + if (reviews.size === 0) { + return new Date().getTime() - referenceTime >= getTimeValue(reviewDelayTolerance); + } + + // If changes are requested, do not skip + if (Array.from(reviews.values()).some((review) => review.state === "CHANGES_REQUESTED")) { + return true; + } + + // If no approvals exist or time reference has exceeded review delay tolerance + const hasApproval = Array.from(reviews.values()).some((review) => review.state === "APPROVED"); + const isTimePassed = new Date().getTime() - referenceTime >= getTimeValue(reviewDelayTolerance); + + return hasApproval || !isTimePassed; +} + +/** + * Returns all the pull-requests pending to be approved, counting as a malus against the PR user's quota. + */ +export async function getPendingOpenedPullRequests(context: Context, username: string) { const { reviewDelayTolerance } = context.config; if (!reviewDelayTolerance) return []; @@ -259,16 +314,15 @@ export async function getAvailableOpenedPullRequests(context: Context, username: const openedPullRequest = openedPullRequests[i]; if (!openedPullRequest) continue; const { owner, repo } = getOwnerRepoFromHtmlUrl(openedPullRequest.html_url); - const reviews = await getAllPullRequestReviews(context, openedPullRequest.number, owner, repo); - - if (reviews.length > 0) { - const approvedReviews = reviews.find((review) => review.state === "APPROVED"); - if (approvedReviews) { - result.push(openedPullRequest); - } - } - - if (reviews.length === 0 && new Date().getTime() - new Date(openedPullRequest.created_at).getTime() >= getTimeValue(reviewDelayTolerance)) { + const latestReviewsByUser = await getReviewByUser(context, openedPullRequest); + const shouldSkipPr = await shouldSkipPullRequest( + context, + openedPullRequest, + latestReviewsByUser, + { owner, repo, issueNumber: openedPullRequest.number }, + reviewDelayTolerance + ); + if (!shouldSkipPr) { result.push(openedPullRequest); } } diff --git a/tests/http/run.http b/tests/http/run.http index 61ddb874..6df24870 100644 --- a/tests/http/run.http +++ b/tests/http/run.http @@ -8,25 +8,59 @@ X-GitHub-Delivery: mock-delivery-id "action": "created", "eventName": "issue_comment.created", "authToken": "{{GITHUB_TOKEN}}", + "ref": "1234", + "signature": "1234", + "settings": {}, + "stateId": "1234", + "command": null, "eventPayload": { "issue": { - "url": "https://api.github.com/repos/ubiquity/work.ubq.fi/issues/119", - "repository_url": "https://api.github.com/repos/ubiquity/work.ubq.fi", - "labels_url": "https://api.github.com/repos/ubiquity/work.ubq.fi/issues/119/labels{/name}", - "comments_url": "https://api.github.com/repos/ubiquity/work.ubq.fi/issues/119/comments", - "events_url": "https://api.github.com/repos/ubiquity/work.ubq.fi/issues/119/events", - "html_url": "https://github.com/ubiquity/work.ubq.fi/issues/119", + "url": "https://api.github.com/repos/{{owner}}/{{repo}}/issues/{{issueId}}", + "repository_url": "https://api.github.com/repos/{{owner}}/{{repo}}", + "labels_url": "https://api.github.com/repos/{{owner}}/{{repo}}/issues/{{issueId}}/labels{/name}", + "comments_url": "https://api.github.com/repos/{{owner}}/{{repo}}/issues/{{issueId}}/comments", + "events_url": "https://api.github.com/repos/{{owner}}/{{repo}}/issues/{{issueId}}/events", + "html_url": "https://github.com/{{owner}}/{{repo}}/issues/{{issueId}}", "id": 12345678, "node_id": "I_kwDOA1234567", - "number": 119, + "number": {{issueId}}, "title": "Sample Issue Title", + "labels": [ + { + "id": 7215029067, + "node_id": "LA_kwDOMIXMk88AAAABrgybSw", + "url": "https://api.github.com/repos/Meniole/command-start-stop/labels/Time:%20%3C1%20Day", + "name": "Time: <1 Day", + "color": "ededed", + "default": false, + "description": "" + }, + { + "id": 7215029076, + "node_id": "LA_kwDOMIXMk88AAAABrgybVA", + "url": "https://api.github.com/repos/Meniole/command-start-stop/labels/Priority:%204%20(Urgent)", + "name": "Priority: 4 (Urgent)", + "color": "ededed", + "default": false, + "description": "" + }, + { + "id": 7671693611, + "node_id": "LA_kwDOMIXMk88AAAAByUTBKw", + "url": "https://api.github.com/repos/Meniole/command-start-stop/labels/Price:%2037.5%20USD", + "name": "Price: 37.5 USD", + "color": "1f883d", + "default": false, + "description": null + } + ], "user": { - "login": "sshivaditya2019", + "login": "ubiquity-ubiquibot", "id": 12345678, "node_id": "MDQ6VXNlcjEyMzQ1Njc4", "avatar_url": "https://avatars.githubusercontent.com/u/12345678?v=4", - "url": "https://api.github.com/users/sshivaditya2019", - "html_url": "https://github.com/sshivaditya2019", + "url": "https://api.github.com/users/ubiquity-ubiquibot", + "html_url": "https://github.com/ubiquity-ubiquibot", "type": "User", "site_admin": false }, @@ -42,18 +76,18 @@ X-GitHub-Delivery: mock-delivery-id "body": "Original issue description" }, "comment": { - "url": "https://api.github.com/repos/ubiquity/work.ubq.fi/issues/comments/1234567890", - "html_url": "https://github.com/ubiquity/work.ubq.fi/issues/119#issuecomment-1234567890", - "issue_url": "https://api.github.com/repos/ubiquity/work.ubq.fi/issues/119", + "url": "https://api.github.com/repos/{{owner}}/{{repo}}/issues/comments/1234567890", + "html_url": "https://github.com/{{owner}}/{{repo}}/issues/{{issueId}}#issuecomment-1234567890", + "issue_url": "https://api.github.com/repos/{{owner}}/{{repo}}/issues/{{issueId}}", "id": 1234567890, "node_id": "IC_kwDOA1234567", "user": { - "login": "sshivaditya2019", - "id": 12345678, + "login": "ubiquity-ubiquibot", + "id": 163369652, "node_id": "MDQ6VXNlcjEyMzQ1Njc4", "avatar_url": "https://avatars.githubusercontent.com/u/12345678?v=4", - "url": "https://api.github.com/users/sshivaditya2019", - "html_url": "https://github.com/sshivaditya2019", + "url": "https://api.github.com/users/ubiquity-ubiquibot", + "html_url": "https://github.com/ubiquity-ubiquibot", "type": "User", "site_admin": false }, @@ -64,20 +98,20 @@ X-GitHub-Delivery: mock-delivery-id "repository": { "id": 98765432, "node_id": "R_kgDOBcDEFG", - "name": "work.ubq.fi", - "full_name": "ubiquity/work.ubq.fi", + "name": "{{repo}}", + "full_name": "{{owner}}/{{repo}}", "private": false, "owner": { - "login": "ubiquity", + "login": "{{owner}}", "id": 87654321, "node_id": "MDEyOk9yZ2FuaXphdGlvbjg3NjU0MzIx", "avatar_url": "https://avatars.githubusercontent.com/u/87654321?v=4", - "url": "https://api.github.com/users/ubiquity", - "html_url": "https://github.com/ubiquity", + "url": "https://api.github.com/users/{{owner}}", + "html_url": "https://github.com/{{owner}}", "type": "Organization", "site_admin": false }, - "html_url": "https://github.com/ubiquity/work.ubq.fi", + "html_url": "https://github.com/{{owner}}/{{repo}}", "description": "Work portal for Ubiquity DAO", "fork": false, "created_at": "2024-01-01T00:00:00Z", @@ -86,26 +120,26 @@ X-GitHub-Delivery: mock-delivery-id "default_branch": "development" }, "organization": { - "login": "ubiquity", + "login": "{{owner}}", "id": 87654321, "node_id": "MDEyOk9yZ2FuaXphdGlvbjg3NjU0MzIx", - "url": "https://api.github.com/orgs/ubiquity", - "repos_url": "https://api.github.com/orgs/ubiquity/repos", - "events_url": "https://api.github.com/orgs/ubiquity/events", - "hooks_url": "https://api.github.com/orgs/ubiquity/hooks", - "issues_url": "https://api.github.com/orgs/ubiquity/issues", - "members_url": "https://api.github.com/orgs/ubiquity/members{/member}", - "public_members_url": "https://api.github.com/orgs/ubiquity/public_members{/member}", + "url": "https://api.github.com/orgs/{{owner}}", + "repos_url": "https://api.github.com/orgs/{{owner}}/repos", + "events_url": "https://api.github.com/orgs/{{owner}}/events", + "hooks_url": "https://api.github.com/orgs/{{owner}}/hooks", + "issues_url": "https://api.github.com/orgs/{{owner}}/issues", + "members_url": "https://api.github.com/orgs/{{owner}}/members{/member}", + "public_members_url": "https://api.github.com/orgs/{{owner}}/public_members{/member}", "avatar_url": "https://avatars.githubusercontent.com/u/87654321?v=4", "description": "Ubiquity Organization" }, "sender": { - "login": "sshivaditya2019", - "id": 12345678, + "login": "ubiquity-ubiquibot", + "id": 163369652, "node_id": "MDQ6VXNlcjEyMzQ1Njc4", "avatar_url": "https://avatars.githubusercontent.com/u/12345678?v=4", - "url": "https://api.github.com/users/sshivaditya2019", - "html_url": "https://github.com/sshivaditya2019", + "url": "https://api.github.com/users/ubiquity-ubiquibot", + "html_url": "https://github.com/ubiquity-ubiquibot", "type": "User", "site_admin": false } diff --git a/tests/main.test.ts b/tests/main.test.ts index 5fcceda4..b49c61a3 100644 --- a/tests/main.test.ts +++ b/tests/main.test.ts @@ -1,17 +1,17 @@ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect } from "@jest/globals"; import { drop } from "@mswjs/data"; +import { TransformDecodeError, Value } from "@sinclair/typebox/value"; import { createClient } from "@supabase/supabase-js"; import { cleanLogString, Logs } from "@ubiquity-os/ubiquity-os-logger"; import dotenv from "dotenv"; import { createAdapters } from "../src/adapters"; +import { HttpStatusCode } from "../src/handlers/result-types"; import { userStartStop, userUnassigned } from "../src/handlers/user-start-stop"; import { AssignedIssueScope, Context, envSchema, Role, Sender, SupportedEvents } from "../src/types"; import { db } from "./__mocks__/db"; import issueTemplate from "./__mocks__/issue-template"; import { server } from "./__mocks__/node"; import usersGet from "./__mocks__/users-get.json"; -import { HttpStatusCode } from "../src/handlers/result-types"; -import { TransformDecodeError, Value } from "@sinclair/typebox/value"; dotenv.config(); @@ -700,7 +700,7 @@ export function createContext( BOT_USER_ID: appId as unknown as number, }, command: null, - }; + } as unknown as Context; } export function getSupabase(withData = true) { diff --git a/wrangler.toml b/wrangler.toml index b81ff797..ffd3573a 100644 --- a/wrangler.toml +++ b/wrangler.toml @@ -1,5 +1,5 @@ name = "ubiquity-os-command-start-stop" -main = "src/worker.ts" +main = "src/index.ts" compatibility_date = "2024-09-23" compatibility_flags = [ "nodejs_compat" ] [env.dev]