Add PR review queue badge foundation
- Add provider-neutral review queue types, adapter, and checks-panel badges.\n- Keep the design doc out of the PR diff.
This commit is contained in:
parent
027f561b5b
commit
c90b9f72ef
|
|
@ -20,6 +20,7 @@ import {
|
|||
classifyListIssuesError,
|
||||
getIssueOwnerRepo,
|
||||
getOwnerRepo,
|
||||
parseGitHubRemoteIdentity,
|
||||
parseGitHubOwnerRepo,
|
||||
resolveIssueSource
|
||||
} from './gh-utils'
|
||||
|
|
@ -47,6 +48,20 @@ describe('github owner/repo resolution', () => {
|
|||
expect(parseGitHubOwnerRepo('git@example.com:stablyai/orca.git')).toBeNull()
|
||||
})
|
||||
|
||||
it('parses GitHub Enterprise host identity', () => {
|
||||
expect(parseGitHubRemoteIdentity('https://ghe.acme.internal/acme/orca.git')).toEqual({
|
||||
host: 'ghe.acme.internal',
|
||||
owner: 'acme',
|
||||
repo: 'orca'
|
||||
})
|
||||
expect(parseGitHubRemoteIdentity('git@ghe.acme.internal:acme/orca.git')).toEqual({
|
||||
host: 'ghe.acme.internal',
|
||||
owner: 'acme',
|
||||
repo: 'orca'
|
||||
})
|
||||
expect(parseGitHubOwnerRepo('https://ghe.acme.internal/acme/orca.git')).toBeNull()
|
||||
})
|
||||
|
||||
it('keeps getOwnerRepo origin-based', async () => {
|
||||
gitExecFileAsyncMock.mockResolvedValueOnce({
|
||||
stdout: 'git@github.com:fork/orca.git\n'
|
||||
|
|
|
|||
|
|
@ -113,6 +113,8 @@ export function classifyListIssuesError(stderr: string): ClassifiedError {
|
|||
// short local name `OwnerRepo`.
|
||||
export type OwnerRepo = GitHubOwnerRepo
|
||||
|
||||
export type GitHubRemoteIdentity = GitHubOwnerRepo & { host: string }
|
||||
|
||||
export type GitHubRepoContext = {
|
||||
repoPath: string
|
||||
connectionId?: string | null
|
||||
|
|
@ -143,11 +145,24 @@ export function _resetOwnerRepoCache(): void {
|
|||
}
|
||||
|
||||
export function parseGitHubOwnerRepo(remoteUrl: string): OwnerRepo | null {
|
||||
const match = remoteUrl.trim().match(/github\.com[:/]([^/]+)\/([^/]+?)(?:\.git)?$/)
|
||||
if (!match) {
|
||||
const identity = parseGitHubRemoteIdentity(remoteUrl)
|
||||
if (!identity || identity.host.toLowerCase() !== 'github.com') {
|
||||
return null
|
||||
}
|
||||
return { owner: match[1], repo: match[2] }
|
||||
return { owner: identity.owner, repo: identity.repo }
|
||||
}
|
||||
|
||||
export function parseGitHubRemoteIdentity(remoteUrl: string): GitHubRemoteIdentity | null {
|
||||
const trimmed = remoteUrl.trim()
|
||||
const httpsMatch = trimmed.match(/^https?:\/\/([^/]+)\/([^/]+)\/([^/]+?)(?:\.git)?\/?$/i)
|
||||
if (httpsMatch) {
|
||||
return { host: httpsMatch[1], owner: httpsMatch[2], repo: httpsMatch[3] }
|
||||
}
|
||||
const sshMatch = trimmed.match(/^git@([^:]+):([^/]+)\/([^/]+?)(?:\.git)?$/i)
|
||||
if (sshMatch) {
|
||||
return { host: sshMatch[1], owner: sshMatch[2], repo: sshMatch[3] }
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export async function getRemoteUrlForRepo(
|
||||
|
|
|
|||
|
|
@ -22,6 +22,11 @@ import { getConnectionId } from '@/lib/connection-context'
|
|||
import { CreatePullRequestDialog } from './CreatePullRequestDialog'
|
||||
import type { HostedReviewCreationEligibility } from '../../../../shared/hosted-review'
|
||||
import { toast } from 'sonner'
|
||||
import {
|
||||
classifyHostedReview,
|
||||
type HostedReviewClassificationOptions
|
||||
} from '../../../../shared/hosted-review-queue'
|
||||
import { hostedReviewSummaryFromGitHubPRInfo } from '../../../../shared/hosted-review-github'
|
||||
|
||||
export default function ChecksPanel(): React.JSX.Element {
|
||||
const activeWorktree = useActiveWorktree()
|
||||
|
|
@ -579,6 +584,68 @@ export default function ChecksPanel(): React.JSX.Element {
|
|||
]
|
||||
)
|
||||
|
||||
const activeReviewClassification = React.useMemo(() => {
|
||||
if (!pr || !repo) {
|
||||
return null
|
||||
}
|
||||
let host = 'github.com'
|
||||
let owner = 'unknown'
|
||||
let repoName = 'unknown'
|
||||
try {
|
||||
const parsed = new URL(pr.url)
|
||||
host = parsed.host || host
|
||||
const segments = parsed.pathname.split('/').filter(Boolean)
|
||||
if (segments.length >= 2) {
|
||||
owner = segments[0]
|
||||
repoName = segments[1]
|
||||
}
|
||||
} catch {
|
||||
// Why: malformed URLs should not block queue-state classification.
|
||||
}
|
||||
|
||||
// Why: unresolved thread data is paginated and fetched separately. Until
|
||||
// comments have loaded for this PR, do not let queue badges imply a clean review.
|
||||
const commentsForClassification =
|
||||
commentsFetchedAt !== undefined && !commentsLoading ? comments : undefined
|
||||
const summary = hostedReviewSummaryFromGitHubPRInfo({
|
||||
pr,
|
||||
owner,
|
||||
repo: repoName,
|
||||
host,
|
||||
comments: commentsForClassification,
|
||||
checks
|
||||
})
|
||||
const options: HostedReviewClassificationOptions = {
|
||||
agentAuthorLogins: [],
|
||||
viewer: null
|
||||
}
|
||||
return classifyHostedReview(summary, options)
|
||||
}, [pr, repo, comments, commentsFetchedAt, commentsLoading, checks])
|
||||
|
||||
const queueBadges = React.useMemo(() => {
|
||||
if (!activeReviewClassification) {
|
||||
return [] as string[]
|
||||
}
|
||||
const badges: string[] = []
|
||||
if (activeReviewClassification.needsResponse) {
|
||||
badges.push('Needs response')
|
||||
}
|
||||
if (activeReviewClassification.readyToMerge) {
|
||||
badges.push('Ready to merge')
|
||||
}
|
||||
if (activeReviewClassification.requested) {
|
||||
badges.push('Review requested')
|
||||
}
|
||||
if (activeReviewClassification.state === 'mine') {
|
||||
badges.push('My PR')
|
||||
} else if (activeReviewClassification.state === 'agent') {
|
||||
badges.push('AI PR')
|
||||
} else {
|
||||
badges.push('Teammate PR')
|
||||
}
|
||||
return badges
|
||||
}, [activeReviewClassification])
|
||||
|
||||
// ── Empty state ──
|
||||
if (!activeWorktree) {
|
||||
return (
|
||||
|
|
@ -763,6 +830,19 @@ export default function ChecksPanel(): React.JSX.Element {
|
|||
</div>
|
||||
)}
|
||||
|
||||
{queueBadges.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{queueBadges.map((badge) => (
|
||||
<span
|
||||
key={badge}
|
||||
className="rounded border border-border bg-accent/30 px-1.5 py-0.5 text-[9px] font-medium uppercase tracking-wide text-muted-foreground"
|
||||
>
|
||||
{badge}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Merge / Delete Worktree actions */}
|
||||
{activeWorktree && repo && (
|
||||
<PRActions pr={pr} repo={repo} worktree={activeWorktree} onRefreshPR={handleRefreshPR} />
|
||||
|
|
|
|||
|
|
@ -0,0 +1,98 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import { hostedReviewSummaryFromGitHubPRInfo } from './hosted-review-github'
|
||||
import type { PRInfo } from './types'
|
||||
|
||||
const pr: PRInfo = {
|
||||
number: 12,
|
||||
title: 'Add queue badges',
|
||||
state: 'open',
|
||||
url: 'https://github.com/acme/orca/pull/12',
|
||||
checksStatus: 'pending',
|
||||
updatedAt: '2026-05-12T00:00:00.000Z',
|
||||
mergeable: 'MERGEABLE',
|
||||
headSha: 'abc123'
|
||||
}
|
||||
|
||||
describe('hostedReviewSummaryFromGitHubPRInfo', () => {
|
||||
it('maps PRInfo into provider-neutral summary with host identity', () => {
|
||||
const summary = hostedReviewSummaryFromGitHubPRInfo({
|
||||
pr,
|
||||
owner: 'acme',
|
||||
repo: 'orca',
|
||||
host: 'github.acme.internal'
|
||||
})
|
||||
|
||||
expect(summary.identity).toEqual({
|
||||
provider: 'github',
|
||||
host: 'github.acme.internal',
|
||||
owner: 'acme',
|
||||
repo: 'orca',
|
||||
number: 12
|
||||
})
|
||||
expect(summary.checksStatus).toBe('pending')
|
||||
expect(summary.threadSummary).toBeUndefined()
|
||||
})
|
||||
|
||||
it('derives unresolved thread count and failing status from enrichers', () => {
|
||||
const summary = hostedReviewSummaryFromGitHubPRInfo({
|
||||
pr: { ...pr, checksStatus: 'success' },
|
||||
owner: 'acme',
|
||||
repo: 'orca',
|
||||
comments: [
|
||||
{
|
||||
id: 1,
|
||||
author: 'a',
|
||||
authorAvatarUrl: '',
|
||||
body: '',
|
||||
createdAt: '',
|
||||
url: '',
|
||||
threadId: 't1',
|
||||
isResolved: false
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
author: 'b',
|
||||
authorAvatarUrl: '',
|
||||
body: '',
|
||||
createdAt: '',
|
||||
url: '',
|
||||
threadId: 't1',
|
||||
isResolved: false
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
author: 'c',
|
||||
authorAvatarUrl: '',
|
||||
body: '',
|
||||
createdAt: '',
|
||||
url: '',
|
||||
threadId: 't2',
|
||||
isResolved: true
|
||||
}
|
||||
],
|
||||
checks: [{ name: 'ci', status: 'completed', conclusion: 'failure', url: null }]
|
||||
})
|
||||
|
||||
expect(summary.threadSummary).toEqual({ unresolvedCount: 1, dataCompleteness: 'partial' })
|
||||
expect(summary.checksStatus).toBe('failure')
|
||||
})
|
||||
|
||||
it('distinguishes loaded empty comments from unknown comments', () => {
|
||||
expect(
|
||||
hostedReviewSummaryFromGitHubPRInfo({
|
||||
pr,
|
||||
owner: 'acme',
|
||||
repo: 'orca'
|
||||
}).threadSummary
|
||||
).toBeUndefined()
|
||||
|
||||
expect(
|
||||
hostedReviewSummaryFromGitHubPRInfo({
|
||||
pr,
|
||||
owner: 'acme',
|
||||
repo: 'orca',
|
||||
comments: []
|
||||
}).threadSummary
|
||||
).toEqual({ unresolvedCount: 0, dataCompleteness: 'partial' })
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,88 @@
|
|||
import type { PRCheckDetail, PRComment, PRInfo } from './types'
|
||||
import type { HostedReviewQueueSummary } from './hosted-review'
|
||||
|
||||
export type HostedReviewFromGitHubPRInfoArgs = {
|
||||
pr: PRInfo
|
||||
owner: string
|
||||
repo: string
|
||||
host?: string
|
||||
authorLogin?: string | null
|
||||
authorIsBot?: boolean
|
||||
requestedReviewerLogins?: string[] | null
|
||||
comments?: PRComment[]
|
||||
checks?: PRCheckDetail[]
|
||||
lastViewedAt?: number
|
||||
}
|
||||
|
||||
function unresolvedThreadCount(comments?: PRComment[]): number | null {
|
||||
if (comments === undefined) {
|
||||
return null
|
||||
}
|
||||
const unresolved = new Set<string>()
|
||||
for (const comment of comments) {
|
||||
if (!comment.threadId || comment.isResolved !== false) {
|
||||
continue
|
||||
}
|
||||
unresolved.add(comment.threadId)
|
||||
}
|
||||
return unresolved.size
|
||||
}
|
||||
|
||||
function deriveChecksStatus(
|
||||
prChecksStatus: PRInfo['checksStatus'],
|
||||
checks?: PRCheckDetail[]
|
||||
): PRInfo['checksStatus'] {
|
||||
if (!checks || checks.length === 0) {
|
||||
return prChecksStatus
|
||||
}
|
||||
const hasFailure = checks.some(
|
||||
(check) => check.conclusion === 'failure' || check.conclusion === 'timed_out'
|
||||
)
|
||||
if (hasFailure) {
|
||||
return 'failure'
|
||||
}
|
||||
const hasPending = checks.some(
|
||||
(check) =>
|
||||
check.status !== 'completed' || check.conclusion === null || check.conclusion === 'pending'
|
||||
)
|
||||
if (hasPending) {
|
||||
return 'pending'
|
||||
}
|
||||
const hasSuccess = checks.some((check) => check.conclusion === 'success')
|
||||
if (hasSuccess) {
|
||||
return 'success'
|
||||
}
|
||||
return 'neutral'
|
||||
}
|
||||
|
||||
export function hostedReviewSummaryFromGitHubPRInfo(
|
||||
args: HostedReviewFromGitHubPRInfoArgs
|
||||
): HostedReviewQueueSummary {
|
||||
const unresolvedCount = unresolvedThreadCount(args.comments)
|
||||
return {
|
||||
identity: {
|
||||
provider: 'github',
|
||||
host: args.host ?? 'github.com',
|
||||
owner: args.owner,
|
||||
repo: args.repo,
|
||||
number: args.pr.number
|
||||
},
|
||||
title: args.pr.title,
|
||||
url: args.pr.url,
|
||||
state: args.pr.state,
|
||||
author: args.authorLogin ? { login: args.authorLogin, isBot: args.authorIsBot } : null,
|
||||
updatedAt: args.pr.updatedAt,
|
||||
mergeable: args.pr.mergeable,
|
||||
checksStatus: deriveChecksStatus(args.pr.checksStatus, args.checks),
|
||||
threadSummary:
|
||||
unresolvedCount === null
|
||||
? undefined
|
||||
: {
|
||||
unresolvedCount,
|
||||
dataCompleteness: 'partial'
|
||||
},
|
||||
requestedReviewerLogins: args.requestedReviewerLogins,
|
||||
lastViewedAt: args.lastViewedAt,
|
||||
draft: args.pr.state === 'draft'
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,103 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import type { HostedReviewQueueSummary } from './hosted-review'
|
||||
import {
|
||||
classifyHostedReview,
|
||||
hostedReviewIdentityKey,
|
||||
reviewNeedsResponse,
|
||||
reviewReadyToMerge
|
||||
} from './hosted-review-queue'
|
||||
|
||||
function baseSummary(overrides: Partial<HostedReviewQueueSummary> = {}): HostedReviewQueueSummary {
|
||||
return {
|
||||
identity: { provider: 'github', host: 'github.com', owner: 'acme', repo: 'orca', number: 42 },
|
||||
title: 'Improve checks panel',
|
||||
url: 'https://github.com/acme/orca/pull/42',
|
||||
state: 'open',
|
||||
author: { login: 'teammate' },
|
||||
updatedAt: '2026-05-10T00:00:00.000Z',
|
||||
mergeable: 'MERGEABLE',
|
||||
checksStatus: 'success',
|
||||
threadSummary: { unresolvedCount: 0 },
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
describe('hostedReviewIdentityKey', () => {
|
||||
it('includes provider and host for enterprise-safe keys', () => {
|
||||
const dotcom = hostedReviewIdentityKey({
|
||||
provider: 'github',
|
||||
host: 'github.com',
|
||||
owner: 'acme',
|
||||
repo: 'orca',
|
||||
number: 7
|
||||
})
|
||||
const ghe = hostedReviewIdentityKey({
|
||||
provider: 'github',
|
||||
host: 'github.acme.internal',
|
||||
owner: 'acme',
|
||||
repo: 'orca',
|
||||
number: 7
|
||||
})
|
||||
expect(dotcom).not.toBe(ghe)
|
||||
})
|
||||
})
|
||||
|
||||
describe('classifyHostedReview', () => {
|
||||
it('classifies mine/requested/agent/teammate', () => {
|
||||
expect(
|
||||
classifyHostedReview(baseSummary({ author: { login: 'me' } }), {
|
||||
viewer: { login: 'me' }
|
||||
}).state
|
||||
).toBe('mine')
|
||||
|
||||
expect(
|
||||
classifyHostedReview(baseSummary({ requestedReviewerLogins: ['me'] }), {
|
||||
viewer: { login: 'me' }
|
||||
}).state
|
||||
).toBe('requested')
|
||||
|
||||
expect(
|
||||
classifyHostedReview(baseSummary({ author: { login: 'orca-ci' } }), {
|
||||
agentAuthorLogins: ['orca-ci']
|
||||
}).state
|
||||
).toBe('agent')
|
||||
|
||||
expect(classifyHostedReview(baseSummary()).state).toBe('teammate')
|
||||
})
|
||||
})
|
||||
|
||||
describe('reviewNeedsResponse', () => {
|
||||
it('returns true for unresolved threads, failed checks, conflicts, and newer remote updates', () => {
|
||||
expect(reviewNeedsResponse(baseSummary({ threadSummary: { unresolvedCount: 1 } }))).toBe(true)
|
||||
expect(reviewNeedsResponse(baseSummary({ checksStatus: 'failure' }))).toBe(true)
|
||||
expect(reviewNeedsResponse(baseSummary({ mergeable: 'CONFLICTING' }))).toBe(true)
|
||||
expect(
|
||||
reviewNeedsResponse(
|
||||
baseSummary({
|
||||
updatedAt: '2026-05-11T00:00:00.000Z',
|
||||
lastViewedAt: Date.parse('2026-05-10T00:00:00.000Z')
|
||||
})
|
||||
)
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('does not mark needs-response from updatedAt alone when lastViewedAt is missing', () => {
|
||||
expect(reviewNeedsResponse(baseSummary({ updatedAt: '2026-05-11T00:00:00.000Z' }))).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('reviewReadyToMerge', () => {
|
||||
it('rejects drafts, conflicts, failed/pending checks, unresolved threads, and unknown mergeability', () => {
|
||||
expect(reviewReadyToMerge(baseSummary({ state: 'draft', draft: true }))).toBe(false)
|
||||
expect(reviewReadyToMerge(baseSummary({ mergeable: 'CONFLICTING' }))).toBe(false)
|
||||
expect(reviewReadyToMerge(baseSummary({ checksStatus: 'failure' }))).toBe(false)
|
||||
expect(reviewReadyToMerge(baseSummary({ checksStatus: 'pending' }))).toBe(false)
|
||||
expect(reviewReadyToMerge(baseSummary({ threadSummary: { unresolvedCount: 2 } }))).toBe(false)
|
||||
expect(reviewReadyToMerge(baseSummary({ threadSummary: undefined }))).toBe(false)
|
||||
expect(reviewReadyToMerge(baseSummary({ mergeable: 'UNKNOWN' }))).toBe(false)
|
||||
})
|
||||
|
||||
it('accepts neutral checks when all other gates pass', () => {
|
||||
expect(reviewReadyToMerge(baseSummary({ checksStatus: 'neutral' }))).toBe(true)
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,122 @@
|
|||
import type {
|
||||
HostedReviewIdentity,
|
||||
HostedReviewQueueClassification,
|
||||
HostedReviewQueueState,
|
||||
HostedReviewQueueSummary,
|
||||
HostedReviewUser
|
||||
} from './hosted-review'
|
||||
|
||||
export type HostedReviewClassificationOptions = {
|
||||
viewer?: HostedReviewUser | null
|
||||
agentAuthorLogins?: string[]
|
||||
}
|
||||
|
||||
export function hostedReviewIdentityKey(identity: HostedReviewIdentity): string {
|
||||
return `${identity.provider}::${identity.host.toLowerCase()}::${identity.owner.toLowerCase()}::${identity.repo.toLowerCase()}::${identity.number}`
|
||||
}
|
||||
|
||||
function hasRequestedReviewerSignal(
|
||||
summary: HostedReviewQueueSummary,
|
||||
viewer?: HostedReviewUser | null
|
||||
): boolean {
|
||||
if (!viewer?.login) {
|
||||
return false
|
||||
}
|
||||
const requested = summary.requestedReviewerLogins
|
||||
if (!requested || requested.length === 0) {
|
||||
return false
|
||||
}
|
||||
const viewerLogin = viewer.login.toLowerCase()
|
||||
return requested.some((login) => login.toLowerCase() === viewerLogin)
|
||||
}
|
||||
|
||||
function isAgentAuthored(
|
||||
summary: HostedReviewQueueSummary,
|
||||
options?: HostedReviewClassificationOptions
|
||||
): boolean {
|
||||
if (summary.author?.isBot) {
|
||||
return true
|
||||
}
|
||||
const author = summary.author?.login?.toLowerCase()
|
||||
if (!author) {
|
||||
return false
|
||||
}
|
||||
if (options?.agentAuthorLogins?.some((login) => login.toLowerCase() === author)) {
|
||||
return true
|
||||
}
|
||||
return author.endsWith('[bot]') || author.includes('bot')
|
||||
}
|
||||
|
||||
function getQueueState(
|
||||
summary: HostedReviewQueueSummary,
|
||||
options?: HostedReviewClassificationOptions
|
||||
): HostedReviewQueueState {
|
||||
const viewerLogin = options?.viewer?.login?.toLowerCase() ?? null
|
||||
const authorLogin = summary.author?.login?.toLowerCase() ?? null
|
||||
if (viewerLogin && authorLogin && viewerLogin === authorLogin) {
|
||||
return 'mine'
|
||||
}
|
||||
if (hasRequestedReviewerSignal(summary, options?.viewer)) {
|
||||
return 'requested'
|
||||
}
|
||||
if (isAgentAuthored(summary, options)) {
|
||||
return 'agent'
|
||||
}
|
||||
return 'teammate'
|
||||
}
|
||||
|
||||
export function reviewNeedsResponse(
|
||||
summary: HostedReviewQueueSummary,
|
||||
viewer?: HostedReviewUser | null
|
||||
): boolean {
|
||||
void viewer
|
||||
if (summary.state !== 'open' && summary.state !== 'draft') {
|
||||
return false
|
||||
}
|
||||
if ((summary.threadSummary?.unresolvedCount ?? 0) > 0) {
|
||||
return true
|
||||
}
|
||||
if (summary.checksStatus === 'failure') {
|
||||
return true
|
||||
}
|
||||
if (summary.mergeable === 'CONFLICTING') {
|
||||
return true
|
||||
}
|
||||
if (summary.lastViewedAt === undefined) {
|
||||
return false
|
||||
}
|
||||
const updatedAt = Date.parse(summary.updatedAt)
|
||||
return Number.isFinite(updatedAt) && updatedAt > summary.lastViewedAt
|
||||
}
|
||||
|
||||
export function reviewReadyToMerge(summary: HostedReviewQueueSummary): boolean {
|
||||
if (summary.state !== 'open') {
|
||||
return false
|
||||
}
|
||||
if (summary.draft) {
|
||||
return false
|
||||
}
|
||||
if (summary.mergeable !== 'MERGEABLE') {
|
||||
return false
|
||||
}
|
||||
if (summary.checksStatus !== 'success' && summary.checksStatus !== 'neutral') {
|
||||
return false
|
||||
}
|
||||
if (summary.threadSummary?.unresolvedCount !== 0) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
export function classifyHostedReview(
|
||||
summary: HostedReviewQueueSummary,
|
||||
options?: HostedReviewClassificationOptions
|
||||
): HostedReviewQueueClassification {
|
||||
const state = getQueueState(summary, options)
|
||||
return {
|
||||
state,
|
||||
requested: hasRequestedReviewerSignal(summary, options?.viewer),
|
||||
needsResponse: reviewNeedsResponse(summary, options?.viewer),
|
||||
readyToMerge: reviewReadyToMerge(summary)
|
||||
}
|
||||
}
|
||||
|
|
@ -113,3 +113,56 @@ export type HostedReviewCreationEligibilityArgs = {
|
|||
linkedBitbucketPR?: number | null
|
||||
linkedGiteaPR?: number | null
|
||||
}
|
||||
|
||||
export type HostedReviewIdentity = {
|
||||
provider: HostedReviewProvider
|
||||
host: string
|
||||
owner: string
|
||||
repo: string
|
||||
number: number
|
||||
}
|
||||
|
||||
export type HostedReviewUser = {
|
||||
login: string | null
|
||||
isBot?: boolean
|
||||
}
|
||||
|
||||
export type HostedReviewDecision = 'approved' | 'changes_requested' | 'review_required' | null
|
||||
|
||||
export type HostedReviewThreadSummary = {
|
||||
unresolvedCount: number | null
|
||||
dataCompleteness?: 'full' | 'partial'
|
||||
}
|
||||
|
||||
export type HostedReviewQueueSummary = {
|
||||
identity: HostedReviewIdentity
|
||||
title: string
|
||||
url: string
|
||||
state: HostedReviewState
|
||||
author: HostedReviewUser | null
|
||||
updatedAt: string
|
||||
lastViewedAt?: number
|
||||
mergeable: PRMergeableState
|
||||
checksStatus: CheckStatus
|
||||
reviewDecision?: HostedReviewDecision
|
||||
threadSummary?: HostedReviewThreadSummary
|
||||
requestedReviewerLogins?: string[] | null
|
||||
draft?: boolean
|
||||
}
|
||||
|
||||
export type HostedReviewQueueKey =
|
||||
| 'mine'
|
||||
| 'requested'
|
||||
| 'agent'
|
||||
| 'teammate'
|
||||
| 'needs-response'
|
||||
| 'ready-to-merge'
|
||||
|
||||
export type HostedReviewQueueState = 'mine' | 'requested' | 'agent' | 'teammate'
|
||||
|
||||
export type HostedReviewQueueClassification = {
|
||||
state: HostedReviewQueueState
|
||||
needsResponse: boolean
|
||||
readyToMerge: boolean
|
||||
requested: boolean
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue