difit 1.1.5 → 1.1.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -8,14 +8,9 @@ A lightweight command-line tool that spins up a local web server to display Git
8
8
 
9
9
  ## ✨ Features
10
10
 
11
- - 🌙 **GitHub-like UI**: Familiar dark theme file list and diff interface
12
- - 💬 **Inline Comments**: Add comments to specific lines and generate Claude Code prompts
13
- - 🔄 **Side-by-Side & Inline Views**: Choose your preferred diff viewing mode
14
- - 🖥️ **Terminal UI Mode**: View diffs directly in your terminal with `--tui` flag
15
11
  - ⚡ **Zero Config**: Just run `npx reviewit <commit>` and it works
16
- - 🔐 **Local Only**: Never exposes data over network - runs on localhost only
17
- - 🛠️ **Modern Stack**: React 18 + TypeScript + Tailwind CSS
18
- - 🎨 **Syntax Highlighting**: Dynamic language loading for Bash, PHP, SQL, Ruby, Java, and more
12
+ - 🌙 **Review for AI**: Add comments and generate Claude Code prompts
13
+ - 🖥️ **Terminal UI**: View diffs directly in terminal with `--tui`
19
14
 
20
15
  ## ⚡ Quick Start
21
16
 
@@ -30,6 +25,7 @@ npx reviewit # View HEAD commit changes in a beautiful diff viewer
30
25
  ```bash
31
26
  npx reviewit <commit-ish> # View single commit diff
32
27
  npx reviewit <commit-ish> [compare-with] # Compare two commits/branches
28
+ npx reviewit --pr <github-pr-url> # Review GitHub pull request
33
29
  ```
34
30
 
35
31
  ### Single commit review
@@ -65,16 +61,29 @@ npx reviewit working # Unstaged changes only (cannot use compare-with)
65
61
  | `staged` | Shows staged changes ready to be committed | ✅ Yes |
66
62
  | `working` | Shows unstaged changes in your working directory | ❌ No |
67
63
 
68
- ### ⚙️ CLI Options
64
+ ### GitHub PR
69
65
 
