Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 5 additions & 5 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion packages/cli/package.json
Original file line numberDiff line numberDiff line change
@@ -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": {
Expand Down
44 changes: 35 additions & 9 deletions packages/cli/src/agent.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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';
Expand DownExpand Up@@ -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__';
Expand DownExpand Up@@ -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 <path> agent list')
.addHelpText('after', `
Examples:
$ diffity agent list --status open --json
Expand DownExpand Up@@ -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(
Expand All@@ -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 <seconds>', 'How long to wait before giving up', '900')
.option('--timeout <seconds>', `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();
Expand DownExpand Up@@ -532,10 +556,11 @@ Examples:
.option('--annotation <text>', '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;
Expand All@@ -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
Expand All@@ -573,8 +599,8 @@ Examples:
.requiredOption('--tour <id>', '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;
Expand Down
17 changes: 17 additions & 0 deletions packages/cli/src/live-unanswered.ts
Original file line numberDiff line numberDiff line change
@@ -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;
}
10 changes: 7 additions & 3 deletions packages/cli/src/tours.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -95,16 +95,20 @@ export function createTour(sessionId: string, topic: string, body: string): Tour
};
}

export function getTour(id: string): Tour | null {
const row = queryOne<TourRow>('SELECT * FROM tours WHERE id = ?', id);
export function getTour(idOrPrefix: string): Tour | null {
let row = queryOne<TourRow>('SELECT * FROM tours WHERE id = ?', idOrPrefix);

if (!row && idOrPrefix.length >= 8) {
row = queryOne<TourRow>('SELECT * FROM tours WHERE id LIKE ?', idOrPrefix + '%');
}

if (!row) {
return null;
}

const stepRows = queryAll<TourStepRow>(
'SELECT * FROM tour_steps WHERE tour_id = ? ORDER BY sort_order ASC',
id,
row.id,
);

return rowToTour(row, stepRows.map(rowToTourStep));
Expand Down
24 changes: 24 additions & 0 deletions packages/cli/tests/live-unanswered.test.ts
Original file line numberDiff line numberDiff line change
@@ -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();
});
});
2 changes: 1 addition & 1 deletion packages/git/package.json
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
{
"name": "@diffity/git",
"version": "0.9.9",
"version": "0.9.10",
"private": true,
"type": "module",
"main": "./dist/index.js",
Expand Down
2 changes: 1 addition & 1 deletion packages/github/package.json
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
{
"name": "@diffity/github",
"version": "0.9.9",
"version": "0.9.10",
"private": true,
"type": "module",
"main": "./dist/index.js",
Expand Down
1 change: 1 addition & 0 deletions packages/github/src/detection.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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),
Expand Down
2 changes: 2 additions & 0 deletions packages/github/src/types.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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. */
Expand Down
2 changes: 1 addition & 1 deletion packages/parser/package.json
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
{
"name": "@diffity/parser",
"version": "0.9.9",
"version": "0.9.10",
"private": true,
"type": "module",
"main": "./dist/index.js",
Expand Down
2 changes: 1 addition & 1 deletion packages/ui/package.json
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
{
"name": "@diffity/ui",
"version": "0.9.9",
"version": "0.9.10",
"type": "module",
"private": true,
"scripts": {
Expand Down
6 changes: 5 additions & 1 deletion packages/ui/src/components/layout/toolbar.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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';
Expand DownExpand Up@@ -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;
}
Expand DownExpand Up@@ -184,6 +185,9 @@ export function Toolbar(props: ToolbarProps) {
>
<GitHubIcon className="w-3 h-3" />
#{githubDetails.prNumber}
{githubDetails.prAuthor && (
<span className="text-text-muted">by {githubDetails.prAuthor}</span>
)}
</button>
)}
</div>
Expand Down
1 change: 1 addition & 0 deletions packages/ui/src/lib/api.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -53,6 +53,7 @@ export interface PrReview {
export interface GitHubDetails {
prNumber: number;
prTitle: string;
prAuthor: string;
prUrl: string;
prCreatedAt: string;
headSha: string;
Expand Down
59 changes: 59 additions & 0 deletions packages/ui/tests/toolbar-pr-author.test.tsx
Original file line numberDiff line numberDiff line change
@@ -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(
<Toolbar
viewMode="split"
onViewModeChange={() => {}}
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();
});
});