also looking at this
feat(review): implement @buddy resolve, which was advertised and absent
#1417
15 files
+500
-1
Review threads live on the whole diff, not on one commit, so none are shown here - a thread's line means something in the branch's final form, and painting it into an intermediate step would put it on code it is not about.
| @@ -1476,6 +1476,10 @@ cli | ||
| 1476 | 1476 | const { runPlan } = await import('../src/agent') |
| 1477 | 1477 | return await runPlan({ config, provider, number, isPullRequest, request, logger }) |
| 1478 | 1478 | }, |
| 1479 | resolve: async (prNumber) => { | |
| 1480 | const { resolveBuddyThreads } = await import('../src/review') | |
| 1481 | return await resolveBuddyThreads({ provider, prNumber, logger }) | |
| 1482 | }, | |
| 1479 | 1483 | remember: async (text, context) => { |
| 1480 | 1484 | const { addLearning, createLearning, loadLearnings, serializeLearnings, DEFAULT_LEARNINGS_FILE } |
| 1481 | 1485 | = await import('../src/ai') |
| @@ -64,6 +64,7 @@ if (supports(provider, 'pinIssues', 'pinIssue')) | ||
| 64 | 64 | | `commentReactions` | `reactToComment` | |
| 65 | 65 | | `ciLogs` | `getWorkflowRunLogs` | |
| 66 | 66 | | `ciRuns` | `listWorkflowRuns`, `rerunWorkflowRun` | |
| 67 | | `reviewThreads` | `listReviewThreads`, `resolveReviewThread` | | |
| 67 | 68 | | `teamReviewers` | Requesting review from a team | |
| 68 | 69 | | `draftPullRequests` | Opening a pull request as a draft | |
| 69 | 70 | | `permissionLookup` | `hasWriteAccess` | |
| @@ -83,6 +84,7 @@ if (supports(provider, 'pinIssues', 'pinIssue')) | ||
| 83 | 84 | | `commentReactions` | ✅ | ✅ (award emoji) | ❌ | |
| 84 | 85 | | `ciLogs` | ✅ | ✅ | ❌ | |
| 85 | 86 | | `ciRuns` | ✅ | ✅ (jobs) | ❌ | |
| 87 | | `reviewThreads` | ✅ | ✅ (discussions) | ❌ | | |
| 86 | 88 | | `teamReviewers` | ✅ | ❌ | ❌ | |
| 87 | 89 | | `draftPullRequests` | ✅ | ✅ (title prefix) | ❌ | |
| 88 | 90 | | `permissionLookup` | ✅ | ✅ | ✅ | |
| @@ -51,6 +51,7 @@ export const HELP_TEXT = `**Buddy commands** | ||
| 51 | 51 | | \`@buddy merge\` | Re-check auto-merge conditions now | |
| 52 | 52 | | \`@buddy fix-ci\` | Diagnose the failing checks, and retry a flake | |
| 53 | 53 | | \`@buddy plan\` | Research the repository and write an implementation plan | |
| 54 | | \`@buddy resolve\` | Close the review threads Buddy opened | | |
| 54 | 55 | | \`@buddy remember <text>\` | Record a durable note for future runs | |
| 55 | 56 | | \`@buddy help\` | Show this table | |
| 56 | 57 | |
| @@ -17,6 +17,8 @@ export interface HandlerDeps { | ||
| 17 | 17 | fixCi: (prNumber: number) => Promise<string> |
| 18 | 18 | /** Turn an issue or pull request into an implementation plan */ |
| 19 | 19 | plan: (number: number, isPullRequest: boolean, request: string) => Promise<string> |
| 20 | /** Close the review threads Buddy opened on a pull request */ | |
| 21 | resolve: (prNumber: number) => Promise<string> | |
| 20 | 22 | /** Record a durable note */ |
| 21 | 23 | remember: (text: string, context: CommandContext) => Promise<string> |
| 22 | 24 | } |
| @@ -117,6 +119,18 @@ export function createHandlers(deps: HandlerDeps): Record<string, CommandHandler | ||
| 117 | 119 | return { handled: true, reply } |
| 118 | 120 | }, |
| 119 | 121 | |
| 122 | async resolve(context): Promise<CommandOutcome> { | |
| 123 | if (!context.isPullRequest) { | |
| 124 | return { | |
| 125 | handled: false, | |
| 126 | reply: 'There are no review threads here — this is an issue, not a pull request.', | |
| 127 | } | |
| 128 | } | |
| 129 | ||
| 130 | const status = await deps.resolve(context.number) | |
| 131 | return { handled: true, reply: status } | |
| 132 | }, | |
| 133 | ||
| 120 | 134 | async plan(context): Promise<CommandOutcome> { |
| 121 | 135 | // Answered as the reply rather than posted separately: the plan *is* the |
| 122 | 136 | // response to the comment, and `handle-comment` already posts one. |
| @@ -94,6 +94,9 @@ export class BitbucketProvider implements GitProvider { | ||
| 94 | 94 | // Bitbucket Pipelines is a separate product with its own API surface, |
| 95 | 95 | // and this provider reads none of it. |
| 96 | 96 | ciRuns: false, |
| 97 | // Bitbucket resolves comment threads in its own UI only; the API | |
| 98 | // exposes no way to mark one resolved. | |
| 99 | reviewThreads: false, | |
| 97 | 100 | teamReviewers: false, |
| 98 | 101 | // Bitbucket has no draft state at all. |
| 99 | 102 | draftPullRequests: false, |
| @@ -1,6 +1,6 @@ | ||
| 1 | 1 | import type { FileChange, Issue, IssueOptions, PullRequest, PullRequestOptions } from '../types' |
| 2 | 2 | import type { Logger } from '../utils/logger' |
| 3 | import type { GitProvider, ListWorkflowRunsOptions, ProviderCapabilities, WorkflowRun } from './provider' | |
| 3 | import type { GitProvider, ListWorkflowRunsOptions, ProviderCapabilities, ReviewThread, WorkflowRun } from './provider' | |
| 4 | 4 | import { Buffer } from 'node:buffer' |
| 5 | 5 | import { spawn } from 'node:child_process' |
| 6 | 6 | import process from 'node:process' |
| @@ -149,6 +149,7 @@ export class GitHubProvider implements GitProvider { | ||
| 149 | 149 | commentReactions: true, |
| 150 | 150 | ciLogs: true, |
| 151 | 151 | ciRuns: true, |
| 152 | reviewThreads: true, | |
| 152 | 153 | teamReviewers: true, |
| 153 | 154 | draftPullRequests: true, |
| 154 | 155 | permissionLookup: true, |
| @@ -1433,6 +1434,95 @@ export class GitHubProvider implements GitProvider { | ||
| 1433 | 1434 | return true |
| 1434 | 1435 | } |
| 1435 | 1436 | |
| 1437 | /** | |
| 1438 | * List the review threads on a pull request, oldest first. | |
| 1439 | * | |
| 1440 | * REST exposes review comments but not the threads they belong to, nor | |
| 1441 | * whether one is resolved — both of which are GraphQL-only. Resolves `[]` | |
| 1442 | * rather than throwing, because a caller tidying up should treat an | |
| 1443 | * unreadable conversation as nothing to tidy. | |
| 1444 | * | |
| 1445 | * @param prNumber - Pull request to read | |
| 1446 | * @returns Threads, oldest first; `[]` when unreadable | |
| 1447 | */ | |
| 1448 | async listReviewThreads(prNumber: number): Promise<ReviewThread[]> { | |
| 1449 | const query = `query($owner: String!, $repo: String!, $number: Int!) { | |
| 1450 | repository(owner: $owner, name: $repo) { | |
| 1451 | pullRequest(number: $number) { | |
| 1452 | reviewThreads(first: 100) { | |
| 1453 | nodes { | |
| 1454 | id | |
| 1455 | isResolved | |
| 1456 | path | |
| 1457 | comments(first: 10) { nodes { author { login } } } | |
| 1458 | } | |
| 1459 | } | |
| 1460 | } | |
| 1461 | } | |
| 1462 | }` | |
| 1463 | ||
| 1464 | try { | |
| 1465 | const response = await this.graphqlRequest(query, { | |
| 1466 | owner: this.owner, | |
| 1467 | repo: this.repo, | |
| 1468 | number: prNumber, | |
| 1469 | }) | |
| 1470 | ||
| 1471 | if (response.errors?.length) { | |
| 1472 | this.logger.debug(`Could not read review threads on #${prNumber}: ${ | |
| 1473 | response.errors.map((error: { message: string }) => error.message).join('; ') | |
| 1474 | }`) | |
| 1475 | return [] | |
| 1476 | } | |
| 1477 | ||
| 1478 | const nodes = response.data?.repository?.pullRequest?.reviewThreads?.nodes ?? [] | |
| 1479 | return nodes.filter(Boolean).map((node: Record<string, any>) => ({ | |
| 1480 | id: String(node.id), | |
| 1481 | isResolved: Boolean(node.isResolved), | |
| 1482 | ...(node.path ? { path: String(node.path) } : {}), | |
| 1483 | authorLogins: (node.comments?.nodes ?? []) | |
| 1484 | .map((comment: { author?: { login?: string } }) => comment?.author?.login) | |
| 1485 | .filter((login: unknown): login is string => typeof login === 'string'), | |
| 1486 | })) | |
| 1487 | } | |
| 1488 | catch (error) { | |
| 1489 | this.logger.debug(`Could not read review threads on #${prNumber}: ${formatError(error)}`) | |
| 1490 | return [] | |
| 1491 | } | |
| 1492 | } | |
| 1493 | ||
| 1494 | /** | |
| 1495 | * Mark a review thread resolved. | |
| 1496 | * | |
| 1497 | * @param _prNumber - Unused; GitHub thread ids are globally addressable | |
| 1498 | * @param threadId - Thread to resolve | |
| 1499 | * @returns Whether GitHub accepted it | |
| 1500 | */ | |
| 1501 | async resolveReviewThread(_prNumber: number, threadId: string): Promise<boolean> { | |
| 1502 | const mutation = `mutation($threadId: ID!) { | |
| 1503 | resolveReviewThread(input: { threadId: $threadId }) { | |
| 1504 | thread { isResolved } | |
| 1505 | } | |
| 1506 | }` | |
| 1507 | ||
| 1508 | try { | |
| 1509 | const response = await this.graphqlRequest(mutation, { threadId }) | |
| 1510 | ||
| 1511 | if (response.errors?.length) { | |
| 1512 | this.logger.warn(`Could not resolve review thread: ${ | |
| 1513 | response.errors.map((error: { message: string }) => error.message).join('; ') | |
| 1514 | }`) | |
| 1515 | return false | |
| 1516 | } | |
| 1517 | ||
| 1518 | return Boolean(response.data?.resolveReviewThread?.thread?.isResolved) | |
| 1519 | } | |
| 1520 | catch (error) { | |
| 1521 | this.logger.warn(`Could not resolve review thread: ${formatError(error)}`) | |
| 1522 | return false | |
| 1523 | } | |
| 1524 | } | |
| 1525 | ||
| 1436 | 1526 | /** |
| 1437 | 1527 | * Aggregate state of the checks and commit statuses on a PR's head commit. |
| 1438 | 1528 | * |
| @@ -7,12 +7,24 @@ import type { | ||
| 7 | 7 | ProviderCapabilities, |
| 8 | 8 | ReviewSubmission, |
| 9 | 9 | ReviewSubmissionResult, |
| 10 | ReviewThread, | |
| 10 | 11 | WorkflowRun, |
| 11 | 12 | } from './provider' |
| 12 | 13 | import { formatError } from '../utils/errors' |
| 13 | 14 | import { fetchWithTimeout } from '../utils/http' |
| 14 | 15 | import { getDefaultLogger } from '../utils/logger' |
| 15 | 16 | |
| 17 | /** GitLab's merge request discussion, as much of it as buddy reads. */ | |
| 18 | interface GitLabDiscussion { | |
| 19 | id: string | |
| 20 | notes?: Array<{ | |
| 21 | author?: { username?: string } | |
| 22 | resolvable?: boolean | |
| 23 | resolved?: boolean | |
| 24 | position?: { new_path?: string } | |
| 25 | }> | |
| 26 | } | |
| 27 | ||
| 16 | 28 | /** GitLab's CI job, as much of it as buddy reads. */ |
| 17 | 29 | interface GitLabJob { |
| 18 | 30 | id: number |
| @@ -145,6 +157,8 @@ export class GitLabProvider implements GitProvider { | ||
| 145 | 157 | ciLogs: true, |
| 146 | 158 | // Jobs, not pipelines — see `listWorkflowRuns`. |
| 147 | 159 | ciRuns: true, |
| 160 | // Discussions, which GitLab resolves per merge request. | |
| 161 | reviewThreads: true, | |
| 148 | 162 | teamReviewers: false, |
| 149 | 163 | draftPullRequests: true, |
| 150 | 164 | permissionLookup: true, |
| @@ -707,6 +721,64 @@ export class GitLabProvider implements GitProvider { | ||
| 707 | 721 | } |
| 708 | 722 | } |
| 709 | 723 | |
| 724 | /** | |
| 725 | * List the resolvable discussions on a merge request, oldest first. | |
| 726 | * | |
| 727 | * Only resolvable ones: GitLab returns system notes and plain comments from | |
| 728 | * the same endpoint, and neither can be resolved, so including them would | |
| 729 | * hand a caller threads it can do nothing with. | |
| 730 | * | |
| 731 | * @param prNumber - Merge request iid | |
| 732 | * @returns Threads, oldest first; `[]` when unreadable | |
| 733 | */ | |
| 734 | async listReviewThreads(prNumber: number): Promise<ReviewThread[]> { | |
| 735 | try { | |
| 736 | const discussions = await this.request<GitLabDiscussion[]>( | |
| 737 | 'GET', | |
| 738 | `/projects/${this.projectId}/merge_requests/${prNumber}/discussions?per_page=100`, | |
| 739 | ) | |
| 740 | ||
| 741 | return (discussions ?? []) | |
| 742 | .filter(discussion => (discussion.notes ?? []).some(note => note.resolvable)) | |
| 743 | .map(discussion => ({ | |
| 744 | id: discussion.id, | |
| 745 | isResolved: (discussion.notes ?? []).every(note => !note.resolvable || note.resolved), | |
| 746 | ...(discussion.notes?.[0]?.position?.new_path | |
| 747 | ? { path: discussion.notes[0].position!.new_path! } | |
| 748 | : {}), | |
| 749 | authorLogins: (discussion.notes ?? []) | |
| 750 | .map(note => note.author?.username) | |
| 751 | .filter((login): login is string => typeof login === 'string'), | |
| 752 | })) | |
| 753 | } | |
| 754 | catch (error) { | |
| 755 | this.logger.debug(`Could not read discussions on !${prNumber}: ${formatError(error)}`) | |
| 756 | return [] | |
| 757 | } | |
| 758 | } | |
| 759 | ||
| 760 | /** | |
| 761 | * Mark a discussion resolved. | |
| 762 | * | |
| 763 | * @param prNumber - Merge request iid the discussion belongs to | |
| 764 | * @param threadId - Discussion id | |
| 765 | * @returns Whether GitLab accepted it | |
| 766 | */ | |
| 767 | async resolveReviewThread(prNumber: number, threadId: string): Promise<boolean> { | |
| 768 | try { | |
| 769 | await this.request( | |
| 770 | 'PUT', | |
| 771 | `/projects/${this.projectId}/merge_requests/${prNumber}/discussions/${encodeURIComponent(threadId)}`, | |
| 772 | { resolved: true }, | |
| 773 | ) | |
| 774 | return true | |
| 775 | } | |
| 776 | catch (error) { | |
| 777 | this.logger.warn(`Could not resolve discussion ${threadId}: ${formatError(error)}`) | |
| 778 | return false | |
| 779 | } | |
| 780 | } | |
| 781 | ||
| 710 | 782 | // -- Housekeeping -------------------------------------------------------- |
| 711 | 783 | |
| 712 | 784 | async getBuddyBranches(): Promise<ProviderBranch[]> { |
| @@ -52,6 +52,14 @@ export interface ProviderCapabilities { | ||
| 52 | 52 | commentReactions: boolean |
| 53 | 53 | /** Read CI logs for a failed run (`getWorkflowRunLogs`) */ |
| 54 | 54 | ciLogs: boolean |
| 55 | /** | |
| 56 | * Read and resolve review threads (`listReviewThreads`, `resolveReviewThread`). | |
| 57 | * | |
| 58 | * Separate from `inlineReviewComments`, which only covers *posting* one. A | |
| 59 | * platform can accept a line-anchored comment without modelling the thread | |
| 60 | * it starts, and resolving needs the thread. | |
| 61 | */ | |
| 62 | reviewThreads: boolean | |
| 55 | 63 | /** |
| 56 | 64 | * Enumerate and re-run CI runs (`listWorkflowRuns`, `rerunWorkflowRun`). |
| 57 | 65 | * |
| @@ -96,6 +104,7 @@ export const NO_CAPABILITIES: ProviderCapabilities = { | ||
| 96 | 104 | commentReactions: false, |
| 97 | 105 | ciLogs: false, |
| 98 | 106 | ciRuns: false, |
| 107 | reviewThreads: false, | |
| 99 | 108 | teamReviewers: false, |
| 100 | 109 | draftPullRequests: false, |
| 101 | 110 | permissionLookup: false, |
| @@ -104,6 +113,24 @@ export const NO_CAPABILITIES: ProviderCapabilities = { | ||
| 104 | 113 | labels: false, |
| 105 | 114 | } |
| 106 | 115 | |
| 116 | /** | |
| 117 | * A review thread as the provider reports it. | |
| 118 | * | |
| 119 | * `authorLogins` rather than a single author because a thread is a | |
| 120 | * conversation: the caller resolving one needs to know who *started* it, and | |
| 121 | * the first login is that person. | |
| 122 | */ | |
| 123 | export interface ReviewThread { | |
| 124 | /** Provider-side identifier, accepted by `resolveReviewThread` */ | |
| 125 | id: string | |
| 126 | /** Whether the thread is already resolved */ | |
| 127 | isResolved: boolean | |
| 128 | /** File the thread is anchored to, when it is anchored */ | |
| 129 | path?: string | |
| 130 | /** Logins that commented, oldest first */ | |
| 131 | authorLogins: string[] | |
| 132 | } | |
| 133 | ||
| 107 | 134 | /** |
| 108 | 135 | * A CI run as the provider reports it. |
| 109 | 136 | * |
| @@ -267,6 +294,23 @@ export interface GitProvider { | ||
| 267 | 294 | /** Requires `ciLogs`; null when logs are unavailable or expired */ |
| 268 | 295 | getWorkflowRunLogs?: (runId: number) => Promise<string | null> |
| 269 | 296 | |
| 297 | /** | |
| 298 | * Requires `reviewThreads`; threads on a pull request, oldest first. | |
| 299 | * | |
| 300 | * Resolves `[]` rather than throwing when they cannot be read, so a caller | |
| 301 | * tidying up treats an unreadable conversation as nothing to tidy. | |
| 302 | */ | |
| 303 | listReviewThreads?: (prNumber: number) => Promise<ReviewThread[]> | |
| 304 | ||
| 305 | /** | |
| 306 | * Requires `reviewThreads`; resolves false when the platform refused. | |
| 307 | * | |
| 308 | * Takes the pull request as well as the thread because a thread id is only | |
| 309 | * unique within one on GitLab, where resolving addresses a discussion under | |
| 310 | * its merge request rather than a globally-addressable node. | |
| 311 | */ | |
| 312 | resolveReviewThread?: (prNumber: number, threadId: string) => Promise<boolean> | |
| 313 | ||
| 270 | 314 | /** |
| 271 | 315 | * Requires `ciRuns`; most recent first. |
| 272 | 316 | * |
| @@ -30,6 +30,8 @@ export { | ||
| 30 | 30 | } from './guidelines' |
| 31 | 31 | export type { RefFileReader } from './guidelines' |
| 32 | 32 | export { prepareReview, renderFinding } from './poster' |
| 33 | export { resolveBuddyThreads } from './resolve' | |
| 34 | export type { ResolveThreadsOptions } from './resolve' | |
| 33 | 35 | export { runReviewForPR } from './run' |
| 34 | 36 | export type { RunReviewOptions } from './run' |
| 35 | 37 | export type { InlineComment, PreparedReview } from './poster' |
| @@ -0,0 +1,92 @@ | ||
| 1 | /** | |
| 2 | * Closing the review threads Buddy opened. | |
| 3 | * | |
| 4 | * `@buddy resolve` was recognised by the command parser and described in the | |
| 5 | * docs — "closes the threads Buddy opened, not the ones your teammates did" — | |
| 6 | * with no handler behind it and no provider method that could have backed one. | |
| 7 | */ | |
| 8 | import type { GitProvider, ReviewThread } from '../git/provider' | |
| 9 | import type { Logger } from '../utils/logger' | |
| 10 | import { supports } from '../git/provider' | |
| 11 | import { getDefaultLogger } from '../utils/logger' | |
| 12 | ||
| 13 | /** Inputs to a resolve run. */ | |
| 14 | export interface ResolveThreadsOptions { | |
| 15 | provider: GitProvider | |
| 16 | prNumber: number | |
| 17 | /** | |
| 18 | * Login whose threads may be closed. | |
| 19 | * | |
| 20 | * Defaults to the bot's own. Passing someone else's would close a human's | |
| 21 | * conversation, which is the thing this must not do. | |
| 22 | */ | |
| 23 | author?: string | |
| 24 | logger?: Logger | |
| 25 | } | |
| 26 | ||
| 27 | /** Logins a review posted by Buddy appears under. */ | |
| 28 | const BOT_LOGINS = ['github-actions[bot]', 'buddy', 'buddy[bot]'] | |
| 29 | ||
| 30 | /** | |
| 31 | * Whether a thread is Buddy's to close. | |
| 32 | * | |
| 33 | * Judged by who *started* it, not who is in it. A maintainer replying to a | |
| 34 | * finding does not make the thread theirs, and a teammate's thread that Buddy | |
| 35 | * happened to comment on is still not Buddy's to close. | |
| 36 | * | |
| 37 | * @param thread - The thread in question | |
| 38 | * @param author - Login to match, when the caller knows it | |
| 39 | */ | |
| 40 | function startedByBuddy(thread: ReviewThread, author?: string): boolean { | |
| 41 | const opener = thread.authorLogins[0] | |
| 42 | if (!opener) | |
| 43 | return false | |
| 44 | ||
| 45 | return author ? opener === author : BOT_LOGINS.includes(opener) | |
| 46 | } | |
| 47 | ||
| 48 | /** | |
| 49 | * Resolve the review threads Buddy opened on a pull request. | |
| 50 | * | |
| 51 | * @param options - Provider, pull request and whose threads to close | |
| 52 | * @returns A short status line describing what happened | |
| 53 | * @example | |
| 54 | * ```ts | |
| 55 | * const status = await resolveBuddyThreads({ provider, prNumber: 128 }) | |
| 56 | * ``` | |
| 57 | */ | |
| 58 | export async function resolveBuddyThreads(options: ResolveThreadsOptions): Promise<string> { | |
| 59 | const logger = options.logger ?? getDefaultLogger() | |
| 60 | const { provider, prNumber } = options | |
| 61 | ||
| 62 | if (!supports(provider, 'reviewThreads', 'listReviewThreads')) | |
| 63 | return 'This provider cannot resolve review threads.' | |
| 64 | ||
| 65 | const threads = await provider.listReviewThreads(prNumber) | |
| 66 | const mine = threads.filter(thread => !thread.isResolved && startedByBuddy(thread, options.author)) | |
| 67 | ||
| 68 | if (mine.length === 0) { | |
| 69 | // Distinguished deliberately: "nothing of mine is open" and "there is | |
| 70 | // nothing here at all" are different answers to the same request. | |
| 71 | return threads.length === 0 | |
| 72 | ? 'There are no review threads on this pull request.' | |
| 73 | : 'None of the open threads here are mine to close.' | |
| 74 | } | |
| 75 | ||
| 76 | if (!supports(provider, 'reviewThreads', 'resolveReviewThread')) | |
| 77 | return 'This provider cannot resolve review threads.' | |
| 78 | ||
| 79 | let resolved = 0 | |
| 80 | for (const thread of mine) { | |
| 81 | if (await provider.resolveReviewThread(prNumber, thread.id)) | |
| 82 | resolved++ | |
| 83 | else | |
| 84 | logger.warn(`Could not resolve thread ${thread.id} on #${prNumber}`) | |
| 85 | } | |
| 86 | ||
| 87 | if (resolved === 0) | |
| 88 | return `I could not close any of the ${mine.length} thread(s) I opened.` | |
| 89 | ||
| 90 | const partial = resolved < mine.length ? ` (${mine.length - resolved} refused)` : '' | |
| 91 | return `Closed ${resolved} thread(s) I opened${partial}.` | |
| 92 | } | |
| @@ -29,6 +29,10 @@ function deps(overrides: Partial<HandlerDeps> = {}): HandlerDeps & { calls: stri | ||
| 29 | 29 | calls.push(`fixCi:${pr}`) |
| 30 | 30 | return 'reported' |
| 31 | 31 | }, |
| 32 | resolve: async (pr) => { | |
| 33 | calls.push(`resolve:${pr}`) | |
| 34 | return 'Closed 2 thread(s) I opened.' | |
| 35 | }, | |
| 32 | 36 | plan: async (number, isPullRequest, request) => { |
| 33 | 37 | calls.push(`plan:${number}:${isPullRequest}:${request}`) |
| 34 | 38 | return '## Plan\n\n1. Do the thing' |
| @@ -135,4 +139,26 @@ describe('command handlers', () => { | ||
| 135 | 139 | expect(outcome.reply).toContain('## Plan') |
| 136 | 140 | }) |
| 137 | 141 | }) |
| 142 | ||
| 143 | describe('resolve', () => { | |
| 144 | it('success case - closes the threads on the pull request', async () => { | |
| 145 | const dependencies = deps() | |
| 146 | const outcome = await createHandlers(dependencies).resolve( | |
| 147 | context({ command: { name: 'resolve', args: '', raw: '@buddy resolve' } }), | |
| 148 | ) | |
| 149 | ||
| 150 | expect(outcome.handled).toBe(true) | |
| 151 | expect(dependencies.calls).toEqual(['resolve:42']) | |
| 152 | }) | |
| 153 | ||
| 154 | it('failure case - an issue has no review threads', async () => { | |
| 155 | const dependencies = deps() | |
| 156 | const outcome = await createHandlers(dependencies).resolve( | |
| 157 | context({ command: { name: 'resolve', args: '', raw: '' }, isPullRequest: false }), | |
| 158 | ) | |
| 159 | ||
| 160 | expect(outcome.handled).toBe(false) | |
| 161 | expect(dependencies.calls).toEqual([]) | |
| 162 | }) | |
| 163 | }) | |
| 138 | 164 | }) |
| @@ -35,6 +35,7 @@ export function runProviderConformance(name: string, create: () => GitProvider): | ||
| 35 | 35 | 'commentReactions', |
| 36 | 36 | 'ciLogs', |
| 37 | 37 | 'ciRuns', |
| 38 | 'reviewThreads', | |
| 38 | 39 | 'teamReviewers', |
| 39 | 40 | 'draftPullRequests', |
| 40 | 41 | 'permissionLookup', |
| @@ -62,6 +63,8 @@ export function runProviderConformance(name: string, create: () => GitProvider): | ||
| 62 | 63 | ['ciLogs', 'getWorkflowRunLogs'], |
| 63 | 64 | ['ciRuns', 'listWorkflowRuns'], |
| 64 | 65 | ['ciRuns', 'rerunWorkflowRun'], |
| 66 | ['reviewThreads', 'listReviewThreads'], | |
| 67 | ['reviewThreads', 'resolveReviewThread'], | |
| 65 | 68 | ['permissionLookup', 'hasWriteAccess'], |
| 66 | 69 | ['branchHousekeeping', 'cleanupStaleBranches'], |
| 67 | 70 | ['reopenPullRequests', 'reopenPullRequest'], |
| @@ -0,0 +1,144 @@ | ||
| 1 | import type { GitProvider, ReviewThread } from '../src/git/provider' | |
| 2 | import { describe, expect, it } from 'bun:test' | |
| 3 | import { resolveBuddyThreads } from '../src/review/resolve' | |
| 4 | import { NO_CAPABILITIES } from '../src/git/provider' | |
| 5 | import { Logger } from '../src/utils/logger' | |
| 6 | ||
| 7 | /** A provider over a fixed thread list, recording what it was asked to close. */ | |
| 8 | function stub(threads: ReviewThread[], options: { capable?: boolean, refuse?: boolean } = {}) { | |
| 9 | const closed: string[] = [] | |
| 10 | ||
| 11 | const provider = { | |
| 12 | capabilities: () => ({ ...NO_CAPABILITIES, reviewThreads: options.capable ?? true }), | |
| 13 | listReviewThreads: async () => threads, | |
| 14 | resolveReviewThread: async (_pr: number, id: string) => { | |
| 15 | if (options.refuse) | |
| 16 | return false | |
| 17 | ||
| 18 | closed.push(id) | |
| 19 | return true | |
| 20 | }, | |
| 21 | } as unknown as GitProvider | |
| 22 | ||
| 23 | return { provider, closed } | |
| 24 | } | |
| 25 | ||
| 26 | function thread(overrides: Partial<ReviewThread> = {}): ReviewThread { | |
| 27 | return { | |
| 28 | id: 't1', | |
| 29 | isResolved: false, | |
| 30 | path: 'src/app.ts', | |
| 31 | authorLogins: ['github-actions[bot]'], | |
| 32 | ...overrides, | |
| 33 | } | |
| 34 | } | |
| 35 | ||
| 36 | const base = { prNumber: 7, logger: Logger.silent() } | |
| 37 | ||
| 38 | /** | |
| 39 | * `@buddy resolve` was recognised by the parser and described in the docs — | |
| 40 | * "closes the threads Buddy opened, not the ones your teammates did" — with no | |
| 41 | * handler behind it and no provider method that could have backed one. | |
| 42 | */ | |
| 43 | describe('resolveBuddyThreads', () => { | |
| 44 | describe('whose threads get closed', () => { | |
| 45 | it('success case - closes a thread buddy opened', async () => { | |
| 46 | const { provider, closed } = stub([thread({ id: 'mine' })]) | |
| 47 | ||
| 48 | const status = await resolveBuddyThreads({ ...base, provider }) | |
| 49 | ||
| 50 | expect(closed).toEqual(['mine']) | |
| 51 | expect(status).toContain('Closed 1 thread') | |
| 52 | }) | |
| 53 | ||
| 54 | it('failure case - leaves a teammate\'s thread alone', async () => { | |
| 55 | // The whole promise of the command. Closing a human's conversation | |
| 56 | // because a bot was asked to tidy up is the failure that matters. | |
| 57 | const { provider, closed } = stub([thread({ id: 'theirs', authorLogins: ['alice'] })]) | |
| 58 | ||
| 59 | const status = await resolveBuddyThreads({ ...base, provider }) | |
| 60 | ||
| 61 | expect(closed).toEqual([]) | |
| 62 | expect(status).toContain('None of the open threads') | |
| 63 | }) | |
| 64 | ||
| 65 | it('failure case - a thread buddy only replied to is not buddy\'s', async () => { | |
| 66 | // Judged by who started it. A bot commenting on a maintainer's thread | |
| 67 | // does not make the thread the bot's to close. | |
| 68 | const { provider, closed } = stub([ | |
| 69 | thread({ id: 'theirs', authorLogins: ['alice', 'github-actions[bot]'] }), | |
| 70 | ]) | |
| 71 | ||
| 72 | await resolveBuddyThreads({ ...base, provider }) | |
| 73 | ||
| 74 | expect(closed).toEqual([]) | |
| 75 | }) | |
| 76 | ||
| 77 | it('success case - a maintainer replying does not take the thread away', async () => { | |
| 78 | const { provider, closed } = stub([ | |
| 79 | thread({ id: 'mine', authorLogins: ['github-actions[bot]', 'alice'] }), | |
| 80 | ]) | |
| 81 | ||
| 82 | await resolveBuddyThreads({ ...base, provider }) | |
| 83 | ||
| 84 | expect(closed).toEqual(['mine']) | |
| 85 | }) | |
| 86 | ||
| 87 | it('success case - an explicit author overrides the bot logins', async () => { | |
| 88 | const { provider, closed } = stub([thread({ id: 'mine', authorLogins: ['custom-bot'] })]) | |
| 89 | ||
| 90 | await resolveBuddyThreads({ ...base, provider, author: 'custom-bot' }) | |
| 91 | ||
| 92 | expect(closed).toEqual(['mine']) | |
| 93 | }) | |
| 94 | ||
| 95 | it('edge case - an already-resolved thread is not touched again', async () => { | |
| 96 | const { provider, closed } = stub([thread({ id: 'done', isResolved: true })]) | |
| 97 | ||
| 98 | await resolveBuddyThreads({ ...base, provider }) | |
| 99 | ||
| 100 | expect(closed).toEqual([]) | |
| 101 | }) | |
| 102 | ||
| 103 | it('edge case - a thread with no author is not claimed', async () => { | |
| 104 | const { provider, closed } = stub([thread({ id: 'orphan', authorLogins: [] })]) | |
| 105 | ||
| 106 | await resolveBuddyThreads({ ...base, provider }) | |
| 107 | ||
| 108 | expect(closed).toEqual([]) | |
| 109 | }) | |
| 110 | }) | |
| 111 | ||
| 112 | describe('what it reports', () => { | |
| 113 | it('success case - distinguishes an empty pull request from a busy one', async () => { | |
| 114 | // "Nothing of mine is open" and "there is nothing here at all" are | |
| 115 | // different answers to the same request. | |
| 116 | expect(await resolveBuddyThreads({ ...base, provider: stub([]).provider })) | |
| 117 | .toContain('no review threads') | |
| 118 | ||
| 119 | expect(await resolveBuddyThreads({ | |
| 120 | ...base, | |
| 121 | provider: stub([thread({ authorLogins: ['alice'] })]).provider, | |
| 122 | })).toContain('None of the open threads') | |
| 123 | }) | |
| 124 | ||
| 125 | it('success case - counts several', async () => { | |
| 126 | const { provider, closed } = stub([thread({ id: 'a' }), thread({ id: 'b' })]) | |
| 127 | ||
| 128 | expect(await resolveBuddyThreads({ ...base, provider })).toContain('Closed 2 thread') | |
| 129 | expect(closed).toEqual(['a', 'b']) | |
| 130 | }) | |
| 131 | ||
| 132 | it('failure case - a refused close is reported rather than claimed', async () => { | |
| 133 | const { provider } = stub([thread()], { refuse: true }) | |
| 134 | ||
| 135 | expect(await resolveBuddyThreads({ ...base, provider })).toContain('could not close') | |
| 136 | }) | |
| 137 | ||
| 138 | it('failure case - a provider without the capability says so', async () => { | |
| 139 | const { provider } = stub([thread()], { capable: false }) | |
| 140 | ||
| 141 | expect(await resolveBuddyThreads({ ...base, provider })).toContain('cannot resolve') | |
| 142 | }) | |
| 143 | }) | |
| 144 | }) | |