ghreview 1.0.0

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.
@@ -0,0 +1,10 @@
1
+ {
2
+ "permissions": {
3
+ "allow": [
4
+ "Bash(npm run lint)",
5
+ "Bash(npm test)",
6
+ "Bash(npm run lint:*)"
7
+ ],
8
+ "deny": []
9
+ }
10
+ }
@@ -0,0 +1,16 @@
1
+ version: 2
2
+ updates:
3
+ - package-ecosystem: "github-actions"
4
+ directory: "/"
5
+ schedule:
6
+ interval: "daily"
7
+ commit-message:
8
+ prefix: "chore"
9
+ include: "scope"
10
+ - package-ecosystem: "npm"
11
+ directory: "/"
12
+ schedule:
13
+ interval: "daily"
14
+ commit-message:
15
+ prefix: "chore"
16
+ include: "scope"
@@ -0,0 +1,50 @@
1
+ name: Test & Maybe Release
2
+ on: [push, pull_request]
3
+ jobs:
4
+ test:
5
+ strategy:
6
+ fail-fast: false
7
+ matrix:
8
+ node: [22.x, lts/*, current]
9
+ os: [macos-latest, ubuntu-latest, windows-latest]
10
+ runs-on: ${{ matrix.os }}
11
+ steps:
12
+ - name: Checkout Repository
13
+ uses: actions/checkout@v4.2.2
14
+ - name: Use Node.js ${{ matrix.node }}
15
+ uses: actions/setup-node@v4.4.0
16
+ with:
17
+ node-version: ${{ matrix.node }}
18
+ - name: Install Dependencies
19
+ run: |
20
+ npm install --no-progress
21
+ - name: Run tests
22
+ run: |
23
+ npm config set script-shell bash
24
+ npm run test
25
+ release:
26
+ name: Release
27
+ needs: test
28
+ runs-on: ubuntu-latest
29
+ if: github.event_name == 'push' && github.ref == 'refs/heads/master'
30
+ steps:
31
+ - name: Checkout
32
+ uses: actions/checkout@v4.2.2
33
+ with:
34
+ fetch-depth: 0
35
+ - name: Setup Node.js
36
+ uses: actions/setup-node@v4.4.0
37
+ with:
38
+ node-version: lts/*
39
+ - name: Install dependencies
40
+ run: |
41
+ npm install --no-progress --no-save
42
+ - name: Build
43
+ run: |
44
+ npm run build
45
+ - name: Release
46
+ env:
47
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
48
+ NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
49
+ run: npx semantic-release
50
+
package/AGENTS.md ADDED
@@ -0,0 +1,40 @@
1
+ # ghreview
2
+
3
+ GitHub PR-based code review workflow for AI agents.
4
+
5
+ ## IMPORTANT INSTRUCTIONS FOR AI AGENTS
6
+ - NEVER perform destructive/write git commands unless explicitly asked (commit, reset, etc.)
7
+ - The user manages their git commits - do not commit for them
8
+ - ALWAYS run `npm run lint:fix` and `npm test` before considering changes complete
9
+
10
+ ## Commands
11
+ - `ghreview init` - Push HEAD + unstaged changes as PR
12
+ - `ghreview collect <PR>` - Output formatted review feedback (PR can be number or full URL)
13
+
14
+ ## Key Files
15
+ - lib/index.js - Core logic (init, collect)
16
+ - lib/github.js - GitHub API (Octokit)
17
+ - lib/git.js - Git operations (simple-git)
18
+ - lib/config.js - Config management
19
+
20
+ ## Architecture
21
+ - Uses review remote to avoid polluting project
22
+ - Creates base/timestamp and review/timestamp branches
23
+ - Resets local changes after push
24
+ - Formats PR comments for AI consumption
25
+
26
+ ## Testing
27
+ `npm test` - Run vitest tests
28
+
29
+ ## Config
30
+ ~/.ghreview/config.json:
31
+ ```json
32
+ {
33
+ "reviewRepo": "owner/repo",
34
+ "githubToken": "github_pat_...",
35
+ "author": {"name": "AI", "email": "ai@example.com"}
36
+ }
37
+ ```
38
+
39
+ ## Environment
40
+ - Node.js >= 22 (ES modules)
package/LICENSE ADDED
@@ -0,0 +1,174 @@
1
+ Apache License
2
+ Version 2.0, January 2004
3
+ http://www.apache.org/licenses/
4
+
5
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
6
+
7
+ 1. Definitions.
8
+
9
+ "License" shall mean the terms and conditions for use, reproduction,
10
+ and distribution as defined by Sections 1 through 9 of this document.
11
+
12
+ "Licensor" shall mean the copyright owner or entity authorized by
13
+ the copyright owner that is granting the License.
14
+
15
+ "Legal Entity" shall mean the union of the acting entity and all
16
+ other entities that control, are controlled by, or are under common
17
+ control with that entity. For the purposes of this definition,
18
+ "control" means (i) the power, direct or indirect, to cause the
19
+ direction or management of such entity, whether by contract or
20
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
21
+ outstanding shares, or (iii) beneficial ownership of such entity.
22
+
23
+ "You" (or "Your") shall mean an individual or Legal Entity
24
+ exercising permissions granted by this License.
25
+
26
+ "Source" form shall mean the preferred form for making modifications,
27
+ including but not limited to software source code, documentation
28
+ source, and configuration files.
29
+
30
+ "Object" form shall mean any form resulting from mechanical
31
+ transformation or translation of a Source form, including but
32
+ not limited to compiled object code, generated documentation,
33
+ and conversions to other media types.
34
+
35
+ "Work" shall mean the work of authorship, whether in Source or
36
+ Object form, made available under the License, as indicated by a
37
+ copyright notice that is included in or attached to the work
38
+ (an example is provided in the Appendix below).
39
+
40
+ "Derivative Works" shall mean any work, whether in Source or Object
41
+ form, that is based on (or derived from) the Work and for which the
42
+ editorial revisions, annotations, elaborations, or other modifications
43
+ represent, as a whole, an original work of authorship. For the purposes
44
+ of this License, Derivative Works shall not include works that remain
45
+ separable from, or merely link (or bind by name) to the interfaces of,
46
+ the Work and Derivative Works thereof.
47
+
48
+ "Contribution" shall mean any work of authorship, including
49
+ the original version of the Work and any modifications or additions
50
+ to that Work or Derivative Works thereof, that is intentionally
51
+ submitted to Licensor for inclusion in the Work by the copyright owner
52
+ or by an individual or Legal Entity authorized to submit on behalf of
53
+ the copyright owner. For the purposes of this definition, "submitted"
54
+ means any form of electronic, verbal, or written communication sent
55
+ to the Licensor or its representatives, including but not limited to
56
+ communication on electronic mailing lists, source code control systems,
57
+ and issue tracking systems that are managed by, or on behalf of, the
58
+ Licensor for the purpose of discussing and improving the Work, but
59
+ excluding communication that is conspicuously marked or otherwise
60
+ designated in writing by the copyright owner as "Not a Contribution."
61
+
62
+ "Contributor" shall mean Licensor and any individual or Legal Entity
63
+ on behalf of whom a Contribution has been received by Licensor and
64
+ subsequently incorporated within the Work.
65
+
66
+ 2. Grant of Copyright License. Subject to the terms and conditions of
67
+ this License, each Contributor hereby grants to You a perpetual,
68
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
69
+ copyright license to reproduce, prepare Derivative Works of,
70
+ publicly display, publicly perform, sublicense, and distribute the
71
+ Work and such Derivative Works in Source or Object form.
72
+
73
+ 3. Grant of Patent License. Subject to the terms and conditions of
74
+ this License, each Contributor hereby grants to You a perpetual,
75
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
76
+ (except as stated in this section) patent license to make, have made,
77
+ use, offer to sell, sell, import, and otherwise transfer the Work,
78
+ where such license applies only to those patent claims licensable
79
+ by such Contributor that are necessarily infringed by their
80
+ Contribution(s) alone or by combination of their Contribution(s)
81
+ with the Work to which such Contribution(s) was submitted. If You
82
+ institute patent litigation against any entity (including a
83
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
84
+ or a Contribution incorporated within the Work constitutes direct
85
+ or contributory patent infringement, then any patent licenses
86
+ granted to You under this License for that Work shall terminate
87
+ as of the date such litigation is filed.
88
+
89
+ 4. Redistribution. You may reproduce and distribute copies of the
90
+ Work or Derivative Works thereof in any medium, with or without
91
+ modifications, and in Source or Object form, provided that You
92
+ meet the following conditions:
93
+
94
+ (a) You must give any other recipients of the Work or
95
+ Derivative Works a copy of this License; and
96
+
97
+ (b) You must cause any modified files to carry prominent notices
98
+ stating that You changed the files; and
99
+
100
+ (c) You must retain, in the Source form of any Derivative Works
101
+ that You distribute, all copyright, patent, trademark, and
102
+ attribution notices from the Source form of the Work,
103
+ excluding those notices that do not pertain to any part of
104
+ the Derivative Works; and
105
+
106
+ (d) If the Work includes a "NOTICE" text file as part of its
107
+ distribution, then any Derivative Works that You distribute must
108
+ include a readable copy of the attribution notices contained
109
+ within such NOTICE file, excluding those notices that do not
110
+ pertain to any part of the Derivative Works, in at least one
111
+ of the following places: within a NOTICE text file distributed
112
+ as part of the Derivative Works; within the Source form or
113
+ documentation, if provided along with the Derivative Works; or,
114
+ within a display generated by the Derivative Works, if and
115
+ wherever such third-party notices normally appear. The contents
116
+ of the NOTICE file are for informational purposes only and
117
+ do not modify the License. You may add Your own attribution
118
+ notices within Derivative Works that You distribute, alongside
119
+ or as an addendum to the NOTICE text from the Work, provided
120
+ that such additional attribution notices cannot be construed
121
+ as modifying the License.
122
+
123
+ You may add Your own copyright statement to Your modifications and
124
+ may provide additional or different license terms and conditions
125
+ for use, reproduction, or distribution of Your modifications, or
126
+ for any such Derivative Works as a whole, provided Your use,
127
+ reproduction, and distribution of the Work otherwise complies with
128
+ the conditions stated in this License.
129
+
130
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
131
+ any Contribution intentionally submitted for inclusion in the Work
132
+ by You to the Licensor shall be under the terms and conditions of
133
+ this License, without any additional terms or conditions.
134
+ Notwithstanding the above, nothing herein shall supersede or modify
135
+ the terms of any separate license agreement you may have executed
136
+ with Licensor regarding such Contributions.
137
+
138
+ 6. Trademarks. This License does not grant permission to use the trade
139
+ names, trademarks, service marks, or product names of the Licensor,
140
+ except as required for reasonable and customary use in describing the
141
+ origin of the Work and reproducing the content of the NOTICE file.
142
+
143
+ 7. Disclaimer of Warranty. Unless required by applicable law or
144
+ agreed to in writing, Licensor provides the Work (and each
145
+ Contributor provides its Contributions) on an "AS IS" BASIS,
146
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
147
+ implied, including, without limitation, any warranties or conditions
148
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
149
+ PARTICULAR PURPOSE. You are solely responsible for determining the
150
+ appropriateness of using or redistributing the Work and assume any
151
+ risks associated with Your exercise of permissions under this License.
152
+
153
+ 8. Limitation of Liability. In no event and under no legal theory,
154
+ whether in tort (including negligence), contract, or otherwise,
155
+ unless required by applicable law (such as deliberate and grossly
156
+ negligent acts) or agreed to in writing, shall any Contributor be
157
+ liable to You for damages, including any direct, indirect, special,
158
+ incidental, or consequential damages of any character arising as a
159
+ result of this License or out of the use or inability to use the
160
+ Work (including but not limited to damages for loss of goodwill,
161
+ work stoppage, computer failure or malfunction, or any and all
162
+ other commercial damages or losses), even if such Contributor
163
+ has been advised of the possibility of such damages.
164
+
165
+ 9. Accepting Warranty or Additional Liability. While redistributing
166
+ the Work or Derivative Works thereof, You may choose to offer,
167
+ and charge a fee for, acceptance of support, warranty, indemnity,
168
+ or other liability obligations and/or rights consistent with this
169
+ License. However, in accepting such obligations, You may act only
170
+ on Your own behalf and on Your sole responsibility, not on behalf
171
+ of any other Contributor, and only if You agree to indemnify,
172
+ defend, and hold each Contributor harmless for any liability
173
+ incurred by, or claims asserted against, such Contributor by reason
174
+ of your accepting any such warranty or additional liability.
package/README.md ADDED
@@ -0,0 +1,185 @@
1
+ # ghreview
2
+
3
+ GitHub PR-based code review workflow for AI-assisted development.
4
+
5
+ ## Overview
6
+
7
+ `ghreview` enables you to use GitHub's pull request review interface to review uncommitted code changes, particularly useful when working with AI coding assistants. It pushes your current state and uncommitted changes as a PR, lets you review and comment, then collects that feedback in a format suitable for AI consumption.
8
+
9
+ ## Quick Start
10
+
11
+ ### Without Installation (Recommended)
12
+
13
+ ```bash
14
+ # Create review PR
15
+ npx ghreview init
16
+
17
+ # Collect feedback after reviewing on GitHub
18
+ npx ghreview collect <PR-number>
19
+ ```
20
+
21
+ ### With Global Installation
22
+
23
+ ```bash
24
+ # Install globally
25
+ npm install -g ghreview
26
+
27
+ # Use commands
28
+ ghreview init
29
+ ghreview collect <PR-number>
30
+ ```
31
+
32
+ ## Setup
33
+
34
+ ### 1. Create a GitHub Personal Access Token
35
+
36
+ 1. Go to https://github.com/settings/tokens/new (or navigate to Settings → Developer settings → Personal access tokens → Tokens (classic))
37
+ 2. Give your token a descriptive name (e.g., "ghreview")
38
+ 3. Set an expiration (or select "No expiration" for permanent access)
39
+ 4. Select the **`repo`** scope - this grants full control of private repositories
40
+ - ✓ repo (Full control of private repositories)
41
+ - ✓ repo:status
42
+ - ✓ repo_deployment
43
+ - ✓ public_repo
44
+ - ✓ repo:invite
45
+ - ✓ security_events
46
+ 5. Click "Generate token"
47
+ 6. **Important**: Copy the token immediately - you won't be able to see it again!
48
+
49
+ ### 2. Configure ghreview
50
+
51
+ Create a configuration file at `~/.ghreview/config.json`:
52
+
53
+ ```json
54
+ {
55
+ "reviewRepo": "yourusername/ai-code-reviews",
56
+ "githubToken": "ghp_xxxxxxxxxxxxxxxxxxxx"
57
+ }
58
+ ```
59
+
60
+ Replace `yourusername` with your GitHub username and paste your token in place of `ghp_xxxxxxxxxxxxxxxxxxxx`.
61
+
62
+ **Security Note**: Keep your token secure! The config file contains sensitive credentials. You may want to:
63
+ - Set appropriate file permissions: `chmod 600 ~/.ghreview/config.json`
64
+ - Never commit this file to version control
65
+ - Consider using a token with an expiration date
66
+
67
+ The review repository will be created automatically as a private repo if it doesn't exist.
68
+
69
+ ## Configuration Options
70
+
71
+ ### Basic Configuration
72
+
73
+ ```json
74
+ {
75
+ "reviewRepo": "yourusername/ai-code-reviews",
76
+ "githubToken": "ghp_xxxxxxxxxxxxxxxxxxxx"
77
+ }
78
+ ```
79
+
80
+ ### With Custom Author
81
+
82
+ ```json
83
+ {
84
+ "reviewRepo": "yourusername/ai-code-reviews",
85
+ "githubToken": "ghp_xxxxxxxxxxxxxxxxxxxx",
86
+ "author": {
87
+ "name": "AI Assistant",
88
+ "email": "ai@example.com"
89
+ }
90
+ }
91
+ ```
92
+
93
+ The `author` configuration makes commits appear as if they were created by the AI, making it clearer that you're reviewing the AI's proposed changes.
94
+
95
+ ## Workflow
96
+
97
+ 1. **Make changes to your code** (but don't commit them)
98
+
99
+ 2. **Create a review PR**:
100
+ ```bash
101
+ npx ghreview init
102
+ ```
103
+ This will:
104
+ - Push your current commit as a base branch
105
+ - Push your uncommitted changes as a review branch
106
+ - Create a PR comparing the two
107
+ - Output a URL to review the changes
108
+
109
+ 3. **Review on GitHub**:
110
+ - Click the provided URL
111
+ - Review the changes using GitHub's PR interface
112
+ - Leave inline comments on specific lines
113
+ - Add general comments about architecture or approach
114
+
115
+ 4. **Collect feedback**:
116
+ ```bash
117
+ # Using PR number (defaults to review repo)
118
+ npx ghreview collect 42
119
+
120
+ # Using full PR URL (works with any GitHub repo)
121
+ npx ghreview collect https://github.com/owner/repo/pull/123
122
+ ```
123
+ This outputs formatted feedback that you can copy and paste into your AI assistant.
124
+
125
+ 5. **Iterate**: Your local changes remain uncommitted, so you can continue working and create new review PRs as needed.
126
+
127
+ ## Example Output
128
+
129
+ ```markdown
130
+ ## Code Review Feedback
131
+
132
+ ### File: src/auth/login.js
133
+
134
+ #### Line 45
135
+ Variable 'username' could be undefined here. Add null check.
136
+
137
+ #### Lines 67-70
138
+ Use consistent async/await instead of mixing with .then()
139
+
140
+ ### File: src/utils/validation.js
141
+
142
+ #### Line 12
143
+ Regex for email validation is too permissive
144
+
145
+ #### Lines 23-28
146
+ **Comment 1:** This validation logic is duplicated in register.js
147
+ **Comment 2:** Consider extracting to a shared validation utility
148
+
149
+ ### General Comments
150
+
151
+ ---
152
+
153
+ Consider adding rate limiting to login attempts
154
+
155
+ ---
156
+
157
+ The session token storage in localStorage is insecure
158
+ ```
159
+
160
+ ## Development
161
+
162
+ ```bash
163
+ # Install dependencies
164
+ npm install
165
+
166
+ # Run linter
167
+ npm run lint
168
+
169
+ # Fix linting issues automatically
170
+ npm run lint:fix
171
+
172
+ # Run tests
173
+ npm test
174
+ ```
175
+
176
+ ## Requirements
177
+
178
+ - Node.js >= 22
179
+ - Git
180
+ - GitHub personal access token (configured in `~/.ghreview/config.json`)
181
+ - SSH access to GitHub (the tool uses `git@github.com:` URLs for pushing)
182
+
183
+ ## License
184
+
185
+ Apache-2.0
@@ -0,0 +1,50 @@
1
+ #!/usr/bin/env node
2
+
3
+ import yargs from 'yargs'
4
+ import { hideBin } from 'yargs/helpers'
5
+ import { init, collect } from '../lib/index.js'
6
+ import chalk from 'chalk'
7
+
8
+ // Handle graceful shutdown
9
+ process.on('SIGINT', () => {
10
+ console.log('\n' + chalk.yellow('Operation cancelled by user'))
11
+ process.exit(1)
12
+ })
13
+
14
+ process.on('SIGTERM', () => {
15
+ process.exit(1)
16
+ })
17
+
18
+ yargs(hideBin(process.argv))
19
+ .scriptName('ghreview')
20
+ .usage('$0 <command> [args]')
21
+ .command('init', 'Create a PR for review', {}, async () => {
22
+ try {
23
+ await init()
24
+ } catch (error) {
25
+ console.error(chalk.red('Error:'), error.message)
26
+ process.exit(1)
27
+ }
28
+ })
29
+ .command('collect <pr>', 'Collect feedback from PR',
30
+ (yargs) => {
31
+ return yargs.positional('pr', {
32
+ describe: 'PR number or full GitHub PR URL',
33
+ type: 'string'
34
+ })
35
+ },
36
+ async (argv) => {
37
+ try {
38
+ await collect(argv.pr)
39
+ } catch (error) {
40
+ console.error(chalk.red('Error:'), error.message)
41
+ process.exit(1)
42
+ }
43
+ }
44
+ )
45
+ .demandCommand(1, 'You need to specify a command')
46
+ .help()
47
+ .alias('help', 'h')
48
+ .version()
49
+ .alias('version', 'v')
50
+ .parse()
package/lib/config.js ADDED
@@ -0,0 +1,42 @@
1
+ import fs from 'fs/promises'
2
+ import path from 'path'
3
+ import os from 'os'
4
+
5
+ const CONFIG_DIR = path.join(os.homedir(), '.ghreview')
6
+ const CONFIG_FILE = path.join(CONFIG_DIR, 'config.json')
7
+
8
+ export async function loadConfig () {
9
+ try {
10
+ const configData = await fs.readFile(CONFIG_FILE, 'utf8')
11
+ const config = JSON.parse(configData)
12
+
13
+ // Validate required fields
14
+ if (!config.reviewRepo) {
15
+ throw new Error('Missing required config field: reviewRepo')
16
+ }
17
+
18
+ if (!config.githubToken) {
19
+ throw new Error('Missing required config field: githubToken\n\nTo create a GitHub token:\n1. Go to https://github.com/settings/tokens/new\n2. Give it a name (e.g., "ghreview")\n3. Select scopes: "repo" (Full control of private repositories)\n4. Click "Generate token"\n5. Copy the token and add it to your config file')
20
+ }
21
+
22
+ // Validate repo format
23
+ if (!config.reviewRepo.includes('/')) {
24
+ throw new Error('Invalid reviewRepo format. Expected: owner/repo')
25
+ }
26
+
27
+ return config
28
+ } catch (error) {
29
+ if (error.code === 'ENOENT') {
30
+ throw new Error(`Config file not found at ${CONFIG_FILE}\nCreate it with: {"reviewRepo": "owner/repo", "githubToken": "your_github_token"}`)
31
+ }
32
+ throw error
33
+ }
34
+ }
35
+
36
+ export async function ensureConfigDir () {
37
+ try {
38
+ await fs.mkdir(CONFIG_DIR, { recursive: true })
39
+ } catch (error) {
40
+ // Directory might already exist, that's fine
41
+ }
42
+ }
package/lib/git.js ADDED
@@ -0,0 +1,73 @@
1
+ import simpleGit from 'simple-git'
2
+
3
+ export async function getCurrentBranch () {
4
+ const git = simpleGit()
5
+ try {
6
+ const status = await git.status()
7
+ return status.current
8
+ } catch (error) {
9
+ throw new Error(`Failed to get current branch: ${error.message}`)
10
+ }
11
+ }
12
+
13
+ export async function hasUnstagedChanges () {
14
+ const git = simpleGit()
15
+ const status = await git.status()
16
+ return status.files.length > 0
17
+ }
18
+
19
+ export async function createReviewCommit (message, authorConfig) {
20
+ const git = simpleGit()
21
+
22
+ // Add all changes
23
+ await git.add('.')
24
+
25
+ // Set up environment for custom author if provided
26
+ const env = {}
27
+ if (authorConfig?.name && authorConfig?.email) {
28
+ env.GIT_AUTHOR_NAME = authorConfig.name
29
+ env.GIT_AUTHOR_EMAIL = authorConfig.email
30
+ env.GIT_COMMITTER_NAME = authorConfig.name
31
+ env.GIT_COMMITTER_EMAIL = authorConfig.email
32
+ }
33
+
34
+ // Create commit with optional custom author
35
+ await git.env(env).commit(message)
36
+ }
37
+
38
+ export async function pushBranch (remote, branch) {
39
+ const git = simpleGit()
40
+ try {
41
+ await git.push(remote, branch)
42
+ } catch (error) {
43
+ throw new Error(`Failed to push to ${remote} ${branch}: ${error.message}`)
44
+ }
45
+ }
46
+
47
+ export async function resetLastCommit () {
48
+ const git = simpleGit()
49
+ await git.reset(['HEAD~1'])
50
+ }
51
+
52
+ export async function getCurrentCommitHash () {
53
+ const git = simpleGit()
54
+ const log = await git.log(['-1'])
55
+ return log.latest.hash
56
+ }
57
+
58
+ export async function ensureReviewRemote (repoUrl) {
59
+ const git = simpleGit()
60
+ const remotes = await git.getRemotes(true) // Get verbose info with URLs
61
+
62
+ const reviewRemote = remotes.find(r => r.name === 'review')
63
+
64
+ if (!reviewRemote) {
65
+ await git.addRemote('review', repoUrl)
66
+ } else if (reviewRemote.refs.push !== repoUrl) {
67
+ // Update if URL changed (e.g., user switched review repos)
68
+ await git.removeRemote('review')
69
+ await git.addRemote('review', repoUrl)
70
+ }
71
+
72
+ return 'review'
73
+ }
package/lib/github.js ADDED
@@ -0,0 +1,163 @@
1
+ import { Octokit } from '@octokit/rest'
2
+ import chalk from 'chalk'
3
+
4
+ export function createOctokit (token) {
5
+ if (!token) {
6
+ throw new Error('GitHub token is required in config file.\n\nTo create a token:\n1. Go to https://github.com/settings/tokens/new\n2. Give it a name (e.g., "ghreview")\n3. Select scopes: "repo" (Full control of private repositories)\n4. Click "Generate token"\n5. Copy the token and add it to ~/.ghreview/config.json')
7
+ }
8
+
9
+ return new Octokit({
10
+ auth: token
11
+ })
12
+ }
13
+
14
+ export async function ensureRepoExists (owner, repo, token) {
15
+ const octokit = createOctokit(token)
16
+
17
+ try {
18
+ await octokit.rest.repos.get({ owner, repo })
19
+ return true
20
+ } catch (error) {
21
+ if (error.status === 404) {
22
+ console.log(chalk.yellow(`Creating review repository: ${owner}/${repo}`))
23
+ await octokit.rest.repos.createForAuthenticatedUser({
24
+ name: repo,
25
+ private: true,
26
+ description: 'AI code review workspace',
27
+ auto_init: true
28
+ })
29
+ return true
30
+ }
31
+ throw error
32
+ }
33
+ }
34
+
35
+ export async function createPullRequest (owner, repo, head, base, title, token) {
36
+ const octokit = createOctokit(token)
37
+
38
+ const { data: pr } = await octokit.rest.pulls.create({
39
+ owner,
40
+ repo,
41
+ title,
42
+ head,
43
+ base,
44
+ body: `Review checkpoint for AI-assisted development
45
+
46
+ This PR contains uncommitted changes for review.
47
+
48
+ ## How to Review
49
+
50
+ 1. **Inline Comments**: Click on specific lines and add comments. Use the "Add a suggestion" feature to propose specific code changes.
51
+ 2. **Line Ranges**: You can select multiple lines by clicking and dragging to comment on blocks of code.
52
+ 3. **General Comments**: Use the main PR comment box at the bottom for architectural or high-level feedback.
53
+
54
+ All comments will be collected with line numbers and context for AI consumption.`
55
+ })
56
+
57
+ return pr
58
+ }
59
+
60
+ export async function fetchReviewComments (owner, repo, prNumber, token) {
61
+ const octokit = createOctokit(token)
62
+
63
+ // Fetch inline comments
64
+ const { data: comments } = await octokit.rest.pulls.listReviewComments({
65
+ owner,
66
+ repo,
67
+ pull_number: prNumber
68
+ })
69
+
70
+ // Fetch PR reviews (general comments)
71
+ const { data: reviews } = await octokit.rest.pulls.listReviews({
72
+ owner,
73
+ repo,
74
+ pull_number: prNumber
75
+ })
76
+
77
+ // Fetch issue comments (PR discussion)
78
+ const { data: issueComments } = await octokit.rest.issues.listComments({
79
+ owner,
80
+ repo,
81
+ issue_number: prNumber
82
+ })
83
+
84
+ return {
85
+ inline: comments,
86
+ reviews: reviews.filter(r => r.body), // Only reviews with comments
87
+ discussion: issueComments
88
+ }
89
+ }
90
+
91
+ export function formatFeedback (comments, filterBots = true) {
92
+ let output = '## Code Review Feedback\n\n'
93
+
94
+ // Filter out bot comments if requested
95
+ const isBot = (comment) => {
96
+ return filterBots && (
97
+ comment.user?.type === 'Bot' ||
98
+ comment.user?.login?.includes('[bot]') ||
99
+ comment.user?.login?.includes('-bot')
100
+ )
101
+ }
102
+
103
+ // Group inline comments by file and line range
104
+ const byFile = {}
105
+ // First, collect all comments without grouping
106
+ const allComments = comments.inline
107
+ .filter(comment => !isBot(comment))
108
+ .map(comment => ({
109
+ file: comment.path,
110
+ line: comment.line || comment.original_line,
111
+ startLine: comment.start_line || comment.original_start_line,
112
+ position: comment.position,
113
+ body: comment.body,
114
+ id: comment.id
115
+ }))
116
+
117
+ // Group by file
118
+ allComments.forEach(comment => {
119
+ if (!byFile[comment.file]) {
120
+ byFile[comment.file] = []
121
+ }
122
+ byFile[comment.file].push(comment)
123
+ })
124
+
125
+ // Format inline comments with better structure
126
+ if (Object.keys(byFile).length > 0) {
127
+ Object.entries(byFile).forEach(([file, fileComments]) => {
128
+ output += `### File: ${file}\n\n`
129
+
130
+ // Sort by position (or line number as fallback)
131
+ const sortedComments = fileComments.sort((a, b) => {
132
+ // Try position first, then line number
133
+ if (a.position && b.position) return a.position - b.position
134
+ return a.line - b.line
135
+ })
136
+
137
+ // Don't group comments - treat each as individual
138
+ sortedComments.forEach(comment => {
139
+ const lineRange = comment.startLine && comment.startLine !== comment.line
140
+ ? `Lines ${comment.startLine}-${comment.line}`
141
+ : `Line ${comment.line}`
142
+
143
+ output += `#### ${lineRange}\n`
144
+ output += `${comment.body}\n\n`
145
+ })
146
+ })
147
+ }
148
+
149
+ // Format general review comments (filter bots)
150
+ const generalComments = [
151
+ ...comments.reviews.filter(r => !isBot(r)).map(r => r.body),
152
+ ...comments.discussion.filter(c => !isBot(c)).map(c => c.body)
153
+ ].filter(Boolean)
154
+
155
+ if (generalComments.length > 0) {
156
+ output += '### General Comments\n\n'
157
+ generalComments.forEach((comment, index) => {
158
+ output += `---\n\n${comment}\n\n`
159
+ })
160
+ }
161
+
162
+ return output
163
+ }
package/lib/index.js ADDED
@@ -0,0 +1,149 @@
1
+ import chalk from 'chalk'
2
+ import ora from 'ora'
3
+ import { loadConfig } from './config.js'
4
+ import {
5
+ getCurrentBranch,
6
+ hasUnstagedChanges,
7
+ createReviewCommit,
8
+ pushBranch,
9
+ resetLastCommit,
10
+ ensureReviewRemote
11
+ } from './git.js'
12
+ import {
13
+ ensureRepoExists,
14
+ createPullRequest,
15
+ fetchReviewComments,
16
+ formatFeedback
17
+ } from './github.js'
18
+
19
+ export async function init () {
20
+ const spinner = ora('Initializing review').start()
21
+ let commitCreated = false
22
+
23
+ try {
24
+ // Check if we're in a git repository
25
+ try {
26
+ await getCurrentBranch()
27
+ } catch (error) {
28
+ spinner.fail('Not in a git repository')
29
+ console.error(chalk.red('Please run this command from within a git repository'))
30
+ console.error(chalk.yellow('\nTo initialize a new repository with minimal setup:'))
31
+ console.error(chalk.gray(' git init'))
32
+ console.error(chalk.gray(' echo "# Project" > README.md'))
33
+ console.error(chalk.gray(' git add README.md'))
34
+ console.error(chalk.gray(' git commit -m "Initial commit"'))
35
+ return
36
+ }
37
+
38
+ // Load config
39
+ const config = await loadConfig()
40
+ const [owner, repo] = config.reviewRepo.split('/')
41
+
42
+ // Check for unstaged changes
43
+ if (!await hasUnstagedChanges()) {
44
+ spinner.fail('No unstaged changes to review')
45
+ return
46
+ }
47
+
48
+ // Ensure review repo exists
49
+ spinner.text = 'Checking review repository'
50
+ await ensureRepoExists(owner, repo, config.githubToken)
51
+
52
+ // Set up review remote (using SSH for authentication)
53
+ const repoUrl = `git@github.com:${config.reviewRepo}.git`
54
+ await ensureReviewRemote(repoUrl)
55
+
56
+ // Generate timestamp for branch names
57
+ const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, -5)
58
+ const baseBranch = `base/${timestamp}`
59
+ const reviewBranch = `review/${timestamp}`
60
+
61
+ // Push current HEAD as base branch
62
+ spinner.text = 'Pushing base branch'
63
+ const currentBranch = await getCurrentBranch()
64
+ await pushBranch('review', `${currentBranch}:${baseBranch}`)
65
+
66
+ // Create temporary commit with changes
67
+ spinner.text = 'Creating review commit'
68
+ await createReviewCommit(
69
+ `Review checkpoint ${timestamp}`,
70
+ config.author
71
+ )
72
+ commitCreated = true
73
+
74
+ // Push review branch
75
+ spinner.text = 'Pushing review branch'
76
+ await pushBranch('review', `HEAD:${reviewBranch}`)
77
+
78
+ // Create PR
79
+ spinner.text = 'Creating pull request'
80
+ const pr = await createPullRequest(
81
+ owner,
82
+ repo,
83
+ reviewBranch,
84
+ baseBranch,
85
+ `AI Code Review - ${timestamp}`,
86
+ config.githubToken
87
+ )
88
+
89
+ spinner.succeed('Review created successfully')
90
+ console.log(chalk.green('✓'), 'Created PR #' + pr.number)
91
+ console.log(chalk.blue('🔗'), pr.html_url)
92
+ } catch (error) {
93
+ spinner.fail('Failed to create review')
94
+ throw error
95
+ } finally {
96
+ // Always reset the commit if we created one
97
+ if (commitCreated) {
98
+ try {
99
+ await resetLastCommit()
100
+ } catch (resetError) {
101
+ console.error(chalk.yellow('Warning: Failed to reset temporary commit'))
102
+ }
103
+ }
104
+ }
105
+ }
106
+
107
+ export async function collect (prInput) {
108
+ const spinner = ora('Collecting feedback').start()
109
+
110
+ try {
111
+ // Load config
112
+ const config = await loadConfig()
113
+
114
+ let owner, repo, prNumber
115
+
116
+ // Check if input is a URL or just a number
117
+ if (prInput.toString().includes('github.com')) {
118
+ // Parse GitHub PR URL
119
+ const urlMatch = prInput.match(/github\.com\/([^/]+)\/([^/]+)\/pull\/(\d+)/)
120
+ if (!urlMatch) {
121
+ throw new Error('Invalid GitHub PR URL format')
122
+ }
123
+ [, owner, repo, prNumber] = urlMatch
124
+ prNumber = parseInt(prNumber, 10)
125
+ } else {
126
+ // Use default review repo
127
+ [owner, repo] = config.reviewRepo.split('/')
128
+ prNumber = parseInt(prInput, 10)
129
+
130
+ // Validate the PR number
131
+ if (isNaN(prNumber) || prNumber <= 0) {
132
+ throw new Error(`Invalid PR number: ${prInput}`)
133
+ }
134
+ }
135
+
136
+ // Fetch all comments
137
+ spinner.text = `Fetching review comments from ${owner}/${repo}#${prNumber}`
138
+ const comments = await fetchReviewComments(owner, repo, prNumber, config.githubToken)
139
+
140
+ // Format feedback
141
+ const feedback = formatFeedback(comments)
142
+
143
+ spinner.succeed('Feedback collected')
144
+ console.log('\n' + feedback)
145
+ } catch (error) {
146
+ spinner.fail('Failed to collect feedback')
147
+ throw error
148
+ }
149
+ }
package/package.json ADDED
@@ -0,0 +1,130 @@
1
+ {
2
+ "name": "ghreview",
3
+ "version": "1.0.0",
4
+ "description": "GitHub PR-based code review workflow for AI-assisted development",
5
+ "type": "module",
6
+ "main": "lib/index.js",
7
+ "bin": {
8
+ "ghreview": "bin/ghreview.js"
9
+ },
10
+ "scripts": {
11
+ "test": "npm run lint && vitest run",
12
+ "test:watch": "npm run lint && vitest",
13
+ "lint": "standard",
14
+ "lint:fix": "standard --fix",
15
+ "prepublishOnly": "npm test"
16
+ },
17
+ "keywords": [
18
+ "github",
19
+ "code-review",
20
+ "ai",
21
+ "cli",
22
+ "pull-request"
23
+ ],
24
+ "author": "Rod <rod@vagg.org> (http://r.va.gg/)",
25
+ "license": "Apache-2.0",
26
+ "dependencies": {
27
+ "@octokit/rest": "^22.0.0",
28
+ "chalk": "^5.4.1",
29
+ "ora": "^8.2.0",
30
+ "simple-git": "^3.28.0",
31
+ "yargs": "^18.0.0"
32
+ },
33
+ "devDependencies": {
34
+ "@semantic-release/changelog": "^6.0.3",
35
+ "@semantic-release/commit-analyzer": "^13.0.1",
36
+ "@semantic-release/git": "^10.0.1",
37
+ "@semantic-release/github": "^11.0.3",
38
+ "@semantic-release/npm": "^12.0.2",
39
+ "@semantic-release/release-notes-generator": "^14.0.3",
40
+ "conventional-changelog-conventionalcommits": "^9.0.0",
41
+ "nock": "^14.0.7",
42
+ "standard": "^17.1.2",
43
+ "vitest": "^3.2.4"
44
+ },
45
+ "repository": {
46
+ "type": "git",
47
+ "url": "https://github.com/rvagg/ghreview.git"
48
+ },
49
+ "release": {
50
+ "branches": [
51
+ "master"
52
+ ],
53
+ "plugins": [
54
+ [
55
+ "@semantic-release/commit-analyzer",
56
+ {
57
+ "preset": "conventionalcommits",
58
+ "releaseRules": [
59
+ {
60
+ "breaking": true,
61
+ "release": "major"
62
+ },
63
+ {
64
+ "revert": true,
65
+ "release": "patch"
66
+ },
67
+ {
68
+ "type": "feat",
69
+ "release": "minor"
70
+ },
71
+ {
72
+ "type": "fix",
73
+ "release": "patch"
74
+ },
75
+ {
76
+ "type": "chore",
77
+ "release": "patch"
78
+ },
79
+ {
80
+ "type": "docs",
81
+ "release": "patch"
82
+ },
83
+ {
84
+ "type": "test",
85
+ "release": "patch"
86
+ },
87
+ {
88
+ "scope": "no-release",
89
+ "release": false
90
+ }
91
+ ]
92
+ }
93
+ ],
94
+ [
95
+ "@semantic-release/release-notes-generator",
96
+ {
97
+ "preset": "conventionalcommits",
98
+ "presetConfig": {
99
+ "types": [
100
+ {
101
+ "type": "feat",
102
+ "section": "Features"
103
+ },
104
+ {
105
+ "type": "fix",
106
+ "section": "Bug Fixes"
107
+ },
108
+ {
109
+ "type": "chore",
110
+ "section": "Trivial Changes"
111
+ },
112
+ {
113
+ "type": "docs",
114
+ "section": "Trivial Changes"
115
+ },
116
+ {
117
+ "type": "test",
118
+ "section": "Tests"
119
+ }
120
+ ]
121
+ }
122
+ }
123
+ ],
124
+ "@semantic-release/changelog",
125
+ "@semantic-release/npm",
126
+ "@semantic-release/github",
127
+ "@semantic-release/git"
128
+ ]
129
+ }
130
+ }
@@ -0,0 +1,96 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest'
2
+ import { init, collect } from '../lib/index.js'
3
+ import * as config from '../lib/config.js'
4
+ import * as git from '../lib/git.js'
5
+ import * as github from '../lib/github.js'
6
+
7
+ vi.mock('../lib/config.js')
8
+ vi.mock('../lib/git.js')
9
+ vi.mock('../lib/github.js')
10
+
11
+ describe('ghreview', () => {
12
+ beforeEach(() => {
13
+ vi.clearAllMocks()
14
+ })
15
+
16
+ describe('init', () => {
17
+ it('should create a PR when there are unstaged changes', async () => {
18
+ // Mock config
19
+ vi.mocked(config.loadConfig).mockResolvedValue({
20
+ reviewRepo: 'owner/repo',
21
+ githubToken: 'test-token',
22
+ author: { name: 'AI', email: 'ai@example.com' }
23
+ })
24
+
25
+ // Mock git operations
26
+ vi.mocked(git.hasUnstagedChanges).mockResolvedValue(true)
27
+ vi.mocked(git.getCurrentBranch).mockResolvedValue('main')
28
+ vi.mocked(git.ensureReviewRemote).mockResolvedValue('review')
29
+ vi.mocked(git.createReviewCommit).mockResolvedValue()
30
+ vi.mocked(git.pushBranch).mockResolvedValue()
31
+ vi.mocked(git.resetLastCommit).mockResolvedValue()
32
+
33
+ // Mock GitHub operations
34
+ vi.mocked(github.ensureRepoExists).mockResolvedValue(true)
35
+ vi.mocked(github.createPullRequest).mockResolvedValue({
36
+ number: 42,
37
+ html_url: 'https://github.com/owner/repo/pull/42'
38
+ })
39
+
40
+ // Run init
41
+ await init()
42
+
43
+ // Verify calls
44
+ expect(git.hasUnstagedChanges).toHaveBeenCalled()
45
+ expect(git.createReviewCommit).toHaveBeenCalled()
46
+ expect(git.pushBranch).toHaveBeenCalledTimes(2) // base and review
47
+ expect(git.resetLastCommit).toHaveBeenCalled()
48
+ expect(github.createPullRequest).toHaveBeenCalled()
49
+ })
50
+
51
+ it('should exit early when no unstaged changes', async () => {
52
+ vi.mocked(config.loadConfig).mockResolvedValue({
53
+ reviewRepo: 'owner/repo',
54
+ githubToken: 'test-token'
55
+ })
56
+ vi.mocked(git.hasUnstagedChanges).mockResolvedValue(false)
57
+
58
+ await init()
59
+
60
+ expect(git.createReviewCommit).not.toHaveBeenCalled()
61
+ expect(github.createPullRequest).not.toHaveBeenCalled()
62
+ })
63
+ })
64
+
65
+ describe('collect', () => {
66
+ it('should collect and format feedback', async () => {
67
+ vi.mocked(config.loadConfig).mockResolvedValue({
68
+ reviewRepo: 'owner/repo',
69
+ githubToken: 'test-token'
70
+ })
71
+
72
+ const mockComments = {
73
+ inline: [{
74
+ path: 'src/main.js',
75
+ line: 10,
76
+ body: 'Fix this'
77
+ }],
78
+ reviews: [{
79
+ body: 'General comment'
80
+ }],
81
+ discussion: []
82
+ }
83
+
84
+ vi.mocked(github.fetchReviewComments).mockResolvedValue(mockComments)
85
+ vi.mocked(github.formatFeedback).mockReturnValue('## Formatted feedback')
86
+
87
+ const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
88
+
89
+ await collect(42)
90
+
91
+ expect(github.fetchReviewComments).toHaveBeenCalledWith('owner', 'repo', 42, 'test-token')
92
+ expect(github.formatFeedback).toHaveBeenCalledWith(mockComments)
93
+ expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('Formatted feedback'))
94
+ })
95
+ })
96
+ })