mirror of
https://github.com/stablyai/orca.git
synced 2026-10-21 08:03:33 +00:00
feat(github): add PR comment reactions
This commit is contained in:
@@ -178,6 +178,7 @@ import {
|
||||
getPullRequestPushTarget,
|
||||
mergePR,
|
||||
resolveReviewThread,
|
||||
setPRCommentReaction,
|
||||
setPRAutoMerge,
|
||||
updatePRState,
|
||||
updatePRTitle,
|
||||
@@ -4082,6 +4083,113 @@ describe('GitHub GraphQL rate-limit guard', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('returns GraphQL reaction subjects and viewer state for PR comments', async () => {
|
||||
getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' })
|
||||
ghExecFileAsyncMock
|
||||
.mockResolvedValueOnce({
|
||||
stdout: JSON.stringify({
|
||||
data: {
|
||||
repository: {
|
||||
pullRequest: {
|
||||
comments: {
|
||||
nodes: [
|
||||
{
|
||||
id: 'IC_1',
|
||||
databaseId: 10,
|
||||
author: { login: 'octo', avatarUrl: '', __typename: 'User' },
|
||||
body: 'Issue comment',
|
||||
createdAt: '2026-04-01T00:00:00Z',
|
||||
url: 'https://example.test/issue-comment',
|
||||
reactionGroups: [
|
||||
{
|
||||
content: 'THUMBS_UP',
|
||||
viewerHasReacted: true,
|
||||
reactors: { totalCount: 2 }
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
reviewThreads: {
|
||||
nodes: [
|
||||
{
|
||||
id: 'PRRT_1',
|
||||
isResolved: false,
|
||||
line: 4,
|
||||
startLine: null,
|
||||
originalLine: 4,
|
||||
originalStartLine: null,
|
||||
comments: {
|
||||
nodes: [
|
||||
{
|
||||
id: 'PRRC_1',
|
||||
databaseId: 11,
|
||||
author: { login: 'reviewer', avatarUrl: '', __typename: 'User' },
|
||||
body: 'Inline comment',
|
||||
createdAt: '2026-04-01T00:01:00Z',
|
||||
url: 'https://example.test/review-comment',
|
||||
path: 'src/app.ts',
|
||||
reactionGroups: [
|
||||
{
|
||||
content: 'EYES',
|
||||
viewerHasReacted: false,
|
||||
reactors: { totalCount: 1 }
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
.mockResolvedValueOnce({ stdout: '[]' })
|
||||
.mockResolvedValueOnce({ stdout: '[]' })
|
||||
|
||||
const comments = await getPRComments('/repo-root', 7, {
|
||||
prRepo: { owner: 'acme', repo: 'widgets', host: 'github.com' }
|
||||
})
|
||||
|
||||
expect(comments).toEqual([
|
||||
expect.objectContaining({
|
||||
id: 10,
|
||||
reactionSubjectId: 'IC_1',
|
||||
reactions: [{ content: '+1', count: 2, viewerHasReacted: true }]
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: 11,
|
||||
reactionSubjectId: 'PRRC_1',
|
||||
reactions: [{ content: 'eyes', count: 1, viewerHasReacted: false }]
|
||||
})
|
||||
])
|
||||
})
|
||||
|
||||
it('adds and removes PR comment reactions through GraphQL', async () => {
|
||||
getOwnerRepoMock.mockResolvedValue({ owner: 'acme', repo: 'widgets' })
|
||||
ghExecFileAsyncMock.mockResolvedValue({ stdout: '{}', stderr: '' })
|
||||
|
||||
await expect(setPRCommentReaction('/repo-root', 'IC_1', '+1', true)).resolves.toBe(true)
|
||||
await expect(setPRCommentReaction('/repo-root', 'PRRC_1', 'eyes', false)).resolves.toBe(true)
|
||||
|
||||
expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
expect.arrayContaining(['subjectId=IC_1', 'content=THUMBS_UP']),
|
||||
expect.any(Object)
|
||||
)
|
||||
expect(ghExecFileAsyncMock.mock.calls[0]?.[0].join(' ')).toContain('addReaction')
|
||||
expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.arrayContaining(['subjectId=PRRC_1', 'content=EYES']),
|
||||
expect.any(Object)
|
||||
)
|
||||
expect(ghExecFileAsyncMock.mock.calls[1]?.[0].join(' ')).toContain('removeReaction')
|
||||
expect(noteRateLimitSpendMock).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('uses explicit PR repo for merge and title mutations', async () => {
|
||||
ghExecFileAsyncMock
|
||||
.mockResolvedValueOnce({
|
||||
|
||||
@@ -12,6 +12,7 @@ import type {
|
||||
PRCheckDetail,
|
||||
PRCheckRunDetails,
|
||||
GitHubCommentResult,
|
||||
GitHubReactionContent,
|
||||
GitHubPRReviewCommentInput,
|
||||
PRComment,
|
||||
GitHubViewer,
|
||||
@@ -110,7 +111,11 @@ import {
|
||||
mapPRState,
|
||||
deriveCheckStatus
|
||||
} from './mappers'
|
||||
import { mapGraphQLReactionGroups, type GitHubGraphQLReactionGroup } from './comment-reactions'
|
||||
import {
|
||||
mapGraphQLReactionGroups,
|
||||
toGraphQLReactionContent,
|
||||
type GitHubGraphQLReactionGroup
|
||||
} from './comment-reactions'
|
||||
import {
|
||||
getRateLimit,
|
||||
noteRepositoryRateLimitSpend,
|
||||
@@ -4117,6 +4122,7 @@ query($owner: String!, $repo: String!, $pr: Int!) {
|
||||
originalStartLine
|
||||
comments(first: 100) {
|
||||
nodes {
|
||||
id
|
||||
databaseId
|
||||
author { __typename login avatarUrl(size: 48) }
|
||||
body
|
||||
@@ -4125,6 +4131,7 @@ query($owner: String!, $repo: String!, $pr: Int!) {
|
||||
path
|
||||
reactionGroups {
|
||||
content
|
||||
viewerHasReacted
|
||||
reactors {
|
||||
totalCount
|
||||
}
|
||||
@@ -4135,6 +4142,7 @@ query($owner: String!, $repo: String!, $pr: Int!) {
|
||||
}
|
||||
comments(first: 100) {
|
||||
nodes {
|
||||
id
|
||||
databaseId
|
||||
author { __typename login avatarUrl(size: 48) }
|
||||
body
|
||||
@@ -4142,6 +4150,7 @@ query($owner: String!, $repo: String!, $pr: Int!) {
|
||||
url
|
||||
reactionGroups {
|
||||
content
|
||||
viewerHasReacted
|
||||
reactors {
|
||||
totalCount
|
||||
}
|
||||
@@ -4255,6 +4264,7 @@ export async function getPRComments(
|
||||
originalStartLine: number | null
|
||||
comments: {
|
||||
nodes: {
|
||||
id: string
|
||||
databaseId: number
|
||||
author: { __typename?: string; login: string; avatarUrl: string } | null
|
||||
body: string
|
||||
@@ -4266,6 +4276,7 @@ export async function getPRComments(
|
||||
}
|
||||
}
|
||||
type GQLIssueComment = {
|
||||
id: string
|
||||
databaseId: number
|
||||
author: { __typename?: string; login: string; avatarUrl: string } | null
|
||||
body: string
|
||||
@@ -4295,6 +4306,7 @@ export async function getPRComments(
|
||||
createdAt: c.createdAt,
|
||||
url: c.url,
|
||||
isBot: c.author?.__typename === 'Bot',
|
||||
reactionSubjectId: c.id,
|
||||
reactions: mapGraphQLReactionGroups(c.reactionGroups)
|
||||
})
|
||||
)
|
||||
@@ -4313,6 +4325,7 @@ export async function getPRComments(
|
||||
createdAt: c.createdAt,
|
||||
url: c.url,
|
||||
isBot: c.author?.__typename === 'Bot',
|
||||
reactionSubjectId: c.id,
|
||||
reactions: mapGraphQLReactionGroups(c.reactionGroups),
|
||||
path: c.path,
|
||||
threadId: thread.id,
|
||||
@@ -4393,6 +4406,62 @@ export async function getPRComments(
|
||||
}
|
||||
}
|
||||
|
||||
export async function setPRCommentReaction(
|
||||
repoPath: string,
|
||||
reactionSubjectId: string,
|
||||
content: GitHubReactionContent,
|
||||
reacted: boolean,
|
||||
connectionId?: string | null,
|
||||
prRepo?: GitHubApiRepository | null,
|
||||
localGitOptions: LocalGitExecOptions = {}
|
||||
): Promise<boolean> {
|
||||
const mutation = reacted ? 'addReaction' : 'removeReaction'
|
||||
const query = `mutation($subjectId: ID!, $content: ReactionContent!) {
|
||||
${mutation}(input: { subjectId: $subjectId, content: $content }) {
|
||||
subject { id }
|
||||
}
|
||||
}`
|
||||
const { ownerRepo, ghOptions } = await resolveGitHubRepoExecution(
|
||||
repoPath,
|
||||
prRepo,
|
||||
connectionId,
|
||||
localGitOptions
|
||||
)
|
||||
if (!ownerRepo) {
|
||||
return false
|
||||
}
|
||||
const guard = repositoryRateLimitGuard(ownerRepo, 'graphql', ghOptions)
|
||||
if (guard.blocked) {
|
||||
console.warn(
|
||||
`${mutation} skipped: GitHub GraphQL rate limit nearly exhausted (${guard.remaining}/${guard.limit})`
|
||||
)
|
||||
return false
|
||||
}
|
||||
await acquire()
|
||||
try {
|
||||
noteRepositoryRateLimitSpend(ownerRepo, 'graphql', 1, ghOptions)
|
||||
await ghExecFileAsync(
|
||||
[
|
||||
'api',
|
||||
'graphql',
|
||||
'-f',
|
||||
`query=${query}`,
|
||||
'-f',
|
||||
`subjectId=${reactionSubjectId}`,
|
||||
'-f',
|
||||
`content=${toGraphQLReactionContent(content)}`
|
||||
],
|
||||
ghOptions
|
||||
)
|
||||
return true
|
||||
} catch (err) {
|
||||
console.warn(`${mutation} failed:`, err)
|
||||
return false
|
||||
} finally {
|
||||
release()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark or unmark a PR file as viewed via GitHub's GraphQL API.
|
||||
*/
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { mapGraphQLReactionGroups } from './comment-reactions'
|
||||
import { mapGraphQLReactionGroups, toGraphQLReactionContent } from './comment-reactions'
|
||||
|
||||
describe('mapGraphQLReactionGroups', () => {
|
||||
it('normalizes GraphQL reaction groups into GitHub comment reactions', () => {
|
||||
expect(
|
||||
mapGraphQLReactionGroups([
|
||||
{ content: 'EYES', reactors: { totalCount: 1 } },
|
||||
{ content: 'THUMBS_UP', reactors: { totalCount: 2 } },
|
||||
{ content: 'EYES', reactors: { totalCount: 1 }, viewerHasReacted: false },
|
||||
{ content: 'THUMBS_UP', reactors: { totalCount: 2 }, viewerHasReacted: true },
|
||||
{ content: 'HEART', reactors: { totalCount: 0 } },
|
||||
{ content: 'UNKNOWN', reactors: { totalCount: 9 } }
|
||||
])
|
||||
).toEqual([
|
||||
{ content: '+1', count: 2 },
|
||||
{ content: 'eyes', count: 1 }
|
||||
{ content: '+1', count: 2, viewerHasReacted: true },
|
||||
{ content: 'eyes', count: 1, viewerHasReacted: false }
|
||||
])
|
||||
})
|
||||
|
||||
@@ -22,4 +22,9 @@ describe('mapGraphQLReactionGroups', () => {
|
||||
undefined
|
||||
)
|
||||
})
|
||||
|
||||
it('maps REST reaction content to the GraphQL enum', () => {
|
||||
expect(toGraphQLReactionContent('+1')).toBe('THUMBS_UP')
|
||||
expect(toGraphQLReactionContent('hooray')).toBe('HOORAY')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -13,6 +13,7 @@ type GitHubGraphQLReactionContent =
|
||||
export type GitHubGraphQLReactionGroup = {
|
||||
content?: string | null
|
||||
reactors?: { totalCount?: number | null } | null
|
||||
viewerHasReacted?: boolean | null
|
||||
}
|
||||
|
||||
const GRAPHQL_REACTION_CONTENT: Record<GitHubGraphQLReactionContent, GitHubReactionContent> = {
|
||||
@@ -37,10 +38,27 @@ const REACTION_ORDER: GitHubReactionContent[] = [
|
||||
'eyes'
|
||||
]
|
||||
|
||||
const REACTION_CONTENT_TO_GRAPHQL: Record<GitHubReactionContent, GitHubGraphQLReactionContent> = {
|
||||
'+1': 'THUMBS_UP',
|
||||
'-1': 'THUMBS_DOWN',
|
||||
laugh: 'LAUGH',
|
||||
confused: 'CONFUSED',
|
||||
heart: 'HEART',
|
||||
hooray: 'HOORAY',
|
||||
rocket: 'ROCKET',
|
||||
eyes: 'EYES'
|
||||
}
|
||||
|
||||
export function toGraphQLReactionContent(
|
||||
content: GitHubReactionContent
|
||||
): GitHubGraphQLReactionContent {
|
||||
return REACTION_CONTENT_TO_GRAPHQL[content]
|
||||
}
|
||||
|
||||
export function mapGraphQLReactionGroups(
|
||||
groups?: GitHubGraphQLReactionGroup[] | null
|
||||
): GitHubReaction[] | undefined {
|
||||
const counts = new Map<GitHubReactionContent, number>()
|
||||
const reactionsByContent = new Map<GitHubReactionContent, GitHubReaction>()
|
||||
for (const group of groups ?? []) {
|
||||
const content =
|
||||
group.content && group.content in GRAPHQL_REACTION_CONTENT
|
||||
@@ -50,12 +68,20 @@ export function mapGraphQLReactionGroups(
|
||||
if (!content || count <= 0) {
|
||||
continue
|
||||
}
|
||||
counts.set(content, (counts.get(content) ?? 0) + count)
|
||||
const existing = reactionsByContent.get(content)
|
||||
const viewerHasReacted = Boolean(existing?.viewerHasReacted || group.viewerHasReacted)
|
||||
reactionsByContent.set(content, {
|
||||
content,
|
||||
count: (existing?.count ?? 0) + count,
|
||||
...(existing?.viewerHasReacted !== undefined || group.viewerHasReacted != null
|
||||
? { viewerHasReacted }
|
||||
: {})
|
||||
})
|
||||
}
|
||||
|
||||
const reactions = REACTION_ORDER.flatMap((content) => {
|
||||
const count = counts.get(content) ?? 0
|
||||
return count > 0 ? [{ content, count }] : []
|
||||
const reaction = reactionsByContent.get(content)
|
||||
return reaction ? [reaction] : []
|
||||
})
|
||||
return reactions.length > 0 ? reactions : undefined
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ const {
|
||||
getPRChecksMock,
|
||||
getPRCheckDetailsMock,
|
||||
getPRCommentsMock,
|
||||
setPRCommentReactionMock,
|
||||
resolveReviewThreadMock,
|
||||
setPRFileViewedMock,
|
||||
addPRReviewCommentMock,
|
||||
@@ -64,6 +65,7 @@ const {
|
||||
getPRChecksMock: vi.fn(),
|
||||
getPRCheckDetailsMock: vi.fn(),
|
||||
getPRCommentsMock: vi.fn(),
|
||||
setPRCommentReactionMock: vi.fn(),
|
||||
resolveReviewThreadMock: vi.fn(),
|
||||
setPRFileViewedMock: vi.fn(),
|
||||
addPRReviewCommentMock: vi.fn(),
|
||||
@@ -123,6 +125,7 @@ vi.mock('../github/client', () => ({
|
||||
getPRChecks: getPRChecksMock,
|
||||
getPRCheckDetails: getPRCheckDetailsMock,
|
||||
getPRComments: getPRCommentsMock,
|
||||
setPRCommentReaction: setPRCommentReactionMock,
|
||||
resolveReviewThread: resolveReviewThreadMock,
|
||||
setPRFileViewed: setPRFileViewedMock,
|
||||
addPRReviewComment: addPRReviewCommentMock,
|
||||
@@ -212,6 +215,7 @@ describe('registerGitHubHandlers', () => {
|
||||
getPRChecksMock.mockReset()
|
||||
getPRCheckDetailsMock.mockReset()
|
||||
getPRCommentsMock.mockReset()
|
||||
setPRCommentReactionMock.mockReset()
|
||||
resolveReviewThreadMock.mockReset()
|
||||
setPRFileViewedMock.mockReset()
|
||||
addPRReviewCommentMock.mockReset()
|
||||
@@ -907,6 +911,7 @@ describe('registerGitHubHandlers', () => {
|
||||
getPRChecksMock.mockResolvedValue([])
|
||||
getPRCheckDetailsMock.mockResolvedValue(null)
|
||||
getPRCommentsMock.mockResolvedValue([])
|
||||
setPRCommentReactionMock.mockResolvedValue(true)
|
||||
resolveReviewThreadMock.mockResolvedValue(true)
|
||||
setPRFileViewedMock.mockResolvedValue(true)
|
||||
addPRReviewCommentReplyMock.mockResolvedValue({ ok: true })
|
||||
@@ -964,6 +969,13 @@ describe('registerGitHubHandlers', () => {
|
||||
prRepo,
|
||||
noCache: true
|
||||
})
|
||||
await handlers['gh:setPRCommentReaction'](null, {
|
||||
repoPath: '/workspace/repo',
|
||||
reactionSubjectId: ' IC_1 ',
|
||||
content: '+1',
|
||||
reacted: true,
|
||||
prRepo
|
||||
})
|
||||
await handlers['gh:resolveReviewThread'](null, {
|
||||
repoPath: '/workspace/repo',
|
||||
threadId: 'thread-1',
|
||||
@@ -1124,6 +1136,15 @@ describe('registerGitHubHandlers', () => {
|
||||
null,
|
||||
localGitOptions
|
||||
)
|
||||
expect(setPRCommentReactionMock).toHaveBeenCalledWith(
|
||||
'/workspace/repo',
|
||||
'IC_1',
|
||||
'+1',
|
||||
true,
|
||||
null,
|
||||
prRepo,
|
||||
localGitOptions
|
||||
)
|
||||
expect(resolveReviewThreadMock).toHaveBeenCalledWith(
|
||||
'/workspace/repo',
|
||||
'thread-1',
|
||||
|
||||
@@ -13,6 +13,7 @@ import type {
|
||||
GitHubPRRefreshCandidate,
|
||||
GitHubPRRefreshEnqueueResult,
|
||||
GitHubPRRefreshReason,
|
||||
GitHubReactionContent,
|
||||
PRRefreshOutcome,
|
||||
GitHubPRFile
|
||||
} from '../../shared/types'
|
||||
@@ -39,6 +40,7 @@ import {
|
||||
getPRChecks,
|
||||
getPRCheckDetails,
|
||||
getPRComments,
|
||||
setPRCommentReaction,
|
||||
resolveReviewThread,
|
||||
setPRFileViewed,
|
||||
addPRReviewComment,
|
||||
@@ -667,6 +669,36 @@ export function registerGitHubHandlers(store: Store, stats: StatsCollector): voi
|
||||
}
|
||||
)
|
||||
|
||||
ipcMain.handle(
|
||||
'gh:setPRCommentReaction',
|
||||
(
|
||||
_event,
|
||||
args: {
|
||||
repoPath: string
|
||||
repoId?: string | null
|
||||
sourceContext?: TaskSourceContext | null
|
||||
reactionSubjectId: string
|
||||
content: GitHubReactionContent
|
||||
reacted: boolean
|
||||
prRepo?: GitHubOwnerRepo | null
|
||||
}
|
||||
) => {
|
||||
const repo = assertRegisteredRepo(args, store)
|
||||
if (!args.reactionSubjectId?.trim()) {
|
||||
return false
|
||||
}
|
||||
return setPRCommentReaction(
|
||||
repo.path,
|
||||
args.reactionSubjectId.trim(),
|
||||
args.content,
|
||||
args.reacted,
|
||||
repoConnectionId(repo),
|
||||
args.prRepo ?? null,
|
||||
...localGitOptionArgs(store, repo)
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
ipcMain.handle(
|
||||
'gh:resolveReviewThread',
|
||||
async (
|
||||
|
||||
@@ -228,6 +228,7 @@ import type {
|
||||
GitHubPullRequestStateUpdate,
|
||||
GitHubPRFile,
|
||||
GitHubPRReviewCommentInput,
|
||||
GitHubReactionContent,
|
||||
GitLabIssueUpdate,
|
||||
GitLabMRInlineCommentInput,
|
||||
GitLabProjectRef,
|
||||
@@ -591,6 +592,7 @@ import {
|
||||
getPRCheckDetails,
|
||||
rerunPRChecks,
|
||||
getPRComments,
|
||||
setPRCommentReaction,
|
||||
getIssue,
|
||||
resolveReviewThread,
|
||||
setPRFileViewed,
|
||||
@@ -20147,6 +20149,25 @@ export class OrcaRuntimeService {
|
||||
)
|
||||
}
|
||||
|
||||
async setRepoPRCommentReaction(
|
||||
repoSelector: string,
|
||||
reactionSubjectId: string,
|
||||
content: GitHubReactionContent,
|
||||
reacted: boolean,
|
||||
prRepo?: GitHubOwnerRepo | null
|
||||
): Promise<Awaited<ReturnType<typeof setPRCommentReaction>>> {
|
||||
const repo = await this.resolveRepoSelector(repoSelector)
|
||||
return setPRCommentReaction(
|
||||
repo.path,
|
||||
reactionSubjectId,
|
||||
content,
|
||||
reacted,
|
||||
repo.connectionId ?? null,
|
||||
prRepo ?? null,
|
||||
...this.getLocalGitExecutionOptionArgs(repo)
|
||||
)
|
||||
}
|
||||
|
||||
async getRepoPRFileContents(
|
||||
repoSelector: string,
|
||||
args: {
|
||||
|
||||
@@ -219,6 +219,31 @@ describe('github RPC methods', () => {
|
||||
expect(response).toMatchObject({ ok: true, result: [] })
|
||||
})
|
||||
|
||||
it('sets a PR comment reaction on the runtime server', async () => {
|
||||
const runtime = {
|
||||
getRuntimeId: () => 'test-runtime',
|
||||
setRepoPRCommentReaction: vi.fn().mockResolvedValue(true)
|
||||
} as unknown as OrcaRuntimeService
|
||||
const dispatcher = new RpcDispatcher({ runtime, methods: GITHUB_METHODS })
|
||||
|
||||
const response = await dispatcher.dispatch(
|
||||
makeRequest('github.setPRCommentReaction', {
|
||||
repo: 'repo-1',
|
||||
reactionSubjectId: 'IC_1',
|
||||
content: 'heart',
|
||||
reacted: true,
|
||||
prRepo: { owner: 'acme', repo: 'widgets', host: 'github.acme.test' }
|
||||
})
|
||||
)
|
||||
|
||||
expect(runtime.setRepoPRCommentReaction).toHaveBeenCalledWith('repo-1', 'IC_1', 'heart', true, {
|
||||
owner: 'acme',
|
||||
repo: 'widgets',
|
||||
host: 'github.acme.test'
|
||||
})
|
||||
expect(response).toMatchObject({ ok: true, result: true })
|
||||
})
|
||||
|
||||
it('fetches PR file contents on the runtime server', async () => {
|
||||
const runtime = {
|
||||
getRuntimeId: () => 'test-runtime',
|
||||
|
||||
@@ -73,6 +73,13 @@ const PullRequest = RepoSelector.extend({
|
||||
prRepo: SlugRepo.nullable().optional()
|
||||
})
|
||||
|
||||
const PRCommentReaction = RepoSelector.extend({
|
||||
reactionSubjectId: requiredString('Missing reaction subject ID'),
|
||||
content: z.enum(['+1', '-1', 'laugh', 'confused', 'heart', 'hooray', 'rocket', 'eyes']),
|
||||
reacted: z.boolean(),
|
||||
prRepo: SlugRepo.nullable().optional()
|
||||
})
|
||||
|
||||
const PullRequestChecks = PullRequest.extend({
|
||||
headSha: OptionalString
|
||||
})
|
||||
@@ -459,6 +466,18 @@ export const GITHUB_METHODS: RpcMethod[] = [
|
||||
noCache: params.noCache
|
||||
})
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'github.setPRCommentReaction',
|
||||
params: PRCommentReaction,
|
||||
handler: async (params, { runtime }) =>
|
||||
runtime.setRepoPRCommentReaction(
|
||||
params.repo,
|
||||
params.reactionSubjectId,
|
||||
params.content,
|
||||
params.reacted,
|
||||
params.prRepo ?? null
|
||||
)
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'github.prFileContents',
|
||||
params: PullRequestFileContents,
|
||||
|
||||
@@ -226,6 +226,7 @@ import type {
|
||||
MarkdownDocument,
|
||||
FloatingTerminalCwdRequest,
|
||||
GitHubIssueUpdate,
|
||||
GitHubReactionContent,
|
||||
GitHubPRRefreshCandidate,
|
||||
GitHubPRRefreshEnqueueResult,
|
||||
GitHubPRRefreshEvent,
|
||||
@@ -1882,6 +1883,15 @@ export type PreloadApi = {
|
||||
prRepo?: GitHubOwnerRepo | null
|
||||
noCache?: boolean
|
||||
}) => Promise<PRComment[]>
|
||||
setPRCommentReaction: (args: {
|
||||
repoPath: string
|
||||
repoId?: string
|
||||
sourceContext?: TaskSourceContext | null
|
||||
reactionSubjectId: string
|
||||
content: GitHubReactionContent
|
||||
reacted: boolean
|
||||
prRepo?: GitHubOwnerRepo | null
|
||||
}) => Promise<boolean>
|
||||
resolveReviewThread: (args: {
|
||||
repoPath: string
|
||||
repoId?: string
|
||||
|
||||
@@ -75,6 +75,7 @@ import type {
|
||||
GitHubCommentResult,
|
||||
GitHubCreateIssueResult,
|
||||
GitHubOwnerRepo,
|
||||
GitHubReactionContent,
|
||||
GitHubWorkItem,
|
||||
JiraProjectStatusOrder,
|
||||
GitPushTarget,
|
||||
@@ -1470,6 +1471,16 @@ const api = {
|
||||
noCache?: boolean
|
||||
}): Promise<unknown[]> => ipcRenderer.invoke('gh:prComments', args),
|
||||
|
||||
setPRCommentReaction: (args: {
|
||||
repoPath: string
|
||||
repoId?: string
|
||||
sourceContext?: TaskSourceContext | null
|
||||
reactionSubjectId: string
|
||||
content: GitHubReactionContent
|
||||
reacted: boolean
|
||||
prRepo?: GitHubOwnerRepo | null
|
||||
}): Promise<boolean> => ipcRenderer.invoke('gh:setPRCommentReaction', args),
|
||||
|
||||
resolveReviewThread: (args: {
|
||||
repoPath: string
|
||||
repoId?: string
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import React from 'react'
|
||||
import { SmilePlus } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import type { GitHubReaction } from '../../../../shared/types'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { GITHUB_REACTION_ORDER } from '@/lib/pr-comment-reactions'
|
||||
import type { GitHubReaction, GitHubReactionContent } from '../../../../shared/types'
|
||||
|
||||
const REACTION_EMOJI: Record<GitHubReaction['content'], string> = {
|
||||
'+1': '👍',
|
||||
@@ -13,36 +19,160 @@ const REACTION_EMOJI: Record<GitHubReaction['content'], string> = {
|
||||
eyes: '👀'
|
||||
}
|
||||
|
||||
const REACTION_LABEL: Record<GitHubReactionContent, string> = {
|
||||
'+1': 'thumbs up',
|
||||
'-1': 'thumbs down',
|
||||
laugh: 'laugh',
|
||||
confused: 'confused',
|
||||
heart: 'heart',
|
||||
hooray: 'hooray',
|
||||
rocket: 'rocket',
|
||||
eyes: 'eyes'
|
||||
}
|
||||
|
||||
export function CommentReactions({
|
||||
reactions
|
||||
reactions,
|
||||
className,
|
||||
onReactionChange
|
||||
}: {
|
||||
reactions?: GitHubReaction[]
|
||||
className?: string
|
||||
onReactionChange?: (content: GitHubReactionContent, reacted: boolean) => Promise<void> | void
|
||||
}): React.JSX.Element | null {
|
||||
const visibleReactions = (reactions ?? []).filter((reaction) => reaction.count > 0)
|
||||
if (visibleReactions.length === 0) {
|
||||
const [open, setOpen] = React.useState(false)
|
||||
const [pendingContent, setPendingContent] = React.useState<GitHubReactionContent | null>(null)
|
||||
if (visibleReactions.length === 0 && !onReactionChange) {
|
||||
return null
|
||||
}
|
||||
|
||||
const changeReaction = async (
|
||||
content: GitHubReactionContent,
|
||||
reacted: boolean
|
||||
): Promise<void> => {
|
||||
if (!onReactionChange || pendingContent) {
|
||||
return
|
||||
}
|
||||
setPendingContent(content)
|
||||
setOpen(false)
|
||||
try {
|
||||
await onReactionChange(content, reacted)
|
||||
} finally {
|
||||
setPendingContent(null)
|
||||
}
|
||||
}
|
||||
|
||||
const pickerLabel = translate(
|
||||
'auto.components.github.CommentReactions.addReaction',
|
||||
'Add reaction'
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="mt-2 flex flex-wrap gap-1.5">
|
||||
<div className={cn('mt-2 flex flex-wrap items-center gap-1.5', className)}>
|
||||
{visibleReactions.map((reaction) => (
|
||||
<span
|
||||
key={reaction.content}
|
||||
className="inline-flex h-6 items-center gap-1 rounded-full border border-border/60 bg-muted/35 px-2 text-[12px] leading-none text-foreground"
|
||||
aria-label={translate(
|
||||
'auto.components.GitHubItemDialog.a18f669c7a',
|
||||
'{{value0}} {{value1}} reaction{{value2}}',
|
||||
{
|
||||
value0: reaction.count,
|
||||
value1: reaction.content,
|
||||
value2: reaction.count === 1 ? '' : 's'
|
||||
}
|
||||
<React.Fragment key={reaction.content}>
|
||||
{onReactionChange ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="xs"
|
||||
disabled={pendingContent !== null}
|
||||
className={cn(
|
||||
'h-6 gap-1 rounded-full px-2 text-[12px] font-normal',
|
||||
reaction.viewerHasReacted && 'border-ring bg-accent text-accent-foreground'
|
||||
)}
|
||||
aria-pressed={Boolean(reaction.viewerHasReacted)}
|
||||
aria-label={translate(
|
||||
'auto.components.GitHubItemDialog.a18f669c7a',
|
||||
'{{value0}} {{value1}} reaction{{value2}}',
|
||||
{
|
||||
value0: reaction.count,
|
||||
value1: REACTION_LABEL[reaction.content],
|
||||
value2: reaction.count === 1 ? '' : 's'
|
||||
}
|
||||
)}
|
||||
onClick={() => void changeReaction(reaction.content, !reaction.viewerHasReacted)}
|
||||
>
|
||||
<span aria-hidden="true">{REACTION_EMOJI[reaction.content]}</span>
|
||||
<span className="tabular-nums">{reaction.count}</span>
|
||||
</Button>
|
||||
) : (
|
||||
<span
|
||||
className="inline-flex h-6 items-center gap-1 rounded-full border border-border/60 bg-muted/35 px-2 text-[12px] leading-none text-foreground"
|
||||
aria-label={translate(
|
||||
'auto.components.GitHubItemDialog.a18f669c7a',
|
||||
'{{value0}} {{value1}} reaction{{value2}}',
|
||||
{
|
||||
value0: reaction.count,
|
||||
value1: REACTION_LABEL[reaction.content],
|
||||
value2: reaction.count === 1 ? '' : 's'
|
||||
}
|
||||
)}
|
||||
>
|
||||
<span aria-hidden="true">{REACTION_EMOJI[reaction.content]}</span>
|
||||
<span className="tabular-nums">{reaction.count}</span>
|
||||
</span>
|
||||
)}
|
||||
>
|
||||
<span aria-hidden="true">{REACTION_EMOJI[reaction.content]}</span>
|
||||
<span className="tabular-nums">{reaction.count}</span>
|
||||
</span>
|
||||
</React.Fragment>
|
||||
))}
|
||||
{onReactionChange ? (
|
||||
<Popover open={open} onOpenChange={(nextOpen) => !pendingContent && setOpen(nextOpen)}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
disabled={pendingContent !== null}
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
aria-label={pickerLabel}
|
||||
>
|
||||
<SmilePlus className="size-3.5" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" sideOffset={4}>
|
||||
{pickerLabel}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<PopoverContent align="start" side="top" sideOffset={6} className="w-auto p-1.5">
|
||||
<div className="grid grid-cols-4 gap-1" aria-label={pickerLabel} role="group">
|
||||
{GITHUB_REACTION_ORDER.map((content) => {
|
||||
const reaction = reactions?.find((candidate) => candidate.content === content)
|
||||
const reacted = Boolean(reaction?.viewerHasReacted)
|
||||
return (
|
||||
<Button
|
||||
key={content}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
disabled={pendingContent !== null}
|
||||
className={cn('text-lg', reacted && 'bg-accent text-accent-foreground')}
|
||||
aria-label={
|
||||
reacted
|
||||
? translate(
|
||||
'auto.components.github.CommentReactions.removeNamedReaction',
|
||||
'Remove {{value0}} reaction',
|
||||
{ value0: REACTION_LABEL[content] }
|
||||
)
|
||||
: translate(
|
||||
'auto.components.github.CommentReactions.addNamedReaction',
|
||||
'Add {{value0}} reaction',
|
||||
{ value0: REACTION_LABEL[content] }
|
||||
)
|
||||
}
|
||||
aria-pressed={reacted}
|
||||
onClick={() => void changeReaction(content, !reacted)}
|
||||
>
|
||||
<span aria-hidden="true">{REACTION_EMOJI[content]}</span>
|
||||
</Button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ import { useActiveWorktree, useRepoById } from '@/store/selectors'
|
||||
import { useChecksPanelTerminalWorktree } from './use-checks-panel-terminal-worktree'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { openHttpLink } from '@/lib/http-link-routing'
|
||||
import { setReactionOnSubject } from '@/lib/pr-comment-reactions'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { DetachedHeadBadge } from '@/components/DetachedHeadBadge'
|
||||
import {
|
||||
@@ -64,6 +65,7 @@ import type {
|
||||
PRCheckDetail,
|
||||
PRCheckRunDetails,
|
||||
PRComment,
|
||||
GitHubReactionContent,
|
||||
PRRefreshErrorType
|
||||
} from '../../../../shared/types'
|
||||
import { getConnectionId } from '@/lib/connection-context'
|
||||
@@ -496,6 +498,7 @@ export default function ChecksPanel(): React.JSX.Element {
|
||||
const fetchPRComments = useAppStore((s) => s.fetchPRComments)
|
||||
const addPRConversationComment = useAppStore((s) => s.addPRConversationComment)
|
||||
const addPRReviewCommentReply = useAppStore((s) => s.addPRReviewCommentReply)
|
||||
const setPRCommentReaction = useAppStore((s) => s.setPRCommentReaction)
|
||||
const resolveReviewThread = useAppStore((s) => s.resolveReviewThread)
|
||||
const detectedAgentIds = useAppStore((s) => s.detectedAgentIds)
|
||||
const remoteDetectedAgentIds = useAppStore((s) => {
|
||||
@@ -2941,6 +2944,49 @@ export default function ChecksPanel(): React.JSX.Element {
|
||||
[pr?.prRepo, confirm]
|
||||
)
|
||||
|
||||
const handleSetReaction = useCallback(
|
||||
async (comment: PRComment, content: GitHubReactionContent, reacted: boolean): Promise<void> => {
|
||||
const reactionSubjectId = comment.reactionSubjectId
|
||||
if (!repo || !prNumber || !pr?.prRepo || !reactionSubjectId) {
|
||||
return
|
||||
}
|
||||
const requestKey = checksPanelAsyncResultKey(
|
||||
prCacheKey,
|
||||
branch,
|
||||
prNumber,
|
||||
pr.prRepo,
|
||||
pr.headSha
|
||||
)
|
||||
const previousReactions = comment.reactions
|
||||
setComments((current) => setReactionOnSubject(current, reactionSubjectId, content, reacted))
|
||||
const ok = await setPRCommentReaction(
|
||||
repo.path,
|
||||
prNumber,
|
||||
reactionSubjectId,
|
||||
content,
|
||||
reacted,
|
||||
{ repoId: repo.id, prRepo: pr.prRepo }
|
||||
)
|
||||
if (!isCurrentAsyncResult(requestKey) || ok) {
|
||||
return
|
||||
}
|
||||
setComments((current) =>
|
||||
current.map((entry) =>
|
||||
entry.reactionSubjectId === reactionSubjectId
|
||||
? { ...entry, reactions: previousReactions }
|
||||
: entry
|
||||
)
|
||||
)
|
||||
toast.error(
|
||||
translate(
|
||||
'auto.components.right.sidebar.ChecksPanel.updateReactionFailed',
|
||||
'Failed to update reaction.'
|
||||
)
|
||||
)
|
||||
},
|
||||
[branch, isCurrentAsyncResult, pr, prCacheKey, prNumber, repo, setPRCommentReaction]
|
||||
)
|
||||
|
||||
const handleReplyToComment = useCallback(
|
||||
async (comment: PRComment, body: string, options: { notifyOnFailure?: boolean } = {}) => {
|
||||
const notifyOnFailure = options.notifyOnFailure !== false
|
||||
@@ -4506,6 +4552,7 @@ export default function ChecksPanel(): React.JSX.Element {
|
||||
onResolve={pr || activeGitLabReview ? handleResolve : undefined}
|
||||
onEditComment={pr ? handleEditComment : undefined}
|
||||
onDeleteComment={pr ? handleDeleteComment : undefined}
|
||||
onSetReaction={canTargetPRComments ? handleSetReaction : undefined}
|
||||
/>
|
||||
<SourceControlAgentActionDialog
|
||||
open={sourceControlAiActionsVisible && agentComposerState !== null}
|
||||
|
||||
@@ -55,6 +55,7 @@ import {
|
||||
} from '@/components/ui/context-menu'
|
||||
import { cn } from '@/lib/utils'
|
||||
import CommentMarkdown from '@/components/sidebar/CommentMarkdown'
|
||||
import { CommentReactions } from '@/components/github/CommentReactions'
|
||||
import { CheckJobLogTail } from './check-job-log-tail'
|
||||
import {
|
||||
filterPRCommentsByAudience,
|
||||
@@ -86,6 +87,7 @@ import type {
|
||||
PRCheckDetail,
|
||||
PRCheckRunDetails,
|
||||
PRComment,
|
||||
GitHubReactionContent,
|
||||
PRConflictSummary,
|
||||
PRMergeableState
|
||||
} from '../../../../shared/types'
|
||||
@@ -1732,6 +1734,7 @@ function CommentRow({
|
||||
onReply,
|
||||
onEditComment,
|
||||
onDeleteComment,
|
||||
onSetReaction,
|
||||
onQueueForAgent
|
||||
}: {
|
||||
comment: PRComment
|
||||
@@ -1749,6 +1752,11 @@ function CommentRow({
|
||||
onReply?: (comment: PRComment) => void
|
||||
onEditComment?: (comment: PRComment, body: string) => Promise<boolean>
|
||||
onDeleteComment?: (comment: PRComment) => void | Promise<void>
|
||||
onSetReaction?: (
|
||||
comment: PRComment,
|
||||
content: GitHubReactionContent,
|
||||
reacted: boolean
|
||||
) => Promise<void>
|
||||
onQueueForAgent?: () => void
|
||||
}): React.JSX.Element {
|
||||
const automated = isBotPRComment(comment, botAuthorOverrides)
|
||||
@@ -2007,13 +2015,17 @@ function CommentRow({
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<CommentMarkdown
|
||||
content={comment.body}
|
||||
className={cn(
|
||||
isReply ? presentation.commentBodyReply : presentation.commentBody,
|
||||
presentation.commentBodyMarkdown
|
||||
)}
|
||||
/>
|
||||
<div className={isReply ? presentation.commentBodyReply : presentation.commentBody}>
|
||||
<CommentMarkdown content={comment.body} className={presentation.commentBodyMarkdown} />
|
||||
<CommentReactions
|
||||
reactions={comment.reactions}
|
||||
onReactionChange={
|
||||
comment.reactionSubjectId && onSetReaction
|
||||
? (content, reacted) => onSetReaction(comment, content, reacted)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -2036,6 +2048,7 @@ function PRCommentGroupView({
|
||||
onReply,
|
||||
onEditComment,
|
||||
onDeleteComment,
|
||||
onSetReaction,
|
||||
onQueueForAgent
|
||||
}: {
|
||||
group: PRCommentGroup
|
||||
@@ -2053,6 +2066,11 @@ function PRCommentGroupView({
|
||||
onReply?: (comment: PRComment, body: string) => Promise<RightPanelCommentSubmitResult>
|
||||
onEditComment?: (comment: PRComment, body: string) => Promise<boolean>
|
||||
onDeleteComment?: (comment: PRComment) => void | Promise<void>
|
||||
onSetReaction?: (
|
||||
comment: PRComment,
|
||||
content: GitHubReactionContent,
|
||||
reacted: boolean
|
||||
) => Promise<void>
|
||||
onQueueForAgent?: () => void
|
||||
}): React.JSX.Element {
|
||||
// Reply targets a specific comment id so any comment in a thread — root or
|
||||
@@ -2099,6 +2117,7 @@ function PRCommentGroupView({
|
||||
onResolve,
|
||||
onEditComment,
|
||||
onDeleteComment,
|
||||
onSetReaction,
|
||||
onQueueForAgent
|
||||
}
|
||||
|
||||
@@ -2181,7 +2200,8 @@ function ResolvedCommentGroupsSection({
|
||||
onCancelReply,
|
||||
onReply,
|
||||
onEditComment,
|
||||
onDeleteComment
|
||||
onDeleteComment,
|
||||
onSetReaction
|
||||
}: {
|
||||
groups: PRCommentGroup[]
|
||||
botAuthorOverrides: ReadonlySet<string>
|
||||
@@ -2195,6 +2215,11 @@ function ResolvedCommentGroupsSection({
|
||||
onReply?: (comment: PRComment, body: string) => Promise<RightPanelCommentSubmitResult>
|
||||
onEditComment?: (comment: PRComment, body: string) => Promise<boolean>
|
||||
onDeleteComment?: (comment: PRComment) => void | Promise<void>
|
||||
onSetReaction?: (
|
||||
comment: PRComment,
|
||||
content: GitHubReactionContent,
|
||||
reacted: boolean
|
||||
) => Promise<void>
|
||||
}): React.JSX.Element | null {
|
||||
if (groups.length === 0) {
|
||||
return null
|
||||
@@ -2230,6 +2255,7 @@ function ResolvedCommentGroupsSection({
|
||||
onReply={onReply}
|
||||
onEditComment={onEditComment}
|
||||
onDeleteComment={onDeleteComment}
|
||||
onSetReaction={onSetReaction}
|
||||
/>
|
||||
))}
|
||||
</AccordionContent>
|
||||
@@ -2296,7 +2322,8 @@ export function PRCommentsList({
|
||||
onReply,
|
||||
onResolve,
|
||||
onEditComment,
|
||||
onDeleteComment
|
||||
onDeleteComment,
|
||||
onSetReaction
|
||||
}: {
|
||||
comments: PRComment[]
|
||||
commentsLoading: boolean
|
||||
@@ -2313,6 +2340,11 @@ export function PRCommentsList({
|
||||
onResolve?: (threadId: string, resolve: boolean) => boolean | Promise<boolean>
|
||||
onEditComment?: (comment: PRComment, body: string) => Promise<boolean>
|
||||
onDeleteComment?: (comment: PRComment) => void | Promise<void>
|
||||
onSetReaction?: (
|
||||
comment: PRComment,
|
||||
content: GitHubReactionContent,
|
||||
reacted: boolean
|
||||
) => Promise<void>
|
||||
}): React.JSX.Element {
|
||||
const presentation = React.useMemo(() => getPRCommentPresentationClasses(), [])
|
||||
const [commentFilter, setCommentFilter] = useState<PRCommentAudienceFilter>('all')
|
||||
@@ -2435,6 +2467,7 @@ export function PRCommentsList({
|
||||
onReply={onReply}
|
||||
onEditComment={onEditComment}
|
||||
onDeleteComment={onDeleteComment}
|
||||
onSetReaction={onSetReaction}
|
||||
onQueueForAgent={canQueue ? () => addGroupToSelection(groupId) : undefined}
|
||||
/>
|
||||
)
|
||||
@@ -2765,6 +2798,7 @@ export function PRCommentsList({
|
||||
onReply={onReply}
|
||||
onEditComment={onEditComment}
|
||||
onDeleteComment={onDeleteComment}
|
||||
onSetReaction={onSetReaction}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -2412,6 +2412,11 @@
|
||||
"label": "Close as duplicate",
|
||||
"description": "Duplicate of another issue"
|
||||
}
|
||||
},
|
||||
"CommentReactions": {
|
||||
"addReaction": "Add reaction",
|
||||
"removeNamedReaction": "Remove {{value0}} reaction",
|
||||
"addNamedReaction": "Add {{value0}} reaction"
|
||||
}
|
||||
},
|
||||
"linear": {
|
||||
@@ -10566,7 +10571,8 @@
|
||||
"pending": "Syncing…",
|
||||
"branch": "Sync Branch"
|
||||
},
|
||||
"7e4b2a19c0": "Could not resolve the GitHub PR to reply on."
|
||||
"7e4b2a19c0": "Could not resolve the GitHub PR to reply on.",
|
||||
"updateReactionFailed": "Failed to update reaction."
|
||||
},
|
||||
"CreatePullRequestDialog": {
|
||||
"2bc1b4345e": "Cancel",
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { PRComment } from '../../../shared/types'
|
||||
import { setCommentReaction, setReactionOnSubject } from './pr-comment-reactions'
|
||||
|
||||
function comment(overrides: Partial<PRComment> = {}): PRComment {
|
||||
return {
|
||||
id: 1,
|
||||
author: 'octocat',
|
||||
authorAvatarUrl: '',
|
||||
body: 'Looks good',
|
||||
createdAt: '2026-08-09T12:00:00Z',
|
||||
url: 'https://github.com/acme/orca/pull/1#issuecomment-1',
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
describe('setCommentReaction', () => {
|
||||
it('adds a viewer reaction in GitHub order', () => {
|
||||
expect(
|
||||
setCommentReaction(
|
||||
comment({ reactions: [{ content: 'heart', count: 2, viewerHasReacted: false }] }),
|
||||
'+1',
|
||||
true
|
||||
).reactions
|
||||
).toEqual([
|
||||
{ content: '+1', count: 1, viewerHasReacted: true },
|
||||
{ content: 'heart', count: 2, viewerHasReacted: false }
|
||||
])
|
||||
})
|
||||
|
||||
it('removes the final viewer reaction', () => {
|
||||
expect(
|
||||
setCommentReaction(
|
||||
comment({ reactions: [{ content: 'rocket', count: 1, viewerHasReacted: true }] }),
|
||||
'rocket',
|
||||
false
|
||||
).reactions
|
||||
).toBeUndefined()
|
||||
})
|
||||
|
||||
it('updates only the targeted comment', () => {
|
||||
const target = comment({ reactionSubjectId: 'IC_1' })
|
||||
const other = comment({ id: 2, reactionSubjectId: 'IC_2' })
|
||||
const result = setReactionOnSubject([target, other], 'IC_1', 'eyes', true)
|
||||
|
||||
expect(result[0].reactions).toEqual([{ content: 'eyes', count: 1, viewerHasReacted: true }])
|
||||
expect(result[1]).toBe(other)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,53 @@
|
||||
import type { GitHubReaction, GitHubReactionContent, PRComment } from '../../../shared/types'
|
||||
|
||||
export const GITHUB_REACTION_ORDER: readonly GitHubReactionContent[] = [
|
||||
'+1',
|
||||
'-1',
|
||||
'laugh',
|
||||
'confused',
|
||||
'heart',
|
||||
'hooray',
|
||||
'rocket',
|
||||
'eyes'
|
||||
]
|
||||
|
||||
export function setCommentReaction(
|
||||
comment: PRComment,
|
||||
content: GitHubReactionContent,
|
||||
reacted: boolean
|
||||
): PRComment {
|
||||
const reactions = comment.reactions ?? []
|
||||
const current = reactions.find((reaction) => reaction.content === content)
|
||||
if (Boolean(current?.viewerHasReacted) === reacted) {
|
||||
return comment
|
||||
}
|
||||
|
||||
const nextCount = Math.max(0, (current?.count ?? 0) + (reacted ? 1 : -1))
|
||||
const nextReaction: GitHubReaction = {
|
||||
content,
|
||||
count: nextCount,
|
||||
viewerHasReacted: reacted
|
||||
}
|
||||
const nextReactions = reactions
|
||||
.filter((reaction) => reaction.content !== content)
|
||||
.concat(nextCount > 0 ? nextReaction : [])
|
||||
.sort(
|
||||
(left, right) =>
|
||||
GITHUB_REACTION_ORDER.indexOf(left.content) - GITHUB_REACTION_ORDER.indexOf(right.content)
|
||||
)
|
||||
|
||||
return { ...comment, reactions: nextReactions.length > 0 ? nextReactions : undefined }
|
||||
}
|
||||
|
||||
export function setReactionOnSubject(
|
||||
comments: readonly PRComment[],
|
||||
reactionSubjectId: string,
|
||||
content: GitHubReactionContent,
|
||||
reacted: boolean
|
||||
): PRComment[] {
|
||||
return comments.map((comment) =>
|
||||
comment.reactionSubjectId === reactionSubjectId
|
||||
? setCommentReaction(comment, content, reacted)
|
||||
: comment
|
||||
)
|
||||
}
|
||||
@@ -45,6 +45,7 @@ const mockApi = {
|
||||
prComments: vi.fn().mockResolvedValue([]),
|
||||
addIssueComment: vi.fn(),
|
||||
addPRReviewCommentReply: vi.fn(),
|
||||
setPRCommentReaction: vi.fn(),
|
||||
resolveReviewThread: vi.fn(),
|
||||
listWorkItems: vi.fn(),
|
||||
countWorkItems: vi.fn().mockResolvedValue(0),
|
||||
@@ -1503,6 +1504,7 @@ describe('createGitHubSlice PR comment mutations', () => {
|
||||
url: ''
|
||||
}
|
||||
})
|
||||
mockApi.gh.setPRCommentReaction.mockResolvedValue(true)
|
||||
})
|
||||
|
||||
it('deduplicates merged PR comments and preserves existing thread metadata', () => {
|
||||
@@ -1666,6 +1668,131 @@ describe('createGitHubSlice PR comment mutations', () => {
|
||||
).toMatchObject({ body: 'reply', threadId: 'PRRT_1', path: 'src/a.ts', line: 8 })
|
||||
})
|
||||
|
||||
it('updates cached reactions through local GitHub IPC', async () => {
|
||||
const store = createTestStore()
|
||||
const repoPath = '/repo'
|
||||
const repoId = 'repo-id'
|
||||
const cacheKey = `${repoId}::pr-comments::acme/widgets::12`
|
||||
store.setState({
|
||||
repos: [{ id: repoId, path: repoPath, name: 'repo', kind: 'git' }],
|
||||
commentsCache: {
|
||||
[cacheKey]: {
|
||||
data: [
|
||||
{
|
||||
id: 10,
|
||||
reactionSubjectId: 'IC_10',
|
||||
author: 'reviewer',
|
||||
authorAvatarUrl: '',
|
||||
body: 'Nice',
|
||||
createdAt: '2026-03-28T00:00:00Z',
|
||||
url: ''
|
||||
}
|
||||
],
|
||||
fetchedAt: 1
|
||||
}
|
||||
}
|
||||
} as unknown as Partial<AppState>)
|
||||
|
||||
await store.getState().setPRCommentReaction(repoPath, 12, 'IC_10', 'heart', true, {
|
||||
repoId,
|
||||
prRepo: { owner: 'Acme', repo: 'Widgets' }
|
||||
})
|
||||
|
||||
expect(mockApi.gh.setPRCommentReaction).toHaveBeenCalledWith({
|
||||
repoPath,
|
||||
repoId,
|
||||
reactionSubjectId: 'IC_10',
|
||||
content: 'heart',
|
||||
reacted: true,
|
||||
prRepo: { owner: 'Acme', repo: 'Widgets' },
|
||||
sourceContext: undefined
|
||||
})
|
||||
expect(store.getState().commentsCache[cacheKey]?.data?.[0].reactions).toEqual([
|
||||
{ content: 'heart', count: 1, viewerHasReacted: true }
|
||||
])
|
||||
})
|
||||
|
||||
it('rolls back an optimistic reaction when GitHub rejects it', async () => {
|
||||
mockApi.gh.setPRCommentReaction.mockResolvedValueOnce(false)
|
||||
const store = createTestStore()
|
||||
const repoPath = '/repo'
|
||||
const repoId = 'repo-id'
|
||||
const cacheKey = `${repoId}::pr-comments::acme/widgets::12`
|
||||
store.setState({
|
||||
repos: [{ id: repoId, path: repoPath, name: 'repo', kind: 'git' }],
|
||||
commentsCache: {
|
||||
[cacheKey]: {
|
||||
data: [
|
||||
{
|
||||
id: 10,
|
||||
reactionSubjectId: 'IC_10',
|
||||
author: 'reviewer',
|
||||
authorAvatarUrl: '',
|
||||
body: 'Nice',
|
||||
createdAt: '2026-03-28T00:00:00Z',
|
||||
url: '',
|
||||
reactions: [{ content: 'heart', count: 2, viewerHasReacted: false }]
|
||||
}
|
||||
],
|
||||
fetchedAt: 1
|
||||
}
|
||||
}
|
||||
} as unknown as Partial<AppState>)
|
||||
|
||||
const ok = await store.getState().setPRCommentReaction(repoPath, 12, 'IC_10', 'heart', true, {
|
||||
repoId,
|
||||
prRepo: { owner: 'Acme', repo: 'Widgets' }
|
||||
})
|
||||
|
||||
expect(ok).toBe(false)
|
||||
expect(store.getState().commentsCache[cacheKey]?.data?.[0].reactions).toEqual([
|
||||
{ content: 'heart', count: 2, viewerHasReacted: false }
|
||||
])
|
||||
})
|
||||
|
||||
it('routes reaction mutations through the workspace runtime', async () => {
|
||||
runtimeEnvironmentCall.mockResolvedValueOnce({
|
||||
id: 'rpc-pr-reaction',
|
||||
ok: true,
|
||||
result: true,
|
||||
_meta: { runtimeId: 'remote-runtime' }
|
||||
})
|
||||
const store = createTestStore()
|
||||
const repoPath = '/repo'
|
||||
const repoId = 'repo-id'
|
||||
store.setState({
|
||||
settings: { activeRuntimeEnvironmentId: 'env-1' } as AppState['settings'],
|
||||
repos: [
|
||||
{
|
||||
id: repoId,
|
||||
path: repoPath,
|
||||
name: 'repo',
|
||||
kind: 'git',
|
||||
executionHostId: 'runtime:env-1'
|
||||
}
|
||||
]
|
||||
} as unknown as Partial<AppState>)
|
||||
|
||||
await store.getState().setPRCommentReaction(repoPath, 12, 'PRRC_10', 'rocket', true, {
|
||||
repoId,
|
||||
prRepo: { owner: 'Acme', repo: 'Widgets' }
|
||||
})
|
||||
|
||||
expect(runtimeEnvironmentCall).toHaveBeenCalledWith({
|
||||
selector: 'env-1',
|
||||
method: 'github.setPRCommentReaction',
|
||||
params: {
|
||||
repo: repoId,
|
||||
reactionSubjectId: 'PRRC_10',
|
||||
content: 'rocket',
|
||||
reacted: true,
|
||||
prRepo: { owner: 'Acme', repo: 'Widgets' }
|
||||
},
|
||||
timeoutMs: 30_000
|
||||
})
|
||||
expect(mockApi.gh.setPRCommentReaction).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not mutate the PR comments cache when GitHub omits the comment payload', async () => {
|
||||
mockApi.gh.addIssueComment.mockResolvedValueOnce({ ok: true })
|
||||
const store = createTestStore()
|
||||
|
||||
@@ -17,6 +17,7 @@ import type {
|
||||
PRRefreshErrorType,
|
||||
PRRefreshOutcome,
|
||||
GitHubCommentResult,
|
||||
GitHubReactionContent,
|
||||
IssueInfo,
|
||||
PRCheckDetail,
|
||||
PRCheckRunDetails,
|
||||
@@ -74,6 +75,7 @@ import {
|
||||
type TaskSourceContext
|
||||
} from '../../../../shared/task-source-context'
|
||||
import { normalizeGitHubPRForBranchOutcome } from '../../../../shared/github-pr-for-branch-outcome'
|
||||
import { setReactionOnSubject } from '@/lib/pr-comment-reactions'
|
||||
|
||||
// ─── ProjectV2 cache types ────────────────────────────────────────────
|
||||
// Why: separate from CacheEntry<T> — project-view has a single GraphQL source (no issue/PR fallback) and a distinct error union.
|
||||
@@ -1890,6 +1892,14 @@ export type GitHubSlice = {
|
||||
line?: number
|
||||
}
|
||||
) => Promise<GitHubCommentResult>
|
||||
setPRCommentReaction: (
|
||||
repoPath: string,
|
||||
prNumber: number,
|
||||
reactionSubjectId: string,
|
||||
content: GitHubReactionContent,
|
||||
reacted: boolean,
|
||||
options?: RepoScopedFetchOptions & { prRepo?: GitHubOwnerRepo | null }
|
||||
) => Promise<boolean>
|
||||
resolveReviewThread: (
|
||||
repoPath: string,
|
||||
prNumber: number,
|
||||
@@ -3839,6 +3849,111 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s
|
||||
return { ok: true, comment }
|
||||
},
|
||||
|
||||
setPRCommentReaction: async (
|
||||
repoPath,
|
||||
prNumber,
|
||||
reactionSubjectId,
|
||||
content,
|
||||
reacted,
|
||||
options
|
||||
) => {
|
||||
const repo = get().repos?.find((candidate) =>
|
||||
options?.repoId ? candidate.id === options.repoId : candidate.path === repoPath
|
||||
)
|
||||
const repoId = options?.repoId ?? repo?.id
|
||||
const requestSettings = getGitHubRepoSourceSettings(
|
||||
get().settings,
|
||||
repo,
|
||||
options?.sourceContext
|
||||
)
|
||||
const cacheKey = sourceScopedRepoCacheKey(
|
||||
repoPath,
|
||||
repoId,
|
||||
prCommentsCacheSuffix(prNumber, options?.prRepo),
|
||||
requestSettings,
|
||||
repo?.connectionId,
|
||||
repo?.executionHostId,
|
||||
options?.sourceContext,
|
||||
repo !== undefined
|
||||
)
|
||||
const previousComment = get().commentsCache[cacheKey]?.data?.find(
|
||||
(comment) => comment.reactionSubjectId === reactionSubjectId
|
||||
)
|
||||
set((state) => {
|
||||
const entry = state.commentsCache[cacheKey]
|
||||
if (!entry?.data) {
|
||||
return state
|
||||
}
|
||||
return {
|
||||
commentsCache: {
|
||||
...state.commentsCache,
|
||||
[cacheKey]: {
|
||||
...entry,
|
||||
data: setReactionOnSubject(entry.data, reactionSubjectId, content, reacted)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const requestContext = getGitHubWorkItemRequestContext(
|
||||
get(),
|
||||
requestSettings,
|
||||
repoId ?? repoPath,
|
||||
repoPath,
|
||||
options?.sourceContext
|
||||
)
|
||||
let ok = false
|
||||
try {
|
||||
ok =
|
||||
requestContext.target.kind === 'environment'
|
||||
? await callRuntimeRpc<boolean>(
|
||||
{ kind: 'environment', environmentId: requestContext.target.environmentId },
|
||||
'github.setPRCommentReaction',
|
||||
{
|
||||
repo: requestContext.target.runtimeRepoId,
|
||||
reactionSubjectId,
|
||||
content,
|
||||
reacted,
|
||||
prRepo: options?.prRepo ?? null
|
||||
},
|
||||
{ timeoutMs: 30_000 }
|
||||
)
|
||||
: await window.api.gh.setPRCommentReaction({
|
||||
repoPath,
|
||||
repoId,
|
||||
reactionSubjectId,
|
||||
content,
|
||||
reacted,
|
||||
prRepo: options?.prRepo ?? null,
|
||||
sourceContext: options?.sourceContext
|
||||
})
|
||||
} catch (err) {
|
||||
console.error('Failed to update PR comment reaction:', err)
|
||||
}
|
||||
if (!ok && previousComment) {
|
||||
set((state) => {
|
||||
const entry = state.commentsCache[cacheKey]
|
||||
if (!entry?.data) {
|
||||
return state
|
||||
}
|
||||
return {
|
||||
commentsCache: {
|
||||
...state.commentsCache,
|
||||
[cacheKey]: {
|
||||
...entry,
|
||||
data: entry.data.map((comment) =>
|
||||
comment.reactionSubjectId === reactionSubjectId
|
||||
? { ...comment, reactions: previousComment.reactions }
|
||||
: comment
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
return ok
|
||||
},
|
||||
|
||||
resolveReviewThread: async (repoPath, prNumber, threadId, resolve, options) => {
|
||||
const repo = get().repos?.find((candidate) =>
|
||||
options?.repoId ? candidate.id === options.repoId : candidate.path === repoPath
|
||||
|
||||
@@ -3769,6 +3769,7 @@ describe('web GitHub preload API', () => {
|
||||
'resolveProjectRef',
|
||||
'resolveReviewThread',
|
||||
'setPRAutoMerge',
|
||||
'setPRCommentReaction',
|
||||
'setPRFileViewed',
|
||||
'starOrca',
|
||||
'updateIssue',
|
||||
@@ -3950,6 +3951,17 @@ describe('web GitHub preload API', () => {
|
||||
expectedMethod: 'github.prComments',
|
||||
expectedParams: withRepo({ repoPath, prNumber: 7, noCache: true })
|
||||
},
|
||||
{
|
||||
key: 'setPRCommentReaction',
|
||||
args: { repoPath, reactionSubjectId: 'IC_1', content: 'heart', reacted: true },
|
||||
expectedMethod: 'github.setPRCommentReaction',
|
||||
expectedParams: withRepo({
|
||||
repoPath,
|
||||
reactionSubjectId: 'IC_1',
|
||||
content: 'heart',
|
||||
reacted: true
|
||||
})
|
||||
},
|
||||
{
|
||||
key: 'resolveReviewThread',
|
||||
args: { repoPath, threadId: 'thread-1', resolve: true },
|
||||
|
||||
@@ -289,6 +289,7 @@ type WebGitHubRouteKey =
|
||||
| 'prCheckDetails'
|
||||
| 'rerunPRChecks'
|
||||
| 'prComments'
|
||||
| 'setPRCommentReaction'
|
||||
| 'resolveReviewThread'
|
||||
| 'setPRFileViewed'
|
||||
| 'updatePRTitle'
|
||||
@@ -337,6 +338,7 @@ type WebGitHubRuntimeMethod =
|
||||
| 'github.prCheckDetails'
|
||||
| 'github.rerunPRChecks'
|
||||
| 'github.prComments'
|
||||
| 'github.setPRCommentReaction'
|
||||
| 'github.resolveReviewThread'
|
||||
| 'github.setPRFileViewed'
|
||||
| 'github.updatePRTitle'
|
||||
@@ -438,6 +440,7 @@ export const GITHUB_WEB_RPC_METHODS = {
|
||||
prCheckDetails: 'github.prCheckDetails',
|
||||
rerunPRChecks: 'github.rerunPRChecks',
|
||||
prComments: 'github.prComments',
|
||||
setPRCommentReaction: 'github.setPRCommentReaction',
|
||||
resolveReviewThread: 'github.resolveReviewThread',
|
||||
setPRFileViewed: 'github.setPRFileViewed',
|
||||
updatePRTitle: 'github.updatePRTitle',
|
||||
@@ -2385,6 +2388,11 @@ function createGitHubApi(): WebGitHubApi {
|
||||
route<WebGitHubResult<'rerunPRChecks'>>(GITHUB_WEB_RPC_METHODS.rerunPRChecks, args),
|
||||
prComments: (args) =>
|
||||
route<WebGitHubResult<'prComments'>>(GITHUB_WEB_RPC_METHODS.prComments, args),
|
||||
setPRCommentReaction: (args) =>
|
||||
route<WebGitHubResult<'setPRCommentReaction'>>(
|
||||
GITHUB_WEB_RPC_METHODS.setPRCommentReaction,
|
||||
args
|
||||
),
|
||||
resolveReviewThread: (args) =>
|
||||
route<WebGitHubResult<'resolveReviewThread'>>(
|
||||
GITHUB_WEB_RPC_METHODS.resolveReviewThread,
|
||||
|
||||
@@ -1485,6 +1485,7 @@ export type GitHubReactionContent =
|
||||
export type GitHubReaction = {
|
||||
content: GitHubReactionContent
|
||||
count: number
|
||||
viewerHasReacted?: boolean
|
||||
}
|
||||
|
||||
export type PRComment = {
|
||||
@@ -1495,6 +1496,8 @@ export type PRComment = {
|
||||
createdAt: string
|
||||
url: string
|
||||
reactions?: GitHubReaction[]
|
||||
/** GraphQL node ID for GitHub comments that support reaction mutations. */
|
||||
reactionSubjectId?: string
|
||||
/** File path for inline review comments (absent for top-level conversation comments). */
|
||||
path?: string
|
||||
/** GraphQL node ID of the review thread — present only for inline review comments.
|
||||
|
||||
@@ -15,6 +15,7 @@ export const FIXTURE_COMMENTS: PRComment[] = [
|
||||
body: 'Please update this handler before merge.',
|
||||
createdAt: '2026-05-14T10:00:00.000Z',
|
||||
url: 'https://github.com/acme/orca/pull/73#discussion_r101',
|
||||
reactionSubjectId: 'PRRC_101',
|
||||
threadId: 'thread-open',
|
||||
path: 'src/handler.ts',
|
||||
isResolved: false
|
||||
@@ -25,7 +26,8 @@ export const FIXTURE_COMMENTS: PRComment[] = [
|
||||
authorAvatarUrl: '',
|
||||
body: 'LGTM on the overall approach.',
|
||||
createdAt: '2026-05-14T11:00:00.000Z',
|
||||
url: 'https://github.com/acme/orca/pull/73#issuecomment-102'
|
||||
url: 'https://github.com/acme/orca/pull/73#issuecomment-102',
|
||||
reactionSubjectId: 'IC_102'
|
||||
},
|
||||
{
|
||||
id: 103,
|
||||
@@ -72,7 +74,8 @@ export async function seedPRCommentsSidebarFixture(page: Page): Promise<PRCommen
|
||||
url: `https://github.com/acme/orca/pull/${prNumber}`,
|
||||
checksStatus: 'pending',
|
||||
updatedAt: '2026-05-15T00:00:00.000Z',
|
||||
mergeable: 'MERGEABLE'
|
||||
mergeable: 'MERGEABLE',
|
||||
prRepo: { owner: 'acme', repo: 'orca' }
|
||||
}
|
||||
const prCacheEntries = {
|
||||
[`${repo.id}::${branch}`]: {
|
||||
@@ -131,6 +134,7 @@ export async function seedPRCommentsSidebarFixture(page: Page): Promise<PRCommen
|
||||
},
|
||||
fetchPRChecks: async () => [],
|
||||
fetchPRComments: async () => comments,
|
||||
setPRCommentReaction: async () => true,
|
||||
fetchUpstreamStatus: async () => undefined,
|
||||
setUpstreamStatus: () => undefined
|
||||
}))
|
||||
|
||||
@@ -124,6 +124,39 @@ test.describe('PR comments sidebar cards view', () => {
|
||||
expect(positions[1]).toBeLessThan(positions[2])
|
||||
})
|
||||
|
||||
test('adds reactions to conversation and review-thread comments', async ({ orcaPage }) => {
|
||||
const { worktreeId } = await seedPRCommentsSidebarFixture(orcaPage)
|
||||
await openChecks(orcaPage, worktreeId)
|
||||
await expect(orcaPage.getByText('Needs review · 1')).toBeVisible({ timeout: 10_000 })
|
||||
|
||||
const conversationCard = orcaPage.getByTestId('pr-comment-group').filter({
|
||||
hasText: 'LGTM on the overall approach.'
|
||||
})
|
||||
const conversationReactionButton = conversationCard.getByRole('button', {
|
||||
name: 'Add reaction'
|
||||
})
|
||||
await conversationReactionButton.evaluate((element) => (element as HTMLElement).click())
|
||||
const heartReactionButton = orcaPage.getByRole('button', { name: 'Add heart reaction' })
|
||||
await expect(heartReactionButton).toBeVisible()
|
||||
await heartReactionButton.evaluate((element) => (element as HTMLElement).click())
|
||||
await expect(
|
||||
conversationCard.getByRole('button', { name: '1 heart reaction' })
|
||||
).toHaveAttribute('aria-pressed', 'true')
|
||||
await expect(orcaPage.getByRole('button', { name: 'Add rocket reaction' })).toHaveCount(0)
|
||||
|
||||
const reviewThreadCard = orcaPage.getByTestId('pr-comment-group').filter({
|
||||
hasText: 'Please update this handler before merge.'
|
||||
})
|
||||
const threadReactionButton = reviewThreadCard.getByRole('button', { name: 'Add reaction' })
|
||||
await threadReactionButton.evaluate((element) => (element as HTMLElement).click())
|
||||
await orcaPage
|
||||
.getByRole('button', { name: 'Add rocket reaction' })
|
||||
.evaluate((element) => (element as HTMLElement).click())
|
||||
await expect(
|
||||
reviewThreadCard.getByRole('button', { name: '1 rocket reaction' })
|
||||
).toHaveAttribute('aria-pressed', 'true')
|
||||
})
|
||||
|
||||
test('queues an open thread for the agent from the visible row action and menu fallback', async ({
|
||||
orcaPage
|
||||
}) => {
|
||||
|
||||
Reference in New Issue
Block a user