diff --git a/apps/cli/src/main.test.ts b/apps/cli/src/main.test.ts index 63fbc39..3b19a06 100644 --- a/apps/cli/src/main.test.ts +++ b/apps/cli/src/main.test.ts @@ -19,6 +19,8 @@ test('run prints usage with no command', async () => { assert.match(output, /ghcrawl /); assert.match(output, /\n version\n/); assert.match(output, /refresh /); + assert.match(output, /refresh-user --login /); + assert.match(output, /refresh-users /); assert.match(output, /threads /); assert.match(output, /author --login /); assert.match(output, /close-thread --number /); @@ -42,6 +44,8 @@ test('run prints usage for help flag', async () => { assert.match(output, /ghcrawl /); assert.match(output, /\n version\n/); assert.match(output, /refresh /); + assert.match(output, /refresh-user --login /); + assert.match(output, /refresh-users /); assert.match(output, /threads /); assert.match(output, /author --login /); assert.match(output, /close-thread --number /); @@ -162,6 +166,13 @@ test('parseRepoFlags accepts include-closed boolean flag', () => { assert.equal(parsed.values['include-closed'], true); }); +test('parseRepoFlags accepts force boolean flag', () => { + const parsed = parseRepoFlags(['openclaw/openclaw', '--force']); + assert.equal(parsed.owner, 'openclaw'); + assert.equal(parsed.repo, 'openclaw'); + assert.equal(parsed.values.force, true); +}); + test('resolveSinceValue keeps ISO timestamps', () => { assert.equal(resolveSinceValue('2026-03-01T00:00:00Z'), '2026-03-01T00:00:00.000Z'); }); diff --git a/apps/cli/src/main.ts b/apps/cli/src/main.ts index 7d88b59..bfb7e5e 100644 --- a/apps/cli/src/main.ts +++ b/apps/cli/src/main.ts @@ -15,6 +15,8 @@ type CommandName = | 'version' | 'sync' | 'refresh' + | 'refresh-user' + | 'refresh-users' | 'threads' | 'author' | 'close-thread' @@ -44,6 +46,8 @@ function usage(devMode = false): string { ' version', ' sync [--since ] [--limit ] [--include-comments] [--full-reconcile]', ' refresh [--no-sync] [--no-embed] [--no-cluster]', + ' refresh-user --login [--force]', + ' refresh-users [--mode flagged|trusted_prs] [--limit ] [--force]', ' threads [--numbers ] [--kind issue|pull_request] [--include-closed]', ' author --login [--include-closed]', ' close-thread --number ', @@ -119,6 +123,7 @@ export function parseRepoFlags(args: string[]): { owner: string; repo: string; v 'no-sync': { type: 'boolean' }, 'no-embed': { type: 'boolean' }, 'no-cluster': { type: 'boolean' }, + force: { type: 'boolean' }, }, }); @@ -335,6 +340,34 @@ export async function run(argv: string[], stdout: NodeJS.WritableStream = proces stdout.write(`${JSON.stringify(result, null, 2)}\n`); return; } + case 'refresh-user': { + const { owner, repo, values } = parseRepoFlags(rest); + if (typeof values.login !== 'string' || values.login.trim().length === 0) { + throw new Error('Missing --login'); + } + const result = await getService().refreshRepoUser({ + owner, + repo, + login: values.login, + force: values.force === true, + }); + stdout.write(`${JSON.stringify(result, null, 2)}\n`); + return; + } + case 'refresh-users': { + const { owner, repo, values } = parseRepoFlags(rest); + const mode = values.mode === 'trusted_prs' ? 'trusted_prs' : 'flagged'; + const result = await getService().refreshRepoUsers({ + owner, + repo, + mode, + limit: typeof values.limit === 'string' ? parsePositiveInteger('limit', values.limit) : undefined, + force: values.force === true, + onProgress: writeProgress, + }); + stdout.write(`${JSON.stringify(result, null, 2)}\n`); + return; + } case 'threads': { const { owner, repo, values } = parseRepoFlags(rest); const kind = values.kind === 'issue' || values.kind === 'pull_request' ? values.kind : undefined; diff --git a/apps/cli/src/tui/app.test.ts b/apps/cli/src/tui/app.test.ts index 86ff761..d54a209 100644 --- a/apps/cli/src/tui/app.test.ts +++ b/apps/cli/src/tui/app.test.ts @@ -4,7 +4,10 @@ import assert from 'node:assert/strict'; import type { TuiClusterDetail, TuiRepoStats, TuiThreadDetail } from '@ghcrawl/api-core'; import { + buildFooterCommandHints, buildRefreshCliArgs, + buildRefreshUserCliArgs, + buildRefreshUsersCliArgs, buildHelpContent, buildUpdatePipelineLabels, describeUpdateTask, @@ -184,6 +187,15 @@ test('buildUpdatePipelineLabels marks the selected tasks and includes task guida test('buildHelpContent includes the full key command list', () => { const content = buildHelpContent(); + assert.match(content, /Slash Commands/); + assert.match(content, /\/clusters\s+switch to the current issue and PR cluster explorer/); + assert.match(content, /\/users\s+switch to the flagged contributor explorer/); + assert.match(content, /\/users flagged\s+show low-reputation, stale, or hidden-activity contributors/); + assert.match(content, /\/users trusted\s+show high-reputation contributors with open PRs/); + assert.match(content, /\/refresh\s+reload the current view, or open the user refresh menu on \/users/); + assert.match(content, /\/user-refresh\s+refresh the selected user profile and reputation signals/); + assert.match(content, /\/user-refresh-bulk\s+open the bulk user refresh menu for the current user mode/); + assert.match(content, /\/filter\s+open the cluster filter prompt/); assert.match(content, /Tab \/ Shift-Tab/); assert.match(content, /Left \/ Right\s+cycle focus backward or forward across panes/); assert.match(content, /Up \/ Down\s+move selection, or scroll detail when detail is focused/); @@ -195,10 +207,17 @@ test('buildHelpContent includes the full key command list', () => { assert.match(content, /x\s+show or hide locally closed clusters and members/); assert.match(content, /h or \?\s+open this help popup/); assert.match(content, /q\s+quit the TUI/); + assert.doesNotMatch(content, /\/\s+filter clusters by title\/member text/); assert.doesNotMatch(content, /j \/ k/); assert.match(content, /This popup scrolls\./); }); +test('buildFooterCommandHints leads with slash commands for each screen', () => { + assert.match(buildFooterCommandHints('clusters')[0], /\/clusters \/users \/filter \/repos \/update \/help \/quit/); + assert.match(buildFooterCommandHints('users')[0], /\/users flagged \/users trusted \/refresh \/user-refresh-bulk \/user-open \/repos \/help \/quit/); + assert.match(buildFooterCommandHints('users')[1], /r refresh menu/); +}); + test('buildRefreshCliArgs maps the staged selection to refresh skip flags', () => { assert.deepEqual(buildRefreshCliArgs({ owner: 'openclaw', repo: 'openclaw' }, { sync: true, embed: true, cluster: true }), [ 'refresh', @@ -211,3 +230,23 @@ test('buildRefreshCliArgs maps the staged selection to refresh skip flags', () = '--no-cluster', ]); }); + +test('buildRefreshUsersCliArgs maps user refresh mode and limit', () => { + assert.deepEqual(buildRefreshUsersCliArgs({ owner: 'openclaw', repo: 'openclaw' }, { mode: 'flagged', limit: 25 }), [ + 'refresh-users', + 'openclaw/openclaw', + '--mode', + 'flagged', + '--limit', + '25', + ]); +}); + +test('buildRefreshUserCliArgs maps a selected user refresh', () => { + assert.deepEqual(buildRefreshUserCliArgs({ owner: 'openclaw', repo: 'openclaw' }, { login: 'alice' }), [ + 'refresh-user', + 'openclaw/openclaw', + '--login', + 'alice', + ]); +}); diff --git a/apps/cli/src/tui/app.ts b/apps/cli/src/tui/app.ts index 0fd8226..19e3685 100644 --- a/apps/cli/src/tui/app.ts +++ b/apps/cli/src/tui/app.ts @@ -12,25 +12,47 @@ import type { TuiClusterDetail, TuiClusterSortMode, TuiRepoStats, + RepoUserExplorerMode, TuiSnapshot, TuiThreadDetail, TuiWideLayoutPreference, } from '@ghcrawl/api-core'; import { getTuiRepositoryPreference, writeTuiRepositoryPreference } from '@ghcrawl/api-core'; +import { + filterCommands, + formatCommandLabel, + resolveCommands, + selectCommandFromQuery, + type TuiCommandDefinition, + type TuiResolvedCommand, +} from './commands.js'; import { buildMemberRows, cycleFocusPane, cycleMinSizeFilter, cycleSortMode, findSelectableIndex, + getScreenDefinition, + getScreenFocusOrder, moveSelectableIndex, preserveSelectedId, selectedThreadIdFromRow, type MemberListRow, type TuiFocusPane, type TuiMinSizeFilter, + type TuiScreenId, } from './state.js'; import { computeTuiLayout } from './layout.js'; +import { + buildRepoUserListRows, + buildRepoUserThreadRows, + describeRepoUserMode, + renderRepoUserDetail, + type RepoUserDetailPayload, + type RepoUsersPayload, + type UserListRow, + type UserThreadRow, +} from './users.js'; type StartTuiParams = { service: GHCrawlService; @@ -60,6 +82,11 @@ type AuthorThreadChoice = { label: string; }; +type UserRefreshChoice = + | { kind: 'reload-local'; label: string } + | { kind: 'selected-user'; label: string } + | { kind: 'bulk'; label: string; limit: number | null }; + type Widgets = { screen: blessed.Widgets.Screen; header: blessed.Widgets.BoxElement; @@ -67,6 +94,10 @@ type Widgets = { members: blessed.Widgets.ListElement; detail: blessed.Widgets.BoxElement; footer: blessed.Widgets.BoxElement; + commandPalette: blessed.Widgets.BoxElement; + commandInput: blessed.Widgets.BoxElement; + commandList: blessed.Widgets.ListElement; + commandHint: blessed.Widgets.BoxElement; }; type ThreadDetailCacheEntry = { @@ -80,6 +111,13 @@ type UpdateTaskSelection = { cluster: boolean; }; +type JobActivityFlags = { + sync: boolean; + embed: boolean; + cluster: boolean; + userRefresh: boolean; +}; + type BackgroundJobResult = { code: number | null; signal: NodeJS.Signals | null; @@ -87,15 +125,36 @@ type BackgroundJobResult = { error: Error | null; }; -type BackgroundRefreshJob = { +type BackgroundJob = { child: ChildProcessByStdio; repo: RepositoryTarget; - selection: UpdateTaskSelection; + label: string; + flags: JobActivityFlags; stdoutBuffer: string; terminatedByUser: boolean; + onSuccess: () => void; + onError: (message: string) => void; + summarizeStdout?: (stdout: string) => void; exitPromise: Promise; }; +type TuiCommandContext = { + activeScreen: TuiScreenId; + currentRepository: RepositoryTarget; + hasSnapshot: boolean; + hasSelectedThread: boolean; + hasSelectedUser: boolean; + hasActiveJobs: boolean; +}; + +type CommandPaletteState = { + open: boolean; + query: string; + selectedIndex: number; + previousFocusPane: TuiFocusPane; + previousScreen: TuiScreenId; +}; + export function resolveBlessedTerminal(env: NodeJS.ProcessEnv = process.env): string | undefined { const term = env.TERM; if (!term) { @@ -126,6 +185,25 @@ export function buildRefreshCliArgs(target: RepositoryTarget, selection: UpdateT return args; } +export function buildRefreshUsersCliArgs( + target: RepositoryTarget, + params: { mode: RepoUserExplorerMode; limit?: number | null; force?: boolean }, +): string[] { + const args = ['refresh-users', `${target.owner}/${target.repo}`, '--mode', params.mode]; + if (params.limit != null) args.push('--limit', String(params.limit)); + if (params.force === true) args.push('--force'); + return args; +} + +export function buildRefreshUserCliArgs( + target: RepositoryTarget, + params: { login: string; force?: boolean }, +): string[] { + const args = ['refresh-user', `${target.owner}/${target.repo}`, '--login', params.login]; + if (params.force === true) args.push('--force'); + return args; +} + function createCliLaunch(args: string[]): { command: string; args: string[] } { const here = path.dirname(fileURLToPath(import.meta.url)); const distEntrypoint = path.resolve(here, '..', 'main.js'); @@ -147,6 +225,7 @@ export async function startTui(params: StartTuiParams): Promise { let currentRepository = selectedRepository ?? { owner: '', repo: '' }; const widgets = createWidgets(currentRepository.owner, currentRepository.repo); + let activeScreen: TuiScreenId = 'clusters'; let focusPane: TuiFocusPane = 'clusters'; const initialPreference = selectedRepository ? getTuiRepositoryPreference(params.service.config, currentRepository.owner, currentRepository.repo) @@ -165,6 +244,14 @@ export async function startTui(params: StartTuiParams): Promise { let selectedMemberThreadId: number | null = null; let memberRows: MemberListRow[] = []; let memberIndex = -1; + let userMode: RepoUserExplorerMode = 'flagged'; + let userList: RepoUsersPayload | null = null; + let userRows: UserListRow[] = []; + let selectedUserLogin: string | null = null; + let userDetail: RepoUserDetailPayload | null = null; + let userThreadRows: UserThreadRow[] = []; + let selectedUserThreadId: number | null = null; + let userThreadIndex = -1; let status = 'Ready'; const activityLines: string[] = []; const clusterDetailCache = new Map(); @@ -172,15 +259,42 @@ export async function startTui(params: StartTuiParams): Promise { let syncJobRunning = false; let embedJobRunning = false; let clusterJobRunning = false; - let activeJob: BackgroundRefreshJob | null = null; + let userRefreshJobRunning = false; + let activeJob: BackgroundJob | null = null; let modalOpen = false; let exitRequested = false; + let commandDefinitions: TuiCommandDefinition[] = []; + let commandPalette: CommandPaletteState = { + open: false, + query: '', + selectedIndex: 0, + previousFocusPane: focusPane, + previousScreen: activeScreen, + }; + + const hasActiveJobs = (): boolean => activeJob !== null; + const hasBlockingOverlay = (): boolean => modalOpen || commandPalette.open; + const buildCommandContext = (): TuiCommandContext => ({ + activeScreen, + currentRepository, + hasSnapshot: snapshot !== null, + hasSelectedThread: selectedMemberThreadId !== null && threadDetail?.thread.htmlUrl !== undefined, + hasSelectedUser: selectedUserLogin !== null, + hasActiveJobs: hasActiveJobs(), + }); const clearCaches = (): void => { clusterDetailCache.clear(); threadDetailCache.clear(); }; + const preserveSelectedLogin = (logins: string[], selectedLogin: string | null): string | null => { + if (selectedLogin !== null && logins.includes(selectedLogin)) { + return selectedLogin; + } + return logins[0] ?? null; + }; + const rebuildClusterItems = (): void => { if (!snapshot) { clusterItems = ['Pick a repository with p']; @@ -190,7 +304,7 @@ export async function startTui(params: StartTuiParams): Promise { } clusterIndexById = new Map(); - clusterItems = snapshot.clusters.map((cluster, index) => { + clusterItems = snapshot.clusters.map((cluster: TuiSnapshot['clusters'][number], index: number) => { clusterIndexById.set(cluster.clusterId, index); const updated = formatClusterDateColumn(cluster.latestUpdatedAt); const label = `${String(cluster.totalCount).padStart(3, ' ')} C${String(cluster.clusterId).padStart(5, ' ')} ${String(cluster.pullRequestCount).padStart(2, ' ')}P/${String(cluster.issueCount).padStart(2, ' ')}I ${updated} ${cluster.displayTitle}`; @@ -207,10 +321,11 @@ export async function startTui(params: StartTuiParams): Promise { render(); }; - const setActiveJobFlags = (selection: UpdateTaskSelection | null): void => { - syncJobRunning = selection?.sync === true; - embedJobRunning = selection?.embed === true; - clusterJobRunning = selection?.cluster === true; + const setActiveJobFlags = (flags: JobActivityFlags | null): void => { + syncJobRunning = flags?.sync === true; + embedJobRunning = flags?.embed === true; + clusterJobRunning = flags?.cluster === true; + userRefreshJobRunning = flags?.userRefresh === true; }; const loadClusterDetail = (clusterId: number): TuiClusterDetail => { @@ -254,7 +369,7 @@ export async function startTui(params: StartTuiParams): Promise { } const selectFromSnapshot = (): boolean => { - const cluster = snapshot?.clusters.find((item) => item.clusterId === clusterId) ?? null; + const cluster = snapshot?.clusters.find((item: TuiSnapshot['clusters'][number]) => item.clusterId === clusterId) ?? null; if (!cluster) { return false; } @@ -304,7 +419,7 @@ export async function startTui(params: StartTuiParams): Promise { search, includeClosedClusters: showClosed, }); - selectedClusterId = preserveSelectedId(snapshot.clusters.map((cluster) => cluster.clusterId), previousClusterId); + selectedClusterId = preserveSelectedId(snapshot.clusters.map((cluster: TuiSnapshot['clusters'][number]) => cluster.clusterId), previousClusterId); rebuildClusterItems(); if (selectedClusterId !== null) { @@ -320,7 +435,7 @@ export async function startTui(params: StartTuiParams): Promise { includeClosedClusters: showClosed, }); rebuildClusterItems(); - selectedClusterId = preserveSelectedId(snapshot.clusters.map((cluster) => cluster.clusterId), null); + selectedClusterId = preserveSelectedId(snapshot.clusters.map((cluster: TuiSnapshot['clusters'][number]) => cluster.clusterId), null); clusterDetail = selectedClusterId !== null ? loadClusterDetail(selectedClusterId) : null; } } @@ -345,8 +460,70 @@ export async function startTui(params: StartTuiParams): Promise { render(); }; + const loadSelectedUserDetail = (): void => { + if (!selectedUserLogin) { + userDetail = null; + userThreadRows = []; + selectedUserThreadId = null; + userThreadIndex = -1; + return; + } + + userDetail = params.service.getRepoUserDetail({ + owner: currentRepository.owner, + repo: currentRepository.repo, + login: selectedUserLogin, + }); + userThreadRows = buildRepoUserThreadRows(userDetail, userMode); + const selectableThreadIds = userThreadRows.filter((row): row is Extract => row.selectable).map((row) => row.threadId); + if (!selectableThreadIds.includes(selectedUserThreadId ?? -1)) { + selectedUserThreadId = selectableThreadIds[0] ?? null; + } + userThreadIndex = userThreadRows.findIndex((row) => row.selectable && row.threadId === selectedUserThreadId); + }; + + const refreshUserExplorer = (preserveSelection: boolean): void => { + const previousLogin = preserveSelection ? selectedUserLogin : null; + const previousThreadId = preserveSelection ? selectedUserThreadId : null; + userList = params.service.listRepoUsers({ + owner: currentRepository.owner, + repo: currentRepository.repo, + mode: userMode, + includeStale: true, + }); + userRows = buildRepoUserListRows(userList); + selectedUserLogin = preserveSelectedLogin( + userList.users.map((user) => user.login), + previousLogin, + ); + selectedUserThreadId = previousThreadId; + if (selectedUserLogin) { + try { + loadSelectedUserDetail(); + } catch (error) { + userDetail = null; + userThreadRows = []; + selectedUserThreadId = null; + userThreadIndex = -1; + status = error instanceof Error ? error.message : 'Failed to load user detail'; + render(); + return; + } + } else { + userDetail = null; + userThreadRows = []; + selectedUserThreadId = null; + userThreadIndex = -1; + } + status = `Loaded ${userList.users.length} user(s) for ${describeRepoUserMode(userMode)}`; + render(); + }; + + const getDefaultFocusPane = (): TuiFocusPane => getScreenFocusOrder(activeScreen)[0] ?? 'detail'; + const updateFocus = (nextFocus: TuiFocusPane): void => { - focusPane = nextFocus; + const allowedFocus = getScreenFocusOrder(activeScreen); + focusPane = allowedFocus.includes(nextFocus) ? nextFocus : getDefaultFocusPane(); if (focusPane === 'detail' && selectedMemberThreadId !== null) { loadSelectedThreadDetail(true); } @@ -356,6 +533,26 @@ export async function startTui(params: StartTuiParams): Promise { render(); }; + const switchScreen = (nextScreen: TuiScreenId): void => { + if (activeScreen === nextScreen) { + status = `Already in ${getScreenDefinition(nextScreen).label}`; + render(); + return; + } + activeScreen = nextScreen; + focusPane = getDefaultFocusPane(); + status = + nextScreen === 'users' + ? `Switched to ${describeRepoUserMode(userMode)}` + : 'Switched to Clusters Explorer'; + if (nextScreen === 'users') { + refreshUserExplorer(true); + updateFocus('clusters'); + return; + } + updateFocus(getDefaultFocusPane()); + }; + const render = (): void => { const width = widgets.screen.width as number; const height = widgets.screen.height as number; @@ -368,6 +565,7 @@ export async function startTui(params: StartTuiParams): Promise { widgets.screen.title = currentRepository.owner && currentRepository.repo ? `ghcrawl ${currentRepository.owner}/${currentRepository.repo}` : 'ghcrawl'; const repoLabel = snapshot?.repository.fullName ?? (currentRepository.owner && currentRepository.repo ? `${currentRepository.owner}/${currentRepository.repo}` : 'ghcrawl'); + const screenDefinition = getScreenDefinition(activeScreen); const ghStatus = formatRelativeTime(snapshot?.stats.lastGithubReconciliationAt ?? null); const embedAge = formatRelativeTime(snapshot?.stats.lastEmbedRefreshAt ?? null); const embedStatus = @@ -378,21 +576,46 @@ export async function startTui(params: StartTuiParams): Promise { snapshot?.stats.latestClusterRunId != null ? `#${snapshot.stats.latestClusterRunId} ${formatRelativeTime(snapshot.stats.latestClusterRunFinishedAt ?? null)}` : 'never'; - widgets.header.setContent( - `{bold}${repoLabel}{/bold} {cyan-fg}${snapshot?.stats.openPullRequestCount ?? 0} PR{/cyan-fg} {green-fg}${snapshot?.stats.openIssueCount ?? 0} issues{/green-fg} GH:${ghStatus} Emb:${embedStatus} Cl:${clusterStatus} sort:${sortMode} min:${minSize === 0 ? 'all' : `${minSize}+`} layout:${wideLayout === 'columns' ? 'cols' : 'stack'} closed:${showClosed ? 'shown' : 'hidden'} filter:${search || 'none'}`, - ); - - const clusterIndex = snapshot && selectedClusterId !== null ? Math.max(0, clusterIndexById.get(selectedClusterId) ?? -1) : 0; - widgets.clusters.select(clusterIndex); + if (activeScreen === 'clusters') { + widgets.header.setContent( + `{bold}${repoLabel}{/bold} view:${escapeBlessedText(screenDefinition.label)} {cyan-fg}${snapshot?.stats.openPullRequestCount ?? 0} PR{/cyan-fg} {green-fg}${snapshot?.stats.openIssueCount ?? 0} issues{/green-fg} GH:${ghStatus} Emb:${embedStatus} Cl:${clusterStatus} sort:${sortMode} min:${minSize === 0 ? 'all' : `${minSize}+`} layout:${wideLayout === 'columns' ? 'cols' : 'stack'} closed:${showClosed ? 'shown' : 'hidden'} filter:${search || 'none'}`, + ); + } else { + widgets.header.setContent( + `{bold}${repoLabel}{/bold} view:${escapeBlessedText(screenDefinition.label)} mode:${escapeBlessedText(describeRepoUserMode(userMode))} matched:${userList?.totals.matchingUserCount ?? 0} issues:${userList?.totals.openIssueCount ?? 0} prs:${userList?.totals.openPullRequestCount ?? 0} waiting:${userList?.totals.waitingPullRequestCount ?? 0} GH:${ghStatus} Emb:${embedStatus} Cl:${clusterStatus}`, + ); + } - widgets.members.setItems(memberRows.length > 0 ? memberRows.map((row) => row.label) : ['No members']); - if (memberIndex >= 0) { - widgets.members.select(memberIndex); + if (activeScreen === 'clusters') { + widgets.clusters.setLabel(' Clusters '); + widgets.members.setLabel(' Members '); + widgets.detail.setLabel(' Detail '); + const clusterIndex = snapshot && selectedClusterId !== null ? Math.max(0, clusterIndexById.get(selectedClusterId) ?? -1) : 0; + widgets.clusters.setItems(clusterItems); + widgets.clusters.select(clusterIndex); + widgets.members.setItems(memberRows.length > 0 ? memberRows.map((row) => row.label) : ['No members']); + if (memberIndex >= 0) { + widgets.members.select(memberIndex); + } + widgets.detail.setContent(renderDetailPane(threadDetail, clusterDetail, focusPane)); + } else { + widgets.clusters.setLabel(' Users '); + widgets.members.setLabel(userMode === 'trusted_prs' ? ' Waiting PRs ' : ' Issues & PRs '); + widgets.detail.setLabel(' User Detail '); + widgets.clusters.setItems(userRows.length > 0 ? userRows.map((row) => row.label) : ['No matching users']); + const userIndex = selectedUserLogin ? Math.max(0, userRows.findIndex((row) => row.login === selectedUserLogin)) : 0; + widgets.clusters.select(userIndex); + widgets.members.setItems(userThreadRows.length > 0 ? userThreadRows.map((row) => row.label) : ['No open threads for this user']); + if (userThreadIndex >= 0) { + widgets.members.select(userThreadIndex); + } else { + widgets.members.select(0); + } + widgets.detail.setContent(renderRepoUserDetail(userDetail, selectedUserThreadId)); } - widgets.detail.setContent(renderDetailPane(threadDetail, clusterDetail, focusPane)); updatePaneStyles(widgets, focusPane); - const activeJobs = [syncJobRunning ? 'sync' : null, embedJobRunning ? 'embed' : null, clusterJobRunning ? 'cluster' : null] + const activeJobs = [syncJobRunning ? 'sync' : null, embedJobRunning ? 'embed' : null, clusterJobRunning ? 'cluster' : null, userRefreshJobRunning ? 'user-refresh' : null] .filter(Boolean) .join(', ') || 'idle'; const logLines = activityLines.slice(-FOOTER_LOG_LINES); @@ -400,13 +623,16 @@ export async function startTui(params: StartTuiParams): Promise { while (footerLines.length < FOOTER_LOG_LINES) { footerLines.unshift(''); } - footerLines.push( - `${status} | jobs:${activeJobs} | h/? help # jump g update p repos u author / filter s sort f min l layout x closed`, - ); - footerLines.push( - `Tab focus arrows move-or-scroll PgUp/PgDn page r refresh o open q quit`, - ); + const footerHints = buildFooterCommandHints(activeScreen); + footerLines.push(`${status} | jobs:${activeJobs} | ${footerHints[0]}`); + footerLines.push(footerHints[1]); widgets.footer.setContent(footerLines.join('\n')); + renderCommandPaletteOverlay(widgets, { + width, + footerTop: layout.footer.top, + palette: commandPalette, + commands: filterCommands(resolveCommands(commandDefinitions, buildCommandContext()), commandPalette.query), + }); widgets.screen.render(); }; @@ -442,7 +668,7 @@ export async function startTui(params: StartTuiParams): Promise { }); }; - const finalizeBackgroundJob = (job: BackgroundRefreshJob): void => { + const finalizeBackgroundJob = (job: BackgroundJob): void => { void (async () => { const result = await job.exitPromise; if (activeJob === job) { @@ -451,37 +677,20 @@ export async function startTui(params: StartTuiParams): Promise { setActiveJobFlags(null); if (job.terminatedByUser) { - pushActivity(`[jobs] update pipeline terminated for ${job.repo.owner}/${job.repo.repo}`); + pushActivity(`[jobs] ${job.label} terminated for ${job.repo.owner}/${job.repo.repo}`); } else if (result.error) { - pushActivity(`[jobs] update pipeline failed for ${job.repo.owner}/${job.repo.repo}: ${result.error.message}`); + const message = `[jobs] ${job.label} failed for ${job.repo.owner}/${job.repo.repo}: ${result.error.message}`; + pushActivity(message); + job.onError(result.error.message); } else if (result.code === 0) { - pushActivity(`[jobs] update pipeline complete for ${job.repo.owner}/${job.repo.repo}`); - try { - const parsed = JSON.parse(result.stdout.trim()) as { - sync?: { threadsSynced?: number; threadsClosed?: number } | null; - embed?: { embedded?: number } | null; - cluster?: { clusters?: number; edges?: number } | null; - }; - const summaryParts = [ - parsed.sync ? `sync:${parsed.sync.threadsSynced ?? 0} threads` : null, - parsed.sync ? `closed:${parsed.sync.threadsClosed ?? 0}` : null, - parsed.embed ? `embed:${parsed.embed.embedded ?? 0}` : null, - parsed.cluster ? `cluster:${parsed.cluster.clusters ?? 0}` : null, - parsed.cluster ? `edges:${parsed.cluster.edges ?? 0}` : null, - ].filter((value): value is string => value !== null); - if (summaryParts.length > 0) { - pushActivity(`[jobs] result ${summaryParts.join(' ')}`); - } - } catch { - // Ignore malformed stdout; progress is already visible in the activity log. - } - if (currentRepository.owner === job.repo.owner && currentRepository.repo === job.repo.repo) { - refreshAll(true); - } + pushActivity(`[jobs] ${job.label} complete for ${job.repo.owner}/${job.repo.repo}`); + job.summarizeStdout?.(result.stdout); + job.onSuccess(); } else { const exitSuffix = result.signal !== null ? `signal=${result.signal}` : `code=${result.code ?? 1}`; - pushActivity(`[jobs] update pipeline failed for ${job.repo.owner}/${job.repo.repo}: exited ${exitSuffix}`); + pushActivity(`[jobs] ${job.label} failed for ${job.repo.owner}/${job.repo.repo}: exited ${exitSuffix}`); + job.onError(`exited ${exitSuffix}`); } status = 'Ready'; @@ -491,29 +700,37 @@ export async function startTui(params: StartTuiParams): Promise { })(); }; - const startBackgroundUpdatePipeline = (target: RepositoryTarget, selection: UpdateTaskSelection): boolean => { + const startBackgroundJob = (params: { + target: RepositoryTarget; + label: string; + args: string[]; + flags: JobActivityFlags; + startActivity: string; + statusText: string; + onSuccess: () => void; + onError: (message: string) => void; + summarizeStdout?: (stdout: string) => void; + }): boolean => { if (activeJob !== null) { - pushActivity('[jobs] another update pipeline is already running'); + pushActivity('[jobs] another background job is already running'); return false; } - if (!selection.sync && !selection.embed && !selection.cluster) { - pushActivity('[jobs] select at least one update step'); - return false; - } - - const cliArgs = buildRefreshCliArgs(target, selection); - const launch = createCliLaunch(cliArgs); + const launch = createCliLaunch(params.args); const child = spawn(launch.command, launch.args, { env: process.env, stdio: ['ignore', 'pipe', 'pipe'], }); - const job: BackgroundRefreshJob = { + const job: BackgroundJob = { child, - repo: target, - selection, + repo: params.target, + label: params.label, + flags: params.flags, stdoutBuffer: '', terminatedByUser: false, + onSuccess: params.onSuccess, + onError: params.onError, + summarizeStdout: params.summarizeStdout, exitPromise: new Promise((resolve) => { let resolved = false; const finish = (result: BackgroundJobResult): void => { @@ -537,20 +754,192 @@ export async function startTui(params: StartTuiParams): Promise { consumeStreamLines(child.stderr, (line) => pushActivity(line, { raw: true })); activeJob = job; - setActiveJobFlags(selection); - status = `Running update pipeline for ${target.owner}/${target.repo}`; - pushActivity( - `[jobs] starting update pipeline for ${target.owner}/${target.repo}: ${UPDATE_TASK_ORDER.filter((task) => selection[task]).join(' -> ')}`, - ); + setActiveJobFlags(params.flags); + status = params.statusText; + pushActivity(params.startActivity); render(); finalizeBackgroundJob(job); return true; }; + const summarizeUpdatePipelineStdout = (stdout: string): void => { + try { + const parsed = JSON.parse(stdout.trim()) as { + sync?: { threadsSynced?: number; threadsClosed?: number } | null; + embed?: { embedded?: number } | null; + cluster?: { clusters?: number; edges?: number } | null; + }; + const summaryParts = [ + parsed.sync ? `sync:${parsed.sync.threadsSynced ?? 0} threads` : null, + parsed.sync ? `closed:${parsed.sync.threadsClosed ?? 0}` : null, + parsed.embed ? `embed:${parsed.embed.embedded ?? 0}` : null, + parsed.cluster ? `cluster:${parsed.cluster.clusters ?? 0}` : null, + parsed.cluster ? `edges:${parsed.cluster.edges ?? 0}` : null, + ].filter((value): value is string => value !== null); + if (summaryParts.length > 0) { + pushActivity(`[jobs] result ${summaryParts.join(' ')}`); + } + } catch { + // Ignore malformed stdout; progress is already visible in the activity log. + } + }; + + const startBackgroundUpdatePipeline = (target: RepositoryTarget, selection: UpdateTaskSelection): boolean => { + if (!selection.sync && !selection.embed && !selection.cluster) { + pushActivity('[jobs] select at least one update step'); + return false; + } + + return startBackgroundJob({ + target, + label: 'update pipeline', + args: buildRefreshCliArgs(target, selection), + flags: { + sync: selection.sync, + embed: selection.embed, + cluster: selection.cluster, + userRefresh: false, + }, + startActivity: `[jobs] starting update pipeline for ${target.owner}/${target.repo}: ${UPDATE_TASK_ORDER.filter((task) => selection[task]).join(' -> ')}`, + statusText: `Running update pipeline for ${target.owner}/${target.repo}`, + summarizeStdout: summarizeUpdatePipelineStdout, + onSuccess: () => { + if (currentRepository.owner === target.owner && currentRepository.repo === target.repo) { + refreshAll(true); + } + }, + onError: () => { + status = 'Update pipeline failed'; + }, + }); + }; + + const summarizeUserRefreshStdout = (stdout: string): void => { + try { + const parsed = JSON.parse(stdout.trim()) as { + refreshedCount?: number; + skippedCount?: number; + failedCount?: number; + failures?: Array<{ login?: string }>; + }; + pushActivity( + `[jobs] user refresh result refreshed:${parsed.refreshedCount ?? 0} skipped:${parsed.skippedCount ?? 0} failed:${parsed.failedCount ?? 0}`, + ); + if (Array.isArray(parsed.failures) && parsed.failures.length > 0) { + pushActivity(`[users] refresh failures: ${parsed.failures.slice(0, 5).map((failure) => `@${failure.login ?? 'unknown'}`).join(', ')}`); + } + } catch { + // Ignore malformed stdout; progress is already visible in the activity log. + } + }; + + const startBackgroundUserRefresh = (target: RepositoryTarget, params: { mode: RepoUserExplorerMode; limit: number | null }): boolean => { + const selectedCount = params.limit ?? userList?.totals.matchingUserCount ?? userRows.length; + return startBackgroundJob({ + target, + label: 'user refresh', + args: buildRefreshUsersCliArgs(target, { mode: params.mode, limit: params.limit }), + flags: { + sync: false, + embed: false, + cluster: false, + userRefresh: true, + }, + startActivity: `[jobs] starting user refresh for ${target.owner}/${target.repo}: mode=${params.mode} limit=${params.limit ?? 'all'} selected=${selectedCount}`, + statusText: `Running user refresh for ${target.owner}/${target.repo}`, + summarizeStdout: summarizeUserRefreshStdout, + onSuccess: () => { + if (currentRepository.owner === target.owner && currentRepository.repo === target.repo && activeScreen === 'users') { + refreshUserExplorer(true); + } + }, + onError: () => { + status = 'User refresh failed'; + }, + }); + }; + + const startBackgroundSelectedUserRefresh = (target: RepositoryTarget, login: string): boolean => + startBackgroundJob({ + target, + label: 'user refresh', + args: buildRefreshUserCliArgs(target, { login }), + flags: { + sync: false, + embed: false, + cluster: false, + userRefresh: true, + }, + startActivity: `[jobs] starting user refresh for ${target.owner}/${target.repo}: @${login}`, + statusText: `Running user refresh for @${login}`, + summarizeStdout: (stdout) => { + try { + const parsed = JSON.parse(stdout.trim()) as { login?: string; refreshedAt?: string }; + pushActivity(`[jobs] user refresh result @${parsed.login ?? login} refreshed_at=${parsed.refreshedAt ?? 'unknown'}`); + } catch { + // Ignore malformed stdout; progress is already visible in the activity log. + } + }, + onSuccess: () => { + if (currentRepository.owner === target.owner && currentRepository.repo === target.repo && activeScreen === 'users') { + refreshUserExplorer(true); + } + }, + onError: () => { + status = 'User refresh failed'; + }, + }); + const moveSelection = (delta: -1 | 1, options?: { steps?: number; wrap?: boolean }): void => { - if (!snapshot) return; const steps = Math.max(1, options?.steps ?? 1); const wrap = options?.wrap ?? true; + if (activeScreen === 'users') { + if (focusPane === 'clusters') { + if (userRows.length === 0) return; + const currentIndex = Math.max(0, selectedUserLogin === null ? -1 : userRows.findIndex((row) => row.login === selectedUserLogin)); + let nextIndex = currentIndex + delta * steps; + if (wrap) { + nextIndex = ((nextIndex % userRows.length) + userRows.length) % userRows.length; + } else { + nextIndex = Math.max(0, Math.min(userRows.length - 1, nextIndex)); + } + selectedUserLogin = userRows[nextIndex]?.login ?? null; + selectedUserThreadId = null; + loadSelectedUserDetail(); + status = selectedUserLogin ? `User @${selectedUserLogin} (${nextIndex + 1}/${userRows.length})` : status; + render(); + return; + } + + if (focusPane === 'members') { + if (userThreadRows.length === 0) return; + let nextIndex = userThreadIndex < 0 ? 0 : userThreadIndex; + for (let index = 0; index < steps; index += 1) { + nextIndex += delta; + while (nextIndex < 0) nextIndex = wrap ? userThreadRows.length - 1 : 0; + while (nextIndex >= userThreadRows.length) nextIndex = wrap ? 0 : userThreadRows.length - 1; + if (userThreadRows[nextIndex]?.selectable) { + break; + } + if (!wrap && (nextIndex === 0 || nextIndex === userThreadRows.length - 1)) { + break; + } + } + if (!userThreadRows[nextIndex]?.selectable) { + nextIndex = userThreadRows.findIndex((row) => row.selectable); + } + userThreadIndex = nextIndex; + const selectedRow = userThreadRows[nextIndex]; + selectedUserThreadId = selectedRow?.selectable ? selectedRow.threadId : null; + status = selectedUserThreadId !== null ? `Selected user thread #${selectedUserThreadId}` : status; + render(); + return; + } + return; + } + + if (!snapshot) return; + if (focusPane === 'clusters') { if (snapshot.clusters.length === 0) return; const currentIndex = Math.max(0, selectedClusterId === null ? -1 : (clusterIndexById.get(selectedClusterId) ?? -1)); @@ -615,7 +1004,115 @@ export async function startTui(params: StartTuiParams): Promise { moveSelection(delta, { steps: getFocusedListPageSize(), wrap: false }); }; + const requireClustersScreen = (actionLabel: string): boolean => { + if (activeScreen === 'clusters') { + return true; + } + status = `${actionLabel} is only available in Clusters Explorer`; + render(); + return false; + }; + + const closeCommandPalette = (options?: { restoreFocus?: boolean }): void => { + if (!commandPalette.open) return; + const restoreFocus = options?.restoreFocus ?? false; + commandPalette = { + open: false, + query: '', + selectedIndex: 0, + previousFocusPane: commandPalette.previousFocusPane, + previousScreen: commandPalette.previousScreen, + }; + if (restoreFocus) { + if (activeScreen !== commandPalette.previousScreen) { + focusPane = getDefaultFocusPane(); + } else { + focusPane = commandPalette.previousFocusPane; + } + updateFocus(focusPane); + return; + } + render(); + }; + + const openCommandPalette = (): void => { + if (modalOpen) return; + commandPalette = { + open: true, + query: '', + selectedIndex: 0, + previousFocusPane: focusPane, + previousScreen: activeScreen, + }; + render(); + }; + + const getFilteredCommands = (): TuiResolvedCommand[] => + filterCommands(resolveCommands(commandDefinitions, buildCommandContext()), commandPalette.query); + + const moveCommandPaletteSelection = (delta: -1 | 1): void => { + const filtered = getFilteredCommands(); + if (filtered.length === 0) return; + commandPalette.selectedIndex = + ((commandPalette.selectedIndex + delta) % filtered.length + filtered.length) % filtered.length; + render(); + }; + + const executeCommandFromPalette = (): void => { + const command = selectCommandFromQuery(resolveCommands(commandDefinitions, buildCommandContext()), commandPalette.query, commandPalette.selectedIndex); + if (!command) { + status = 'No matching command'; + render(); + return; + } + if (!command.enabled) { + status = command.reason ?? `/${command.definition.slash} is not available yet`; + render(); + return; + } + closeCommandPalette(); + command.definition.execute(); + }; + + const updateCommandPaletteQuery = (value: string): void => { + commandPalette.query = value; + commandPalette.selectedIndex = 0; + render(); + }; + + const handleCommandPaletteKeypress = (char: string, key: blessed.Widgets.Events.IKeyEventArg): void => { + if (!commandPalette.open) return; + if (key.name === 'escape') { + closeCommandPalette({ restoreFocus: true }); + return; + } + if (key.name === 'up') { + moveCommandPaletteSelection(-1); + return; + } + if (key.name === 'down') { + moveCommandPaletteSelection(1); + return; + } + if (key.name === 'enter') { + executeCommandFromPalette(); + return; + } + if (key.name === 'backspace' || key.name === 'delete') { + updateCommandPaletteQuery(commandPalette.query.slice(0, -1)); + return; + } + if (key.name === 'space') { + updateCommandPaletteQuery(`${commandPalette.query} `); + return; + } + if (char && !key.ctrl && !key.meta && !key.shift && char.length === 1 && char >= ' ') { + updateCommandPaletteQuery(`${commandPalette.query}${char}`); + } + }; + const promptFilter = (): void => { + if (!requireClustersScreen('Filtering')) return; modalOpen = true; const prompt = blessed.prompt({ parent: widgets.screen, @@ -644,6 +1141,7 @@ export async function startTui(params: StartTuiParams): Promise { }; const promptThreadJump = (): void => { + if (!requireClustersScreen('Jumping to threads')) return; if (modalOpen) return; modalOpen = true; const prompt = blessed.prompt({ @@ -693,6 +1191,7 @@ export async function startTui(params: StartTuiParams): Promise { }; const openSelectedThread = (): void => { + if (!requireClustersScreen('Opening threads')) return; const url = threadDetail?.thread.htmlUrl; if (!url) { status = 'No thread selected to open'; @@ -705,6 +1204,7 @@ export async function startTui(params: StartTuiParams): Promise { }; const promptAuthorThreads = (): void => { + if (!requireClustersScreen('Author browsing')) return; if (modalOpen) return; const authorLogin = threadDetail?.thread.authorLogin?.trim() ?? ''; if (!authorLogin) { @@ -803,8 +1303,8 @@ export async function startTui(params: StartTuiParams): Promise { modalOpen = true; try { const confirmed = await promptConfirm( - 'Stop Update Pipeline', - `A background update pipeline is still running for ${activeJob.repo.owner}/${activeJob.repo.repo}.\nQuitting now will send SIGTERM to that refresh process and wait for it to exit.`, + 'Stop Background Job', + `A background ${activeJob.label} is still running for ${activeJob.repo.owner}/${activeJob.repo.repo}.\nQuitting now will send SIGTERM to that refresh process and wait for it to exit.`, ); if (!confirmed) { render(); @@ -812,8 +1312,8 @@ export async function startTui(params: StartTuiParams): Promise { } exitRequested = true; - status = 'Stopping background update pipeline'; - pushActivity(`[jobs] stopping update pipeline for ${activeJob.repo.owner}/${activeJob.repo.repo}`); + status = `Stopping background ${activeJob.label}`; + pushActivity(`[jobs] stopping ${activeJob.label} for ${activeJob.repo.owner}/${activeJob.repo.repo}`); render(); activeJob.terminatedByUser = true; activeJob.child.kill('SIGTERM'); @@ -851,8 +1351,6 @@ export async function startTui(params: StartTuiParams): Promise { })(); }; - const hasActiveJobs = (): boolean => activeJob !== null; - const persistRepositoryPreference = (): void => { writeTuiRepositoryPreference(params.service.config, { owner: currentRepository.owner, @@ -917,6 +1415,9 @@ export async function startTui(params: StartTuiParams): Promise { memberIndex = -1; status = `Switched to ${target.owner}/${target.repo}`; refreshAll(false); + if (activeScreen === 'users') { + refreshUserExplorer(false); + } }; const runRepositoryBootstrap = (target: RepositoryTarget): boolean => { @@ -1004,22 +1505,337 @@ export async function startTui(params: StartTuiParams): Promise { } }; + const cycleSortAction = (): void => { + if (!requireClustersScreen('Sorting clusters')) return; + sortMode = cycleSortMode(sortMode); + persistRepositoryPreference(); + status = `Sort: ${sortMode}`; + refreshAll(false); + }; + + const cycleMinSizeAction = (): void => { + if (!requireClustersScreen('Changing the minimum size filter')) return; + minSize = cycleMinSizeFilter(minSize); + persistRepositoryPreference(); + status = `Min size: ${minSize === 0 ? 'all' : `${minSize}+`}`; + refreshAll(false); + }; + + const toggleLayoutAction = (): void => { + wideLayout = wideLayout === 'columns' ? 'right-stack' : 'columns'; + persistRepositoryPreference(); + status = `Layout: ${wideLayout === 'columns' ? 'three columns' : 'wide left + stacked right'}`; + render(); + }; + + const toggleClosedAction = (): void => { + if (!requireClustersScreen('Toggling closed clusters')) return; + showClosed = !showClosed; + status = showClosed ? 'Showing closed clusters and members' : 'Hiding closed clusters and members'; + refreshAll(true); + }; + + const refreshAction = (): void => { + if (activeScreen === 'users') { + promptUserRefreshAction(); + return; + } + status = 'Refreshing'; + refreshAll(true); + }; + + const switchUserMode = (nextMode: RepoUserExplorerMode): void => { + userMode = nextMode; + activeScreen = 'users'; + focusPane = 'clusters'; + refreshUserExplorer(false); + updateFocus('clusters'); + }; + + const refreshSelectedUserAction = (): void => { + if (activeScreen !== 'users' || !selectedUserLogin) { + status = 'Select a user first'; + render(); + return; + } + if (hasActiveJobs()) { + status = 'A background job is already running'; + render(); + return; + } + + if (!startBackgroundSelectedUserRefresh(currentRepository, selectedUserLogin)) { + status = 'A background job is already running'; + render(); + } + }; + + const refreshBulkUsersAction = (limit: number | null): void => { + if (activeScreen !== 'users') { + status = 'Open User Explorer first'; + render(); + return; + } + const selectedCount = limit ?? userList?.totals.matchingUserCount ?? userRows.length; + if (selectedCount <= 0) { + status = 'No matching users to refresh'; + render(); + return; + } + if (!startBackgroundUserRefresh(currentRepository, { mode: userMode, limit })) { + status = 'A background job is already running'; + render(); + } + }; + + const promptUserRefreshAction = (): void => { + if (activeScreen !== 'users' || modalOpen) return; + void (async () => { + let deferredAction: null | (() => void) = null; + modalOpen = true; + try { + const choice = await promptUserRefreshChoice( + widgets.screen, + describeRepoUserMode(userMode), + userList?.totals.matchingUserCount ?? userRows.length, + selectedUserLogin, + ); + if (!choice) { + render(); + return; + } + if (choice.kind === 'reload-local') { + status = 'Refreshing local user view'; + refreshUserExplorer(true); + return; + } + if (choice.kind === 'selected-user') { + deferredAction = () => refreshSelectedUserAction(); + return; + } + deferredAction = () => refreshBulkUsersAction(choice.limit); + } finally { + modalOpen = false; + } + deferredAction?.(); + })(); + }; + + const openSelectedUserProfileAction = (): void => { + if (activeScreen !== 'users' || !userDetail?.profile.profileUrl) { + status = 'No selected user profile to open'; + render(); + return; + } + openUrl(userDetail.profile.profileUrl); + status = `Opened ${userDetail.profile.profileUrl}`; + render(); + }; + + const focusForwardAction = (): void => { + updateFocus(cycleFocusPane(focusPane, 1, getScreenFocusOrder(activeScreen))); + }; + + const focusBackwardAction = (): void => { + updateFocus(cycleFocusPane(focusPane, -1, getScreenFocusOrder(activeScreen))); + }; + + commandDefinitions = [ + { + id: 'view.clusters', + slash: 'clusters', + label: 'Clusters Explorer', + description: 'Switch to the issue and PR cluster explorer.', + aliases: ['cluster', 'home'], + execute: () => switchScreen('clusters'), + }, + { + id: 'view.users', + slash: 'users', + label: 'User Explorer', + description: 'Switch to the flagged contributor explorer.', + aliases: ['user'], + execute: () => switchUserMode('flagged'), + }, + { + id: 'view.users-flagged', + slash: 'users flagged', + label: 'Flagged contributors', + description: 'Show low-reputation, hidden, stale, or unknown contributors.', + aliases: ['flagged'], + execute: () => switchUserMode('flagged'), + }, + { + id: 'view.users-trusted', + slash: 'users trusted', + label: 'Trusted PRs', + description: 'Show high-reputation contributors with open PRs.', + aliases: ['trusted'], + execute: () => switchUserMode('trusted_prs'), + }, + { + id: 'view.filter', + slash: 'filter', + label: 'Filter clusters', + description: 'Filter clusters by title and member text.', + screens: ['clusters'], + execute: () => promptFilter(), + }, + { + id: 'view.refresh', + slash: 'refresh', + label: 'Refresh view', + description: 'Reload the current local TUI view from SQLite.', + aliases: ['reload'], + execute: () => refreshAction(), + }, + { + id: 'view.layout', + slash: 'layout', + label: 'Toggle layout', + description: 'Switch between wide columns and stacked-right layout.', + aliases: ['wide'], + execute: () => toggleLayoutAction(), + }, + { + id: 'view.toggle-closed', + slash: 'toggle-closed', + label: 'Toggle closed items', + description: 'Show or hide locally closed clusters and members.', + screens: ['clusters'], + aliases: ['closed'], + execute: () => toggleClosedAction(), + }, + { + id: 'view.sort', + slash: 'sort', + label: 'Cycle sort mode', + description: 'Toggle cluster ordering between recent and size.', + screens: ['clusters'], + execute: () => cycleSortAction(), + }, + { + id: 'view.min-size', + slash: 'min-size', + label: 'Cycle minimum cluster size', + description: 'Rotate through the minimum cluster size presets.', + screens: ['clusters'], + aliases: ['min'], + execute: () => cycleMinSizeAction(), + }, + { + id: 'data.repos', + slash: 'repos', + label: 'Browse repositories', + description: 'Open the repository browser or sync a new repository.', + aliases: ['repo'], + getAvailability: () => (hasActiveJobs() ? { enabled: false, reason: 'blocked while jobs are running' } : { enabled: true }), + execute: () => browseRepositories(), + }, + { + id: 'data.update', + slash: 'update', + label: 'Run update pipeline', + description: 'Start the staged GitHub, embed, and cluster refresh flow.', + aliases: ['refresh-pipeline'], + getAvailability: () => (hasActiveJobs() ? { enabled: false, reason: 'already running' } : { enabled: true }), + execute: () => promptUpdatePipeline(), + }, + { + id: 'data.open', + slash: 'open', + label: 'Open selected thread', + description: 'Open the selected issue or PR in your browser.', + screens: ['clusters'], + getAvailability: () => ({ enabled: selectedMemberThreadId !== null, reason: 'select a thread first' }), + execute: () => openSelectedThread(), + }, + { + id: 'data.author', + slash: 'author', + label: 'Browse selected author', + description: 'Show the selected author’s open threads.', + screens: ['clusters'], + getAvailability: () => ({ + enabled: Boolean(threadDetail?.thread.authorLogin?.trim()), + reason: 'select a thread with an author login', + }), + execute: () => promptAuthorThreads(), + }, + { + id: 'data.jump', + slash: 'jump', + label: 'Jump to issue or PR', + description: 'Jump directly to a thread number.', + screens: ['clusters'], + aliases: ['thread'], + execute: () => promptThreadJump(), + }, + { + id: 'data.user-refresh', + slash: 'user-refresh', + label: 'Refresh selected user', + description: 'Refresh the selected user profile and reputation signals.', + screens: ['users'], + getAvailability: () => ({ enabled: selectedUserLogin !== null, reason: 'select a user first' }), + execute: () => refreshSelectedUserAction(), + }, + { + id: 'data.user-refresh-bulk', + slash: 'user-refresh-bulk', + label: 'Bulk refresh users', + description: 'Refresh the top contributors in the current user mode.', + screens: ['users'], + getAvailability: () => ({ + enabled: (userList?.totals.matchingUserCount ?? userRows.length) > 0 && !hasActiveJobs(), + reason: hasActiveJobs() ? 'blocked while jobs are running' : 'no matching users', + }), + execute: () => promptUserRefreshAction(), + }, + { + id: 'data.user-open', + slash: 'user-open', + label: 'Open selected user profile', + description: 'Open the selected user profile in your browser.', + screens: ['users'], + getAvailability: () => ({ enabled: Boolean(userDetail?.profile.profileUrl), reason: 'select a user first' }), + execute: () => openSelectedUserProfileAction(), + }, + { + id: 'utility.help', + slash: 'help', + label: 'Help', + description: 'Open the TUI help popup.', + aliases: ['?'], + execute: () => openHelp(), + }, + { + id: 'utility.quit', + slash: 'quit', + label: 'Quit', + description: 'Quit the TUI.', + aliases: ['exit'], + execute: () => requestQuit(), + }, + ]; + widgets.screen.key(['q'], () => { + if (commandPalette.open) return; requestQuit(); }); widgets.screen.key(['C-c'], () => { requestQuit(); }); widgets.screen.key(['tab', 'right'], () => { - if (modalOpen) return; - updateFocus(cycleFocusPane(focusPane, 1)); + if (hasBlockingOverlay()) return; + focusForwardAction(); }); widgets.screen.key(['S-tab', 'left'], () => { - if (modalOpen) return; - updateFocus(cycleFocusPane(focusPane, -1)); + if (hasBlockingOverlay()) return; + focusBackwardAction(); }); widgets.screen.key(['down'], () => { - if (modalOpen) return; + if (hasBlockingOverlay()) return; if (focusPane === 'detail') { scrollDetail(3); return; @@ -1027,7 +1843,7 @@ export async function startTui(params: StartTuiParams): Promise { moveSelection(1); }); widgets.screen.key(['up'], () => { - if (modalOpen) return; + if (hasBlockingOverlay()) return; if (focusPane === 'detail') { scrollDetail(-3); return; @@ -1035,94 +1851,92 @@ export async function startTui(params: StartTuiParams): Promise { moveSelection(-1); }); widgets.screen.key(['pageup'], () => { - if (modalOpen) return; + if (hasBlockingOverlay()) return; pageFocusedPane(-1); }); widgets.screen.key(['pagedown'], () => { - if (modalOpen) return; + if (hasBlockingOverlay()) return; pageFocusedPane(1); }); widgets.screen.key(['home'], () => { - if (modalOpen) return; + if (hasBlockingOverlay()) return; if (focusPane !== 'detail') return; widgets.detail.setScroll(0); widgets.screen.render(); }); widgets.screen.key(['end'], () => { - if (modalOpen) return; + if (hasBlockingOverlay()) return; if (focusPane !== 'detail') return; widgets.detail.setScrollPerc(100); widgets.screen.render(); }); widgets.screen.key(['enter'], () => { - if (modalOpen) return; + if (hasBlockingOverlay()) return; if (focusPane === 'clusters') { updateFocus('members'); return; } if (focusPane === 'members') { - loadSelectedThreadDetail(true); - status = selectedMemberThreadId !== null ? `Loaded neighbors for #${threadDetail?.thread.number ?? '?'}` : status; + if (activeScreen === 'clusters') { + loadSelectedThreadDetail(true); + status = selectedMemberThreadId !== null ? `Loaded neighbors for #${threadDetail?.thread.number ?? '?'}` : status; + } updateFocus('detail'); } }); widgets.screen.key(['s'], () => { - if (modalOpen) return; - sortMode = cycleSortMode(sortMode); - persistRepositoryPreference(); - status = `Sort: ${sortMode}`; - refreshAll(false); + if (hasBlockingOverlay()) return; + cycleSortAction(); }); widgets.screen.key(['f'], () => { - if (modalOpen) return; - minSize = cycleMinSizeFilter(minSize); - persistRepositoryPreference(); - status = `Min size: ${minSize === 0 ? 'all' : `${minSize}+`}`; - refreshAll(false); + if (hasBlockingOverlay()) return; + cycleMinSizeAction(); }); widgets.screen.key(['l'], () => { - if (modalOpen) return; - wideLayout = wideLayout === 'columns' ? 'right-stack' : 'columns'; - persistRepositoryPreference(); - status = `Layout: ${wideLayout === 'columns' ? 'three columns' : 'wide left + stacked right'}`; - render(); + if (hasBlockingOverlay()) return; + toggleLayoutAction(); }); widgets.screen.key(['x'], () => { - if (modalOpen) return; - showClosed = !showClosed; - status = showClosed ? 'Showing closed clusters and members' : 'Hiding closed clusters and members'; - refreshAll(true); + if (hasBlockingOverlay()) return; + toggleClosedAction(); }); widgets.screen.key(['/'], () => { - if (modalOpen) return; - promptFilter(); + if (hasBlockingOverlay()) return; + openCommandPalette(); }); widgets.screen.key(['#'], () => { - if (modalOpen) return; + if (hasBlockingOverlay()) return; promptThreadJump(); }); widgets.screen.key(['h', '?'], () => { - if (modalOpen) return; + if (hasBlockingOverlay()) return; openHelp(); }); - widgets.screen.key(['p'], () => browseRepositories()); + widgets.screen.key(['p'], () => { + if (hasBlockingOverlay()) return; + browseRepositories(); + }); widgets.screen.key(['g'], () => { - if (modalOpen) return; + if (hasBlockingOverlay()) return; promptUpdatePipeline(); }); widgets.screen.key(['r'], () => { - if (modalOpen) return; - status = 'Refreshing'; - refreshAll(true); + if (hasBlockingOverlay()) return; + refreshAction(); }); widgets.screen.key(['o'], () => { - if (modalOpen) return; + if (hasBlockingOverlay()) return; + if (activeScreen === 'users') { + openSelectedUserProfileAction(); + return; + } openSelectedThread(); }); widgets.screen.key(['u'], () => { - if (modalOpen) return; + if (hasBlockingOverlay()) return; promptAuthorThreads(); }); + widgets.screen.on('keypress', handleCommandPaletteKeypress); widgets.screen.on('resize', () => render()); widgets.screen.on('destroy', () => { @@ -1205,8 +2019,58 @@ function createWidgets(owner: string, repo: string): Widgets { tags: false, style: { fg: 'black', bg: '#5bc0eb' }, }); + const commandPalette = blessed.box({ + parent: screen, + border: 'line', + label: ' Commands ', + tags: true, + hidden: true, + style: { + border: { fg: '#5bc0eb' }, + fg: 'white', + bg: '#101522', + }, + }); + const commandInput = blessed.box({ + parent: commandPalette, + top: 1, + left: 1, + right: 1, + height: 1, + tags: true, + style: { + fg: 'white', + bg: '#101522', + }, + }); + const commandList = blessed.list({ + parent: commandPalette, + top: 3, + left: 1, + right: 1, + bottom: 1, + tags: true, + keys: false, + style: { + item: { fg: 'white' }, + selected: { bg: '#5bc0eb', fg: 'black', bold: true }, + }, + scrollbar: { ch: ' ' }, + }); + const commandHint = blessed.box({ + parent: commandPalette, + top: 2, + left: 1, + right: 1, + height: 1, + tags: false, + style: { + fg: '#9bc53d', + bg: '#101522', + }, + }); - return { screen, header, clusters, members, detail, footer }; + return { screen, header, clusters, members, detail, footer, commandPalette, commandInput, commandList, commandHint }; } function updatePaneStyles(widgets: Widgets, focus: TuiFocusPane): void { @@ -1215,6 +2079,33 @@ function updatePaneStyles(widgets: Widgets, focus: TuiFocusPane): void { widgets.detail.style.border = { fg: focus === 'detail' ? 'white' : '#fde74c' }; } +function renderCommandPaletteOverlay( + widgets: Widgets, + params: { + width: number; + footerTop: number; + palette: CommandPaletteState; + commands: TuiResolvedCommand[]; + }, +): void { + widgets.commandPalette.hidden = !params.palette.open; + if (!params.palette.open) { + return; + } + + const paletteWidth = Math.max(42, Math.min(Math.floor(params.width * 0.54), 78)); + const paletteHeight = 10; + widgets.commandPalette.width = paletteWidth; + widgets.commandPalette.height = paletteHeight; + widgets.commandPalette.left = 0; + widgets.commandPalette.top = Math.max(1, params.footerTop - paletteHeight); + widgets.commandInput.setContent(`{bold}/${escapeBlessedText(params.palette.query || '')}{/bold}`); + widgets.commandHint.setContent('Type to filter, arrows to move, Enter to run, Esc to close.'); + const items = params.commands.length > 0 ? params.commands.map((command) => formatCommandLabel(command)) : ['No matching commands']; + widgets.commandList.setItems(items); + widgets.commandList.select(params.commands.length > 0 ? Math.max(0, Math.min(params.palette.selectedIndex, params.commands.length - 1)) : 0); +} + export function renderDetailPane( threadDetail: TuiThreadDetail | null, clusterDetail: TuiClusterDetail | null, @@ -1241,12 +2132,13 @@ export function renderDetailPane( ? `{bold}Closed:{/bold} ${escapeBlessedText(thread.closedAtLocal ?? thread.closedAtGh ?? 'yes')} ${thread.closeReasonLocal ? `(${escapeBlessedText(thread.closeReasonLocal)})` : ''}`.trimEnd() : '{bold}Closed:{/bold} no'; const summaries = Object.entries(threadDetail.summaries) + .filter((entry): entry is [string, string] => typeof entry[1] === 'string') .map(([key, value]) => `{bold}${key}:{/bold}\n${escapeBlessedText(value)}`) .join('\n\n'); const neighbors = threadDetail.neighbors.length > 0 ? threadDetail.neighbors - .map((neighbor) => `#${neighbor.number} ${neighbor.kind} ${(neighbor.score * 100).toFixed(1)}% ${escapeBlessedText(neighbor.title)}`) + .map((neighbor: TuiThreadDetail['neighbors'][number]) => `#${neighbor.number} ${neighbor.kind} ${(neighbor.score * 100).toFixed(1)}% ${escapeBlessedText(neighbor.title)}`) .join('\n') : focusPane === 'detail' ? 'No neighbors available.' @@ -1348,10 +2240,39 @@ export function buildUpdatePipelineLabels( }); } +export function buildFooterCommandHints(activeScreen: TuiScreenId): [string, string] { + const screenHint = + activeScreen === 'clusters' + ? '/clusters /users /filter /repos /update /help /quit' + : '/users flagged /users trusted /refresh /user-refresh-bulk /user-open /repos /help /quit'; + const hotkeyHint = + activeScreen === 'clusters' + ? 'Hotkeys: Tab/arrows move # jump p repos g update q quit' + : 'Hotkeys: Tab/arrows move r refresh menu o profile p repos g update q quit'; + return [`/ commands: ${screenHint}`, hotkeyHint]; +} + export function buildHelpContent(): string { return [ '{bold}ghcrawl TUI Help{/bold}', '', + '{bold}Slash Commands{/bold}', + '/ open the command palette in the bottom-left corner', + '/clusters switch to the current issue and PR cluster explorer', + '/users switch to the flagged contributor explorer', + '/users flagged show low-reputation, stale, or hidden-activity contributors', + '/users trusted show high-reputation contributors with open PRs', + '/filter open the cluster filter prompt', + '/refresh reload the current view, or open the user refresh menu on /users', + '/user-refresh refresh the selected user profile and reputation signals', + '/user-refresh-bulk open the bulk user refresh menu for the current user mode', + '/user-open open the selected user profile in your browser', + '/repos browse repositories or sync a new one', + '/update start the staged background refresh pipeline', + '/help open this popup', + '/quit quit the TUI', + 'Hotkeys remain available as fast aliases while we transition to slash commands.', + '', '{bold}Navigation{/bold}', 'Tab / Shift-Tab cycle focus across clusters, members, and detail', 'Left / Right cycle focus backward or forward across panes', @@ -1366,8 +2287,8 @@ export function buildHelpContent(): string { 'f cycle minimum cluster size filter', 'l toggle wide layout: columns vs. wide-left stacked-right', 'x show or hide locally closed clusters and members', - '/ filter clusters by title/member text', - 'r refresh the current local view from SQLite', + '/filter filter clusters by title/member text', + 'r refresh the current view, or open the user refresh menu on /users', '', '{bold}Actions{/bold}', 'g start the staged update pipeline in the background (GitHub, embeddings, clusters)', @@ -1382,7 +2303,7 @@ export function buildHelpContent(): string { '', '{bold}Notes{/bold}', 'Clusters show C so the cluster id is easy to copy into CLI or skill flows.', - 'The footer only shows the short command list. Open help to see the full list.', + 'The footer leads with slash commands now; hotkeys are still available as aliases.', 'This popup scrolls. Use arrows, PgUp/PgDn, Home, and End if it does not fit.', ].join('\n'); } @@ -1551,13 +2472,100 @@ async function promptUpdatePipelineSelection( }); } +async function promptUserRefreshChoice( + screen: blessed.Widgets.Screen, + modeLabel: string, + matchingUserCount: number, + selectedUserLogin: string | null, +): Promise { + const choices: UserRefreshChoice[] = [ + { kind: 'reload-local', label: 'Reload the local user view only' }, + ...(selectedUserLogin ? [{ kind: 'selected-user' as const, label: `Refresh selected user @${selectedUserLogin}` }] : []), + ]; + for (const limit of [25, 100]) { + if (matchingUserCount >= limit) { + choices.push({ + kind: 'bulk', + limit, + label: `Refresh top ${limit} ${modeLabel.toLowerCase()} contributor${limit === 1 ? '' : 's'}`, + }); + } + } + if (matchingUserCount > 0) { + choices.push({ + kind: 'bulk', + limit: null, + label: `Refresh all ${matchingUserCount} matched contributor${matchingUserCount === 1 ? '' : 's'} (slow)`, + }); + } + + const box = blessed.list({ + parent: screen, + border: 'line', + label: ' User Refresh ', + keys: true, + vi: true, + mouse: false, + top: 'center', + left: 'center', + width: '74%', + height: 11, + style: { + border: { fg: '#5bc0eb' }, + item: { fg: 'white' }, + selected: { bg: '#5bc0eb', fg: 'black', bold: true }, + }, + items: choices.map((choice) => choice.label), + }); + const help = blessed.box({ + parent: screen, + top: 'center-4', + left: 'center', + width: '74%', + height: 4, + style: { fg: 'white', bg: '#101522' }, + content: + `Current mode: ${modeLabel}. Bulk refresh uses cached data and skips fresh profiles automatically.\n` + + 'Use Enter to choose. Start with top 25 or top 100 before trying all matched users.', + }); + + box.focus(); + box.select(0); + screen.render(); + + return await new Promise((resolve) => { + const teardown = (): void => { + screen.off('keypress', handleKeypress); + box.destroy(); + help.destroy(); + screen.render(); + }; + const finish = (value: UserRefreshChoice | null): void => { + teardown(); + resolve(value); + }; + const handleKeypress = (_char: string, key: blessed.Widgets.Events.IKeyEventArg): void => { + if (key.name === 'escape' || key.name === 'q') { + finish(null); + } + }; + + screen.on('keypress', handleKeypress); + box.on('select', (_item, index) => finish(choices[index] ?? null)); + }); +} + export function getRepositoryChoices(service: Pick, now: Date = new Date()): RepositoryChoice[] { + type ListedRepository = ReturnType['repositories'][number]; const repositories = service.listRepositories().repositories .slice() - .sort((left, right) => Date.parse(right.updatedAt) - Date.parse(left.updatedAt) || left.fullName.localeCompare(right.fullName)); + .sort( + (left: ListedRepository, right: ListedRepository) => + Date.parse(right.updatedAt) - Date.parse(left.updatedAt) || left.fullName.localeCompare(right.fullName), + ); return [ - ...repositories.map((repository) => ({ + ...repositories.map((repository: ListedRepository) => ({ kind: 'existing' as const, target: { owner: repository.owner, repo: repository.name }, label: `${repository.fullName} ${formatRelativeTime(repository.updatedAt, now)}`, @@ -1571,7 +2579,7 @@ async function promptAuthorThreadChoice( authorLogin: string, threads: ReturnType['threads'], ): Promise { - const choices: AuthorThreadChoice[] = threads.map((item) => { + const choices: AuthorThreadChoice[] = threads.map((item: ReturnType['threads'][number]) => { const match = item.strongestSameAuthorMatch; const matchLabel = match ? ` sim:${(match.score * 100).toFixed(1)}% -> #${match.number}` : ' sim:none'; const clusterLabel = item.thread.clusterId ? `C${item.thread.clusterId}` : 'C-'; diff --git a/apps/cli/src/tui/commands.test.ts b/apps/cli/src/tui/commands.test.ts new file mode 100644 index 0000000..7fb09eb --- /dev/null +++ b/apps/cli/src/tui/commands.test.ts @@ -0,0 +1,91 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { + filterCommands, + formatCommandLabel, + normalizeSlashQuery, + resolveCommands, + selectCommandFromQuery, + type TuiCommandDefinition, +} from './commands.js'; +import type { TuiScreenId } from './state.js'; + +type CommandContext = { + activeScreen: TuiScreenId; +}; + +function buildCommands(): TuiCommandDefinition[] { + return [ + { + id: 'view.clusters', + slash: 'clusters', + label: 'Clusters Explorer', + description: 'Switch to the clusters explorer.', + aliases: ['cluster', 'home'], + execute: () => {}, + }, + { + id: 'view.users', + slash: 'users', + label: 'User Explorer', + description: 'Switch to the user explorer.', + aliases: ['user'], + execute: () => {}, + }, + { + id: 'view.filter', + slash: 'filter', + label: 'Filter Clusters', + description: 'Filter clusters by title and member text.', + screens: ['clusters'], + execute: () => {}, + }, + { + id: 'future.template-drift', + slash: 'template-drift', + label: 'Template Drift', + description: 'Inspect PRs whose template text barely changed.', + getAvailability: () => ({ enabled: false, reason: 'coming soon' }), + execute: () => {}, + }, + ]; +} + +test('normalizeSlashQuery strips the leading slash and lowercases input', () => { + assert.equal(normalizeSlashQuery('/Users'), 'users'); + assert.equal(normalizeSlashQuery(' /FILTER '), 'filter'); +}); + +test('filterCommands ranks exact, prefix, and fuzzy matches in that order', () => { + const resolved = resolveCommands(buildCommands(), { activeScreen: 'clusters' }); + + assert.equal(filterCommands(resolved, '/users')[0]?.definition.slash, 'users'); + assert.deepEqual(filterCommands(resolved, '/cl').map((command) => command.definition.slash).slice(0, 2), ['clusters', 'filter']); + assert.equal(filterCommands(resolved, 'usr').some((command) => command.definition.slash === 'users'), true); +}); + +test('resolveCommands hides commands outside the active screen scope', () => { + const resolved = resolveCommands(buildCommands(), { activeScreen: 'users' }); + + assert.equal(resolved.some((command) => command.definition.slash === 'filter'), false); + assert.deepEqual(filterCommands(resolved, '').map((command) => command.definition.slash), [ + 'clusters', + 'users', + 'template-drift', + ]); +}); + +test('selectCommandFromQuery prefers an exact alias match over the highlighted result', () => { + const resolved = resolveCommands(buildCommands(), { activeScreen: 'clusters' }); + const selected = selectCommandFromQuery(resolved, '/user', 0); + + assert.equal(selected?.definition.slash, 'users'); +}); + +test('formatCommandLabel marks unavailable commands with the reason', () => { + const [command] = filterCommands(resolveCommands(buildCommands(), { activeScreen: 'clusters' }), 'template'); + + assert.match(formatCommandLabel(command), /coming soon/); + assert.match(formatCommandLabel(command), /gray-fg/); +}); diff --git a/apps/cli/src/tui/commands.ts b/apps/cli/src/tui/commands.ts new file mode 100644 index 0000000..f03012c --- /dev/null +++ b/apps/cli/src/tui/commands.ts @@ -0,0 +1,148 @@ +import type { TuiScreenId } from './state.js'; + +export type TuiCommandAvailability = { + enabled: boolean; + reason?: string; +}; + +export type TuiCommandDefinition = { + id: string; + slash: string; + label: string; + description: string; + aliases?: string[]; + screens?: TuiScreenId[]; + isVisible?: (context: TContext) => boolean; + getAvailability?: (context: TContext) => TuiCommandAvailability; + execute: () => void; +}; + +export type TuiResolvedCommand = { + definition: TuiCommandDefinition; + enabled: boolean; + reason: string | null; + rank: number; + order: number; +}; + +export function normalizeSlashQuery(value: string): string { + return value.trim().replace(/^\/+/, '').toLowerCase(); +} + +export function resolveCommands( + commands: readonly TuiCommandDefinition[], + context: TContext, +): TuiResolvedCommand[] { + const resolved: TuiResolvedCommand[] = []; + const activeScreen = + typeof context === 'object' && context !== null && 'activeScreen' in context + ? ((context as { activeScreen?: TuiScreenId }).activeScreen ?? null) + : null; + for (const [order, definition] of commands.entries()) { + if (definition.screens && activeScreen && !definition.screens.includes(activeScreen)) { + continue; + } + if (definition.isVisible && !definition.isVisible(context)) { + continue; + } + const availability = definition.getAvailability?.(context) ?? { enabled: true }; + resolved.push({ + definition, + enabled: availability.enabled, + reason: availability.reason ?? null, + rank: Number.POSITIVE_INFINITY, + order, + }); + } + return resolved; +} + +export function filterCommands( + commands: readonly TuiResolvedCommand[], + query: string, +): TuiResolvedCommand[] { + const normalizedQuery = normalizeSlashQuery(query); + const ranked = commands + .map((command) => ({ command, rank: getCommandRank(command.definition, normalizedQuery) })) + .filter((entry) => entry.rank !== null) + .map(({ command, rank }) => ({ ...command, rank: rank ?? Number.POSITIVE_INFINITY })); + + ranked.sort((left, right) => left.rank - right.rank || left.order - right.order || left.definition.slash.localeCompare(right.definition.slash)); + return ranked; +} + +export function selectCommandFromQuery( + commands: readonly TuiResolvedCommand[], + query: string, + selectedIndex: number, +): TuiResolvedCommand | null { + const filtered = filterCommands(commands, query); + if (filtered.length === 0) { + return null; + } + + const exact = filtered.find((command) => isExactCommandMatch(command.definition, query)); + if (exact) { + return exact; + } + + return filtered[Math.max(0, Math.min(selectedIndex, filtered.length - 1))] ?? filtered[0] ?? null; +} + +export function formatCommandLabel(command: TuiResolvedCommand): string { + const base = `/${command.definition.slash} ${command.definition.description}`; + if (command.enabled) { + return base; + } + const suffix = command.reason ? ` (${command.reason})` : ' (unavailable)'; + return `{gray-fg}${escapeCommandText(base + suffix)}{/gray-fg}`; +} + +export function isExactCommandMatch( + command: TuiCommandDefinition, + query: string, +): boolean { + const normalizedQuery = normalizeSlashQuery(query); + if (!normalizedQuery) return false; + return getCommandTokens(command).includes(normalizedQuery); +} + +function getCommandRank(command: TuiCommandDefinition, query: string): number | null { + if (!query) { + return 0; + } + + const slash = command.slash.toLowerCase(); + const tokens = getCommandTokens(command); + const label = command.label.toLowerCase(); + const description = command.description.toLowerCase(); + + if (tokens.includes(query)) return 0; + if (slash.startsWith(query)) return 1; + if (tokens.some((token) => token.startsWith(query))) return 2; + if (label.startsWith(query)) return 3; + if (tokens.some((token) => token.includes(query)) || label.includes(query) || description.includes(query)) return 4; + if (tokens.some((token) => isSubsequenceMatch(token, query)) || isSubsequenceMatch(label, query)) return 5; + return null; +} + +function getCommandTokens(command: TuiCommandDefinition): string[] { + return [command.id, command.slash, ...(command.aliases ?? [])].map((value) => value.toLowerCase()); +} + +function isSubsequenceMatch(candidate: string, query: string): boolean { + if (!query) return true; + let candidateIndex = 0; + for (const queryChar of query) { + candidateIndex = candidate.indexOf(queryChar, candidateIndex); + if (candidateIndex === -1) { + return false; + } + candidateIndex += 1; + } + return true; +} + +function escapeCommandText(value: string): string { + return value.replace(/\\/g, '\\\\').replace(/\{/g, '\\{').replace(/\}/g, '\\}'); +} diff --git a/apps/cli/src/tui/state.test.ts b/apps/cli/src/tui/state.test.ts index 26d38d9..c3fe8df 100644 --- a/apps/cli/src/tui/state.test.ts +++ b/apps/cli/src/tui/state.test.ts @@ -1,7 +1,17 @@ import test from 'node:test'; import assert from 'node:assert/strict'; -import { buildMemberRows, cycleFocusPane, cycleMinSizeFilter, cycleSortMode, findSelectableIndex, moveSelectableIndex, preserveSelectedId, applyClusterFilters } from './state.js'; +import { + applyClusterFilters, + buildMemberRows, + cycleFocusPane, + cycleMinSizeFilter, + cycleSortMode, + findSelectableIndex, + getScreenDefinition, + moveSelectableIndex, + preserveSelectedId, +} from './state.js'; import type { TuiClusterDetail, TuiClusterSummary } from '@ghcrawl/api-core'; test('cycleSortMode toggles recent and size', () => { @@ -20,6 +30,12 @@ test('cycleMinSizeFilter rotates through presets', () => { test('cycleFocusPane moves forward and backward', () => { assert.equal(cycleFocusPane('clusters', 1), 'members'); assert.equal(cycleFocusPane('clusters', -1), 'detail'); + assert.equal(cycleFocusPane('detail', 1, ['detail']), 'detail'); +}); + +test('getScreenDefinition exposes route metadata for future screens', () => { + assert.equal(getScreenDefinition('clusters').label, 'Clusters Explorer'); + assert.equal(getScreenDefinition('users').focusOrder[0], 'clusters'); }); test('applyClusterFilters sorts by recent and size and respects min size/search', () => { diff --git a/apps/cli/src/tui/state.ts b/apps/cli/src/tui/state.ts index 696b944..25874b2 100644 --- a/apps/cli/src/tui/state.ts +++ b/apps/cli/src/tui/state.ts @@ -1,7 +1,14 @@ import type { TuiClusterDetail, TuiClusterSortMode, TuiClusterSummary } from '@ghcrawl/api-core'; +export type TuiScreenId = 'clusters' | 'users'; export type TuiFocusPane = 'clusters' | 'members' | 'detail'; export type TuiMinSizeFilter = 0 | 1 | 10 | 20 | 50; +export type TuiScreenDefinition = { + id: TuiScreenId; + label: string; + description: string; + focusOrder: readonly TuiFocusPane[]; +}; export type MemberListRow = | { key: string; label: string; selectable: false } @@ -10,6 +17,28 @@ export type MemberListRow = export const SORT_MODE_ORDER: TuiClusterSortMode[] = ['recent', 'size']; export const MIN_SIZE_FILTER_ORDER: TuiMinSizeFilter[] = [1, 10, 20, 50, 0]; export const FOCUS_PANE_ORDER: TuiFocusPane[] = ['clusters', 'members', 'detail']; +export const TUI_SCREEN_DEFINITIONS: Record = { + clusters: { + id: 'clusters', + label: 'Clusters Explorer', + description: 'Issue and PR similarity clusters.', + focusOrder: FOCUS_PANE_ORDER, + }, + users: { + id: 'users', + label: 'User Explorer', + description: 'Author-centric explorer for future user workflows.', + focusOrder: FOCUS_PANE_ORDER, + }, +}; + +export function getScreenDefinition(screen: TuiScreenId): TuiScreenDefinition { + return TUI_SCREEN_DEFINITIONS[screen]; +} + +export function getScreenFocusOrder(screen: TuiScreenId): readonly TuiFocusPane[] { + return getScreenDefinition(screen).focusOrder; +} export function cycleSortMode(current: TuiClusterSortMode): TuiClusterSortMode { const index = SORT_MODE_ORDER.indexOf(current); @@ -21,10 +50,11 @@ export function cycleMinSizeFilter(current: TuiMinSizeFilter): TuiMinSizeFilter return MIN_SIZE_FILTER_ORDER[(index + 1) % MIN_SIZE_FILTER_ORDER.length] ?? 10; } -export function cycleFocusPane(current: TuiFocusPane, direction: 1 | -1 = 1): TuiFocusPane { - const index = FOCUS_PANE_ORDER.indexOf(current); - const next = (index + direction + FOCUS_PANE_ORDER.length) % FOCUS_PANE_ORDER.length; - return FOCUS_PANE_ORDER[next] ?? 'clusters'; +export function cycleFocusPane(current: TuiFocusPane, direction: 1 | -1 = 1, order: readonly TuiFocusPane[] = FOCUS_PANE_ORDER): TuiFocusPane { + const index = order.indexOf(current); + const baseIndex = index >= 0 ? index : 0; + const next = (baseIndex + direction + order.length) % order.length; + return order[next] ?? order[0] ?? 'detail'; } export function applyClusterFilters( @@ -49,9 +79,9 @@ export function preserveSelectedId(ids: number[], selectedId: number | null): nu export function buildMemberRows(detail: TuiClusterDetail | null, options?: { includeClosedMembers?: boolean }): MemberListRow[] { if (!detail) return []; const includeClosedMembers = options?.includeClosedMembers ?? true; - const visibleMembers = includeClosedMembers ? detail.members : detail.members.filter((member) => !member.isClosed); - const issues = visibleMembers.filter((member) => member.kind === 'issue'); - const pullRequests = visibleMembers.filter((member) => member.kind === 'pull_request'); + const visibleMembers = includeClosedMembers ? detail.members : detail.members.filter((member: TuiClusterDetail['members'][number]) => !member.isClosed); + const issues = visibleMembers.filter((member: TuiClusterDetail['members'][number]) => member.kind === 'issue'); + const pullRequests = visibleMembers.filter((member: TuiClusterDetail['members'][number]) => member.kind === 'pull_request'); const rows: MemberListRow[] = []; if (issues.length > 0) { diff --git a/apps/cli/src/tui/users.test.ts b/apps/cli/src/tui/users.test.ts new file mode 100644 index 0000000..07948eb --- /dev/null +++ b/apps/cli/src/tui/users.test.ts @@ -0,0 +1,169 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { + buildRepoUserListRows, + buildRepoUserThreadRows, + describeRepoUserMode, + renderRepoUserDetail, + type RepoUserDetailPayload, + type RepoUsersPayload, +} from './users.js'; + +function buildUsersPayload(mode: RepoUsersPayload['mode'] = 'flagged'): RepoUsersPayload { + return { + repository: { + id: 1, + owner: 'openclaw', + name: 'openclaw', + fullName: 'openclaw/openclaw', + githubRepoId: null, + updatedAt: '2026-03-12T00:00:00Z', + }, + mode, + totals: { + matchingUserCount: 1, + openIssueCount: 2, + openPullRequestCount: 1, + waitingPullRequestCount: 1, + }, + users: [ + { + login: 'alice', + reputationTier: mode === 'trusted_prs' ? 'high' : 'low', + likelyHiddenActivity: mode !== 'trusted_prs', + isStale: mode !== 'trusted_prs', + lastRefreshedAt: '2026-03-10T00:00:00Z', + lastRefreshError: null, + accountCreatedAt: '2025-01-01T00:00:00Z', + accountAgeDays: 435, + openIssueCount: 2, + openPullRequestCount: 1, + waitingPullRequestCount: 1, + matchedLowReputation: mode !== 'trusted_prs', + matchedLikelyHiddenActivity: mode !== 'trusted_prs', + reasons: ['example reason'], + }, + ], + }; +} + +function buildDetail(): RepoUserDetailPayload { + return { + repository: { + id: 1, + owner: 'openclaw', + name: 'openclaw', + fullName: 'openclaw/openclaw', + githubRepoId: null, + updatedAt: '2026-03-12T00:00:00Z', + }, + profile: { + login: 'alice', + githubUserId: '42', + profileUrl: 'https://github.com/alice', + avatarUrl: null, + userType: 'User', + accountCreatedAt: '2025-01-01T00:00:00Z', + accountAgeDays: 435, + publicRepoCount: 12, + publicGistCount: 1, + followers: 10, + following: 2, + recentPublicEventCount: 4, + reputationTier: 'high', + likelyHiddenActivity: false, + isStale: false, + lastGlobalRefreshAt: '2026-03-10T00:00:00Z', + lastRepoRefreshAt: '2026-03-10T00:00:00Z', + lastRefreshError: null, + firstSeenAt: '2026-01-01T00:00:00Z', + lastSeenAt: '2026-03-12T00:00:00Z', + reasons: ['established public contribution history'], + }, + totals: { + matchingUserCount: 1, + openIssueCount: 1, + openPullRequestCount: 1, + waitingPullRequestCount: 1, + }, + issues: [ + { + threadId: 10, + number: 42, + kind: 'issue', + title: 'Downloader hangs', + htmlUrl: 'https://github.com/openclaw/openclaw/issues/42', + state: 'open', + isDraft: false, + createdAtGh: '2026-03-01T00:00:00Z', + updatedAtGh: '2026-03-10T00:00:00Z', + ageDays: 11, + filesChanged: null, + additions: null, + deletions: null, + }, + ], + pullRequests: [ + { + threadId: 11, + number: 43, + kind: 'pull_request', + title: 'Fix downloader hang', + htmlUrl: 'https://github.com/openclaw/openclaw/pull/43', + state: 'open', + isDraft: false, + createdAtGh: '2026-02-20T00:00:00Z', + updatedAtGh: '2026-03-10T00:00:00Z', + ageDays: 20, + filesChanged: 4, + additions: 120, + deletions: 30, + }, + ], + }; +} + +test('buildRepoUserListRows shows flagged badges and counts', () => { + const rows = buildRepoUserListRows(buildUsersPayload('flagged')); + + assert.match(rows[0]?.label ?? '', /2I 1P/); + assert.match(rows[0]?.label ?? '', /HIDDEN\?/); + assert.match(rows[0]?.label ?? '', /STALE/); +}); + +test('buildRepoUserThreadRows prioritizes pull requests in trusted mode', () => { + const rows = buildRepoUserThreadRows(buildDetail(), 'trusted_prs'); + + assert.equal(rows[0]?.selectable, false); + assert.match(rows[0]?.label ?? '', /PULL REQUESTS/); + assert.match(rows[1]?.label ?? '', /#43/); +}); + +test('buildRepoUserThreadRows hides unknown PR size columns instead of rendering zeroes', () => { + const detail = buildDetail(); + detail.pullRequests[0] = { + ...detail.pullRequests[0], + filesChanged: null, + additions: null, + deletions: null, + }; + + const rows = buildRepoUserThreadRows(detail, 'trusted_prs'); + + assert.doesNotMatch(rows[1]?.label ?? '', /0f \+\s*0 -\s*0/); +}); + +test('renderRepoUserDetail includes reputation reasons and selected PR sizing', () => { + const content = renderRepoUserDetail(buildDetail(), 11); + + assert.match(content, /@alice/); + assert.match(content, /established public contribution history/); + assert.match(content, /4 files/); + assert.match(content, /\+120/); +}); + +test('describeRepoUserMode exposes the user-facing labels', () => { + assert.equal(describeRepoUserMode('flagged'), 'Flagged contributors'); + assert.equal(describeRepoUserMode('trusted_prs'), 'Trusted PRs'); +}); diff --git a/apps/cli/src/tui/users.ts b/apps/cli/src/tui/users.ts new file mode 100644 index 0000000..a0cccba --- /dev/null +++ b/apps/cli/src/tui/users.ts @@ -0,0 +1,129 @@ +import type { GHCrawlService, RepoUserExplorerMode } from '@ghcrawl/api-core'; + +export type RepoUsersPayload = ReturnType; +export type RepoUserDetailPayload = ReturnType; + +export type UserListRow = { + login: string; + label: string; +}; + +export type UserThreadRow = + | { key: string; label: string; selectable: false } + | { key: string; label: string; selectable: true; threadId: number }; + +export function buildRepoUserListRows(response: RepoUsersPayload): UserListRow[] { + return response.users.map((user) => { + const badges = [ + user.reputationTier.toUpperCase(), + user.likelyHiddenActivity ? 'HIDDEN?' : null, + user.isStale ? 'STALE' : null, + user.lastRefreshError ? 'ERR' : null, + ] + .filter((value): value is string => value !== null) + .join(' '); + + const countLead = + response.mode === 'trusted_prs' + ? `${String(user.waitingPullRequestCount).padStart(2, ' ')} waiting` + : `${String(user.openIssueCount).padStart(2, ' ')}I ${String(user.openPullRequestCount).padStart(2, ' ')}P`; + + return { + login: user.login, + label: `${countLead} @${escapeUserText(user.login)} ${escapeUserText(badges || 'UNCLASSIFIED')}`, + }; + }); +} + +export function buildRepoUserThreadRows(detail: RepoUserDetailPayload | null, mode: RepoUserExplorerMode): UserThreadRow[] { + if (!detail) return []; + const rows: UserThreadRow[] = []; + const issueRows = detail.issues; + const pullRequestRows = detail.pullRequests; + const firstSection = mode === 'trusted_prs' ? pullRequestRows : issueRows; + const secondSection = mode === 'trusted_prs' ? issueRows : pullRequestRows; + const firstLabel = mode === 'trusted_prs' ? `PULL REQUESTS (${pullRequestRows.length})` : `ISSUES (${issueRows.length})`; + const secondLabel = mode === 'trusted_prs' ? `ISSUES (${issueRows.length})` : `PULL REQUESTS (${pullRequestRows.length})`; + + pushThreadSection(rows, firstLabel, firstSection); + pushThreadSection(rows, secondLabel, secondSection); + return rows; +} + +export function renderRepoUserDetail(detail: RepoUserDetailPayload | null, selectedThreadId: number | null): string { + if (!detail) { + return 'No user selected.\n\nUse the left pane to choose a contributor.'; + } + + const selected = + detail.issues.find((thread) => thread.threadId === selectedThreadId) ?? + detail.pullRequests.find((thread) => thread.threadId === selectedThreadId) ?? + null; + const profile = detail.profile; + const metrics = [ + `public repos: ${profile.publicRepoCount ?? 'unknown'}`, + `followers: ${profile.followers ?? 'unknown'}`, + `events: ${profile.recentPublicEventCount ?? 'unknown'}`, + ].join(' '); + const badges = [ + profile.reputationTier.toUpperCase(), + profile.likelyHiddenActivity ? 'likely hidden/sparse public activity' : null, + profile.isStale ? 'stale cache' : null, + profile.lastRefreshError ? `refresh error: ${profile.lastRefreshError}` : null, + ] + .filter((value): value is string => value !== null) + .join('\n'); + const selectedText = selected + ? [ + '', + `{bold}${selected.kind === 'pull_request' ? 'PR' : 'Issue'} #${selected.number}{/bold} ${escapeUserText(selected.title)}`, + `{bold}State:{/bold} ${escapeUserText(selected.state)}${selected.isDraft ? ' (draft)' : ''}`, + `{bold}Age:{/bold} ${selected.ageDays ?? 'unknown'}d`, + `{bold}Size:{/bold} ${selected.filesChanged ?? 'n/a'} files +${selected.additions ?? 'n/a'} / -${selected.deletions ?? 'n/a'}`, + `{bold}URL:{/bold} ${escapeUserText(selected.htmlUrl)}`, + ].join('\n') + : '\n\nSelect an issue or PR in the middle pane for more detail.'; + + return [ + `{bold}@${escapeUserText(profile.login)}{/bold}`, + '', + badges || 'No profile flags.', + '', + `{bold}Profile{/bold}`, + metrics, + `{bold}Account age:{/bold} ${profile.accountAgeDays ?? 'unknown'}d`, + `{bold}Last refresh:{/bold} ${escapeUserText(profile.lastGlobalRefreshAt ?? 'never')}`, + `{bold}First seen in repo:{/bold} ${escapeUserText(profile.firstSeenAt ?? 'unknown')}`, + '', + `{bold}Reasons{/bold}`, + profile.reasons.length > 0 ? escapeUserText(profile.reasons.join('; ')) : 'No reasons available.', + selectedText, + ].join('\n'); +} + +export function describeRepoUserMode(mode: RepoUserExplorerMode): string { + return mode === 'trusted_prs' ? 'Trusted PRs' : 'Flagged contributors'; +} + +function pushThreadSection(rows: UserThreadRow[], label: string, items: RepoUserDetailPayload['issues']): void { + if (items.length === 0) return; + rows.push({ key: `${label}-header`, label, selectable: false }); + for (const item of items) { + const hasKnownSize = + item.kind === 'pull_request' && + (item.filesChanged !== null || item.additions !== null || item.deletions !== null); + const size = hasKnownSize + ? ` ${String(item.filesChanged ?? 0).padStart(2, ' ')}f +${String(item.additions ?? 0).padStart(4, ' ')} -${String(item.deletions ?? 0).padStart(4, ' ')}` + : ''; + rows.push({ + key: `thread-${item.threadId}`, + label: `#${item.number} ${String(item.ageDays ?? 0).padStart(3, ' ')}d${size} ${escapeUserText(item.title)}`, + selectable: true, + threadId: item.threadId, + }); + } +} + +function escapeUserText(value: string): string { + return value.replace(/\\/g, '\\\\').replace(/\{/g, '\\{').replace(/\}/g, '\\}'); +} diff --git a/packages/api-contract/src/client.ts b/packages/api-contract/src/client.ts index 0381e71..debd329 100644 --- a/packages/api-contract/src/client.ts +++ b/packages/api-contract/src/client.ts @@ -5,6 +5,12 @@ import { closeResponseSchema, closeThreadRequestSchema, authorThreadsResponseSchema, + repoUserDetailResponseSchema, + repoUserBulkRefreshRequestSchema, + repoUserBulkRefreshResponseSchema, + repoUserRefreshRequestSchema, + repoUserRefreshResponseSchema, + repoUsersResponseSchema, clusterDetailResponseSchema, clusterSummariesResponseSchema, clustersResponseSchema, @@ -18,6 +24,11 @@ import { type ActionResponse, type CloseResponse, type AuthorThreadsResponse, + type RepoUserDetailResponse, + type RepoUserBulkRefreshResponse, + type RepoUserMode, + type RepoUserRefreshResponse, + type RepoUsersResponse, type ClusterDetailResponse, type ClusterSummariesResponse, type ClustersResponse, @@ -35,6 +46,10 @@ export type GitcrawlClient = { listRepositories: () => Promise; listThreads: (params: { owner: string; repo: string; kind?: 'issue' | 'pull_request'; numbers?: number[]; includeClosed?: boolean }) => Promise; listAuthorThreads: (params: { owner: string; repo: string; login: string; includeClosed?: boolean }) => Promise; + listRepoUsers: (params: { owner: string; repo: string; mode: RepoUserMode; limit?: number; includeStale?: boolean }) => Promise; + getRepoUserDetail: (params: { owner: string; repo: string; login: string }) => Promise; + refreshRepoUser: (request: { owner: string; repo: string; login: string; force?: boolean }) => Promise; + refreshRepoUsers: (request: { owner: string; repo: string; mode: RepoUserMode; limit?: number; force?: boolean; includeStale?: boolean }) => Promise; search: (params: { owner: string; repo: string; query: string; mode?: SearchMode }) => Promise; listClusters: (params: { owner: string; repo: string; includeClosed?: boolean }) => Promise; listClusterSummaries: (params: { @@ -97,6 +112,36 @@ export function createGitcrawlClient(baseUrl: string, fetchImpl: FetchLike = fet const res = await fetchImpl(`${normalized}/author-threads?${search.toString()}`); return readJson(res, authorThreadsResponseSchema); }, + async listRepoUsers(params) { + const search = new URLSearchParams({ owner: params.owner, repo: params.repo, mode: params.mode }); + if (params.limit !== undefined) search.set('limit', String(params.limit)); + if (params.includeStale !== undefined) search.set('includeStale', String(params.includeStale)); + const res = await fetchImpl(`${normalized}/repo-users?${search.toString()}`); + return readJson(res, repoUsersResponseSchema); + }, + async getRepoUserDetail(params) { + const search = new URLSearchParams({ owner: params.owner, repo: params.repo, login: params.login }); + const res = await fetchImpl(`${normalized}/repo-user-detail?${search.toString()}`); + return readJson(res, repoUserDetailResponseSchema); + }, + async refreshRepoUser(request) { + const body = repoUserRefreshRequestSchema.parse(request); + const res = await fetchImpl(`${normalized}/actions/refresh-user`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(body), + }); + return readJson(res, repoUserRefreshResponseSchema); + }, + async refreshRepoUsers(request) { + const body = repoUserBulkRefreshRequestSchema.parse(request); + const res = await fetchImpl(`${normalized}/actions/refresh-users`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(body), + }); + return readJson(res, repoUserBulkRefreshResponseSchema); + }, async search(params) { const search = new URLSearchParams({ owner: params.owner, diff --git a/packages/api-contract/src/contracts.test.ts b/packages/api-contract/src/contracts.test.ts index 3c88d08..406fab6 100644 --- a/packages/api-contract/src/contracts.test.ts +++ b/packages/api-contract/src/contracts.test.ts @@ -1,7 +1,15 @@ import test from 'node:test'; import assert from 'node:assert/strict'; -import { actionRequestSchema, healthResponseSchema, neighborsResponseSchema, searchResponseSchema } from './contracts.js'; +import { + actionRequestSchema, + healthResponseSchema, + neighborsResponseSchema, + repoUserBulkRefreshResponseSchema, + repoUserDetailResponseSchema, + repoUsersResponseSchema, + searchResponseSchema, +} from './contracts.js'; test('health schema accepts configured status payload', () => { const parsed = healthResponseSchema.parse({ @@ -87,3 +95,128 @@ test('neighbors schema accepts repository, source thread, and neighbor list', () assert.equal(parsed.neighbors[0].number, 43); }); + +test('repo users schema accepts flagged contributor summaries and totals', () => { + const parsed = repoUsersResponseSchema.parse({ + repository: { + id: 1, + owner: 'openclaw', + name: 'openclaw', + fullName: 'openclaw/openclaw', + githubRepoId: null, + updatedAt: new Date().toISOString(), + }, + mode: 'flagged', + totals: { + matchingUserCount: 1, + openIssueCount: 2, + openPullRequestCount: 1, + waitingPullRequestCount: 1, + }, + users: [ + { + login: 'alice', + reputationTier: 'low', + likelyHiddenActivity: true, + isStale: false, + lastRefreshedAt: new Date().toISOString(), + lastRefreshError: null, + accountCreatedAt: new Date().toISOString(), + accountAgeDays: 42, + openIssueCount: 2, + openPullRequestCount: 1, + waitingPullRequestCount: 1, + matchedLowReputation: true, + matchedLikelyHiddenActivity: true, + reasons: ['new account'], + }, + ], + }); + + assert.equal(parsed.users[0]?.login, 'alice'); +}); + +test('repo user detail schema accepts profile metrics and PR sizing', () => { + const parsed = repoUserDetailResponseSchema.parse({ + repository: { + id: 1, + owner: 'openclaw', + name: 'openclaw', + fullName: 'openclaw/openclaw', + githubRepoId: null, + updatedAt: new Date().toISOString(), + }, + profile: { + login: 'alice', + githubUserId: '42', + profileUrl: 'https://github.com/alice', + avatarUrl: 'https://avatars.githubusercontent.com/u/42?v=4', + userType: 'User', + accountCreatedAt: new Date().toISOString(), + accountAgeDays: 500, + publicRepoCount: 20, + publicGistCount: 3, + followers: 12, + following: 1, + recentPublicEventCount: 5, + reputationTier: 'high', + likelyHiddenActivity: false, + isStale: false, + lastGlobalRefreshAt: new Date().toISOString(), + lastRepoRefreshAt: new Date().toISOString(), + lastRefreshError: null, + firstSeenAt: new Date().toISOString(), + lastSeenAt: new Date().toISOString(), + reasons: ['established account'], + }, + totals: { + matchingUserCount: 1, + openIssueCount: 0, + openPullRequestCount: 1, + waitingPullRequestCount: 1, + }, + issues: [], + pullRequests: [ + { + threadId: 10, + number: 43, + kind: 'pull_request', + title: 'Fix downloader hang', + htmlUrl: 'https://github.com/openclaw/openclaw/pull/43', + state: 'open', + isDraft: false, + createdAtGh: new Date().toISOString(), + updatedAtGh: new Date().toISOString(), + ageDays: 10, + filesChanged: 4, + additions: 120, + deletions: 30, + }, + ], + }); + + assert.equal(parsed.pullRequests[0]?.filesChanged, 4); +}); + +test('repo user bulk refresh schema accepts summary counts and failures', () => { + const parsed = repoUserBulkRefreshResponseSchema.parse({ + ok: true, + repository: { + id: 1, + owner: 'openclaw', + name: 'openclaw', + fullName: 'openclaw/openclaw', + githubRepoId: null, + updatedAt: new Date().toISOString(), + }, + mode: 'flagged', + selectedUserCount: 25, + refreshedCount: 20, + skippedCount: 4, + failedCount: 1, + failures: [{ login: 'alice', error: 'rate limited' }], + }); + + assert.equal(parsed.failedCount, 1); + assert.equal(parsed.failures[0]?.login, 'alice'); +}); diff --git a/packages/api-contract/src/contracts.ts b/packages/api-contract/src/contracts.ts index 1771e66..09ff0c5 100644 --- a/packages/api-contract/src/contracts.ts +++ b/packages/api-contract/src/contracts.ts @@ -80,6 +80,142 @@ export const authorThreadsResponseSchema = z.object({ }); export type AuthorThreadsResponse = z.infer; +export const repoUserModeSchema = z.enum(['flagged', 'trusted_prs']); +export type RepoUserMode = z.infer; + +export const reputationTierSchema = z.enum(['low', 'medium', 'high', 'unknown']); +export type ReputationTier = z.infer; + +export const repoUserTotalsSchema = z.object({ + matchingUserCount: z.number().int().nonnegative(), + openIssueCount: z.number().int().nonnegative(), + openPullRequestCount: z.number().int().nonnegative(), + waitingPullRequestCount: z.number().int().nonnegative(), +}); +export type RepoUserTotalsDto = z.infer; + +export const repoUserSummarySchema = z.object({ + login: z.string(), + reputationTier: reputationTierSchema, + likelyHiddenActivity: z.boolean().default(false), + isStale: z.boolean().default(true), + lastRefreshedAt: z.string().nullable(), + lastRefreshError: z.string().nullable(), + accountCreatedAt: z.string().nullable(), + accountAgeDays: z.number().int().nonnegative().nullable(), + openIssueCount: z.number().int().nonnegative(), + openPullRequestCount: z.number().int().nonnegative(), + waitingPullRequestCount: z.number().int().nonnegative(), + matchedLowReputation: z.boolean().default(false), + matchedLikelyHiddenActivity: z.boolean().default(false), + reasons: z.array(z.string()), +}); +export type RepoUserSummaryDto = z.infer; + +export const repoUsersResponseSchema = z.object({ + repository: repositorySchema, + mode: repoUserModeSchema, + totals: repoUserTotalsSchema, + users: z.array(repoUserSummarySchema), +}); +export type RepoUsersResponse = z.infer; + +export const repoUserThreadSchema = z.object({ + threadId: z.number().int().positive(), + number: z.number().int().positive(), + kind: threadKindSchema, + title: z.string(), + htmlUrl: z.string().url(), + state: z.string(), + isDraft: z.boolean().default(false), + createdAtGh: z.string().nullable(), + updatedAtGh: z.string().nullable(), + ageDays: z.number().int().nonnegative().nullable(), + filesChanged: z.number().int().nonnegative().nullable(), + additions: z.number().int().nonnegative().nullable(), + deletions: z.number().int().nonnegative().nullable(), +}); +export type RepoUserThreadDto = z.infer; + +export const repoUserProfileSchema = z.object({ + login: z.string(), + githubUserId: z.string().nullable(), + profileUrl: z.string().url().nullable(), + avatarUrl: z.string().url().nullable(), + userType: z.string().nullable(), + accountCreatedAt: z.string().nullable(), + accountAgeDays: z.number().int().nonnegative().nullable(), + publicRepoCount: z.number().int().nonnegative().nullable(), + publicGistCount: z.number().int().nonnegative().nullable(), + followers: z.number().int().nonnegative().nullable(), + following: z.number().int().nonnegative().nullable(), + recentPublicEventCount: z.number().int().nonnegative().nullable(), + reputationTier: reputationTierSchema, + likelyHiddenActivity: z.boolean().default(false), + isStale: z.boolean().default(true), + lastGlobalRefreshAt: z.string().nullable(), + lastRepoRefreshAt: z.string().nullable(), + lastRefreshError: z.string().nullable(), + firstSeenAt: z.string().nullable(), + lastSeenAt: z.string().nullable(), + reasons: z.array(z.string()), +}); +export type RepoUserProfileDto = z.infer; + +export const repoUserDetailResponseSchema = z.object({ + repository: repositorySchema, + profile: repoUserProfileSchema, + totals: repoUserTotalsSchema, + issues: z.array(repoUserThreadSchema), + pullRequests: z.array(repoUserThreadSchema), +}); +export type RepoUserDetailResponse = z.infer; + +export const repoUserRefreshRequestSchema = z.object({ + owner: z.string(), + repo: z.string(), + login: z.string(), + force: z.boolean().optional(), +}); +export type RepoUserRefreshRequest = z.infer; + +export const repoUserRefreshResponseSchema = z.object({ + ok: z.boolean(), + repository: repositorySchema, + login: z.string(), + refreshedAt: z.string(), + profile: repoUserProfileSchema, +}); +export type RepoUserRefreshResponse = z.infer; + +export const repoUserBulkRefreshRequestSchema = z.object({ + owner: z.string(), + repo: z.string(), + mode: repoUserModeSchema, + limit: z.number().int().positive().optional(), + force: z.boolean().optional(), + includeStale: z.boolean().optional(), +}); +export type RepoUserBulkRefreshRequest = z.infer; + +export const repoUserBulkRefreshFailureSchema = z.object({ + login: z.string(), + error: z.string(), +}); +export type RepoUserBulkRefreshFailure = z.infer; + +export const repoUserBulkRefreshResponseSchema = z.object({ + ok: z.boolean(), + repository: repositorySchema, + mode: repoUserModeSchema, + selectedUserCount: z.number().int().nonnegative(), + refreshedCount: z.number().int().nonnegative(), + skippedCount: z.number().int().nonnegative(), + failedCount: z.number().int().nonnegative(), + failures: z.array(repoUserBulkRefreshFailureSchema), +}); +export type RepoUserBulkRefreshResponse = z.infer; + export const searchHitSchema = z.object({ thread: threadSchema, keywordScore: z.number().nullable(), diff --git a/packages/api-core/src/api/server.test.ts b/packages/api-core/src/api/server.test.ts index 0661f04..97223e7 100644 --- a/packages/api-core/src/api/server.test.ts +++ b/packages/api-core/src/api/server.test.ts @@ -1,7 +1,19 @@ import test from 'node:test'; import assert from 'node:assert/strict'; -import { authorThreadsResponseSchema, closeResponseSchema, clusterDetailResponseSchema, clusterSummariesResponseSchema, healthResponseSchema, neighborsResponseSchema, threadsResponseSchema } from '@ghcrawl/api-contract'; +import { + authorThreadsResponseSchema, + closeResponseSchema, + clusterDetailResponseSchema, + clusterSummariesResponseSchema, + healthResponseSchema, + neighborsResponseSchema, + repoUserBulkRefreshResponseSchema, + repoUserDetailResponseSchema, + repoUserRefreshResponseSchema, + repoUsersResponseSchema, + threadsResponseSchema, +} from '@ghcrawl/api-contract'; import { createApiServer } from './server.js'; import { GHCrawlService } from '../service.js'; @@ -296,6 +308,208 @@ test('author-threads endpoint returns one author with strongest same-author matc } }); +test('repo user endpoints expose summaries, detail, and refresh', async () => { + const service = new GHCrawlService({ + config: { + workspaceRoot: process.cwd(), + configDir: '/tmp/ghcrawl-test', + configPath: '/tmp/ghcrawl-test/config.json', + configFileExists: true, + dbPath: ':memory:', + dbPathSource: 'config', + apiPort: 5179, + secretProvider: 'plaintext', + githubTokenSource: 'none', + openaiApiKeySource: 'none', + summaryModel: 'gpt-5-mini', + embedModel: 'text-embedding-3-large', + embedBatchSize: 8, + embedConcurrency: 10, + embedMaxUnread: 20, + openSearchIndex: 'ghcrawl-threads', + tuiPreferences: {}, + }, + github: { + checkAuth: async () => undefined, + getRepo: async () => ({}), + listRepositoryIssues: async () => [], + getIssue: async () => ({}), + getPull: async () => ({}), + listIssueComments: async () => [], + listPullReviews: async () => [], + listPullReviewComments: async () => [], + getUser: async () => ({ + id: 42, + login: 'alice', + created_at: '2024-01-01T00:00:00Z', + public_repos: 12, + public_gists: 1, + followers: 11, + following: 2, + html_url: 'https://github.com/alice', + avatar_url: 'https://avatars.githubusercontent.com/u/42?v=4', + type: 'User', + }), + listUserPublicEvents: async () => [{ id: 'evt-1' }, { id: 'evt-2' }], + }, + }); + + const now = '2026-03-09T00:00:00Z'; + service.db + .prepare( + `insert into repositories (id, owner, name, full_name, github_repo_id, raw_json, updated_at) + values (?, ?, ?, ?, ?, ?, ?)`, + ) + .run(1, 'openclaw', 'openclaw', 'openclaw/openclaw', '1', '{}', now); + service.db + .prepare( + `insert into threads ( + id, repo_id, github_id, number, kind, state, title, body, author_login, author_type, html_url, + labels_json, assignees_json, raw_json, content_hash, is_draft, created_at_gh, updated_at_gh, closed_at_gh, + merged_at_gh, files_changed, additions, deletions, first_pulled_at, last_pulled_at, updated_at + ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ) + .run( + 10, + 1, + '100', + 42, + 'issue', + 'open', + 'Downloader hangs', + 'The transfer never finishes.', + 'alice', + 'User', + 'https://github.com/openclaw/openclaw/issues/42', + '[]', + '[]', + '{}', + 'hash-42', + 0, + now, + now, + null, + null, + null, + null, + null, + now, + now, + now, + ); + service.db + .prepare( + `insert into threads ( + id, repo_id, github_id, number, kind, state, title, body, author_login, author_type, html_url, + labels_json, assignees_json, raw_json, content_hash, is_draft, created_at_gh, updated_at_gh, closed_at_gh, + merged_at_gh, files_changed, additions, deletions, first_pulled_at, last_pulled_at, updated_at + ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ) + .run( + 11, + 1, + '101', + 43, + 'pull_request', + 'open', + 'Fix downloader hang', + 'Implements a fix.', + 'alice', + 'User', + 'https://github.com/openclaw/openclaw/pull/43', + '[]', + '[]', + '{}', + 'hash-43', + 0, + '2026-02-20T00:00:00Z', + now, + null, + null, + 4, + 120, + 30, + now, + now, + now, + ); + service.db + .prepare( + `insert into users ( + login, github_user_id, account_created_at, public_repo_count, public_gist_count, followers, following, + profile_url, avatar_url, user_type, recent_public_event_count, likely_hidden_activity, reputation_tier, + reputation_reason_json, last_global_refresh_at, last_refresh_error, updated_at + ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ) + .run( + 'alice', + '42', + '2024-01-01T00:00:00Z', + 12, + 1, + 11, + 2, + 'https://github.com/alice', + 'https://avatars.githubusercontent.com/u/42?v=4', + 'User', + 2, + 0, + 'high', + '["established public contribution history"]', + now, + null, + now, + ); + service.db + .prepare( + `insert into repo_user_state (repo_id, user_login, last_repo_refresh_at, first_seen_at, last_seen_at, last_refresh_error, updated_at) + values (?, ?, ?, ?, ?, ?, ?)`, + ) + .run(1, 'alice', now, now, now, null, now); + + const server = createApiServer(service); + try { + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + const address = server.address(); + assert(address && typeof address === 'object'); + + const listResponse = await fetch( + `http://127.0.0.1:${address.port}/repo-users?owner=openclaw&repo=openclaw&mode=trusted_prs`, + ); + assert.equal(listResponse.status, 200); + const listPayload = repoUsersResponseSchema.parse((await listResponse.json()) as unknown); + assert.equal(listPayload.users[0]?.login, 'alice'); + + const detailResponse = await fetch( + `http://127.0.0.1:${address.port}/repo-user-detail?owner=openclaw&repo=openclaw&login=alice`, + ); + assert.equal(detailResponse.status, 200); + const detailPayload = repoUserDetailResponseSchema.parse((await detailResponse.json()) as unknown); + assert.equal(detailPayload.pullRequests[0]?.filesChanged, 4); + + const refreshResponse = await fetch(`http://127.0.0.1:${address.port}/actions/refresh-user`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ owner: 'openclaw', repo: 'openclaw', login: 'alice', force: true }), + }); + assert.equal(refreshResponse.status, 200); + const refreshPayload = repoUserRefreshResponseSchema.parse((await refreshResponse.json()) as unknown); + assert.equal(refreshPayload.profile.reputationTier, 'high'); + + const bulkRefreshResponse = await fetch(`http://127.0.0.1:${address.port}/actions/refresh-users`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ owner: 'openclaw', repo: 'openclaw', mode: 'trusted_prs', limit: 1 }), + }); + assert.equal(bulkRefreshResponse.status, 200); + const bulkRefreshPayload = repoUserBulkRefreshResponseSchema.parse((await bulkRefreshResponse.json()) as unknown); + assert.equal(bulkRefreshPayload.selectedUserCount, 1); + } finally { + await new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve()))); + service.close(); + } +}); + test('close-thread and includeClosed thread routes expose locally closed items', async () => { const service = new GHCrawlService({ config: { diff --git a/packages/api-core/src/api/server.ts b/packages/api-core/src/api/server.ts index 79032c8..9908dee 100644 --- a/packages/api-core/src/api/server.ts +++ b/packages/api-core/src/api/server.ts @@ -1,6 +1,6 @@ import http from 'node:http'; -import { actionRequestSchema, closeClusterRequestSchema, closeThreadRequestSchema, refreshRequestSchema } from '@ghcrawl/api-contract'; +import { actionRequestSchema, closeClusterRequestSchema, closeThreadRequestSchema, refreshRequestSchema, repoUserBulkRefreshRequestSchema, repoUserRefreshRequestSchema } from '@ghcrawl/api-contract'; import { ZodError } from 'zod'; import { GHCrawlService, parseRepoParams } from '../service.js'; @@ -68,6 +68,36 @@ export function createApiServer(service: GHCrawlService): http.Server { return; } + if (req.method === 'GET' && url.pathname === '/repo-users') { + const params = parseRepoParams(url); + const modeParam = url.searchParams.get('mode'); + const mode = modeParam === 'flagged' || modeParam === 'trusted_prs' ? modeParam : 'flagged'; + const limitValue = url.searchParams.get('limit'); + const includeStale = url.searchParams.get('includeStale') === 'false' ? false : true; + sendJson( + res, + 200, + service.listRepoUsers({ + ...params, + mode, + limit: limitValue ? Number(limitValue) : undefined, + includeStale, + }), + ); + return; + } + + if (req.method === 'GET' && url.pathname === '/repo-user-detail') { + const params = parseRepoParams(url); + const login = (url.searchParams.get('login') ?? '').trim(); + if (!login) { + sendJson(res, 400, { error: 'Missing login parameter' }); + return; + } + sendJson(res, 200, service.getRepoUserDetail({ ...params, login })); + return; + } + if (req.method === 'GET' && url.pathname === '/search') { const params = parseRepoParams(url); const query = url.searchParams.get('query'); @@ -179,6 +209,18 @@ export function createApiServer(service: GHCrawlService): http.Server { return; } + if (req.method === 'POST' && url.pathname === '/actions/refresh-user') { + const body = repoUserRefreshRequestSchema.parse(await readBody(req)); + sendJson(res, 200, await service.refreshRepoUser(body)); + return; + } + + if (req.method === 'POST' && url.pathname === '/actions/refresh-users') { + const body = repoUserBulkRefreshRequestSchema.parse(await readBody(req)); + sendJson(res, 200, await service.refreshRepoUsers(body)); + return; + } + if (req.method === 'POST' && url.pathname === '/actions/close-thread') { const body = closeThreadRequestSchema.parse(await readBody(req)); sendJson(res, 200, service.closeThreadLocally(body)); diff --git a/packages/api-core/src/db/migrate.test.ts b/packages/api-core/src/db/migrate.test.ts index 7aba573..11a9716 100644 --- a/packages/api-core/src/db/migrate.test.ts +++ b/packages/api-core/src/db/migrate.test.ts @@ -19,11 +19,107 @@ test('migrate creates core tables', () => { assert.ok(names.includes('document_embeddings')); assert.ok(names.includes('cluster_runs')); assert.ok(names.includes('repo_sync_state')); + assert.ok(names.includes('users')); + assert.ok(names.includes('repo_user_state')); const threadColumns = db.prepare('pragma table_info(threads)').all() as Array<{ name: string }>; const threadColumnNames = threadColumns.map((column) => column.name); assert.ok(threadColumnNames.includes('first_pulled_at')); assert.ok(threadColumnNames.includes('last_pulled_at')); + assert.ok(threadColumnNames.includes('files_changed')); + assert.ok(threadColumnNames.includes('additions')); + assert.ok(threadColumnNames.includes('deletions')); + } finally { + db.close(); + } +}); + +test('migrate backfills open pull request size columns from raw_json', () => { + const db = openDb(':memory:'); + try { + db.exec(` + create table repositories ( + id integer primary key, + owner text not null, + name text not null, + full_name text not null unique, + github_repo_id text, + raw_json text not null, + updated_at text not null + ); + create table threads ( + id integer primary key, + repo_id integer not null references repositories(id) on delete cascade, + github_id text not null, + number integer not null, + kind text not null, + state text not null, + title text not null, + body text, + author_login text, + author_type text, + html_url text not null, + labels_json text not null, + assignees_json text not null, + raw_json text not null, + content_hash text not null, + is_draft integer not null default 0, + created_at_gh text, + updated_at_gh text, + closed_at_gh text, + merged_at_gh text, + first_pulled_at text, + last_pulled_at text, + updated_at text not null, + unique(repo_id, kind, number) + ); + `); + db.prepare( + `insert into repositories (id, owner, name, full_name, github_repo_id, raw_json, updated_at) + values (?, ?, ?, ?, ?, ?, ?)`, + ).run(1, 'openclaw', 'openclaw', 'openclaw/openclaw', '1', '{}', '2026-03-12T00:00:00Z'); + db.prepare( + `insert into threads ( + id, repo_id, github_id, number, kind, state, title, body, author_login, author_type, html_url, + labels_json, assignees_json, raw_json, content_hash, is_draft, created_at_gh, updated_at_gh, + closed_at_gh, merged_at_gh, first_pulled_at, last_pulled_at, updated_at + ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ).run( + 10, + 1, + '100', + 43, + 'pull_request', + 'open', + 'Fix downloader', + 'body', + 'alice', + 'User', + 'https://github.com/openclaw/openclaw/pull/43', + '[]', + '[]', + JSON.stringify({ changed_files: 7, additions: 120, deletions: 30 }), + 'hash-10', + 0, + '2026-03-01T00:00:00Z', + '2026-03-12T00:00:00Z', + null, + null, + '2026-03-12T00:00:00Z', + '2026-03-12T00:00:00Z', + '2026-03-12T00:00:00Z', + ); + + migrate(db); + + const row = db + .prepare('select files_changed, additions, deletions from threads where id = 10') + .get() as { files_changed: number | null; additions: number | null; deletions: number | null }; + assert.deepEqual(row, { + files_changed: 7, + additions: 120, + deletions: 30, + }); } finally { db.close(); } diff --git a/packages/api-core/src/db/migrate.ts b/packages/api-core/src/db/migrate.ts index 7ec4059..934ee64 100644 --- a/packages/api-core/src/db/migrate.ts +++ b/packages/api-core/src/db/migrate.ts @@ -57,6 +57,39 @@ const migrationStatements = [ ) `, ` + create table if not exists users ( + login text primary key, + github_user_id text, + account_created_at text, + public_repo_count integer, + public_gist_count integer, + followers integer, + following integer, + profile_url text, + avatar_url text, + user_type text, + recent_public_event_count integer, + likely_hidden_activity integer not null default 0, + reputation_tier text not null default 'unknown', + reputation_reason_json text not null default '[]', + last_global_refresh_at text, + last_refresh_error text, + updated_at text not null + ) + `, + ` + create table if not exists repo_user_state ( + repo_id integer not null references repositories(id) on delete cascade, + user_login text not null, + last_repo_refresh_at text, + first_seen_at text, + last_seen_at text, + last_refresh_error text, + updated_at text not null, + primary key (repo_id, user_login) + ) + `, + ` create table if not exists documents ( id integer primary key, thread_id integer not null unique references threads(id) on delete cascade, @@ -238,6 +271,25 @@ export function migrate(db: SqliteDatabase): void { if (!threadColumns.has('close_reason_local')) { db.exec('alter table threads add column close_reason_local text'); } + if (!threadColumns.has('files_changed')) { + db.exec('alter table threads add column files_changed integer'); + } + if (!threadColumns.has('additions')) { + db.exec('alter table threads add column additions integer'); + } + if (!threadColumns.has('deletions')) { + db.exec('alter table threads add column deletions integer'); + } + + db.exec(` + update threads + set files_changed = coalesce(files_changed, cast(json_extract(raw_json, '$.changed_files') as integer), cast(json_extract(raw_json, '$.files_changed') as integer)), + additions = coalesce(additions, cast(json_extract(raw_json, '$.additions') as integer)), + deletions = coalesce(deletions, cast(json_extract(raw_json, '$.deletions') as integer)) + where kind = 'pull_request' + and state = 'open' + and (files_changed is null or additions is null or deletions is null) + `); const clusterColumns = new Set( (db.prepare('pragma table_info(clusters)').all() as Array<{ name: string }>).map((column) => column.name), @@ -250,6 +302,9 @@ export function migrate(db: SqliteDatabase): void { } db.exec('create index if not exists idx_threads_repo_number on threads(repo_id, number)'); + db.exec('create index if not exists idx_threads_repo_author_open on threads(repo_id, author_login, state, closed_at_local)'); + db.exec('create index if not exists idx_users_reputation_tier on users(reputation_tier, last_global_refresh_at)'); + db.exec('create index if not exists idx_repo_user_state_repo_seen on repo_user_state(repo_id, last_repo_refresh_at, first_seen_at)'); db.exec('create index if not exists idx_document_summaries_thread_model on document_summaries(thread_id, model)'); db.exec('create index if not exists idx_cluster_runs_repo_status_id on cluster_runs(repo_id, status, id)'); db.exec('create index if not exists idx_clusters_repo_run_id on clusters(repo_id, cluster_run_id, id)'); diff --git a/packages/api-core/src/github/client.test.ts b/packages/api-core/src/github/client.test.ts new file mode 100644 index 0000000..e17696e --- /dev/null +++ b/packages/api-core/src/github/client.test.ts @@ -0,0 +1,89 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { Octokit } from 'octokit'; + +import { makeGitHubClient } from './client.js'; + +test('makeGitHubClient exposes getUser through the Octokit users API', async () => { + const originalPlugin = Octokit.plugin; + const calls: Array<{ owner?: string; repo?: string; login?: string }> = []; + + class FakeOctokit { + rest = { + users: { + getByUsername: async ({ username }: { username: string }) => { + calls.push({ login: username }); + return { + data: { + id: 42, + login: username, + html_url: `https://github.com/${username}`, + }, + }; + }, + }, + }; + } + + Object.defineProperty(Octokit, 'plugin', { + configurable: true, + value: () => FakeOctokit, + }); + + try { + const client = makeGitHubClient({ token: 'ghp_testtoken1234567890' }); + const payload = await client.getUser?.('alice'); + + assert.deepEqual(calls, [{ login: 'alice' }]); + assert.deepEqual(payload, { + id: 42, + login: 'alice', + html_url: 'https://github.com/alice', + }); + } finally { + Object.defineProperty(Octokit, 'plugin', { + configurable: true, + value: originalPlugin, + }); + } +}); + +test('makeGitHubClient exposes listUserPublicEvents through Octokit pagination', async () => { + const originalPlugin = Octokit.plugin; + const calls: string[] = []; + + class FakeOctokit { + rest = { + activity: { + listPublicEventsForUser: Symbol('listPublicEventsForUser'), + }, + }; + + paginate = { + iterator: (_endpoint: unknown, params: { username: string; per_page: number }) => + (async function* iterator() { + calls.push(params.username); + yield { data: [{ id: 'evt-1' }, { id: 'evt-2' }] }; + })(), + }; + } + + Object.defineProperty(Octokit, 'plugin', { + configurable: true, + value: () => FakeOctokit, + }); + + try { + const client = makeGitHubClient({ token: 'ghp_testtoken1234567890', pageDelayMs: 0 }); + const payload = await client.listUserPublicEvents?.('alice'); + + assert.deepEqual(calls, ['alice']); + assert.deepEqual(payload, [{ id: 'evt-1' }, { id: 'evt-2' }]); + } finally { + Object.defineProperty(Octokit, 'plugin', { + configurable: true, + value: originalPlugin, + }); + } +}); diff --git a/packages/api-core/src/github/client.ts b/packages/api-core/src/github/client.ts index dc2f733..cd39b12 100644 --- a/packages/api-core/src/github/client.ts +++ b/packages/api-core/src/github/client.ts @@ -23,6 +23,8 @@ export type GitHubClient = { number: number, reporter?: GitHubReporter, ) => Promise>>; + getUser?: (login: string, reporter?: GitHubReporter) => Promise>; + listUserPublicEvents?: (login: string, reporter?: GitHubReporter) => Promise>>; }; export type GitHubReporter = (message: string) => void; @@ -238,5 +240,23 @@ export function makeGitHubClient(options: RequestOptions): GitHubClient { }) as AsyncIterable>>, ); }, + async getUser(login, reporter) { + return request(`GET /users/${login}`, reporter, async (octokit) => { + const response = await octokit.rest.users.getByUsername({ username: login }); + return response.data as Record; + }); + }, + async listUserPublicEvents(login, reporter) { + return paginate( + `GET /users/${login}/events/public per_page=100`, + 100, + reporter, + (octokit) => + octokit.paginate.iterator(octokit.rest.activity.listPublicEventsForUser, { + username: login, + per_page: 100, + }) as AsyncIterable>>, + ); + }, }; } diff --git a/packages/api-core/src/service.test.ts b/packages/api-core/src/service.test.ts index 4087df8..7a9a9c0 100644 --- a/packages/api-core/src/service.test.ts +++ b/packages/api-core/src/service.test.ts @@ -2813,3 +2813,332 @@ test('repository-scoped reads and neighbors do not leak across repos in the same service.close(); } }); + +function seedRepoUserExplorerFixture(service: GHCrawlService): void { + const now = '2026-03-09T00:00:00Z'; + service.db + .prepare( + `insert into repositories (id, owner, name, full_name, github_repo_id, raw_json, updated_at) + values (?, ?, ?, ?, ?, ?, ?)`, + ) + .run(1, 'openclaw', 'openclaw', 'openclaw/openclaw', '1', '{}', now); + + const insertThread = service.db.prepare( + `insert into threads ( + id, repo_id, github_id, number, kind, state, title, body, author_login, author_type, html_url, + labels_json, assignees_json, raw_json, content_hash, is_draft, created_at_gh, updated_at_gh, + closed_at_gh, merged_at_gh, first_pulled_at, last_pulled_at, files_changed, additions, deletions, updated_at + ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ); + + insertThread.run(10, 1, '100', 10, 'issue', 'open', 'New account question', 'body', 'bob', 'User', 'https://github.com/openclaw/openclaw/issues/10', '[]', '[]', '{}', 'hash-10', 0, '2026-03-01T00:00:00Z', now, null, null, now, now, null, null, null, now); + insertThread.run(11, 1, '101', 11, 'issue', 'open', 'Another new account issue', 'body', 'bob', 'User', 'https://github.com/openclaw/openclaw/issues/11', '[]', '[]', '{}', 'hash-11', 0, '2026-03-02T00:00:00Z', now, null, null, now, now, null, null, null, now); + insertThread.run(12, 1, '102', 12, 'issue', 'open', 'Third new account issue', 'body', 'bob', 'User', 'https://github.com/openclaw/openclaw/issues/12', '[]', '[]', '{}', 'hash-12', 0, '2026-03-03T00:00:00Z', now, null, null, now, now, null, null, null, now); + insertThread.run(13, 1, '103', 13, 'pull_request', 'open', 'Bob tries a fix', 'body', 'bob', 'User', 'https://github.com/openclaw/openclaw/pull/13', '[]', '[]', '{}', 'hash-13', 0, '2026-03-04T00:00:00Z', now, null, null, now, now, 3, 30, 5, now); + insertThread.run(20, 1, '200', 20, 'issue', 'open', 'Carol issue', 'body', 'carol', 'User', 'https://github.com/openclaw/openclaw/issues/20', '[]', '[]', '{}', 'hash-20', 0, '2026-02-15T00:00:00Z', now, null, null, now, now, null, null, null, now); + insertThread.run(21, 1, '201', 21, 'pull_request', 'open', 'Carol first PR', 'body', 'carol', 'User', 'https://github.com/openclaw/openclaw/pull/21', '[]', '[]', '{}', 'hash-21', 0, '2026-02-01T00:00:00Z', now, null, null, now, now, 5, 80, 10, now); + insertThread.run(22, 1, '202', 22, 'pull_request', 'open', 'Carol second PR', 'body', 'carol', 'User', 'https://github.com/openclaw/openclaw/pull/22', '[]', '[]', '{}', 'hash-22', 0, '2026-02-10T00:00:00Z', now, null, null, now, now, 2, 20, 4, now); + insertThread.run(23, 1, '203', 23, 'pull_request', 'open', 'Carol third PR', 'body', 'carol', 'User', 'https://github.com/openclaw/openclaw/pull/23', '[]', '[]', '{}', 'hash-23', 0, '2026-02-12T00:00:00Z', now, null, null, now, now, 6, 90, 15, now); + insertThread.run(24, 1, '204', 24, 'pull_request', 'open', 'Carol fourth PR', 'body', 'carol', 'User', 'https://github.com/openclaw/openclaw/pull/24', '[]', '[]', '{}', 'hash-24', 0, '2026-02-13T00:00:00Z', now, null, null, now, now, 3, 25, 6, now); + insertThread.run(25, 1, '205', 25, 'pull_request', 'open', 'Carol fifth PR', 'body', 'carol', 'User', 'https://github.com/openclaw/openclaw/pull/25', '[]', '[]', '{}', 'hash-25', 0, '2026-02-14T00:00:00Z', now, null, null, now, now, 1, 10, 2, now); + insertThread.run(30, 1, '300', 30, 'pull_request', 'open', 'Alice polished fix', 'body', 'alice', 'User', 'https://github.com/openclaw/openclaw/pull/30', '[]', '[]', '{}', 'hash-30', 0, '2026-01-10T00:00:00Z', now, null, null, now, now, 4, 120, 30, now); + insertThread.run(31, 1, '301', 31, 'pull_request', 'open', 'Alice follow-up', 'body', 'alice', 'User', 'https://github.com/openclaw/openclaw/pull/31', '[]', '[]', '{}', 'hash-31', 0, '2026-01-05T00:00:00Z', now, null, null, now, now, 7, 200, 40, now); + + const insertUser = service.db.prepare( + `insert into users ( + login, github_user_id, account_created_at, public_repo_count, public_gist_count, followers, following, + profile_url, avatar_url, user_type, recent_public_event_count, likely_hidden_activity, reputation_tier, + reputation_reason_json, last_global_refresh_at, last_refresh_error, updated_at + ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ); + insertUser.run('bob', '1000', '2026-02-01T00:00:00Z', 1, 0, 1, 0, 'https://github.com/bob', 'https://avatars.githubusercontent.com/u/1000?v=4', 'User', 1, 0, 'low', '["new account"]', now, null, now); + insertUser.run('carol', '2000', '2024-01-01T00:00:00Z', 1, 0, 1, 0, 'https://github.com/carol', 'https://avatars.githubusercontent.com/u/2000?v=4', 'User', 0, 1, 'medium', '["likely hidden or sparse public activity relative to repo participation"]', now, null, now); + insertUser.run('alice', '3000', '2020-01-01T00:00:00Z', 24, 2, 12, 4, 'https://github.com/alice', 'https://avatars.githubusercontent.com/u/3000?v=4', 'User', 5, 0, 'high', '["established public contribution history"]', now, null, now); + + const insertRepoUserState = service.db.prepare( + `insert into repo_user_state (repo_id, user_login, last_repo_refresh_at, first_seen_at, last_seen_at, last_refresh_error, updated_at) + values (?, ?, ?, ?, ?, ?, ?)`, + ); + insertRepoUserState.run(1, 'bob', now, '2026-03-01T00:00:00Z', now, null, now); + insertRepoUserState.run(1, 'carol', now, '2026-02-01T00:00:00Z', now, null, now); + insertRepoUserState.run(1, 'alice', now, '2026-01-01T00:00:00Z', now, null, now); +} + +test('syncRepository stores pull request sizing metadata and repo user state for user explorer', async () => { + const service = makeTestService({ + checkAuth: async () => undefined, + getRepo: async () => ({ id: 1, full_name: 'openclaw/openclaw' }), + listRepositoryIssues: async () => [ + { + id: 101, + number: 43, + state: 'open', + title: 'Downloader PR', + body: 'Implements a fix.', + html_url: 'https://github.com/openclaw/openclaw/pull/43', + labels: [{ name: 'bug' }], + assignees: [], + pull_request: { url: 'https://api.github.com/repos/openclaw/openclaw/pulls/43' }, + user: { login: 'alice', type: 'User' }, + }, + ], + getIssue: async () => { + throw new Error('not expected'); + }, + getPull: async (_owner, _repo, number) => ({ + id: 101, + number, + state: 'open', + title: 'Downloader PR', + body: 'Implements a fix.', + html_url: `https://github.com/openclaw/openclaw/pull/${number}`, + labels: [{ name: 'bug' }], + assignees: [], + user: { login: 'alice', type: 'User' }, + draft: false, + files_changed: 6, + additions: 140, + deletions: 22, + created_at: '2026-03-01T00:00:00Z', + updated_at: '2026-03-09T00:00:00Z', + }), + listIssueComments: async () => [], + listPullReviews: async () => [], + listPullReviewComments: async () => [], + }); + + try { + await service.syncRepository({ owner: 'openclaw', repo: 'openclaw' }); + + const threadRow = service.db + .prepare('select files_changed, additions, deletions from threads where number = 43 limit 1') + .get() as { files_changed: number | null; additions: number | null; deletions: number | null }; + assert.deepEqual(threadRow, { files_changed: 6, additions: 140, deletions: 22 }); + + const repoState = service.db + .prepare('select first_seen_at, last_seen_at from repo_user_state where repo_id = 1 and user_login = ? limit 1') + .get('alice') as { first_seen_at: string | null; last_seen_at: string | null }; + assert.ok(repoState.first_seen_at); + assert.ok(repoState.last_seen_at); + } finally { + service.close(); + } +}); + +test('listRepoUsers returns flagged and trusted contributor views with totals and ordering', () => { + const service = makeTestService({ + checkAuth: async () => undefined, + getRepo: async () => ({}), + listRepositoryIssues: async () => [], + getIssue: async () => ({}), + getPull: async () => ({}), + listIssueComments: async () => [], + listPullReviews: async () => [], + listPullReviewComments: async () => [], + }); + + try { + seedRepoUserExplorerFixture(service); + + const flagged = service.listRepoUsers({ owner: 'openclaw', repo: 'openclaw', mode: 'flagged' }); + assert.deepEqual(flagged.users.map((user) => user.login), ['carol', 'bob']); + assert.equal(flagged.totals.matchingUserCount, 2); + assert.equal(flagged.totals.openIssueCount, 4); + assert.equal(flagged.totals.openPullRequestCount, 6); + assert.equal(flagged.users[0]?.matchedLikelyHiddenActivity, true); + assert.equal(flagged.users[1]?.matchedLowReputation, true); + + const trusted = service.listRepoUsers({ owner: 'openclaw', repo: 'openclaw', mode: 'trusted_prs' }); + assert.deepEqual(trusted.users.map((user) => user.login), ['alice']); + assert.equal(trusted.totals.matchingUserCount, 1); + assert.equal(trusted.totals.waitingPullRequestCount, 2); + assert.equal(trusted.users[0]?.reputationTier, 'high'); + } finally { + service.close(); + } +}); + +test('getRepoUserDetail returns repo-local issues, PRs, and sizing metrics', () => { + const service = makeTestService({ + checkAuth: async () => undefined, + getRepo: async () => ({}), + listRepositoryIssues: async () => [], + getIssue: async () => ({}), + getPull: async () => ({}), + listIssueComments: async () => [], + listPullReviews: async () => [], + listPullReviewComments: async () => [], + }); + + try { + seedRepoUserExplorerFixture(service); + + const detail = service.getRepoUserDetail({ owner: 'openclaw', repo: 'openclaw', login: 'alice' }); + assert.equal(detail.profile.login, 'alice'); + assert.equal(detail.profile.reputationTier, 'high'); + assert.equal(detail.pullRequests.length, 2); + assert.equal(detail.pullRequests[0]?.number, 31); + assert.equal(detail.pullRequests[0]?.filesChanged, 7); + assert.equal(detail.pullRequests[0]?.additions, 200); + assert.equal(detail.pullRequests[1]?.number, 30); + assert.equal(detail.issues.length, 0); + } finally { + service.close(); + } +}); + +test('getRepoUserDetail falls back to raw_json PR size fields when typed columns are null', () => { + const service = makeTestService({ + checkAuth: async () => undefined, + getRepo: async () => ({}), + listRepositoryIssues: async () => [], + getIssue: async () => ({}), + getPull: async () => ({}), + listIssueComments: async () => [], + listPullReviews: async () => [], + listPullReviewComments: async () => [], + }); + + try { + seedRepoUserExplorerFixture(service); + service.db + .prepare( + `update threads + set files_changed = null, + additions = null, + deletions = null, + raw_json = ? + where id = ?`, + ) + .run(JSON.stringify({ changed_files: 9, additions: 150, deletions: 12 }), 31); + + const detail = service.getRepoUserDetail({ owner: 'openclaw', repo: 'openclaw', login: 'alice' }); + assert.equal(detail.pullRequests[0]?.number, 31); + assert.equal(detail.pullRequests[0]?.filesChanged, 9); + assert.equal(detail.pullRequests[0]?.additions, 150); + assert.equal(detail.pullRequests[0]?.deletions, 12); + } finally { + service.close(); + } +}); + +test('refreshRepoUser reuses fresh cache and refreshes stale user data on demand', async () => { + let getUserCalls = 0; + let listUserPublicEventsCalls = 0; + const service = makeTestService({ + checkAuth: async () => undefined, + getRepo: async () => ({}), + listRepositoryIssues: async () => [], + getIssue: async () => ({}), + getPull: async () => ({}), + listIssueComments: async () => [], + listPullReviews: async () => [], + listPullReviewComments: async () => [], + getUser: async (login) => { + getUserCalls += 1; + return { + id: 3000, + login, + created_at: '2020-01-01T00:00:00Z', + public_repos: 24, + public_gists: 2, + followers: 12, + following: 4, + html_url: `https://github.com/${login}`, + avatar_url: `https://avatars.githubusercontent.com/${login}`, + type: 'User', + }; + }, + listUserPublicEvents: async () => { + listUserPublicEventsCalls += 1; + return [{ id: 'evt-1' }, { id: 'evt-2' }]; + }, + }); + + try { + seedRepoUserExplorerFixture(service); + + const fresh = await service.refreshRepoUser({ owner: 'openclaw', repo: 'openclaw', login: 'alice' }); + assert.equal(fresh.profile.reputationTier, 'high'); + assert.equal(getUserCalls, 0); + assert.equal(listUserPublicEventsCalls, 0); + + service.db + .prepare('update users set last_global_refresh_at = ?, last_refresh_error = null where login = ?') + .run('2020-01-01T00:00:00Z', 'alice'); + + const refreshed = await service.refreshRepoUser({ owner: 'openclaw', repo: 'openclaw', login: 'alice' }); + assert.equal(refreshed.profile.reputationTier, 'high'); + assert.equal(getUserCalls, 1); + assert.equal(listUserPublicEventsCalls, 1); + + const updatedUser = service.db + .prepare('select recent_public_event_count, reputation_tier, last_refresh_error from users where login = ? limit 1') + .get('alice') as { + recent_public_event_count: number | null; + reputation_tier: string | null; + last_refresh_error: string | null; + }; + assert.equal(updatedUser.recent_public_event_count, 2); + assert.equal(updatedUser.reputation_tier, 'high'); + assert.equal(updatedUser.last_refresh_error, null); + } finally { + service.close(); + } +}); + +test('refreshRepoUsers refreshes the current mode in bulk with counts and failures', async () => { + let getUserCalls = 0; + const service = makeTestService({ + checkAuth: async () => undefined, + getRepo: async () => ({}), + listRepositoryIssues: async () => [], + getIssue: async () => ({}), + getPull: async () => ({}), + listIssueComments: async () => [], + listPullReviews: async () => [], + listPullReviewComments: async () => [], + getUser: async (login) => { + getUserCalls += 1; + if (login === 'carol') { + throw new Error('GitHub profile hidden'); + } + return { + id: 4000, + login, + created_at: '2020-01-01T00:00:00Z', + public_repos: 15, + public_gists: 1, + followers: 12, + following: 4, + html_url: `https://github.com/${login}`, + avatar_url: `https://avatars.githubusercontent.com/${login}`, + type: 'User', + }; + }, + listUserPublicEvents: async () => [{ id: 'evt-1' }], + }); + + try { + seedRepoUserExplorerFixture(service); + service.db + .prepare('update users set last_global_refresh_at = ?, last_refresh_error = null where login in (?, ?)') + .run('2020-01-01T00:00:00Z', 'bob', 'carol'); + + const result = await service.refreshRepoUsers({ + owner: 'openclaw', + repo: 'openclaw', + mode: 'flagged', + limit: 2, + }); + + assert.equal(result.selectedUserCount, 2); + assert.equal(result.refreshedCount, 1); + assert.equal(result.failedCount, 1); + assert.equal(result.skippedCount, 0); + assert.equal(result.failures[0]?.login, 'carol'); + assert.equal(getUserCalls, 2); + } finally { + service.close(); + } +}); diff --git a/packages/api-core/src/service.ts b/packages/api-core/src/service.ts index b03674f..817b0ec 100644 --- a/packages/api-core/src/service.ts +++ b/packages/api-core/src/service.ts @@ -17,6 +17,11 @@ import { embedResultSchema, healthResponseSchema, neighborsResponseSchema, + repoUserBulkRefreshResponseSchema, + repoUserDetailResponseSchema, + repoUserModeSchema, + repoUserRefreshResponseSchema, + repoUsersResponseSchema, refreshResponseSchema, repositoriesResponseSchema, searchResponseSchema, @@ -34,6 +39,11 @@ import { type EmbedResultDto, type HealthResponse, type NeighborsResponse, + type RepoUserBulkRefreshResponse, + type RepoUserDetailResponse, + type RepoUserMode, + type RepoUserRefreshResponse, + type RepoUsersResponse, type RefreshResponse, type RepositoriesResponse, type RepositoryDto, @@ -78,13 +88,59 @@ type ThreadRow = { title: string; body: string | null; author_login: string | null; + author_type: string | null; html_url: string; labels_json: string; + raw_json: string; + is_draft: number; + created_at_gh: string | null; updated_at_gh: string | null; + merged_at_gh: string | null; + files_changed: number | null; + additions: number | null; + deletions: number | null; first_pulled_at: string | null; last_pulled_at: string | null; }; +type UserRow = { + login: string; + github_user_id: string | null; + account_created_at: string | null; + public_repo_count: number | null; + public_gist_count: number | null; + followers: number | null; + following: number | null; + profile_url: string | null; + avatar_url: string | null; + user_type: string | null; + recent_public_event_count: number | null; + likely_hidden_activity: number; + reputation_tier: 'low' | 'medium' | 'high' | 'unknown'; + reputation_reason_json: string; + last_global_refresh_at: string | null; + last_refresh_error: string | null; + updated_at: string; +}; + +type RepoUserStateRow = { + repo_id: number; + user_login: string; + last_repo_refresh_at: string | null; + first_seen_at: string | null; + last_seen_at: string | null; + last_refresh_error: string | null; + updated_at: string; +}; + +type RepoUserAggregateRow = { + user_login: string; + open_issue_count: number; + open_pull_request_count: number; + waiting_pull_request_count: number; + oldest_open_pr_created_at: string | null; +}; + type CommentSeed = { githubId: string; commentType: string; @@ -227,6 +283,9 @@ export type TuiSnapshot = { clusters: TuiClusterSummary[]; }; +export type RepoUserReputationTier = 'low' | 'medium' | 'high' | 'unknown'; +export type RepoUserExplorerMode = RepoUserMode; + export type DoctorResult = { health: HealthResponse; github: { @@ -268,6 +327,7 @@ const EMBED_ESTIMATED_CHARS_PER_TOKEN = 3; const EMBED_MAX_ITEM_TOKENS = 7000; const EMBED_MAX_BATCH_TOKENS = 250000; const EMBED_TRUNCATION_MARKER = '\n\n[truncated for embedding]'; +const USER_REFRESH_STALE_MS = 30 * 24 * 60 * 60 * 1000; function nowIso(): string { return new Date().toISOString(); @@ -279,6 +339,25 @@ function parseIso(value: string | null | undefined): number | null { return Number.isNaN(parsed) ? null : parsed; } +function parseNullableInteger(value: unknown): number | null { + if (typeof value !== 'number' || !Number.isFinite(value)) { + return null; + } + return Math.max(0, Math.trunc(value)); +} + +function ageDaysFrom(value: string | null | undefined, now: Date = new Date()): number | null { + const parsed = parseIso(value); + if (parsed === null) return null; + return Math.max(0, Math.floor((now.getTime() - parsed) / (24 * 60 * 60 * 1000))); +} + +function isStaleAt(lastRefreshAt: string | null | undefined, now: Date = new Date()): boolean { + const parsed = parseIso(lastRefreshAt); + if (parsed === null) return true; + return now.getTime() - parsed > USER_REFRESH_STALE_MS; +} + function isEffectivelyClosed(row: { state: string; closed_at_local: string | null }): boolean { return row.state !== 'open' || row.closed_at_local !== null; } @@ -347,6 +426,125 @@ function userType(payload: Record): string | null { return typeof type === 'string' ? type : null; } +function normalizeLogin(login: string | null | undefined): string | null { + const normalized = (login ?? '').trim().toLowerCase(); + return normalized.length > 0 ? normalized : null; +} + +function parseReasonArray(value: string | null | undefined): string[] { + if (!value) return []; + try { + const parsed = JSON.parse(value) as unknown; + return Array.isArray(parsed) ? parsed.filter((item): item is string => typeof item === 'string') : []; + } catch { + return []; + } +} + +function parsePullFilesChanged(payload: Record): number | null { + return parseNullableInteger(payload.changed_files ?? payload.files_changed); +} + +function parseThreadSizeFromRawJson(rawJson: string | null | undefined): { + filesChanged: number | null; + additions: number | null; + deletions: number | null; +} { + if (!rawJson) { + return { filesChanged: null, additions: null, deletions: null }; + } + try { + const parsed = JSON.parse(rawJson) as Record; + return { + filesChanged: parsePullFilesChanged(parsed), + additions: parseNullableInteger(parsed.additions), + deletions: parseNullableInteger(parsed.deletions), + }; + } catch { + return { filesChanged: null, additions: null, deletions: null }; + } +} + +function canSkipRepoUserRefresh(user: UserRow | null, force = false, now: Date = new Date()): boolean { + return force !== true && Boolean(user) && !isStaleAt(user?.last_global_refresh_at ?? null, now) && !user?.last_refresh_error; +} + +function buildReputationProfile(params: { + user: UserRow | null; + aggregate: RepoUserAggregateRow; + repoState: RepoUserStateRow | null; + now?: Date; +}): { + reputationTier: RepoUserReputationTier; + likelyHiddenActivity: boolean; + reasons: string[]; + accountAgeDays: number | null; + isStale: boolean; + lastRefreshError: string | null; +} { + const now = params.now ?? new Date(); + const accountAgeDays = ageDaysFrom(params.user?.account_created_at ?? null, now); + const isStale = isStaleAt(params.user?.last_global_refresh_at ?? null, now); + const sparsePublicFootprint = + (params.user?.public_repo_count ?? 0) <= 1 && + (params.user?.public_gist_count ?? 0) === 0 && + (params.user?.followers ?? 0) <= 1; + const strongRepoEvidence = + params.aggregate.open_pull_request_count >= 2 || + params.aggregate.open_issue_count + params.aggregate.open_pull_request_count >= 4; + const likelyHiddenActivity = + Boolean(params.user) && + (accountAgeDays ?? 0) >= 90 && + sparsePublicFootprint && + (params.aggregate.open_issue_count + params.aggregate.open_pull_request_count) >= 3 && + (params.user?.recent_public_event_count ?? 0) === 0; + + if (!params.user || params.user.last_refresh_error) { + return { + reputationTier: 'unknown', + likelyHiddenActivity, + reasons: params.user?.last_refresh_error ? [params.user.last_refresh_error] : ['profile not refreshed yet'], + accountAgeDays, + isStale: true, + lastRefreshError: params.user?.last_refresh_error ?? params.repoState?.last_refresh_error ?? null, + }; + } + + const reasons: string[] = []; + let reputationTier: RepoUserReputationTier = 'medium'; + if (accountAgeDays !== null && accountAgeDays < 90) { + reputationTier = 'low'; + reasons.push(`new account (${accountAgeDays}d old)`); + } else if (sparsePublicFootprint && !strongRepoEvidence) { + reputationTier = 'low'; + reasons.push('very sparse public footprint'); + } else if ( + (accountAgeDays ?? 0) >= 365 && + ((params.user.public_repo_count ?? 0) >= 10 || (params.user.followers ?? 0) >= 10 || (params.user.public_gist_count ?? 0) >= 5) + ) { + reputationTier = 'high'; + reasons.push('established public contribution history'); + } else { + reasons.push('moderate public footprint'); + } + + if (likelyHiddenActivity) { + reasons.push('likely hidden or sparse public activity relative to repo participation'); + } + if (strongRepoEvidence) { + reasons.push('strong repo-local participation'); + } + + return { + reputationTier, + likelyHiddenActivity, + reasons, + accountAgeDays, + isStale, + lastRefreshError: params.user.last_refresh_error ?? params.repoState?.last_refresh_error ?? null, + }; +} + function isPullRequestPayload(payload: Record): boolean { return Boolean(payload.pull_request); } @@ -682,6 +880,244 @@ export class GHCrawlService { }); } + listRepoUsers(params: { owner: string; repo: string; mode: RepoUserMode; limit?: number; includeStale?: boolean }): RepoUsersResponse { + const repository = this.requireRepository(params.owner, params.repo); + const mode = repoUserModeSchema.parse(params.mode); + const includeStale = params.includeStale ?? true; + const aggregateRows = this.listRepoUserAggregates(repository.id); + const decorated = aggregateRows + .map((aggregate) => this.buildRepoUserSummaryRecord(repository.id, aggregate)) + .filter((record) => this.matchesRepoUserMode(record.summary, mode, includeStale)); + + decorated.sort((left, right) => { + if (mode === 'trusted_prs') { + return ( + right.summary.waitingPullRequestCount - left.summary.waitingPullRequestCount || + (parseIso(left.oldestOpenPrCreatedAt) ?? Number.POSITIVE_INFINITY) - (parseIso(right.oldestOpenPrCreatedAt) ?? Number.POSITIVE_INFINITY) || + left.summary.login.localeCompare(right.summary.login) + ); + } + const leftTotal = left.summary.openIssueCount + left.summary.openPullRequestCount; + const rightTotal = right.summary.openIssueCount + right.summary.openPullRequestCount; + return ( + rightTotal - leftTotal || + right.summary.openIssueCount - left.summary.openIssueCount || + right.summary.openPullRequestCount - left.summary.openPullRequestCount || + (parseIso(left.firstSeenAt) ?? Number.POSITIVE_INFINITY) - (parseIso(right.firstSeenAt) ?? Number.POSITIVE_INFINITY) || + left.summary.login.localeCompare(right.summary.login) + ); + }); + + const limited = params.limit && params.limit > 0 ? decorated.slice(0, params.limit) : decorated; + const totals = decorated.reduce( + (accumulator, record) => ({ + matchingUserCount: accumulator.matchingUserCount + 1, + openIssueCount: accumulator.openIssueCount + record.summary.openIssueCount, + openPullRequestCount: accumulator.openPullRequestCount + record.summary.openPullRequestCount, + waitingPullRequestCount: accumulator.waitingPullRequestCount + record.summary.waitingPullRequestCount, + }), + { matchingUserCount: 0, openIssueCount: 0, openPullRequestCount: 0, waitingPullRequestCount: 0 }, + ); + + return repoUsersResponseSchema.parse({ + repository, + mode, + totals, + users: limited.map((record) => record.summary), + }); + } + + getRepoUserDetail(params: { owner: string; repo: string; login: string }): RepoUserDetailResponse { + const repository = this.requireRepository(params.owner, params.repo); + const normalizedLogin = normalizeLogin(params.login); + if (!normalizedLogin) { + throw new Error('Missing login'); + } + + const aggregate = this.getRepoUserAggregate(repository.id, normalizedLogin); + if (!aggregate) { + throw new Error(`User ${normalizedLogin} has no open threads in ${repository.fullName}.`); + } + + const profile = this.buildRepoUserProfileDto(repository.id, normalizedLogin, aggregate); + const issueRows = this.listRepoUserThreads(repository.id, normalizedLogin, 'issue'); + const pullRequestRows = this.listRepoUserThreads(repository.id, normalizedLogin, 'pull_request'); + + return repoUserDetailResponseSchema.parse({ + repository, + profile, + totals: { + matchingUserCount: 1, + openIssueCount: aggregate.open_issue_count, + openPullRequestCount: aggregate.open_pull_request_count, + waitingPullRequestCount: aggregate.waiting_pull_request_count, + }, + issues: issueRows.map((row) => this.repoUserThreadToDto(row)), + pullRequests: pullRequestRows.map((row) => this.repoUserThreadToDto(row)), + }); + } + + async refreshRepoUser(params: { owner: string; repo: string; login: string; force?: boolean }): Promise { + const repository = this.requireRepository(params.owner, params.repo); + const normalizedLogin = normalizeLogin(params.login); + if (!normalizedLogin) { + throw new Error('Missing login'); + } + + const aggregate = this.getRepoUserAggregate(repository.id, normalizedLogin); + if (!aggregate) { + throw new Error(`User ${normalizedLogin} has no open threads in ${repository.fullName}.`); + } + + const existing = this.getUserRow(normalizedLogin); + if (canSkipRepoUserRefresh(existing, params.force)) { + return repoUserRefreshResponseSchema.parse({ + ok: true, + repository, + login: normalizedLogin, + refreshedAt: existing?.last_global_refresh_at ?? nowIso(), + profile: this.buildRepoUserProfileDto(repository.id, normalizedLogin, aggregate), + }); + } + + const github = this.requireGithub(); + if (!github.getUser || !github.listUserPublicEvents) { + throw new Error('GitHub client does not support user refresh methods.'); + } + + const refreshedAt = nowIso(); + try { + const payload = await github.getUser(normalizedLogin); + const events = await github.listUserPublicEvents(normalizedLogin); + const profileRow: UserRow = { + login: normalizedLogin, + github_user_id: payload.id != null ? String(payload.id) : null, + account_created_at: typeof payload.created_at === 'string' ? payload.created_at : null, + public_repo_count: parseNullableInteger(payload.public_repos), + public_gist_count: parseNullableInteger(payload.public_gists), + followers: parseNullableInteger(payload.followers), + following: parseNullableInteger(payload.following), + profile_url: typeof payload.html_url === 'string' ? payload.html_url : null, + avatar_url: typeof payload.avatar_url === 'string' ? payload.avatar_url : null, + user_type: typeof payload.type === 'string' ? payload.type : null, + recent_public_event_count: Array.isArray(events) ? events.length : 0, + likely_hidden_activity: 0, + reputation_tier: 'unknown', + reputation_reason_json: '[]', + last_global_refresh_at: refreshedAt, + last_refresh_error: null, + updated_at: refreshedAt, + }; + const repoState = this.getRepoUserStateRow(repository.id, normalizedLogin); + const classification = buildReputationProfile({ user: profileRow, aggregate, repoState }); + profileRow.likely_hidden_activity = classification.likelyHiddenActivity ? 1 : 0; + profileRow.reputation_tier = classification.reputationTier; + profileRow.reputation_reason_json = asJson(classification.reasons); + + this.upsertUserRow(profileRow); + this.upsertRepoUserRefreshState(repository.id, normalizedLogin, { + lastRepoRefreshAt: refreshedAt, + lastRefreshError: null, + }); + + return repoUserRefreshResponseSchema.parse({ + ok: true, + repository, + login: normalizedLogin, + refreshedAt, + profile: this.buildRepoUserProfileDto(repository.id, normalizedLogin, aggregate), + }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + this.upsertUserRow({ + login: normalizedLogin, + github_user_id: existing?.github_user_id ?? null, + account_created_at: existing?.account_created_at ?? null, + public_repo_count: existing?.public_repo_count ?? null, + public_gist_count: existing?.public_gist_count ?? null, + followers: existing?.followers ?? null, + following: existing?.following ?? null, + profile_url: existing?.profile_url ?? null, + avatar_url: existing?.avatar_url ?? null, + user_type: existing?.user_type ?? null, + recent_public_event_count: existing?.recent_public_event_count ?? null, + likely_hidden_activity: existing?.likely_hidden_activity ?? 0, + reputation_tier: existing?.reputation_tier ?? 'unknown', + reputation_reason_json: existing?.reputation_reason_json ?? '[]', + last_global_refresh_at: existing?.last_global_refresh_at ?? null, + last_refresh_error: message, + updated_at: refreshedAt, + }); + this.upsertRepoUserRefreshState(repository.id, normalizedLogin, { + lastRepoRefreshAt: this.getRepoUserStateRow(repository.id, normalizedLogin)?.last_repo_refresh_at ?? null, + lastRefreshError: message, + }); + throw new Error(`Failed to refresh ${normalizedLogin}: ${message}`); + } + } + + async refreshRepoUsers(params: { + owner: string; + repo: string; + mode: RepoUserMode; + limit?: number; + force?: boolean; + includeStale?: boolean; + onProgress?: (message: string) => void; + }): Promise { + const repository = this.requireRepository(params.owner, params.repo); + const mode = repoUserModeSchema.parse(params.mode); + const includeStale = params.includeStale ?? true; + const selectedUsers = this.listRepoUsers({ + owner: params.owner, + repo: params.repo, + mode, + limit: params.limit, + includeStale, + }).users; + + let refreshedCount = 0; + let skippedCount = 0; + let failedCount = 0; + const failures: RepoUserBulkRefreshResponse['failures'] = []; + + for (const [index, user] of selectedUsers.entries()) { + const existing = this.getUserRow(user.login); + const skipped = canSkipRepoUserRefresh(existing, params.force); + params.onProgress?.(`[users] ${index + 1}/${selectedUsers.length} @${user.login}${skipped ? ' (cached)' : ''}`); + if (skipped) { + skippedCount += 1; + continue; + } + try { + await this.refreshRepoUser({ + owner: params.owner, + repo: params.repo, + login: user.login, + force: params.force, + }); + refreshedCount += 1; + } catch (error) { + failedCount += 1; + failures.push({ + login: user.login, + error: error instanceof Error ? error.message : String(error), + }); + } + } + + return repoUserBulkRefreshResponseSchema.parse({ + ok: failedCount === 0, + repository, + mode, + selectedUserCount: selectedUsers.length, + refreshedCount, + skippedCount, + failedCount, + failures, + }); + } + closeThreadLocally(params: { owner: string; repo: string; threadNumber: number }): CloseResponse { const repository = this.requireRepository(params.owner, params.repo); const row = this.db @@ -2171,6 +2607,226 @@ export class GHCrawlService { return comments; } + private listRepoUserAggregates(repoId: number): RepoUserAggregateRow[] { + return this.db + .prepare( + `select + lower(author_login) as user_login, + sum(case when kind = 'issue' then 1 else 0 end) as open_issue_count, + sum(case when kind = 'pull_request' then 1 else 0 end) as open_pull_request_count, + sum(case when kind = 'pull_request' then 1 else 0 end) as waiting_pull_request_count, + min(case when kind = 'pull_request' then created_at_gh end) as oldest_open_pr_created_at + from threads + where repo_id = ? + and author_login is not null + and trim(author_login) != '' + and state = 'open' + and closed_at_local is null + group by lower(author_login)`, + ) + .all(repoId) as RepoUserAggregateRow[]; + } + + private getRepoUserAggregate(repoId: number, login: string): RepoUserAggregateRow | null { + return ( + (this.db + .prepare( + `select + lower(author_login) as user_login, + sum(case when kind = 'issue' then 1 else 0 end) as open_issue_count, + sum(case when kind = 'pull_request' then 1 else 0 end) as open_pull_request_count, + sum(case when kind = 'pull_request' then 1 else 0 end) as waiting_pull_request_count, + min(case when kind = 'pull_request' then created_at_gh end) as oldest_open_pr_created_at + from threads + where repo_id = ? + and lower(author_login) = ? + and state = 'open' + and closed_at_local is null + group by lower(author_login)`, + ) + .get(repoId, login) as RepoUserAggregateRow | undefined) ?? null + ); + } + + private getUserRow(login: string): UserRow | null { + return ((this.db.prepare('select * from users where login = ? limit 1').get(login) as UserRow | undefined) ?? null); + } + + private getRepoUserStateRow(repoId: number, login: string): RepoUserStateRow | null { + return ( + (this.db + .prepare('select * from repo_user_state where repo_id = ? and user_login = ? limit 1') + .get(repoId, login) as RepoUserStateRow | undefined) ?? null + ); + } + + private upsertUserRow(row: UserRow): void { + this.db + .prepare( + `insert into users ( + login, github_user_id, account_created_at, public_repo_count, public_gist_count, followers, following, + profile_url, avatar_url, user_type, recent_public_event_count, likely_hidden_activity, reputation_tier, + reputation_reason_json, last_global_refresh_at, last_refresh_error, updated_at + ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + on conflict(login) do update set + github_user_id = excluded.github_user_id, + account_created_at = excluded.account_created_at, + public_repo_count = excluded.public_repo_count, + public_gist_count = excluded.public_gist_count, + followers = excluded.followers, + following = excluded.following, + profile_url = excluded.profile_url, + avatar_url = excluded.avatar_url, + user_type = excluded.user_type, + recent_public_event_count = excluded.recent_public_event_count, + likely_hidden_activity = excluded.likely_hidden_activity, + reputation_tier = excluded.reputation_tier, + reputation_reason_json = excluded.reputation_reason_json, + last_global_refresh_at = excluded.last_global_refresh_at, + last_refresh_error = excluded.last_refresh_error, + updated_at = excluded.updated_at`, + ) + .run( + row.login, + row.github_user_id, + row.account_created_at, + row.public_repo_count, + row.public_gist_count, + row.followers, + row.following, + row.profile_url, + row.avatar_url, + row.user_type, + row.recent_public_event_count, + row.likely_hidden_activity, + row.reputation_tier, + row.reputation_reason_json, + row.last_global_refresh_at, + row.last_refresh_error, + row.updated_at, + ); + } + + private upsertRepoUserRefreshState(repoId: number, login: string, params: { lastRepoRefreshAt: string | null; lastRefreshError: string | null }): void { + const now = nowIso(); + this.db + .prepare( + `insert into repo_user_state (repo_id, user_login, last_repo_refresh_at, first_seen_at, last_seen_at, last_refresh_error, updated_at) + values (?, ?, ?, null, null, ?, ?) + on conflict(repo_id, user_login) do update set + last_repo_refresh_at = excluded.last_repo_refresh_at, + last_refresh_error = excluded.last_refresh_error, + updated_at = excluded.updated_at`, + ) + .run(repoId, login, params.lastRepoRefreshAt, params.lastRefreshError, now); + } + + private buildRepoUserSummaryRecord(repoId: number, aggregate: RepoUserAggregateRow): { + summary: RepoUsersResponse['users'][number]; + firstSeenAt: string | null; + oldestOpenPrCreatedAt: string | null; + } { + const user = this.getUserRow(aggregate.user_login); + const repoState = this.getRepoUserStateRow(repoId, aggregate.user_login); + const classification = buildReputationProfile({ user, aggregate, repoState }); + return { + summary: { + login: aggregate.user_login, + reputationTier: classification.reputationTier, + likelyHiddenActivity: classification.likelyHiddenActivity, + isStale: classification.isStale, + lastRefreshedAt: user?.last_global_refresh_at ?? null, + lastRefreshError: classification.lastRefreshError, + accountCreatedAt: user?.account_created_at ?? null, + accountAgeDays: classification.accountAgeDays, + openIssueCount: aggregate.open_issue_count, + openPullRequestCount: aggregate.open_pull_request_count, + waitingPullRequestCount: aggregate.waiting_pull_request_count, + matchedLowReputation: classification.reputationTier === 'low', + matchedLikelyHiddenActivity: classification.likelyHiddenActivity, + reasons: classification.reasons, + }, + firstSeenAt: repoState?.first_seen_at ?? null, + oldestOpenPrCreatedAt: aggregate.oldest_open_pr_created_at, + }; + } + + private buildRepoUserProfileDto(repoId: number, login: string, aggregate: RepoUserAggregateRow): RepoUserDetailResponse['profile'] { + const user = this.getUserRow(login); + const repoState = this.getRepoUserStateRow(repoId, login); + const classification = buildReputationProfile({ user, aggregate, repoState }); + return { + login, + githubUserId: user?.github_user_id ?? null, + profileUrl: user?.profile_url ?? `https://github.com/${login}`, + avatarUrl: user?.avatar_url ?? null, + userType: user?.user_type ?? null, + accountCreatedAt: user?.account_created_at ?? null, + accountAgeDays: classification.accountAgeDays, + publicRepoCount: user?.public_repo_count ?? null, + publicGistCount: user?.public_gist_count ?? null, + followers: user?.followers ?? null, + following: user?.following ?? null, + recentPublicEventCount: user?.recent_public_event_count ?? null, + reputationTier: classification.reputationTier, + likelyHiddenActivity: classification.likelyHiddenActivity, + isStale: classification.isStale, + lastGlobalRefreshAt: user?.last_global_refresh_at ?? null, + lastRepoRefreshAt: repoState?.last_repo_refresh_at ?? null, + lastRefreshError: classification.lastRefreshError, + firstSeenAt: repoState?.first_seen_at ?? null, + lastSeenAt: repoState?.last_seen_at ?? null, + reasons: classification.reasons, + }; + } + + private matchesRepoUserMode(summary: RepoUsersResponse['users'][number], mode: RepoUserMode, includeStale: boolean): boolean { + if (mode === 'trusted_prs') { + return summary.reputationTier === 'high' && summary.waitingPullRequestCount > 0; + } + if (summary.matchedLowReputation || summary.matchedLikelyHiddenActivity) { + return true; + } + return includeStale && (summary.reputationTier === 'unknown' || summary.isStale); + } + + private listRepoUserThreads(repoId: number, login: string, kind: 'issue' | 'pull_request'): ThreadRow[] { + return this.db + .prepare( + `select * + from threads + where repo_id = ? + and lower(author_login) = ? + and kind = ? + and state = 'open' + and closed_at_local is null + order by + case when kind = 'pull_request' then coalesce(created_at_gh, updated_at_gh, updated_at) end asc, + coalesce(updated_at_gh, updated_at) desc, + number desc`, + ) + .all(repoId, login, kind) as ThreadRow[]; + } + + private repoUserThreadToDto(row: ThreadRow): RepoUserDetailResponse['issues'][number] { + const rawSize = row.kind === 'pull_request' ? parseThreadSizeFromRawJson(row.raw_json) : { filesChanged: null, additions: null, deletions: null }; + return { + threadId: row.id, + number: row.number, + kind: row.kind, + title: row.title, + htmlUrl: row.html_url, + state: row.state, + isDraft: row.is_draft === 1, + createdAtGh: row.created_at_gh ?? null, + updatedAtGh: row.updated_at_gh ?? null, + ageDays: ageDaysFrom(row.created_at_gh ?? row.updated_at_gh ?? null), + filesChanged: row.files_changed ?? rawSize.filesChanged, + additions: row.additions ?? rawSize.additions, + deletions: row.deletions ?? rawSize.deletions, + }; + } + private requireAi(): AiProvider { if (!this.ai) { requireOpenAiKey(this.config); @@ -2178,6 +2834,25 @@ export class GHCrawlService { return this.ai as AiProvider; } + private touchRepoUserState(repoId: number, login: string | null | undefined, seenAt: string): void { + const normalizedLogin = normalizeLogin(login); + if (!normalizedLogin) return; + this.db + .prepare( + `insert into repo_user_state (repo_id, user_login, last_repo_refresh_at, first_seen_at, last_seen_at, last_refresh_error, updated_at) + values (?, ?, null, ?, ?, null, ?) + on conflict(repo_id, user_login) do update set + first_seen_at = coalesce(repo_user_state.first_seen_at, excluded.first_seen_at), + last_seen_at = case + when repo_user_state.last_seen_at is null then excluded.last_seen_at + when repo_user_state.last_seen_at < excluded.last_seen_at then excluded.last_seen_at + else repo_user_state.last_seen_at + end, + updated_at = excluded.updated_at`, + ) + .run(repoId, normalizedLogin, seenAt, seenAt, seenAt); + } + private requireGithub(): GitHubClient { if (!this.github) { requireGithubToken(this.config); @@ -2226,8 +2901,9 @@ export class GHCrawlService { `insert into threads ( repo_id, github_id, number, kind, state, title, body, author_login, author_type, html_url, labels_json, assignees_json, raw_json, content_hash, is_draft, - created_at_gh, updated_at_gh, closed_at_gh, merged_at_gh, first_pulled_at, last_pulled_at, updated_at - ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + created_at_gh, updated_at_gh, closed_at_gh, merged_at_gh, files_changed, additions, deletions, + first_pulled_at, last_pulled_at, updated_at + ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) on conflict(repo_id, kind, number) do update set github_id = excluded.github_id, state = excluded.state, @@ -2245,6 +2921,9 @@ export class GHCrawlService { updated_at_gh = excluded.updated_at_gh, closed_at_gh = excluded.closed_at_gh, merged_at_gh = excluded.merged_at_gh, + files_changed = excluded.files_changed, + additions = excluded.additions, + deletions = excluded.deletions, last_pulled_at = excluded.last_pulled_at, updated_at = excluded.updated_at`, ) @@ -2268,6 +2947,9 @@ export class GHCrawlService { typeof payload.updated_at === 'string' ? payload.updated_at : null, typeof payload.closed_at === 'string' ? payload.closed_at : null, typeof payload.merged_at === 'string' ? payload.merged_at : null, + parsePullFilesChanged(payload), + parseNullableInteger(payload.additions), + parseNullableInteger(payload.deletions), pulledAt, pulledAt, nowIso(), @@ -2275,6 +2957,7 @@ export class GHCrawlService { const row = this.db .prepare('select id from threads where repo_id = ? and kind = ? and number = ?') .get(repoId, kind, Number(payload.number)) as { id: number }; + this.touchRepoUserState(repoId, userLogin(payload), pulledAt); return row.id; } @@ -2336,6 +3019,9 @@ export class GHCrawlService { updated_at_gh = ?, closed_at_gh = ?, merged_at_gh = ?, + files_changed = ?, + additions = ?, + deletions = ?, last_pulled_at = ?, updated_at = ? where id = ?`, @@ -2346,6 +3032,9 @@ export class GHCrawlService { typeof payload.updated_at === 'string' ? payload.updated_at : null, typeof payload.closed_at === 'string' ? payload.closed_at : null, typeof payload.merged_at === 'string' ? payload.merged_at : null, + parsePullFilesChanged(payload), + parseNullableInteger(payload.additions), + parseNullableInteger(payload.deletions), pulledAt, pulledAt, staleRow.id, @@ -2425,6 +3114,9 @@ export class GHCrawlService { updated_at_gh = ?, closed_at_gh = ?, merged_at_gh = ?, + files_changed = ?, + additions = ?, + deletions = ?, last_pulled_at = ?, updated_at = ? where id = ?`, @@ -2435,6 +3127,9 @@ export class GHCrawlService { typeof payload.updated_at === 'string' ? payload.updated_at : null, typeof payload.closed_at === 'string' ? payload.closed_at : null, typeof payload.merged_at === 'string' ? payload.merged_at : null, + parsePullFilesChanged(payload), + parseNullableInteger(payload.additions), + parseNullableInteger(payload.deletions), pulledAt, pulledAt, row.id,