mirror of
https://github.com/stablyai/orca.git
synced 2026-10-21 00:03:46 +00:00
refactor(task-page): extract pure task-kind, jira, and pagination helpers
Moves seven closed sets of pure helpers out of TaskPage.tsx (13485 -> 13308 lines) into domain-named sibling modules. Function bodies are byte-identical cut/paste; the only production edits in TaskPage.tsx are the removed blocks and the new import statements. - task-page-github-task-kind.ts: isPRFocusedTaskView, normalizeGitHubTaskPreset, getGitHubTaskKind, getDefaultPresetForGitHubTaskKind, scopeGitHubTaskSearch - task-page-jira-create-fields.ts: the Jira create-field visibility, allowed-value, and payload builders - task-page-jira-project-selection.ts: getJiraProjectSelectionKey, compareJiraProjectsByDisplayLabel - task-page-jira-status-tone.ts: getJiraStatusTone - task-page-pr-delta-summary.ts: formatPRDelta - task-page-pagination-page-numbers.ts: getPageNumbers - task-page-string-set-equality.ts: areStringSetsEqual Each module gets a characterization test suite that pins current behavior, including the quirks (case-sensitive matching, truthiness-based option payload fallbacks, allowedValues winning over schema type). Quirks are documented, not fixed. github-enterprise-slug-routing-boundary.test.ts anchored its source-text sections on `function formatPRDelta` and `function getPageNumbers`; both moved, so the sentinels advance to the next declarations. The bounded sections and their assertions are unchanged. No intentional behavior change.
This commit is contained in:
@@ -340,7 +340,6 @@ import {
|
||||
} from '@/components/task-page-jira-load-state'
|
||||
import { deriveTaskPagePRCheckSummary } from '@/components/task-page-pr-check-summary'
|
||||
import { presentGitHubPRMergeState } from '@/components/github-pr-merge-state'
|
||||
import { buildJiraCreateTextAdf } from '@/components/jira-create-adf'
|
||||
import {
|
||||
GITHUB_PR_MERGE_METHOD_LABELS,
|
||||
resolveGitHubPRMergeMethods
|
||||
@@ -444,6 +443,25 @@ import {
|
||||
type LinearViewMode
|
||||
} from '@/components/task-page-localized-options'
|
||||
import { useGitHubTaskSearchCommit } from '@/components/use-github-task-search-commit'
|
||||
import {
|
||||
getDefaultPresetForGitHubTaskKind,
|
||||
getGitHubTaskKind,
|
||||
normalizeGitHubTaskPreset,
|
||||
scopeGitHubTaskSearch
|
||||
} from '@/components/task-page-github-task-kind'
|
||||
import { areStringSetsEqual } from '@/components/task-page-string-set-equality'
|
||||
import { getJiraStatusTone } from '@/components/task-page-jira-status-tone'
|
||||
import {
|
||||
compareJiraProjectsByDisplayLabel,
|
||||
getJiraProjectSelectionKey
|
||||
} from '@/components/task-page-jira-project-selection'
|
||||
import {
|
||||
buildJiraCreateCustomFields,
|
||||
getJiraCreateAllowedValueLabel,
|
||||
isVisibleJiraCreateField
|
||||
} from '@/components/task-page-jira-create-fields'
|
||||
import { formatPRDelta } from '@/components/task-page-pr-delta-summary'
|
||||
import { getPageNumbers } from '@/components/task-page-pagination-page-numbers'
|
||||
|
||||
function isGitLabMRFilter(value: GitLabTaskFilter | GitLabIssueFilter): value is GitLabTaskFilter {
|
||||
return value === 'opened' || value === 'merged' || value === 'closed' || value === 'all'
|
||||
@@ -608,46 +626,6 @@ const GITHUB_TASK_STICKY_TITLE_CELL_CLASS = cn(
|
||||
GITHUB_TASK_ROW_HOVER_SURFACE_CLASS
|
||||
)
|
||||
|
||||
function isPRFocusedTaskView(preset: TaskViewPresetId | null, query: string): boolean {
|
||||
if (preset === 'prs' || preset === 'my-prs' || preset === 'review') {
|
||||
return true
|
||||
}
|
||||
const parsed = parseTaskQuery(query)
|
||||
return (
|
||||
parsed.scope === 'pr' ||
|
||||
parsed.state === 'merged' ||
|
||||
parsed.draft ||
|
||||
parsed.reviewRequested !== null ||
|
||||
parsed.reviewedBy !== null
|
||||
)
|
||||
}
|
||||
|
||||
function normalizeGitHubTaskPreset(preset: TaskViewPresetId | null | undefined): TaskViewPresetId {
|
||||
// Why: the split Issues/PRs tabs dropped the mixed "All" view, so legacy saved defaults land on the first tab instead of mixing rows.
|
||||
return !preset || preset === 'all' ? 'issues' : preset
|
||||
}
|
||||
|
||||
function getGitHubTaskKind(preset: TaskViewPresetId | null, query: string): GitHubTaskKind {
|
||||
return isPRFocusedTaskView(preset, query) ? 'prs' : 'issues'
|
||||
}
|
||||
|
||||
function getDefaultPresetForGitHubTaskKind(kind: GitHubTaskKind): TaskViewPresetId {
|
||||
return kind === 'prs' ? 'prs' : 'issues'
|
||||
}
|
||||
|
||||
function scopeGitHubTaskSearch(query: string, kind: GitHubTaskKind): string {
|
||||
const trimmed = query.trim()
|
||||
if (!trimmed) {
|
||||
return getTaskPresetQuery(getDefaultPresetForGitHubTaskKind(kind))
|
||||
}
|
||||
if (/\bis:(?:issue|pr|pull-request)\b/i.test(trimmed)) {
|
||||
return trimmed
|
||||
}
|
||||
const parsed = parseTaskQuery(trimmed)
|
||||
const inferredKind = parsed.scope === 'pr' ? 'prs' : parsed.scope === 'issue' ? 'issues' : kind
|
||||
return `${inferredKind === 'prs' ? 'is:pr' : 'is:issue'} ${trimmed}`
|
||||
}
|
||||
|
||||
function formatRelativeTime(input: string): string {
|
||||
return formatUiRelativeTimeFromDate(input)
|
||||
}
|
||||
@@ -994,125 +972,6 @@ function getLinearIssueGridTemplate(visibleProperties: ReadonlySet<LinearDisplay
|
||||
return columns.join(' ')
|
||||
}
|
||||
|
||||
function areStringSetsEqual(a: ReadonlySet<string>, b: ReadonlySet<string>): boolean {
|
||||
return a.size === b.size && [...a].every((value) => b.has(value))
|
||||
}
|
||||
|
||||
function getJiraStatusTone(categoryKey: string): string {
|
||||
if (categoryKey === 'done') {
|
||||
return 'border-emerald-500/30 bg-emerald-500/10 text-emerald-700 dark:text-emerald-200'
|
||||
}
|
||||
if (categoryKey === 'indeterminate') {
|
||||
return 'border-sky-500/30 bg-sky-500/10 text-sky-700 dark:text-sky-200'
|
||||
}
|
||||
return 'border-border/50 bg-muted/40 text-muted-foreground'
|
||||
}
|
||||
|
||||
function getJiraProjectSelectionKey(project: JiraProject): string {
|
||||
return `${project.siteId ?? 'selected'}:${project.id}`
|
||||
}
|
||||
|
||||
const jiraProjectLabelCollator = new Intl.Collator(undefined, {
|
||||
numeric: true,
|
||||
sensitivity: 'base'
|
||||
})
|
||||
|
||||
function compareJiraProjectsByDisplayLabel(
|
||||
a: JiraProject,
|
||||
b: JiraProject,
|
||||
includeSiteName: boolean
|
||||
): number {
|
||||
const siteComparison = includeSiteName
|
||||
? jiraProjectLabelCollator.compare(a.siteName ?? '', b.siteName ?? '')
|
||||
: 0
|
||||
if (siteComparison !== 0) {
|
||||
return siteComparison
|
||||
}
|
||||
const nameComparison = jiraProjectLabelCollator.compare(a.name, b.name)
|
||||
if (nameComparison !== 0) {
|
||||
return nameComparison
|
||||
}
|
||||
return jiraProjectLabelCollator.compare(a.key, b.key)
|
||||
}
|
||||
|
||||
const JIRA_CREATE_SYSTEM_FIELD_KEYS = new Set(['project', 'issuetype', 'summary', 'description'])
|
||||
|
||||
function isVisibleJiraCreateField(field: JiraCreateField): boolean {
|
||||
return field.required && !JIRA_CREATE_SYSTEM_FIELD_KEYS.has(field.key)
|
||||
}
|
||||
|
||||
function getJiraCreateAllowedValueLabel(
|
||||
value: NonNullable<JiraCreateField['allowedValues']>[number]
|
||||
): string {
|
||||
return value.name ?? value.value ?? value.id ?? 'Option'
|
||||
}
|
||||
|
||||
function findJiraCreateAllowedValue(field: JiraCreateField, draftValue: string) {
|
||||
return field.allowedValues?.find((value) => {
|
||||
return value.id === draftValue || value.value === draftValue || value.name === draftValue
|
||||
})
|
||||
}
|
||||
|
||||
function getJiraCreateOptionPayload(
|
||||
value: NonNullable<JiraCreateField['allowedValues']>[number] | undefined,
|
||||
fallback: string
|
||||
): Record<string, string> | string {
|
||||
if (value?.id) {
|
||||
return { id: value.id }
|
||||
}
|
||||
if (value?.value) {
|
||||
return { value: value.value }
|
||||
}
|
||||
if (value?.name) {
|
||||
return { name: value.name }
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
function buildJiraCreateFieldValue(field: JiraCreateField, draftValue: string): unknown {
|
||||
const trimmed = draftValue.trim()
|
||||
if (!trimmed) {
|
||||
return undefined
|
||||
}
|
||||
if (field.schema?.type === 'array') {
|
||||
const parts = trimmed
|
||||
.split(',')
|
||||
.map((part) => part.trim())
|
||||
.filter(Boolean)
|
||||
if (field.allowedValues?.length) {
|
||||
return parts.map((part) =>
|
||||
getJiraCreateOptionPayload(findJiraCreateAllowedValue(field, part), part)
|
||||
)
|
||||
}
|
||||
return parts
|
||||
}
|
||||
if (field.allowedValues?.length) {
|
||||
return getJiraCreateOptionPayload(findJiraCreateAllowedValue(field, trimmed), trimmed)
|
||||
}
|
||||
if (field.schema?.type === 'number') {
|
||||
const numberValue = Number(trimmed)
|
||||
return Number.isFinite(numberValue) ? numberValue : trimmed
|
||||
}
|
||||
if (field.schema?.custom?.includes(':textarea') || field.schema?.type === 'textarea') {
|
||||
return buildJiraCreateTextAdf(trimmed)
|
||||
}
|
||||
return trimmed
|
||||
}
|
||||
|
||||
function buildJiraCreateCustomFields(
|
||||
fields: readonly JiraCreateField[],
|
||||
values: Record<string, string>
|
||||
): Record<string, unknown> | undefined {
|
||||
const customFields: Record<string, unknown> = {}
|
||||
for (const field of fields) {
|
||||
const value = buildJiraCreateFieldValue(field, values[field.key] ?? '')
|
||||
if (value !== undefined) {
|
||||
customFields[field.key] = value
|
||||
}
|
||||
}
|
||||
return Object.keys(customFields).length > 0 ? customFields : undefined
|
||||
}
|
||||
|
||||
type TaskPageGitHubWorkItemMutationRunner = {
|
||||
run: (input: {
|
||||
item: GitHubWorkItem
|
||||
@@ -1544,20 +1403,6 @@ function GHStatusCell({
|
||||
)
|
||||
}
|
||||
|
||||
function formatPRDelta(item: GitHubWorkItem): string | null {
|
||||
const parts: string[] = []
|
||||
if (typeof item.additions === 'number') {
|
||||
parts.push(`+${item.additions}`)
|
||||
}
|
||||
if (typeof item.deletions === 'number') {
|
||||
parts.push(`-${item.deletions}`)
|
||||
}
|
||||
if (typeof item.changedFiles === 'number') {
|
||||
parts.push(`${item.changedFiles} ${item.changedFiles === 1 ? 'file' : 'files'}`)
|
||||
}
|
||||
return parts.length > 0 ? parts.join(' ') : null
|
||||
}
|
||||
|
||||
function ReviewChipAvatar({
|
||||
reviewer,
|
||||
avatarHost
|
||||
@@ -3005,28 +2850,6 @@ function PRMergeCell({
|
||||
)
|
||||
}
|
||||
|
||||
// Builds the page-number array with ellipsis gaps: first, last, and a window around the current page.
|
||||
function getPageNumbers(current: number, total: number): (number | 'ellipsis')[] {
|
||||
if (total <= 9) {
|
||||
return Array.from({ length: total }, (_, i) => i)
|
||||
}
|
||||
const pages = new Set<number>()
|
||||
pages.add(0)
|
||||
pages.add(total - 1)
|
||||
for (let i = Math.max(0, current - 2); i <= Math.min(total - 1, current + 2); i++) {
|
||||
pages.add(i)
|
||||
}
|
||||
const sorted = [...pages].sort((a, b) => a - b)
|
||||
const result: (number | 'ellipsis')[] = []
|
||||
for (let i = 0; i < sorted.length; i++) {
|
||||
if (i > 0 && sorted[i] - sorted[i - 1] > 1) {
|
||||
result.push('ellipsis')
|
||||
}
|
||||
result.push(sorted[i])
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
function PaginationBar({
|
||||
currentPage,
|
||||
totalPages,
|
||||
|
||||
@@ -17,7 +17,13 @@ function sourceBetween(source: string, startPattern: string, endPattern: string)
|
||||
describe('GitHub Enterprise slug routing boundaries', () => {
|
||||
it('keeps work-item URL hosts on TaskPage metadata and issue mutations', () => {
|
||||
const source = componentSource('TaskPage.tsx')
|
||||
const statusSection = sourceBetween(source, 'function GHStatusCell', 'function formatPRDelta')
|
||||
// Why: the end sentinel just bounds GHStatusCell's body — formatPRDelta moved out to
|
||||
// task-page-pr-delta-summary.ts, so the next declaration is the boundary now.
|
||||
const statusSection = sourceBetween(
|
||||
source,
|
||||
'function GHStatusCell',
|
||||
'function ReviewChipAvatar'
|
||||
)
|
||||
// Why: the end sentinel just bounds GHAssigneesCell's body — getChecksLabel moved out to
|
||||
// task-page-checks-pill.ts, so the next declaration is the boundary now.
|
||||
const assigneeSection = sourceBetween(
|
||||
@@ -34,7 +40,9 @@ describe('GitHub Enterprise slug routing boundaries', () => {
|
||||
it('uses URL-host fallback for TaskPage reviewer and merge mutations', () => {
|
||||
const source = componentSource('TaskPage.tsx')
|
||||
const reviewSection = sourceBetween(source, 'function PRReviewCell', 'function PRChecksCell')
|
||||
const mergeSection = sourceBetween(source, 'function PRMergeCell', 'function getPageNumbers')
|
||||
// Why: the end sentinel just bounds PRMergeCell's body — getPageNumbers moved out to
|
||||
// task-page-pagination-page-numbers.ts, so the next declaration is the boundary now.
|
||||
const mergeSection = sourceBetween(source, 'function PRMergeCell', 'function PaginationBar')
|
||||
|
||||
expect(reviewSection).toContain('resolveTaskPullRequestRepo(item)')
|
||||
expect(reviewSection.match(/prRepo: reviewRepo/g)).toHaveLength(4)
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
getDefaultPresetForGitHubTaskKind,
|
||||
getGitHubTaskKind,
|
||||
isPRFocusedTaskView,
|
||||
normalizeGitHubTaskPreset,
|
||||
scopeGitHubTaskSearch
|
||||
} from './task-page-github-task-kind'
|
||||
|
||||
describe('isPRFocusedTaskView', () => {
|
||||
const presetCases = [
|
||||
{ preset: 'prs' as const, expected: true },
|
||||
{ preset: 'my-prs' as const, expected: true },
|
||||
{ preset: 'review' as const, expected: true },
|
||||
{ preset: 'issues' as const, expected: false },
|
||||
{ preset: 'my-issues' as const, expected: false },
|
||||
{ preset: 'all' as const, expected: false },
|
||||
{ preset: null, expected: false }
|
||||
]
|
||||
|
||||
for (const { preset, expected } of presetCases) {
|
||||
it(`returns ${expected} for preset ${preset ?? 'null'} with an empty query`, () => {
|
||||
expect(isPRFocusedTaskView(preset, '')).toBe(expected)
|
||||
})
|
||||
}
|
||||
|
||||
const queryCases = [
|
||||
{ query: 'is:pr', expected: true },
|
||||
{ query: 'is:pull-request', expected: true },
|
||||
{ query: 'is:merged', expected: true },
|
||||
{ query: 'is:draft', expected: true },
|
||||
{ query: 'review-requested:@me', expected: true },
|
||||
{ query: 'reviewed-by:octocat', expected: true },
|
||||
{ query: 'is:issue is:open', expected: false },
|
||||
{ query: 'assignee:@me', expected: false },
|
||||
{ query: 'flaky login', expected: false },
|
||||
// characterization: current behavior — is:issue plus is:pr widens scope to
|
||||
// 'all', which is not PR-focused on its own.
|
||||
{ query: 'is:issue is:pr', expected: false }
|
||||
]
|
||||
|
||||
for (const { query, expected } of queryCases) {
|
||||
it(`returns ${expected} for query "${query}" on the issues preset`, () => {
|
||||
expect(isPRFocusedTaskView('issues', query)).toBe(expected)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
describe('normalizeGitHubTaskPreset', () => {
|
||||
const cases = [
|
||||
{ input: null, expected: 'issues' },
|
||||
{ input: undefined, expected: 'issues' },
|
||||
{ input: 'all' as const, expected: 'issues' },
|
||||
{ input: 'issues' as const, expected: 'issues' },
|
||||
{ input: 'my-issues' as const, expected: 'my-issues' },
|
||||
{ input: 'prs' as const, expected: 'prs' },
|
||||
{ input: 'my-prs' as const, expected: 'my-prs' },
|
||||
{ input: 'review' as const, expected: 'review' }
|
||||
]
|
||||
|
||||
for (const { input, expected } of cases) {
|
||||
it(`maps ${String(input)} to ${expected}`, () => {
|
||||
expect(normalizeGitHubTaskPreset(input)).toBe(expected)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
describe('getGitHubTaskKind', () => {
|
||||
it('reports prs when the preset is PR-focused', () => {
|
||||
expect(getGitHubTaskKind('review', '')).toBe('prs')
|
||||
})
|
||||
|
||||
it('reports prs when only the query is PR-focused', () => {
|
||||
expect(getGitHubTaskKind('issues', 'is:pr is:open')).toBe('prs')
|
||||
})
|
||||
|
||||
it('reports issues otherwise', () => {
|
||||
expect(getGitHubTaskKind(null, 'is:issue is:open')).toBe('issues')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getDefaultPresetForGitHubTaskKind', () => {
|
||||
it('maps prs to the prs preset', () => {
|
||||
expect(getDefaultPresetForGitHubTaskKind('prs')).toBe('prs')
|
||||
})
|
||||
|
||||
it('maps issues to the issues preset', () => {
|
||||
expect(getDefaultPresetForGitHubTaskKind('issues')).toBe('issues')
|
||||
})
|
||||
})
|
||||
|
||||
describe('scopeGitHubTaskSearch', () => {
|
||||
it('falls back to the kind default preset query when the input is blank', () => {
|
||||
expect(scopeGitHubTaskSearch('', 'issues')).toBe('is:issue is:open')
|
||||
expect(scopeGitHubTaskSearch(' ', 'prs')).toBe('is:pr is:open')
|
||||
})
|
||||
|
||||
const alreadyScoped = ['is:pr is:open', 'is:issue label:bug', 'IS:PR is:open', 'is:pull-request']
|
||||
for (const query of alreadyScoped) {
|
||||
it(`leaves "${query}" untouched apart from trimming`, () => {
|
||||
expect(scopeGitHubTaskSearch(` ${query} `, 'issues')).toBe(query)
|
||||
})
|
||||
}
|
||||
|
||||
it('prefixes is:issue when the caller kind is issues', () => {
|
||||
expect(scopeGitHubTaskSearch('login bug', 'issues')).toBe('is:issue login bug')
|
||||
})
|
||||
|
||||
it('prefixes is:pr when the caller kind is prs', () => {
|
||||
expect(scopeGitHubTaskSearch('login bug', 'prs')).toBe('is:pr login bug')
|
||||
})
|
||||
|
||||
it('lets a parsed pr scope override the caller kind', () => {
|
||||
// characterization: current behavior — `is:draft` implies a pr scope, so the
|
||||
// issues kind is overridden even though no literal is:pr token is present.
|
||||
expect(scopeGitHubTaskSearch('is:draft', 'issues')).toBe('is:pr is:draft')
|
||||
})
|
||||
|
||||
it('lets a parsed issue scope override the caller kind', () => {
|
||||
// characterization: current behavior — scope comes from parseTaskQuery, and
|
||||
// `no:assignee` alone does not set one, so the caller kind wins.
|
||||
expect(scopeGitHubTaskSearch('no:assignee', 'prs')).toBe('is:pr no:assignee')
|
||||
})
|
||||
|
||||
it('re-prefixes a merged-state query with the caller kind', () => {
|
||||
// characterization: current behavior — `is:merged` sets state, not scope, so
|
||||
// the caller kind decides the prefix.
|
||||
expect(scopeGitHubTaskSearch('is:merged', 'prs')).toBe('is:pr is:merged')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,46 @@
|
||||
import { getTaskPresetQuery } from '@/lib/new-workspace'
|
||||
import { parseTaskQuery } from '../../../shared/task-query'
|
||||
import type { TaskViewPresetId } from '../../../shared/types'
|
||||
import type { GitHubTaskKind } from '@/components/task-page-localized-options'
|
||||
|
||||
export function isPRFocusedTaskView(preset: TaskViewPresetId | null, query: string): boolean {
|
||||
if (preset === 'prs' || preset === 'my-prs' || preset === 'review') {
|
||||
return true
|
||||
}
|
||||
const parsed = parseTaskQuery(query)
|
||||
return (
|
||||
parsed.scope === 'pr' ||
|
||||
parsed.state === 'merged' ||
|
||||
parsed.draft ||
|
||||
parsed.reviewRequested !== null ||
|
||||
parsed.reviewedBy !== null
|
||||
)
|
||||
}
|
||||
|
||||
export function normalizeGitHubTaskPreset(
|
||||
preset: TaskViewPresetId | null | undefined
|
||||
): TaskViewPresetId {
|
||||
// Why: the split Issues/PRs tabs dropped the mixed "All" view, so legacy saved defaults land on the first tab instead of mixing rows.
|
||||
return !preset || preset === 'all' ? 'issues' : preset
|
||||
}
|
||||
|
||||
export function getGitHubTaskKind(preset: TaskViewPresetId | null, query: string): GitHubTaskKind {
|
||||
return isPRFocusedTaskView(preset, query) ? 'prs' : 'issues'
|
||||
}
|
||||
|
||||
export function getDefaultPresetForGitHubTaskKind(kind: GitHubTaskKind): TaskViewPresetId {
|
||||
return kind === 'prs' ? 'prs' : 'issues'
|
||||
}
|
||||
|
||||
export function scopeGitHubTaskSearch(query: string, kind: GitHubTaskKind): string {
|
||||
const trimmed = query.trim()
|
||||
if (!trimmed) {
|
||||
return getTaskPresetQuery(getDefaultPresetForGitHubTaskKind(kind))
|
||||
}
|
||||
if (/\bis:(?:issue|pr|pull-request)\b/i.test(trimmed)) {
|
||||
return trimmed
|
||||
}
|
||||
const parsed = parseTaskQuery(trimmed)
|
||||
const inferredKind = parsed.scope === 'pr' ? 'prs' : parsed.scope === 'issue' ? 'issues' : kind
|
||||
return `${inferredKind === 'prs' ? 'is:pr' : 'is:issue'} ${trimmed}`
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
buildJiraCreateCustomFields,
|
||||
buildJiraCreateFieldValue,
|
||||
findJiraCreateAllowedValue,
|
||||
getJiraCreateAllowedValueLabel,
|
||||
getJiraCreateOptionPayload,
|
||||
isVisibleJiraCreateField
|
||||
} from './task-page-jira-create-fields'
|
||||
import type { JiraCreateField } from '../../../shared/types'
|
||||
|
||||
function field(overrides: Partial<JiraCreateField> = {}): JiraCreateField {
|
||||
return { key: 'customfield_1', name: 'Custom', required: true, ...overrides }
|
||||
}
|
||||
|
||||
describe('isVisibleJiraCreateField', () => {
|
||||
const cases = [
|
||||
{ key: 'customfield_1', required: true, expected: true },
|
||||
{ key: 'customfield_1', required: false, expected: false },
|
||||
{ key: 'project', required: true, expected: false },
|
||||
{ key: 'issuetype', required: true, expected: false },
|
||||
{ key: 'summary', required: true, expected: false },
|
||||
{ key: 'description', required: true, expected: false },
|
||||
// characterization: current behavior — the system-field filter is exact-match
|
||||
// and case-sensitive, so a differently-cased key stays visible.
|
||||
{ key: 'Summary', required: true, expected: true }
|
||||
]
|
||||
|
||||
for (const { key, required, expected } of cases) {
|
||||
it(`returns ${expected} for ${key} (required: ${required})`, () => {
|
||||
expect(isVisibleJiraCreateField(field({ key, required }))).toBe(expected)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
describe('getJiraCreateAllowedValueLabel', () => {
|
||||
it('prefers name, then value, then id', () => {
|
||||
expect(getJiraCreateAllowedValueLabel({ id: 'i', value: 'v', name: 'n' })).toBe('n')
|
||||
expect(getJiraCreateAllowedValueLabel({ id: 'i', value: 'v' })).toBe('v')
|
||||
expect(getJiraCreateAllowedValueLabel({ id: 'i' })).toBe('i')
|
||||
})
|
||||
|
||||
it('falls back to Option when nothing is set', () => {
|
||||
expect(getJiraCreateAllowedValueLabel({})).toBe('Option')
|
||||
})
|
||||
})
|
||||
|
||||
describe('findJiraCreateAllowedValue', () => {
|
||||
const withValues = field({
|
||||
allowedValues: [
|
||||
{ id: 'id-1', value: 'val-1', name: 'Name 1' },
|
||||
{ id: 'id-2', value: 'val-2', name: 'Name 2' }
|
||||
]
|
||||
})
|
||||
|
||||
it('matches on id, value, or name', () => {
|
||||
expect(findJiraCreateAllowedValue(withValues, 'id-2')?.id).toBe('id-2')
|
||||
expect(findJiraCreateAllowedValue(withValues, 'val-1')?.id).toBe('id-1')
|
||||
expect(findJiraCreateAllowedValue(withValues, 'Name 2')?.id).toBe('id-2')
|
||||
})
|
||||
|
||||
it('returns undefined when there is no match or no allowed values', () => {
|
||||
expect(findJiraCreateAllowedValue(withValues, 'nope')).toBeUndefined()
|
||||
expect(findJiraCreateAllowedValue(field(), 'id-1')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('getJiraCreateOptionPayload', () => {
|
||||
it('prefers id, then value, then name', () => {
|
||||
expect(getJiraCreateOptionPayload({ id: 'i', value: 'v', name: 'n' }, 'fb')).toEqual({
|
||||
id: 'i'
|
||||
})
|
||||
expect(getJiraCreateOptionPayload({ value: 'v', name: 'n' }, 'fb')).toEqual({ value: 'v' })
|
||||
expect(getJiraCreateOptionPayload({ name: 'n' }, 'fb')).toEqual({ name: 'n' })
|
||||
})
|
||||
|
||||
it('returns the raw fallback string when the option is absent or empty', () => {
|
||||
expect(getJiraCreateOptionPayload(undefined, 'fb')).toBe('fb')
|
||||
expect(getJiraCreateOptionPayload({}, 'fb')).toBe('fb')
|
||||
})
|
||||
|
||||
it('skips empty-string members', () => {
|
||||
// characterization: current behavior — the checks are truthiness-based, so an
|
||||
// empty id falls through to the next candidate.
|
||||
expect(getJiraCreateOptionPayload({ id: '', value: 'v' }, 'fb')).toEqual({ value: 'v' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildJiraCreateFieldValue', () => {
|
||||
it('returns undefined for blank drafts', () => {
|
||||
expect(buildJiraCreateFieldValue(field(), '')).toBeUndefined()
|
||||
expect(buildJiraCreateFieldValue(field(), ' ')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('trims plain string values', () => {
|
||||
expect(buildJiraCreateFieldValue(field(), ' hello ')).toBe('hello')
|
||||
})
|
||||
|
||||
it('splits array fields on commas and drops blanks', () => {
|
||||
const arrayField = field({ schema: { type: 'array' } })
|
||||
expect(buildJiraCreateFieldValue(arrayField, 'a, b ,, c')).toEqual(['a', 'b', 'c'])
|
||||
})
|
||||
|
||||
it('maps array parts through allowed values', () => {
|
||||
const arrayField = field({
|
||||
schema: { type: 'array' },
|
||||
allowedValues: [{ id: 'id-1', name: 'Name 1' }]
|
||||
})
|
||||
expect(buildJiraCreateFieldValue(arrayField, 'Name 1, other')).toEqual([
|
||||
{ id: 'id-1' },
|
||||
'other'
|
||||
])
|
||||
})
|
||||
|
||||
it('maps scalar option fields through allowed values', () => {
|
||||
const optionField = field({ allowedValues: [{ id: 'id-1', name: 'Name 1' }] })
|
||||
expect(buildJiraCreateFieldValue(optionField, 'Name 1')).toEqual({ id: 'id-1' })
|
||||
expect(buildJiraCreateFieldValue(optionField, 'Unknown')).toBe('Unknown')
|
||||
})
|
||||
|
||||
it('coerces finite numbers and keeps unparseable text', () => {
|
||||
const numberField = field({ schema: { type: 'number' } })
|
||||
expect(buildJiraCreateFieldValue(numberField, ' 42 ')).toBe(42)
|
||||
expect(buildJiraCreateFieldValue(numberField, 'abc')).toBe('abc')
|
||||
// characterization: current behavior — Infinity is not finite, so the trimmed
|
||||
// text is kept instead of the coerced number.
|
||||
expect(buildJiraCreateFieldValue(numberField, 'Infinity')).toBe('Infinity')
|
||||
})
|
||||
|
||||
it('builds an ADF document for textarea fields', () => {
|
||||
const custom = field({ schema: { custom: 'com.atlassian.jira:textarea' } })
|
||||
expect(buildJiraCreateFieldValue(custom, 'line one\nline two')).toEqual({
|
||||
type: 'doc',
|
||||
version: 1,
|
||||
content: [
|
||||
{ type: 'paragraph', content: [{ type: 'text', text: 'line one' }] },
|
||||
{ type: 'paragraph', content: [{ type: 'text', text: 'line two' }] }
|
||||
]
|
||||
})
|
||||
expect(buildJiraCreateFieldValue(field({ schema: { type: 'textarea' } }), 'x')).toEqual({
|
||||
type: 'doc',
|
||||
version: 1,
|
||||
content: [{ type: 'paragraph', content: [{ type: 'text', text: 'x' }] }]
|
||||
})
|
||||
})
|
||||
|
||||
it('prefers allowed values over the number and textarea branches', () => {
|
||||
// characterization: current behavior — the allowedValues branch runs before the
|
||||
// schema-type branches.
|
||||
const numeric = field({ schema: { type: 'number' }, allowedValues: [{ id: 'id-1' }] })
|
||||
expect(buildJiraCreateFieldValue(numeric, '7')).toBe('7')
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildJiraCreateCustomFields', () => {
|
||||
it('returns undefined when every field resolves to undefined', () => {
|
||||
expect(buildJiraCreateCustomFields([field({ key: 'a' })], {})).toBeUndefined()
|
||||
expect(buildJiraCreateCustomFields([], { a: 'x' })).toBeUndefined()
|
||||
})
|
||||
|
||||
it('collects only the fields with values, keyed by field key', () => {
|
||||
const fields = [field({ key: 'a' }), field({ key: 'b' }), field({ key: 'c' })]
|
||||
expect(buildJiraCreateCustomFields(fields, { a: 'one', b: ' ', c: 'three' })).toEqual({
|
||||
a: 'one',
|
||||
c: 'three'
|
||||
})
|
||||
})
|
||||
|
||||
it('treats a missing draft entry as blank', () => {
|
||||
expect(buildJiraCreateCustomFields([field({ key: 'a' })], { other: 'x' })).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,80 @@
|
||||
import { buildJiraCreateTextAdf } from '@/components/jira-create-adf'
|
||||
import type { JiraCreateField } from '../../../shared/types'
|
||||
|
||||
const JIRA_CREATE_SYSTEM_FIELD_KEYS = new Set(['project', 'issuetype', 'summary', 'description'])
|
||||
|
||||
export function isVisibleJiraCreateField(field: JiraCreateField): boolean {
|
||||
return field.required && !JIRA_CREATE_SYSTEM_FIELD_KEYS.has(field.key)
|
||||
}
|
||||
|
||||
export function getJiraCreateAllowedValueLabel(
|
||||
value: NonNullable<JiraCreateField['allowedValues']>[number]
|
||||
): string {
|
||||
return value.name ?? value.value ?? value.id ?? 'Option'
|
||||
}
|
||||
|
||||
export function findJiraCreateAllowedValue(field: JiraCreateField, draftValue: string) {
|
||||
return field.allowedValues?.find((value) => {
|
||||
return value.id === draftValue || value.value === draftValue || value.name === draftValue
|
||||
})
|
||||
}
|
||||
|
||||
export function getJiraCreateOptionPayload(
|
||||
value: NonNullable<JiraCreateField['allowedValues']>[number] | undefined,
|
||||
fallback: string
|
||||
): Record<string, string> | string {
|
||||
if (value?.id) {
|
||||
return { id: value.id }
|
||||
}
|
||||
if (value?.value) {
|
||||
return { value: value.value }
|
||||
}
|
||||
if (value?.name) {
|
||||
return { name: value.name }
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
export function buildJiraCreateFieldValue(field: JiraCreateField, draftValue: string): unknown {
|
||||
const trimmed = draftValue.trim()
|
||||
if (!trimmed) {
|
||||
return undefined
|
||||
}
|
||||
if (field.schema?.type === 'array') {
|
||||
const parts = trimmed
|
||||
.split(',')
|
||||
.map((part) => part.trim())
|
||||
.filter(Boolean)
|
||||
if (field.allowedValues?.length) {
|
||||
return parts.map((part) =>
|
||||
getJiraCreateOptionPayload(findJiraCreateAllowedValue(field, part), part)
|
||||
)
|
||||
}
|
||||
return parts
|
||||
}
|
||||
if (field.allowedValues?.length) {
|
||||
return getJiraCreateOptionPayload(findJiraCreateAllowedValue(field, trimmed), trimmed)
|
||||
}
|
||||
if (field.schema?.type === 'number') {
|
||||
const numberValue = Number(trimmed)
|
||||
return Number.isFinite(numberValue) ? numberValue : trimmed
|
||||
}
|
||||
if (field.schema?.custom?.includes(':textarea') || field.schema?.type === 'textarea') {
|
||||
return buildJiraCreateTextAdf(trimmed)
|
||||
}
|
||||
return trimmed
|
||||
}
|
||||
|
||||
export function buildJiraCreateCustomFields(
|
||||
fields: readonly JiraCreateField[],
|
||||
values: Record<string, string>
|
||||
): Record<string, unknown> | undefined {
|
||||
const customFields: Record<string, unknown> = {}
|
||||
for (const field of fields) {
|
||||
const value = buildJiraCreateFieldValue(field, values[field.key] ?? '')
|
||||
if (value !== undefined) {
|
||||
customFields[field.key] = value
|
||||
}
|
||||
}
|
||||
return Object.keys(customFields).length > 0 ? customFields : undefined
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
compareJiraProjectsByDisplayLabel,
|
||||
getJiraProjectSelectionKey
|
||||
} from './task-page-jira-project-selection'
|
||||
import type { JiraProject } from '../../../shared/types'
|
||||
|
||||
function project(overrides: Partial<JiraProject> = {}): JiraProject {
|
||||
return { id: '1', key: 'AA', name: 'Alpha', ...overrides }
|
||||
}
|
||||
|
||||
describe('getJiraProjectSelectionKey', () => {
|
||||
it('qualifies the project id with its site id', () => {
|
||||
expect(getJiraProjectSelectionKey(project({ id: '10', siteId: 'site-1' }))).toBe('site-1:10')
|
||||
})
|
||||
|
||||
it('falls back to the "selected" site sentinel when siteId is absent', () => {
|
||||
expect(getJiraProjectSelectionKey(project({ id: '10' }))).toBe('selected:10')
|
||||
})
|
||||
|
||||
it('treats an empty siteId as a real site id', () => {
|
||||
// characterization: current behavior — `??` only replaces null/undefined, so an
|
||||
// empty siteId produces a leading-colon key.
|
||||
expect(getJiraProjectSelectionKey(project({ id: '10', siteId: '' }))).toBe(':10')
|
||||
})
|
||||
})
|
||||
|
||||
describe('compareJiraProjectsByDisplayLabel', () => {
|
||||
const sortOf = (projects: JiraProject[], includeSiteName: boolean): string[] =>
|
||||
[...projects]
|
||||
.sort((a, b) => compareJiraProjectsByDisplayLabel(a, b, includeSiteName))
|
||||
.map((entry) => `${entry.siteName ?? ''}/${entry.name}/${entry.key}`)
|
||||
|
||||
it('orders by site name first when site names are included', () => {
|
||||
const projects = [
|
||||
project({ name: 'Alpha', key: 'AA', siteName: 'Zeta' }),
|
||||
project({ name: 'Beta', key: 'BB', siteName: 'Acme' })
|
||||
]
|
||||
expect(sortOf(projects, true)).toEqual(['Acme/Beta/BB', 'Zeta/Alpha/AA'])
|
||||
})
|
||||
|
||||
it('ignores site name when it is not included', () => {
|
||||
const projects = [
|
||||
project({ name: 'Alpha', key: 'AA', siteName: 'Zeta' }),
|
||||
project({ name: 'Beta', key: 'BB', siteName: 'Acme' })
|
||||
]
|
||||
expect(sortOf(projects, false)).toEqual(['Zeta/Alpha/AA', 'Acme/Beta/BB'])
|
||||
})
|
||||
|
||||
it('falls back to project name, then project key', () => {
|
||||
const projects = [
|
||||
project({ name: 'Alpha', key: 'AB' }),
|
||||
project({ name: 'Alpha', key: 'AA' }),
|
||||
project({ name: 'Beta', key: 'BA' })
|
||||
]
|
||||
expect(sortOf(projects, false)).toEqual(['/Alpha/AA', '/Alpha/AB', '/Beta/BA'])
|
||||
})
|
||||
|
||||
it('sorts numeric segments naturally and case-insensitively', () => {
|
||||
const projects = [project({ name: 'Team 10', key: 'A' }), project({ name: 'team 2', key: 'B' })]
|
||||
expect(sortOf(projects, false)).toEqual(['/team 2/B', '/Team 10/A'])
|
||||
})
|
||||
|
||||
it('returns 0 for projects with identical display labels', () => {
|
||||
expect(compareJiraProjectsByDisplayLabel(project(), project({ id: '2' }), true)).toBe(0)
|
||||
})
|
||||
|
||||
it('treats a missing site name as an empty string when comparing sites', () => {
|
||||
const withSite = project({ name: 'Zeta', key: 'ZZ', siteName: 'Acme' })
|
||||
const withoutSite = project({ name: 'Alpha', key: 'AA' })
|
||||
expect(sortOf([withSite, withoutSite], true)).toEqual(['/Alpha/AA', 'Acme/Zeta/ZZ'])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { JiraProject } from '../../../shared/types'
|
||||
|
||||
export function getJiraProjectSelectionKey(project: JiraProject): string {
|
||||
return `${project.siteId ?? 'selected'}:${project.id}`
|
||||
}
|
||||
|
||||
const jiraProjectLabelCollator = new Intl.Collator(undefined, {
|
||||
numeric: true,
|
||||
sensitivity: 'base'
|
||||
})
|
||||
|
||||
export function compareJiraProjectsByDisplayLabel(
|
||||
a: JiraProject,
|
||||
b: JiraProject,
|
||||
includeSiteName: boolean
|
||||
): number {
|
||||
const siteComparison = includeSiteName
|
||||
? jiraProjectLabelCollator.compare(a.siteName ?? '', b.siteName ?? '')
|
||||
: 0
|
||||
if (siteComparison !== 0) {
|
||||
return siteComparison
|
||||
}
|
||||
const nameComparison = jiraProjectLabelCollator.compare(a.name, b.name)
|
||||
if (nameComparison !== 0) {
|
||||
return nameComparison
|
||||
}
|
||||
return jiraProjectLabelCollator.compare(a.key, b.key)
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { getJiraStatusTone } from './task-page-jira-status-tone'
|
||||
|
||||
describe('getJiraStatusTone', () => {
|
||||
it('uses the emerald tone for the done category', () => {
|
||||
expect(getJiraStatusTone('done')).toBe(
|
||||
'border-emerald-500/30 bg-emerald-500/10 text-emerald-700 dark:text-emerald-200'
|
||||
)
|
||||
})
|
||||
|
||||
it('uses the sky tone for the indeterminate category', () => {
|
||||
expect(getJiraStatusTone('indeterminate')).toBe(
|
||||
'border-sky-500/30 bg-sky-500/10 text-sky-700 dark:text-sky-200'
|
||||
)
|
||||
})
|
||||
|
||||
const neutral = ['new', 'undefined', '', 'Done', 'DONE', 'in-progress']
|
||||
for (const categoryKey of neutral) {
|
||||
it(`uses the neutral tone for "${categoryKey}"`, () => {
|
||||
// characterization: current behavior — the match is exact and case-sensitive.
|
||||
expect(getJiraStatusTone(categoryKey)).toBe(
|
||||
'border-border/50 bg-muted/40 text-muted-foreground'
|
||||
)
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,9 @@
|
||||
export function getJiraStatusTone(categoryKey: string): string {
|
||||
if (categoryKey === 'done') {
|
||||
return 'border-emerald-500/30 bg-emerald-500/10 text-emerald-700 dark:text-emerald-200'
|
||||
}
|
||||
if (categoryKey === 'indeterminate') {
|
||||
return 'border-sky-500/30 bg-sky-500/10 text-sky-700 dark:text-sky-200'
|
||||
}
|
||||
return 'border-border/50 bg-muted/40 text-muted-foreground'
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { getPageNumbers } from './task-page-pagination-page-numbers'
|
||||
|
||||
describe('getPageNumbers', () => {
|
||||
it('lists every zero-based page when the total fits the no-ellipsis threshold', () => {
|
||||
expect(getPageNumbers(0, 1)).toEqual([0])
|
||||
expect(getPageNumbers(4, 9)).toEqual([0, 1, 2, 3, 4, 5, 6, 7, 8])
|
||||
})
|
||||
|
||||
it('returns an empty list when there are no pages', () => {
|
||||
expect(getPageNumbers(0, 0)).toEqual([])
|
||||
})
|
||||
|
||||
it('collapses the tail when the current page is near the start', () => {
|
||||
expect(getPageNumbers(0, 20)).toEqual([0, 1, 2, 'ellipsis', 19])
|
||||
})
|
||||
|
||||
it('collapses the head when the current page is near the end', () => {
|
||||
expect(getPageNumbers(19, 20)).toEqual([0, 'ellipsis', 17, 18, 19])
|
||||
})
|
||||
|
||||
it('collapses both sides around a middle page', () => {
|
||||
expect(getPageNumbers(10, 20)).toEqual([0, 'ellipsis', 8, 9, 10, 11, 12, 'ellipsis', 19])
|
||||
})
|
||||
|
||||
it('omits the ellipsis when the window is exactly adjacent to an edge', () => {
|
||||
// characterization: current behavior — a gap of exactly 1 is not elided.
|
||||
expect(getPageNumbers(3, 10)).toEqual([0, 1, 2, 3, 4, 5, 'ellipsis', 9])
|
||||
})
|
||||
|
||||
it('clamps the window to the valid page range', () => {
|
||||
expect(getPageNumbers(-5, 12)).toEqual([0, 'ellipsis', 11])
|
||||
expect(getPageNumbers(100, 12)).toEqual([0, 'ellipsis', 11])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,21 @@
|
||||
// Builds the page-number array with ellipsis gaps: first, last, and a window around the current page.
|
||||
export function getPageNumbers(current: number, total: number): (number | 'ellipsis')[] {
|
||||
if (total <= 9) {
|
||||
return Array.from({ length: total }, (_, i) => i)
|
||||
}
|
||||
const pages = new Set<number>()
|
||||
pages.add(0)
|
||||
pages.add(total - 1)
|
||||
for (let i = Math.max(0, current - 2); i <= Math.min(total - 1, current + 2); i++) {
|
||||
pages.add(i)
|
||||
}
|
||||
const sorted = [...pages].sort((a, b) => a - b)
|
||||
const result: (number | 'ellipsis')[] = []
|
||||
for (let i = 0; i < sorted.length; i++) {
|
||||
if (i > 0 && sorted[i] - sorted[i - 1] > 1) {
|
||||
result.push('ellipsis')
|
||||
}
|
||||
result.push(sorted[i])
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { formatPRDelta } from './task-page-pr-delta-summary'
|
||||
import type { GitHubWorkItem } from '../../../shared/types'
|
||||
|
||||
function prItem(overrides: Partial<GitHubWorkItem> = {}): GitHubWorkItem {
|
||||
return {
|
||||
id: 'pr-1',
|
||||
type: 'pr',
|
||||
number: 1,
|
||||
title: 'Fix login',
|
||||
state: 'open',
|
||||
url: 'https://github.com/acme/app/pull/1',
|
||||
labels: [],
|
||||
updatedAt: '2026-01-01T00:00:00Z',
|
||||
author: 'octocat',
|
||||
repoId: 'repo-1',
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
describe('formatPRDelta', () => {
|
||||
it('returns null when no diffstat fields are present', () => {
|
||||
expect(formatPRDelta(prItem())).toBeNull()
|
||||
})
|
||||
|
||||
it('renders additions, deletions, and file count in order', () => {
|
||||
expect(formatPRDelta(prItem({ additions: 12, deletions: 3, changedFiles: 4 }))).toBe(
|
||||
'+12 -3 4 files'
|
||||
)
|
||||
})
|
||||
|
||||
it('singularizes a one-file change', () => {
|
||||
expect(formatPRDelta(prItem({ changedFiles: 1 }))).toBe('1 file')
|
||||
})
|
||||
|
||||
it('pluralizes a zero-file change', () => {
|
||||
// characterization: current behavior — only exactly 1 is singular.
|
||||
expect(formatPRDelta(prItem({ changedFiles: 0 }))).toBe('0 files')
|
||||
})
|
||||
|
||||
it('keeps zero values because the check is on the type, not truthiness', () => {
|
||||
expect(formatPRDelta(prItem({ additions: 0, deletions: 0 }))).toBe('+0 -0')
|
||||
})
|
||||
|
||||
it('omits fields that are absent', () => {
|
||||
expect(formatPRDelta(prItem({ additions: 5 }))).toBe('+5')
|
||||
expect(formatPRDelta(prItem({ deletions: 5 }))).toBe('-5')
|
||||
})
|
||||
|
||||
it('renders negative deletions with the literal minus prefix', () => {
|
||||
// characterization: current behavior — the prefix is unconditional.
|
||||
expect(formatPRDelta(prItem({ deletions: -2 }))).toBe('--2')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { GitHubWorkItem } from '../../../shared/types'
|
||||
|
||||
export function formatPRDelta(item: GitHubWorkItem): string | null {
|
||||
const parts: string[] = []
|
||||
if (typeof item.additions === 'number') {
|
||||
parts.push(`+${item.additions}`)
|
||||
}
|
||||
if (typeof item.deletions === 'number') {
|
||||
parts.push(`-${item.deletions}`)
|
||||
}
|
||||
if (typeof item.changedFiles === 'number') {
|
||||
parts.push(`${item.changedFiles} ${item.changedFiles === 1 ? 'file' : 'files'}`)
|
||||
}
|
||||
return parts.length > 0 ? parts.join(' ') : null
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { areStringSetsEqual } from './task-page-string-set-equality'
|
||||
|
||||
describe('areStringSetsEqual', () => {
|
||||
it('treats two empty sets as equal', () => {
|
||||
expect(areStringSetsEqual(new Set(), new Set())).toBe(true)
|
||||
})
|
||||
|
||||
it('ignores insertion order', () => {
|
||||
expect(areStringSetsEqual(new Set(['a', 'b']), new Set(['b', 'a']))).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects differing sizes', () => {
|
||||
expect(areStringSetsEqual(new Set(['a']), new Set(['a', 'b']))).toBe(false)
|
||||
expect(areStringSetsEqual(new Set(['a', 'b']), new Set(['a']))).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects same-size sets with different members', () => {
|
||||
expect(areStringSetsEqual(new Set(['a', 'b']), new Set(['a', 'c']))).toBe(false)
|
||||
})
|
||||
|
||||
it('compares by exact string identity', () => {
|
||||
expect(areStringSetsEqual(new Set(['A']), new Set(['a']))).toBe(false)
|
||||
expect(areStringSetsEqual(new Set(['']), new Set(['']))).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,3 @@
|
||||
export function areStringSetsEqual(a: ReadonlySet<string>, b: ReadonlySet<string>): boolean {
|
||||
return a.size === b.size && [...a].every((value) => b.has(value))
|
||||
}
|
||||
Reference in New Issue
Block a user