70
- | Flag | Default | Description |
71
- | ---------------- | ------------ | ------------------------------------------------------------------- |
72
- | `<commit-ish>` | HEAD | Any Git reference: hash, tag, HEAD~n, branch, or Special Arguments |
73
- | `[compare-with]` | (optional) | Optional second commit to compare with (shows diff between the two) |
74
- | `--port` | auto | Preferred port; falls back if occupied |
75
- | `--no-open` | false | Don't automatically open browser |
76
- | `--mode` | side-by-side | Diff mode: `inline` or `side-by-side` |
77
- | `--tui` | false | Use terminal UI mode instead of web interface |
66
+ ```bash
67
+ npx reviewit --pr https://github.com/owner/repo/pull/123
68
+ ```
69
+
70
+ ReviewIt automatically handles GitHub authentication using:
71
+
72
+ 1. **GitHub CLI** (recommended): If you're logged in with `gh auth login`, ReviewIt uses your existing credentials
73
+ 2. **Environment Variable**: Set `GITHUB_TOKEN` environment variable
74
+ 3. **No Authentication**: Public repositories work without authentication (rate-limited)
75
+
76
+ ## ⚙️ CLI Options
77
+
78
+ | Flag | Default | Description |
79
+ | ---------------- | ------------ | ---------------------------------------------------------------------- |
80
+ | `<commit-ish>` | HEAD | Any Git reference: hash, tag, HEAD~n, branch, or Special Arguments |
81
+ | `[compare-with]` | (optional) | Optional second commit to compare with (shows diff between the two) |
82
+ | `--pr <url>` | - | GitHub PR URL to review (e.g., https://github.com/owner/repo/pull/123) |
83
+ | `--port` | auto | Preferred port; falls back if occupied |
84
+ | `--no-open` | false | Don't automatically open browser |
85
+ | `--mode` | side-by-side | Diff mode: `inline` or `side-by-side` |
86
+ | `--tui` | false | Use terminal UI mode instead of web interface |
78
87
 
79
88
  ## 💬 Comment System
80
89
 
@@ -88,10 +97,9 @@ ReviewIt includes an inline commenting system that integrates with Claude Code:
88
97
 
89
98
  ### Comment Prompt Format
90
99
 
91
- ```
92
- File: src/components/Button.tsx
93
- Line: 42
94
- Comment: This function name should probably be more specific
100
+ ```sh
101
+ src/components/Button.tsx:42 # Automatically added this line
102
+ This name should probably be more specific.
95
103
  ```
96
104
 
97
105
  ## 🎨 Syntax Highlighting
@@ -150,19 +158,13 @@ pnpm run typecheck
150
158
 
151
159
  - **CLI**: Commander.js for argument parsing with comprehensive validation
152
160
  - **Backend**: Express server with simple-git for diff processing
161
+ - **GitHub Integration**: Octokit for GitHub API with automatic authentication (GitHub CLI + env vars)
153
162
  - **Frontend**: React 18 + TypeScript + Vite
154
163
  - **Styling**: Tailwind CSS v4 with GitHub-like dark theme
155
164
  - **Syntax Highlighting**: Prism.js with dynamic language loading
156
165
  - **Testing**: Vitest for unit tests with co-located test files
157
166
  - **Quality**: ESLint, Prettier, lefthook pre-commit hooks
158
167
 
159
- ### Key Components
160
-
161
- - **Validation System**: Unified validation logic for CLI arguments with comprehensive error handling
162
- - **Dual Parameter System**: Internal refactoring splits commitish into targetCommitish and baseCommitish for flexibility
163
- - **Special Argument Support**: Working directory, staging area, and uncommitted changes detection
164
- - **Hash Utilities**: Consistent short hash generation for commit display
165
-
166
168
  ## 📋 Requirements
167
169
 
168
170
  - Node.js ≥ 21.0.0
package/dist/cli/index.js CHANGED
@@ -3,7 +3,7 @@ import { Command } from 'commander';
3
3
  import React from 'react';
4
4
  import pkg from '../../package.json' with { type: 'json' };
5
5
  import { startServer } from '../server/server.js';
6
- import { validateDiffArguments } from './utils.js';
6
+ import { validateDiffArguments, resolvePrCommits } from './utils.js';
7
7
  function isSpecialArg(arg) {
8
8
  return arg === 'working' || arg === 'staged' || arg === '.';
9
9
  }
@@ -18,18 +18,42 @@ program
18
18
  .option('--no-open', 'do not automatically open browser')
19
19
  .option('--mode <mode>', 'diff mode (inline only for now)', 'inline')
20
20
  .option('--tui', 'use terminal UI instead of web interface')
21
+ .option('--pr <url>', 'GitHub PR URL to review (e.g., https://github.com/owner/repo/pull/123)')
21
22
  .action(async (commitish, compareWith, options) => {
22
23
  try {
23
24
  // Determine target and base commitish
24
25
  let targetCommitish = commitish;
25
26
  let baseCommitish;
26
- if (compareWith) {
27
+ // Handle PR URL option
28
+ if (options.pr) {
29
+ if (commitish !== 'HEAD' || compareWith) {
30
+ console.error('Error: --pr option cannot be used with positional arguments');
31
+ process.exit(1);
32
+ }
33
+ try {
34
+ const prCommits = await resolvePrCommits(options.pr);
35
+ targetCommitish = prCommits.targetCommitish;
36
+ baseCommitish = prCommits.baseCommitish;
37
+ console.log(`📋 Reviewing PR: ${options.pr}`);
38
+ console.log(`🎯 Target commit: ${targetCommitish.substring(0, 7)}`);
39
+ console.log(`📍 Base commit: ${baseCommitish.substring(0, 7)}`);
40
+ }
41
+ catch (error) {
42
+ console.error(`Error resolving PR: ${error instanceof Error ? error.message : 'Unknown error'}`);
43
+ process.exit(1);
44
+ }
45
+ }
46
+ else if (compareWith) {
27
47
  // If compareWith is provided, use it as base
28
48
  baseCommitish = compareWith;
29
49
  }
30
50
  else {
31
51
  // Handle special arguments
32
- if (isSpecialArg(commitish)) {
52
+ if (commitish === 'working') {
53
+ // working compares working directory with staging area
54
+ baseCommitish = 'staged';
55
+ }
56
+ else if (isSpecialArg(commitish)) {
33
57
  baseCommitish = 'HEAD';
34
58
  }
35
59
  else {
@@ -49,10 +73,13 @@ program
49
73
  render(React.createElement(TuiApp, { targetCommitish, baseCommitish }));
50
74
  return;
51
75
  }
52
- const validation = validateDiffArguments(targetCommitish, compareWith);
53
- if (!validation.valid) {
54
- console.error(`Error: ${validation.error}`);
55
- process.exit(1);
76
+ // Skip validation for PR URLs as they're already resolved to valid commits
77
+ if (!options.pr) {
78
+ const validation = validateDiffArguments(targetCommitish, compareWith);
79
+ if (!validation.valid) {
80
+ console.error(`Error: ${validation.error}`);
81
+ process.exit(1);
82
+ }
56
83
  }
57
84
  const { url, port } = await startServer({
58
85
  targetCommitish,
@@ -1,6 +1,27 @@
1
1
  export declare function validateCommitish(commitish: string): boolean;
2
2
  export declare function shortHash(hash: string): string;
3
3
  export declare function createCommitRangeString(baseHash: string, targetHash: string): string;
4
+ export interface PullRequestInfo {
5
+ owner: string;
6
+ repo: string;
7
+ pullNumber: number;
8
+ }
9
+ export interface PullRequestDetails {
10
+ baseSha: string;
11
+ headSha: string;
12
+ baseRef: string;
13
+ headRef: string;
14
+ }
15
+ export declare function parseGitHubPrUrl(url: string): PullRequestInfo | null;
16
+ export declare function fetchPrDetails(prInfo: PullRequestInfo): Promise<PullRequestDetails>;
17
+ export declare function resolveCommitInLocalRepo(sha: string, context?: {
18
+ owner: string;
19
+ repo: string;
20
+ }): string;
21
+ export declare function resolvePrCommits(prUrl: string): Promise<{
22
+ targetCommitish: string;
23
+ baseCommitish: string;
24
+ }>;
4
25
  export declare function validateDiffArguments(targetCommitish: string, baseCommitish?: string): {
5
26
  valid: boolean;
6
27
  error?: string;
package/dist/cli/utils.js CHANGED
@@ -23,9 +23,117 @@ export function validateCommitish(commitish) {
23
23
  export function shortHash(hash) {
24
24
  return hash.substring(0, 7);
25
25
  }
26
+ import { execSync } from 'child_process';
27
+ import { Octokit } from '@octokit/rest';
26
28
  export function createCommitRangeString(baseHash, targetHash) {
27
29
  return `${baseHash}...${targetHash}`;
28
30
  }
31
+ export function parseGitHubPrUrl(url) {
32
+ try {
33
+ const urlObj = new URL(url);
34
+ if (urlObj.hostname !== 'github.com') {
35
+ return null;
36
+ }
37
+ const pathParts = urlObj.pathname.split('/').filter(Boolean);
38
+ if (pathParts.length < 4 || pathParts[2] !== 'pull') {
39
+ return null;
40
+ }
41
+ const owner = pathParts[0];
42
+ const repo = pathParts[1];
43
+ const pullNumber = parseInt(pathParts[3], 10);
44
+ if (isNaN(pullNumber)) {
45
+ return null;
46
+ }
47
+ return { owner, repo, pullNumber };
48
+ }
49
+ catch {
50
+ return null;
51
+ }
52
+ }
53
+ function getGitHubToken() {
54
+ // Try to get token from environment variable first
55
+ if (process.env.GITHUB_TOKEN) {
56
+ return process.env.GITHUB_TOKEN;
57
+ }
58
+ // Try to get token from GitHub CLI
59
+ try {
60
+ const result = execSync('gh auth token', { encoding: 'utf8', stdio: 'pipe' });
61
+ return result.trim();
62
+ }
63
+ catch {
64
+ // GitHub CLI not available or not authenticated
65
+ return undefined;
66
+ }
67
+ }
68
+ export async function fetchPrDetails(prInfo) {
69
+ const token = getGitHubToken();
70
+ const octokit = new Octokit({
71
+ auth: token,
72
+ });
73
+ try {
74
+ const { data: pr } = await octokit.rest.pulls.get({
75
+ owner: prInfo.owner,
76
+ repo: prInfo.repo,
77
+ pull_number: prInfo.pullNumber,
78
+ });
79
+ return {
80
+ baseSha: pr.base.sha,
81
+ headSha: pr.head.sha,
82
+ baseRef: pr.base.ref,
83
+ headRef: pr.head.ref,
84
+ };
85
+ }
86
+ catch (error) {
87
+ if (error instanceof Error) {
88
+ const authHint = token
89
+ ? ''
90
+ : ' (Try: gh auth login or set GITHUB_TOKEN environment variable)';
91
+ throw new Error(`Failed to fetch PR details: ${error.message}${authHint}`);
92
+ }
93
+ throw new Error('Failed to fetch PR details: Unknown error');
94
+ }
95
+ }
96
+ export function resolveCommitInLocalRepo(sha, context) {
97
+ try {
98
+ // Verify if the commit exists locally
99
+ execSync(`git cat-file -e ${sha}`, { stdio: 'ignore' });
100
+ return sha;
101
+ }
102
+ catch {
103
+ // If commit doesn't exist, try to fetch from remote
104
+ try {
105
+ execSync('git fetch origin', { stdio: 'ignore' });
106
+ execSync(`git cat-file -e ${sha}`, { stdio: 'ignore' });
107
+ return sha;
108
+ }
109
+ catch {
110
+ const errorMessage = [
111
+ `Commit ${sha} not found in local repository.`,
112
+ '',
113
+ 'Common causes:',
114
+ ' • Are you running this command in the correct repository directory?',
115
+ context ? ` • Expected repository: ${context.owner}/${context.repo}` : '',
116
+ ' • Is this PR from a fork?',
117
+ ' • Try: git remote add upstream <original-repo-url> && git fetch upstream',
118
+ ' • Try: git fetch --all to fetch from all remotes',
119
+ ]
120
+ .filter(Boolean)
121
+ .join('\n');
122
+ throw new Error(errorMessage);
123
+ }
124
+ }
125
+ }
126
+ export async function resolvePrCommits(prUrl) {
127
+ const prInfo = parseGitHubPrUrl(prUrl);
128
+ if (!prInfo) {
129
+ throw new Error('Invalid GitHub PR URL format. Expected: https://github.com/owner/repo/pull/123');
130
+ }
131
+ const prDetails = await fetchPrDetails(prInfo);
132
+ const context = { owner: prInfo.owner, repo: prInfo.repo };
133
+ const targetCommitish = resolveCommitInLocalRepo(prDetails.headSha, context);
134
+ const baseCommitish = resolveCommitInLocalRepo(prDetails.baseSha, context);
135
+ return { targetCommitish, baseCommitish };
136
+ }
29
137
  export function validateDiffArguments(targetCommitish, baseCommitish) {
30
138
  // Validate target commitish format
31
139
  if (!validateCommitish(targetCommitish)) {
@@ -35,20 +143,26 @@ export function validateDiffArguments(targetCommitish, baseCommitish) {
35
143
  if (baseCommitish !== undefined && !validateCommitish(baseCommitish)) {
36
144
  return { valid: false, error: 'Invalid base commit-ish format' };
37
145
  }
38
- // Special arguments are only allowed in target, not base
146
+ // Special arguments are only allowed in target, not base (except staged with working)
39
147
  const specialArgs = ['working', 'staged', '.'];
40
148
  if (baseCommitish && specialArgs.includes(baseCommitish)) {
41
- return {
42
- valid: false,
43
- error: `Special arguments (working, staged, .) are only allowed as target, not base. Got base: ${baseCommitish}`,
44
- };
149
+ // Allow 'staged' as base only when target is 'working'
150
+ if (baseCommitish === 'staged' && targetCommitish === 'working') {
151
+ // This is valid: working vs staged
152
+ }
153
+ else {
154
+ return {
155
+ valid: false,
156
+ error: `Special arguments (working, staged, .) are only allowed as target, not base. Got base: ${baseCommitish}`,
157
+ };
158
+ }
45
159
  }
46
160
  // Cannot compare same values
47
161
  if (targetCommitish === baseCommitish) {
48
162
  return { valid: false, error: `Cannot compare ${targetCommitish} with itself` };
49
163
  }
50
- // "working" shows unstaged changes and cannot be compared with another commit
51
- if (targetCommitish === 'working' && baseCommitish) {
164
+ // "working" shows unstaged changes and can only be compared with staging area
165
+ if (targetCommitish === 'working' && baseCommitish && baseCommitish !== 'staged') {
52
166
  return {
53
167
  valid: false,
54
168
  error: '"working" shows unstaged changes and cannot be compared with another commit. Use "." instead to compare all uncommitted changes with a specific commit.',
@@ -1,5 +1,5 @@
1
1
  import { describe, it, expect } from 'vitest';
2
- import { validateCommitish, validateDiffArguments, shortHash } from './utils';
2
+ import { validateCommitish, validateDiffArguments, shortHash, parseGitHubPrUrl } from './utils';
3
3
  describe('CLI Utils', () => {
4
4
  describe('validateCommitish', () => {
5
5
  it('should validate full SHA hashes', () => {
@@ -73,6 +73,12 @@ describe('CLI Utils', () => {
73
73
  expect(validateDiffArguments('staged', 'HEAD')).toEqual({ valid: true });
74
74
  expect(validateDiffArguments('.', 'main')).toEqual({ valid: true });
75
75
  });
76
+ it('should allow staged as base only with working target', () => {
77
+ expect(validateDiffArguments('working', 'staged')).toEqual({ valid: true });
78
+ const result = validateDiffArguments('HEAD', 'staged');
79
+ expect(result.valid).toBe(false);
80
+ expect(result.error).toBe('Special arguments (working, staged, .) are only allowed as target, not base. Got base: staged');
81
+ });
76
82
  });
77
83
  describe('same value comparison', () => {
78
84
  it('should reject same target and base values', () => {
@@ -97,6 +103,17 @@ describe('CLI Utils', () => {
97
103
  it('should allow working without compareWith', () => {
98
104
  expect(validateDiffArguments('working')).toEqual({ valid: true });
99
105
  });
106
+ it('should allow working with staged', () => {
107
+ expect(validateDiffArguments('working', 'staged')).toEqual({ valid: true });
108
+ });
109
+ it('should reject working with other commits', () => {
110
+ const result1 = validateDiffArguments('working', 'main');
111
+ expect(result1.valid).toBe(false);
112
+ expect(result1.error).toBe('"working" shows unstaged changes and cannot be compared with another commit. Use "." instead to compare all uncommitted changes with a specific commit.');
113
+ const result2 = validateDiffArguments('working', 'abc123');
114
+ expect(result2.valid).toBe(false);
115
+ expect(result2.error).toBe('"working" shows unstaged changes and cannot be compared with another commit. Use "." instead to compare all uncommitted changes with a specific commit.');
116
+ });
100
117
  it('should allow other special args with compareWith', () => {
101
118
  expect(validateDiffArguments('staged', 'HEAD')).toEqual({ valid: true });
102
119
  expect(validateDiffArguments('.', 'main')).toEqual({ valid: true });
@@ -127,4 +144,51 @@ describe('CLI Utils', () => {
127
144
  expect(shortHash('')).toBe('');
128
145
  });
129
146
  });
147
+ describe('parseGitHubPrUrl', () => {
148
+ it('should parse valid GitHub PR URLs', () => {
149
+ const result = parseGitHubPrUrl('https://github.com/owner/repo/pull/123');
150
+ expect(result).toEqual({
151
+ owner: 'owner',
152
+ repo: 'repo',
153
+ pullNumber: 123,
154
+ });
155
+ });
156
+ it('should parse GitHub PR URLs with additional path segments', () => {
157
+ const result = parseGitHubPrUrl('https://github.com/owner/repo/pull/456/files');
158
+ expect(result).toEqual({
159
+ owner: 'owner',
160
+ repo: 'repo',
161
+ pullNumber: 456,
162
+ });
163
+ });
164
+ it('should parse GitHub PR URLs with query parameters', () => {
165
+ const result = parseGitHubPrUrl('https://github.com/owner/repo/pull/789?tab=files');
166
+ expect(result).toEqual({
167
+ owner: 'owner',
168
+ repo: 'repo',
169
+ pullNumber: 789,
170
+ });
171
+ });
172
+ it('should handle URLs with hyphens and underscores in owner/repo names', () => {
173
+ const result = parseGitHubPrUrl('https://github.com/owner-name/repo_name/pull/123');
174
+ expect(result).toEqual({
175
+ owner: 'owner-name',
176
+ repo: 'repo_name',
177
+ pullNumber: 123,
178
+ });
179
+ });
180
+ it('should return null for invalid URLs', () => {
181
+ expect(parseGitHubPrUrl('not-a-url')).toBe(null);
182
+ expect(parseGitHubPrUrl('https://example.com/owner/repo/pull/123')).toBe(null);
183
+ expect(parseGitHubPrUrl('https://github.com/owner/repo/issues/123')).toBe(null);
184
+ expect(parseGitHubPrUrl('https://github.com/owner/repo')).toBe(null);
185
+ expect(parseGitHubPrUrl('https://github.com/owner/repo/pull/abc')).toBe(null);
186
+ });
187
+ it('should handle malformed URLs gracefully', () => {
188
+ expect(parseGitHubPrUrl('')).toBe(null);
189
+ expect(parseGitHubPrUrl('https://github.com')).toBe(null);
190
+ expect(parseGitHubPrUrl('https://github.com/owner')).toBe(null);
191
+ expect(parseGitHubPrUrl('https://github.com/owner/repo/pull')).toBe(null);
192
+ });
193
+ });
130
194
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "difit",
3
- "version": "1.1.5",
3
+ "version": "1.1.6",
4
4
  "description": "A lightweight command-line tool that spins up a local web server to display Git commit diffs in a GitHub-like Files changed view",
5
5
  "type": "module",
6
6
  "engines": {
@@ -34,6 +34,7 @@
34
34
  "prepublishOnly": "NODE_ENV=production pnpm run build"
35
35
  },
36
36
  "dependencies": {
37
+ "@octokit/rest": "^22.0.0",
37
38
  "commander": "^14.0.0",
38
39
  "express": "^5.1.0",
39
40
  "ink": "^6.0.1",