diff --git a/package-lock.json b/package-lock.json index dc60121..24a3e9d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8452,7 +8452,7 @@ }, "packages/cli": { "name": "diffity", - "version": "0.9.9", + "version": "0.9.10", "license": "MIT", "dependencies": { "commander": "^14.0.3", @@ -8476,7 +8476,7 @@ }, "packages/git": { "name": "@diffity/git", - "version": "0.9.9", + "version": "0.9.10", "devDependencies": { "@types/node": "^25.5.0", "typescript": "^5.9.3", @@ -8485,7 +8485,7 @@ }, "packages/github": { "name": "@diffity/github", - "version": "0.9.9", + "version": "0.9.10", "dependencies": { "@diffity/parser": "*" }, @@ -8497,7 +8497,7 @@ }, "packages/parser": { "name": "@diffity/parser", - "version": "0.9.9", + "version": "0.9.10", "devDependencies": { "typescript": "^5.9.3", "vitest": "^4.1.0" @@ -8505,7 +8505,7 @@ }, "packages/ui": { "name": "@diffity/ui", - "version": "0.9.9", + "version": "0.9.10", "dependencies": { "@react-router/node": "^7.13.2", "@tailwindcss/vite": "^4.2.1", diff --git a/packages/cli/package.json b/packages/cli/package.json index 4fa0ad5..51737a8 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "diffity", - "version": "0.9.9", + "version": "0.9.10", "description": "GitHub-style git diff viewer in the browser", "type": "module", "bin": { diff --git a/packages/cli/src/agent.ts b/packages/cli/src/agent.ts index 7b0144e..01d3668 100644 --- a/packages/cli/src/agent.ts +++ b/packages/cli/src/agent.ts @@ -15,11 +15,12 @@ import { type Thread, } from './threads.js'; import { answerLiveRequest, type LiveRequest } from './live.js'; -import { clampClientWait } from './live-wait.js'; +import { clampClientWait, CLIENT_WAIT_CAP_SECONDS } from './live-wait.js'; import { directiveFor } from './live-intent.js'; import { findInstanceForRepo, type RegistryEntry } from './registry.js'; import { createHash } from 'node:crypto'; -import { createTour, addTourStep, updateTourStatus, deleteTour, deleteToursForSession } from './tours.js'; +import { createTour, addTourStep, updateTourStatus, deleteTour, deleteToursForSession, getTour } from './tours.js'; +import { unansweredRequest } from './live-unanswered.js'; import { readAnchor, clampToFile, countWorkingTreeLines } from './anchor.js'; import { startReviewRun, finishReviewRun } from './review-run.js'; import { readRepoConfig, DEFAULT_SEVERITIES, resolveInRepo, REPO_CONFIG_FILE } from '@diffity/git'; @@ -71,6 +72,19 @@ function resolveThreadId(shortId: string, sessionId: string): Thread { return thread; } +function resolveTourId(shortId: string, sessionId: string): string { + const tour = getTour(shortId); + if (!tour) { + console.error(pc.red(`Error: Tour not found: ${shortId}`)); + process.exit(1); + } + if (tour.sessionId !== sessionId) { + console.error(pc.red(`Error: Tour ${shortId} does not belong to current session`)); + process.exit(1); + } + return tour.id; +} + function formatThreadLine(thread: Thread): string { const shortId = thread.id.slice(0, 8); const isGeneral = thread.filePath === '__general__'; @@ -150,6 +164,7 @@ export function registerAgentCommands(program: Command): void { const agent = program .command('agent') .description('Agent commands for interacting with review comments') + .addHelpText('after', '\nPass --repo before `agent` when the current directory is not the repository:\n diffity --repo agent list') .addHelpText('after', ` Examples: $ diffity agent list --status open --json @@ -280,6 +295,7 @@ Examples: .action((id: string, opts: { body: string; aside?: boolean; answers?: string }) => { const session = requireSession(); const thread = resolveThreadId(id, session.id); + const stillOpen = unansweredRequest(thread.comments); addReply(thread.id, opts.body, { name: 'Agent', type: 'agent' }, opts.aside ? 'aside' : 'review'); if (opts.answers && !answerLiveRequest(opts.answers)) { console.error( @@ -288,13 +304,21 @@ Examples: ), ); } + if (!opts.answers && stillOpen) { + console.error( + pc.yellow( + `This thread has a request nobody has closed. Replying does not close it — it will be ` + + `re-armed and handed back to you. Close it with --answers ${stillOpen.slice(0, 8)}`, + ), + ); + } console.log(pc.green(`Replied to thread ${thread.id.slice(0, 8)}`)); }); agent .command('await') .description('Wait for the reader to ask something, then exit so the agent can answer') - .option('--timeout ', 'How long to wait before giving up', '900') + .option('--timeout ', `How long to wait before giving up (each poll caps at ${CLIENT_WAIT_CAP_SECONDS}s and returns; call again to keep waiting)`, '900') .action(async (opts: { timeout: string }) => { requireSession(); const instance = findRunningInstance(); @@ -532,10 +556,11 @@ Examples: .option('--annotation ', 'Short inline annotation on highlighted code', '') .option('--json', 'Output as JSON') .action((opts) => { - requireSession(); + const session = requireSession(); assertFileExists(opts.file); + const tourId = resolveTourId(opts.tour, session.id); const endLine = opts.endLine ?? opts.line; - const step = addTourStep(opts.tour, opts.file, opts.line, endLine, opts.body, opts.annotation); + const step = addTourStep(tourId, opts.file, opts.line, endLine, opts.body, opts.annotation); if (opts.json) { console.log(JSON.stringify(step, null, 2)); return; @@ -552,8 +577,9 @@ Examples: .action((tourId: string | undefined, opts: { all?: boolean; includeBuilding?: boolean }) => { const session = requireSession(); if (tourId) { - deleteTour(tourId); - console.log(pc.green(`Removed walkthrough ${tourId.slice(0, 8)}`)); + const resolved = resolveTourId(tourId, session.id); + deleteTour(resolved); + console.log(pc.green(`Removed walkthrough ${resolved.slice(0, 8)}`)); return; } // Deleting every walkthrough has to be asked for. Reaching it by leaving the id off meant @@ -573,8 +599,8 @@ Examples: .requiredOption('--tour ', 'Tour ID') .option('--json', 'Output as JSON') .action((opts) => { - requireSession(); - updateTourStatus(opts.tour, 'ready'); + const session = requireSession(); + updateTourStatus(resolveTourId(opts.tour, session.id), 'ready'); if (opts.json) { console.log(JSON.stringify({ ok: true })); return; diff --git a/packages/cli/src/live-unanswered.ts b/packages/cli/src/live-unanswered.ts new file mode 100644 index 0000000..2cca78d --- /dev/null +++ b/packages/cli/src/live-unanswered.ts @@ -0,0 +1,17 @@ +interface CommentLike { + id: string; + liveRequestedAt?: string | null; + liveAnsweredAt?: string | null; +} + +/** + * A request on this thread that has been made and never answered. + * + * Answering is what closes a request, and it is a separate act from replying: stale claims are + * re-armed every few minutes, so a request left open comes back round and the agent is handed a + * question it has already answered, with nothing to say it has. + */ +export function unansweredRequest(comments: CommentLike[]): string | null { + const open = comments.find(comment => comment.liveRequestedAt && !comment.liveAnsweredAt); + return open?.id ?? null; +} diff --git a/packages/cli/src/tours.ts b/packages/cli/src/tours.ts index b9365e6..a696bb6 100644 --- a/packages/cli/src/tours.ts +++ b/packages/cli/src/tours.ts @@ -95,8 +95,12 @@ export function createTour(sessionId: string, topic: string, body: string): Tour }; } -export function getTour(id: string): Tour | null { - const row = queryOne('SELECT * FROM tours WHERE id = ?', id); +export function getTour(idOrPrefix: string): Tour | null { + let row = queryOne('SELECT * FROM tours WHERE id = ?', idOrPrefix); + + if (!row && idOrPrefix.length >= 8) { + row = queryOne('SELECT * FROM tours WHERE id LIKE ?', idOrPrefix + '%'); + } if (!row) { return null; @@ -104,7 +108,7 @@ export function getTour(id: string): Tour | null { const stepRows = queryAll( 'SELECT * FROM tour_steps WHERE tour_id = ? ORDER BY sort_order ASC', - id, + row.id, ); return rowToTour(row, stepRows.map(rowToTourStep)); diff --git a/packages/cli/tests/live-unanswered.test.ts b/packages/cli/tests/live-unanswered.test.ts new file mode 100644 index 0000000..6c5343b --- /dev/null +++ b/packages/cli/tests/live-unanswered.test.ts @@ -0,0 +1,24 @@ +import { describe, it, expect } from 'vitest'; +import { unansweredRequest } from '../src/live-unanswered.js'; + +const asked = { id: 'c1', liveRequestedAt: '2026-08-25T10:00:00Z', liveAnsweredAt: null }; +const answered = { id: 'c2', liveRequestedAt: '2026-08-25T10:00:00Z', liveAnsweredAt: '2026-08-25T10:05:00Z' }; +const plain = { id: 'c3' }; + +describe('unansweredRequest', () => { + it('finds a request nobody has closed', () => { + expect(unansweredRequest([plain, asked])).toBe('c1'); + }); + + it('ignores one that was answered', () => { + expect(unansweredRequest([plain, answered])).toBeNull(); + }); + + it('ignores comments that never asked for anything', () => { + expect(unansweredRequest([plain, plain])).toBeNull(); + }); + + it('is empty on an empty thread', () => { + expect(unansweredRequest([])).toBeNull(); + }); +}); diff --git a/packages/git/package.json b/packages/git/package.json index 855df98..31cc63d 100644 --- a/packages/git/package.json +++ b/packages/git/package.json @@ -1,6 +1,6 @@ { "name": "@diffity/git", - "version": "0.9.9", + "version": "0.9.10", "private": true, "type": "module", "main": "./dist/index.js", diff --git a/packages/github/package.json b/packages/github/package.json index bb789ab..c680bf7 100644 --- a/packages/github/package.json +++ b/packages/github/package.json @@ -1,6 +1,6 @@ { "name": "@diffity/github", - "version": "0.9.9", + "version": "0.9.10", "private": true, "type": "module", "main": "./dist/index.js", diff --git a/packages/github/src/detection.ts b/packages/github/src/detection.ts index 5877102..4c4ed52 100644 --- a/packages/github/src/detection.ts +++ b/packages/github/src/detection.ts @@ -46,6 +46,7 @@ export function fetchDetails(owner: string, repo: string, prNumber?: number): Gi prCreatedAt: pr.createdAt, headSha: pr.headSha, commentCount, + prAuthor: pr.authorLogin ?? '', viewerDidAuthor: !!pr.authorLogin && pr.authorLogin === getViewerLogin(), prBody: pr.body, reviews: getReviews(owner, repo, pr.number), diff --git a/packages/github/src/types.ts b/packages/github/src/types.ts index 864f4aa..626ae08 100644 --- a/packages/github/src/types.ts +++ b/packages/github/src/types.ts @@ -10,6 +10,8 @@ export interface GitHubDetails { prCreatedAt: string; headSha: string; commentCount: number; + /** Who opened it, which is not visible anywhere else in the page. */ + prAuthor: string; /** GitHub refuses to approve or request changes on your own pull request. */ viewerDidAuthor: boolean; /** The description, which is where the author says what the change is for. */ diff --git a/packages/parser/package.json b/packages/parser/package.json index 37b1554..27898f5 100644 --- a/packages/parser/package.json +++ b/packages/parser/package.json @@ -1,6 +1,6 @@ { "name": "@diffity/parser", - "version": "0.9.9", + "version": "0.9.10", "private": true, "type": "module", "main": "./dist/index.js", diff --git a/packages/ui/package.json b/packages/ui/package.json index 5455ab5..0858311 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,6 +1,6 @@ { "name": "@diffity/ui", - "version": "0.9.9", + "version": "0.9.10", "type": "module", "private": true, "scripts": { diff --git a/packages/ui/src/components/layout/toolbar.tsx b/packages/ui/src/components/layout/toolbar.tsx index 1ed0341..c69ad39 100644 --- a/packages/ui/src/components/layout/toolbar.tsx +++ b/packages/ui/src/components/layout/toolbar.tsx @@ -9,6 +9,7 @@ import { EyeOffIcon } from '../icons/eye-off-icon'; import { KeyboardIcon } from '../icons/keyboard-icon'; import { GitBranchIcon } from '../icons/git-branch-icon'; import { GitHubIcon } from '../icons/github-icon'; +import type { GitHubDetails } from '../../lib/api'; import { DiffStats } from '../diff/diff-stats'; import { GitHubDialog } from './github-dialog'; import { CommentToolbarActions } from '../comments/comment-toolbar-actions'; @@ -43,7 +44,7 @@ interface ToolbarProps { repoName: string | null; branch: string | null; description: string | null; - githubDetails?: { prNumber: number; prTitle: string; prUrl: string; prCreatedAt: string; headSha: string; commentCount: number } | null; + githubDetails?: GitHubDetails | null; sessionId?: string | null; onGitHubPulled?: () => void; } @@ -184,6 +185,9 @@ export function Toolbar(props: ToolbarProps) { > #{githubDetails.prNumber} + {githubDetails.prAuthor && ( + by {githubDetails.prAuthor} + )} )} diff --git a/packages/ui/src/lib/api.ts b/packages/ui/src/lib/api.ts index 6e82218..4a3451f 100644 --- a/packages/ui/src/lib/api.ts +++ b/packages/ui/src/lib/api.ts @@ -53,6 +53,7 @@ export interface PrReview { export interface GitHubDetails { prNumber: number; prTitle: string; + prAuthor: string; prUrl: string; prCreatedAt: string; headSha: string; diff --git a/packages/ui/tests/toolbar-pr-author.test.tsx b/packages/ui/tests/toolbar-pr-author.test.tsx new file mode 100644 index 0000000..0ad0659 --- /dev/null +++ b/packages/ui/tests/toolbar-pr-author.test.tsx @@ -0,0 +1,59 @@ +import { describe, it, expect, afterEach } from 'vitest'; +import { render, cleanup, screen } from '@testing-library/react'; +import { Toolbar } from '../src/components/layout/toolbar'; +import type { GitHubDetails } from '../src/lib/api'; + +afterEach(cleanup); + +const details: GitHubDetails = { + prNumber: 14390, + prTitle: 'fix: no longer pregnant fix for never pregnant user', + prAuthor: 'nc-felicia', + prUrl: 'https://github.com/NaturalCycles/NCBackend3/pull/14390', + prCreatedAt: '2026-08-25T08:00:00Z', + headSha: 'abc123', + commentCount: 0, + viewerDidAuthor: false, + prBody: '', + reviews: [], +}; + +function show(githubDetails: GitHubDetails | null) { + render( + {}} + hideWhitespace={false} + onHideWhitespaceChange={() => {}} + theme="dark" + onToggleTheme={() => {}} + wrapLines={false} + onToggleWrapLines={() => {}} + onShowHelp={() => {}} + threads={[]} + onDeleteAllComments={() => {}} + onScrollToThread={() => {}} + repoName="NCBackend3" + branch="DEV-13465-no-longer-preg" + description="Changes from master" + githubDetails={githubDetails} + />, + ); +} + +describe('the toolbar says whose pull request this is', () => { + it('names the author beside the number', () => { + show(details); + + expect(screen.getByText('#14390')).toBeTruthy(); + expect(screen.getByText('by nc-felicia')).toBeTruthy(); + }); + + // An author is not always known — a detached session, or gh returning nothing useful. + it('shows the number alone when it is not', () => { + show({ ...details, prAuthor: '' }); + + expect(screen.getByText('#14390')).toBeTruthy(); + expect(screen.queryByText(/^by /)).toBeNull(); + }); +});