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.
- package/.claude/settings.local.json +10 -0
- package/.github/dependabot.yml +16 -0
- package/.github/workflows/test-and-release.yml +50 -0
- package/AGENTS.md +40 -0
- package/LICENSE +174 -0
- package/README.md +185 -0
- package/bin/ghreview.js +50 -0
- package/lib/config.js +42 -0
- package/lib/git.js +73 -0
- package/lib/github.js +163 -0
- package/lib/index.js +149 -0
- package/package.json +130 -0
- package/test/ghreview.test.js +96 -0
|
@@ -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
|
package/bin/ghreview.js
ADDED
|
@@ -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
|
+
})
|