ReviewOS

also looking at this

stacks/buddy

feat(review): implement @buddy resolve, which was advertised and absent

#1417
Merged glennmichael123 wants to merge feat/resolve-threads into main
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.

bin/cli.tsmodified+4-0
Changes to bin/cli.ts
@@ -1476,6 +1476,10 @@ cli
14761476 const { runPlan } = await import('../src/agent')
14771477 return await runPlan({ config, provider, number, isPullRequest, request, logger })
14781478 },
1479 resolve: async (prNumber) => {
1480 const { resolveBuddyThreads } = await import('../src/review')
1481 return await resolveBuddyThreads({ provider, prNumber, logger })
1482 },
14791483 remember: async (text, context) => {
14801484 const { addLearning, createLearning, loadLearnings, serializeLearnings, DEFAULT_LEARNINGS_FILE }
14811485 = await import('../src/ai')
docs/advanced/providers.mdmodified+2-0
Changes to docs/advanced/providers.md
@@ -64,6 +64,7 @@ if (supports(provider, 'pinIssues', 'pinIssue'))
6464| `commentReactions` | `reactToComment` |
6565| `ciLogs` | `getWorkflowRunLogs` |
6666| `ciRuns` | `listWorkflowRuns`, `rerunWorkflowRun` |
67| `reviewThreads` | `listReviewThreads`, `resolveReviewThread` |
6768| `teamReviewers` | Requesting review from a team |
6869| `draftPullRequests` | Opening a pull request as a draft |
6970| `permissionLookup` | `hasWriteAccess` |
@@ -83,6 +84,7 @@ if (supports(provider, 'pinIssues', 'pinIssue'))
8384| `commentReactions` | ✅ | ✅ (award emoji) | ❌ |
8485| `ciLogs` | ✅ | ✅ | ❌ |
8586| `ciRuns` | ✅ | ✅ (jobs) | ❌ |
87| `reviewThreads` | ✅ | ✅ (discussions) | ❌ |
8688| `teamReviewers` | ✅ | ❌ | ❌ |
8789| `draftPullRequests` | ✅ | ✅ (title prefix) | ❌ |
8890| `permissionLookup` | ✅ | ✅ | ✅ |
src/commands/dispatcher.tsmodified+1-0
Changes to src/commands/dispatcher.ts
@@ -51,6 +51,7 @@ export const HELP_TEXT = `**Buddy commands**
5151| \`@buddy merge\` | Re-check auto-merge conditions now |
5252| \`@buddy fix-ci\` | Diagnose the failing checks, and retry a flake |
5353| \`@buddy plan\` | Research the repository and write an implementation plan |
54| \`@buddy resolve\` | Close the review threads Buddy opened |
5455| \`@buddy remember <text>\` | Record a durable note for future runs |
5556| \`@buddy help\` | Show this table |
5657
src/commands/handlers.tsmodified+14-0
Changes to src/commands/handlers.ts
@@ -17,6 +17,8 @@ export interface HandlerDeps {
1717 fixCi: (prNumber: number) => Promise<string>
1818 /** Turn an issue or pull request into an implementation plan */
1919 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>
2022 /** Record a durable note */
2123 remember: (text: string, context: CommandContext) => Promise<string>
2224}
@@ -117,6 +119,18 @@ export function createHandlers(deps: HandlerDeps): Record<string, CommandHandler
117119 return { handled: true, reply }
118120 },
119121
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
120134 async plan(context): Promise<CommandOutcome> {
121135 // Answered as the reply rather than posted separately: the plan *is* the
122136 // response to the comment, and `handle-comment` already posts one.
src/git/bitbucket-provider.tsmodified+3-0
Changes to src/git/bitbucket-provider.ts
@@ -94,6 +94,9 @@ export class BitbucketProvider implements GitProvider {
9494 // Bitbucket Pipelines is a separate product with its own API surface,
9595 // and this provider reads none of it.
9696 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,
97100 teamReviewers: false,
98101 // Bitbucket has no draft state at all.
99102 draftPullRequests: false,
src/git/github-provider.tsmodified+91-1
Changes to src/git/github-provider.ts
@@ -1,6 +1,6 @@
11import type { FileChange, Issue, IssueOptions, PullRequest, PullRequestOptions } from '../types'
22import type { Logger } from '../utils/logger'
3import type { GitProvider, ListWorkflowRunsOptions, ProviderCapabilities, WorkflowRun } from './provider'
3import type { GitProvider, ListWorkflowRunsOptions, ProviderCapabilities, ReviewThread, WorkflowRun } from './provider'
44import { Buffer } from 'node:buffer'
55import { spawn } from 'node:child_process'
66import process from 'node:process'
@@ -149,6 +149,7 @@ export class GitHubProvider implements GitProvider {
149149 commentReactions: true,
150150 ciLogs: true,
151151 ciRuns: true,
152 reviewThreads: true,
152153 teamReviewers: true,
153154 draftPullRequests: true,
154155 permissionLookup: true,
@@ -1433,6 +1434,95 @@ export class GitHubProvider implements GitProvider {
14331434 return true
14341435 }
14351436
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
14361526 /**
14371527 * Aggregate state of the checks and commit statuses on a PR's head commit.
14381528 *
src/git/gitlab-provider.tsmodified+72-0
Changes to src/git/gitlab-provider.ts
@@ -7,12 +7,24 @@ import type {
77 ProviderCapabilities,
88 ReviewSubmission,
99 ReviewSubmissionResult,
10 ReviewThread,
1011 WorkflowRun,
1112} from './provider'
1213import { formatError } from '../utils/errors'
1314import { fetchWithTimeout } from '../utils/http'
1415import { getDefaultLogger } from '../utils/logger'
1516
17/** GitLab's merge request discussion, as much of it as buddy reads. */
18interface 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
1628/** GitLab's CI job, as much of it as buddy reads. */
1729interface GitLabJob {
1830 id: number
@@ -145,6 +157,8 @@ export class GitLabProvider implements GitProvider {
145157 ciLogs: true,
146158 // Jobs, not pipelines — see `listWorkflowRuns`.
147159 ciRuns: true,
160 // Discussions, which GitLab resolves per merge request.
161 reviewThreads: true,
148162 teamReviewers: false,
149163 draftPullRequests: true,
150164 permissionLookup: true,
@@ -707,6 +721,64 @@ export class GitLabProvider implements GitProvider {
707721 }
708722 }
709723
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
710782 // -- Housekeeping --------------------------------------------------------
711783
712784 async getBuddyBranches(): Promise<ProviderBranch[]> {
src/git/provider.tsmodified+44-0
Changes to src/git/provider.ts
@@ -52,6 +52,14 @@ export interface ProviderCapabilities {
5252 commentReactions: boolean
5353 /** Read CI logs for a failed run (`getWorkflowRunLogs`) */
5454 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
5563 /**
5664 * Enumerate and re-run CI runs (`listWorkflowRuns`, `rerunWorkflowRun`).
5765 *
@@ -96,6 +104,7 @@ export const NO_CAPABILITIES: ProviderCapabilities = {
96104 commentReactions: false,
97105 ciLogs: false,
98106 ciRuns: false,
107 reviewThreads: false,
99108 teamReviewers: false,
100109 draftPullRequests: false,
101110 permissionLookup: false,
@@ -104,6 +113,24 @@ export const NO_CAPABILITIES: ProviderCapabilities = {
104113 labels: false,
105114}
106115
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 */
123export 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
107134/**
108135 * A CI run as the provider reports it.
109136 *
@@ -267,6 +294,23 @@ export interface GitProvider {
267294 /** Requires `ciLogs`; null when logs are unavailable or expired */
268295 getWorkflowRunLogs?: (runId: number) => Promise<string | null>
269296
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
270314 /**
271315 * Requires `ciRuns`; most recent first.
272316 *
src/index.tsmodified+1-0
Changes to src/index.ts
@@ -33,6 +33,7 @@ export type {
3333 ProviderCapabilities,
3434 ProviderConfig,
3535 ReviewSubmission,
36 ReviewThread,
3637 ReviewSubmissionResult,
3738 WorkflowRun,
3839} from './git/provider'
src/review/index.tsmodified+2-0
Changes to src/review/index.ts
@@ -30,6 +30,8 @@ export {
3030} from './guidelines'
3131export type { RefFileReader } from './guidelines'
3232export { prepareReview, renderFinding } from './poster'
33export { resolveBuddyThreads } from './resolve'
34export type { ResolveThreadsOptions } from './resolve'
3335export { runReviewForPR } from './run'
3436export type { RunReviewOptions } from './run'
3537export type { InlineComment, PreparedReview } from './poster'
src/review/resolve.tsadded+92-0
Changes to src/review/resolve.ts
@@ -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 */
8import type { GitProvider, ReviewThread } from '../git/provider'
9import type { Logger } from '../utils/logger'
10import { supports } from '../git/provider'
11import { getDefaultLogger } from '../utils/logger'
12
13/** Inputs to a resolve run. */
14export 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. */
28const 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 */
40function 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 */
58export 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}
src/types.tsmodified+1-0
Changes to src/types.ts
@@ -14,6 +14,7 @@ export type {
1414 ProviderBranch,
1515 ProviderCapabilities,
1616 ReviewSubmission,
17 ReviewThread,
1718 ReviewSubmissionResult,
1819 WorkflowRun,
1920} from './git/provider'
test/command-handlers.test.tsmodified+26-0
Changes to test/command-handlers.test.ts
@@ -29,6 +29,10 @@ function deps(overrides: Partial<HandlerDeps> = {}): HandlerDeps & { calls: stri
2929 calls.push(`fixCi:${pr}`)
3030 return 'reported'
3131 },
32 resolve: async (pr) => {
33 calls.push(`resolve:${pr}`)
34 return 'Closed 2 thread(s) I opened.'
35 },
3236 plan: async (number, isPullRequest, request) => {
3337 calls.push(`plan:${number}:${isPullRequest}:${request}`)
3438 return '## Plan\n\n1. Do the thing'
@@ -135,4 +139,26 @@ describe('command handlers', () => {
135139 expect(outcome.reply).toContain('## Plan')
136140 })
137141 })
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 })
138164})
test/git/provider-conformance.tsmodified+3-0
Changes to test/git/provider-conformance.ts
@@ -35,6 +35,7 @@ export function runProviderConformance(name: string, create: () => GitProvider):
3535 'commentReactions',
3636 'ciLogs',
3737 'ciRuns',
38 'reviewThreads',
3839 'teamReviewers',
3940 'draftPullRequests',
4041 'permissionLookup',
@@ -62,6 +63,8 @@ export function runProviderConformance(name: string, create: () => GitProvider):
6263 ['ciLogs', 'getWorkflowRunLogs'],
6364 ['ciRuns', 'listWorkflowRuns'],
6465 ['ciRuns', 'rerunWorkflowRun'],
66 ['reviewThreads', 'listReviewThreads'],
67 ['reviewThreads', 'resolveReviewThread'],
6568 ['permissionLookup', 'hasWriteAccess'],
6669 ['branchHousekeeping', 'cleanupStaleBranches'],
6770 ['reopenPullRequests', 'reopenPullRequest'],
test/review-resolve.test.tsadded+144-0
Changes to test/review-resolve.test.ts
@@ -0,0 +1,144 @@
1import type { GitProvider, ReviewThread } from '../src/git/provider'
2import { describe, expect, it } from 'bun:test'
3import { resolveBuddyThreads } from '../src/review/resolve'
4import { NO_CAPABILITIES } from '../src/git/provider'
5import { Logger } from '../src/utils/logger'
6
7/** A provider over a fixed thread list, recording what it was asked to close. */
8function 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
26function 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
36const 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 */
43describe('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})