@aaronshaf/ger 0.3.2 → 0.3.3
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/EXAMPLES.md +48 -0
- package/README.md +87 -0
- package/package.json +1 -1
- package/src/api/gerrit.ts +28 -0
- package/src/cli/commands/add-reviewer.ts +135 -0
- package/src/cli/commands/build-status.ts +49 -0
- package/src/cli/commands/push.ts +430 -0
- package/src/cli/commands/search.ts +162 -0
- package/src/cli/commands/show.ts +20 -0
- package/src/cli/index.ts +115 -74
- package/src/schemas/gerrit.ts +43 -0
- package/src/services/commit-hook.ts +314 -0
- package/tests/add-reviewer.test.ts +393 -0
- package/tests/integration/commit-hook.test.ts +246 -0
- package/tests/search.test.ts +712 -0
- package/tests/unit/commands/push.test.ts +194 -0
- package/tests/unit/patterns/push-patterns.test.ts +148 -0
- package/tests/unit/services/commit-hook.test.ts +132 -0
package/EXAMPLES.md
CHANGED
|
@@ -117,6 +117,54 @@ const program = pipe(
|
|
|
117
117
|
Effect.runPromise(program).catch(console.error)
|
|
118
118
|
```
|
|
119
119
|
|
|
120
|
+
### 2b. Search Changes with Query Syntax
|
|
121
|
+
|
|
122
|
+
```typescript
|
|
123
|
+
import { Effect, pipe } from 'effect'
|
|
124
|
+
import {
|
|
125
|
+
GerritApiService,
|
|
126
|
+
GerritApiServiceLive,
|
|
127
|
+
ConfigServiceLive,
|
|
128
|
+
} from '@aaronshaf/ger'
|
|
129
|
+
|
|
130
|
+
const searchChanges = (query: string, limit = 25) =>
|
|
131
|
+
Effect.gen(function* () {
|
|
132
|
+
const api = yield* GerritApiService
|
|
133
|
+
|
|
134
|
+
// Use Gerrit query syntax to search changes
|
|
135
|
+
const fullQuery = query.includes('limit:') ? query : `${query} limit:${limit}`
|
|
136
|
+
const changes = yield* api.listChanges(fullQuery)
|
|
137
|
+
|
|
138
|
+
// Group by project for organized output
|
|
139
|
+
const byProject = new Map<string, typeof changes>()
|
|
140
|
+
for (const change of changes) {
|
|
141
|
+
const existing = byProject.get(change.project) ?? []
|
|
142
|
+
existing.push(change)
|
|
143
|
+
byProject.set(change.project, existing)
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
console.log(`Found ${changes.length} changes:`)
|
|
147
|
+
for (const [project, projectChanges] of byProject) {
|
|
148
|
+
console.log(`\n${project}:`)
|
|
149
|
+
for (const change of projectChanges) {
|
|
150
|
+
console.log(` #${change._number} - ${change.subject} (${change.status})`)
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
return changes
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
// Example queries
|
|
158
|
+
const program = pipe(
|
|
159
|
+
// Search for merged changes in the last week
|
|
160
|
+
searchChanges('status:merged age:7d', 10),
|
|
161
|
+
Effect.provide(GerritApiServiceLive),
|
|
162
|
+
Effect.provide(ConfigServiceLive)
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
Effect.runPromise(program).catch(console.error)
|
|
166
|
+
```
|
|
167
|
+
|
|
120
168
|
### 3. Post a Comment
|
|
121
169
|
|
|
122
170
|
```typescript
|
package/README.md
CHANGED
|
@@ -49,9 +49,16 @@ ger show 12345
|
|
|
49
49
|
# Add a comment
|
|
50
50
|
ger comment 12345 -m "LGTM"
|
|
51
51
|
|
|
52
|
+
# Add reviewers to a change
|
|
53
|
+
ger add-reviewer user@example.com -c 12345
|
|
54
|
+
|
|
52
55
|
# Get diff for review
|
|
53
56
|
ger diff 12345
|
|
54
57
|
|
|
58
|
+
# Search for changes using Gerrit query syntax
|
|
59
|
+
ger search "owner:self status:open"
|
|
60
|
+
ger search "project:my-project" -n 10
|
|
61
|
+
|
|
55
62
|
# Extract URLs from messages (e.g., Jenkins build links)
|
|
56
63
|
ger extract-url "build-summary-report" | tail -1
|
|
57
64
|
|
|
@@ -98,6 +105,54 @@ ger workspace
|
|
|
98
105
|
ger workspace --pretty
|
|
99
106
|
```
|
|
100
107
|
|
|
108
|
+
### Search Changes
|
|
109
|
+
|
|
110
|
+
Search for changes across your Gerrit instance using native query syntax:
|
|
111
|
+
|
|
112
|
+
```bash
|
|
113
|
+
# Search for all open changes (default)
|
|
114
|
+
ger search
|
|
115
|
+
|
|
116
|
+
# Search for your open changes
|
|
117
|
+
ger search "owner:self status:open"
|
|
118
|
+
|
|
119
|
+
# Search for changes by a specific user
|
|
120
|
+
ger search "owner:john@example.com"
|
|
121
|
+
|
|
122
|
+
# Search by project
|
|
123
|
+
ger search "project:my-project status:open"
|
|
124
|
+
|
|
125
|
+
# Search with date filters
|
|
126
|
+
ger search "owner:self after:2025-01-01"
|
|
127
|
+
ger search "status:merged age:7d"
|
|
128
|
+
|
|
129
|
+
# Combine filters
|
|
130
|
+
ger search "owner:self status:merged before:2025-06-01"
|
|
131
|
+
|
|
132
|
+
# Limit results (default: 25)
|
|
133
|
+
ger search "project:my-project" -n 10
|
|
134
|
+
|
|
135
|
+
# XML output for automation
|
|
136
|
+
ger search "owner:self" --xml
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
#### Common query operators:
|
|
140
|
+
| Operator | Description |
|
|
141
|
+
|----------|-------------|
|
|
142
|
+
| `owner:USER` | Changes owned by USER (use 'self' for yourself) |
|
|
143
|
+
| `status:STATE` | open, merged, abandoned, closed |
|
|
144
|
+
| `project:NAME` | Changes in a specific project |
|
|
145
|
+
| `branch:NAME` | Changes targeting a branch |
|
|
146
|
+
| `age:TIME` | Time since last update (e.g., 1d, 2w, 1mon) |
|
|
147
|
+
| `before:DATE` | Changes modified before date (YYYY-MM-DD) |
|
|
148
|
+
| `after:DATE` | Changes modified after date (YYYY-MM-DD) |
|
|
149
|
+
| `is:wip` | Work-in-progress changes |
|
|
150
|
+
| `is:submittable` | Changes ready to submit |
|
|
151
|
+
| `reviewer:USER` | Changes where USER is a reviewer |
|
|
152
|
+
| `label:NAME=VALUE` | Filter by label (e.g., label:Code-Review+2) |
|
|
153
|
+
|
|
154
|
+
See the [full query syntax documentation](https://gerrit-review.googlesource.com/Documentation/user-search.html).
|
|
155
|
+
|
|
101
156
|
### Comments
|
|
102
157
|
|
|
103
158
|
#### Overall Comments
|
|
@@ -359,6 +414,38 @@ ger abandon 12345
|
|
|
359
414
|
ger abandon 12345 -m "Reason"
|
|
360
415
|
```
|
|
361
416
|
|
|
417
|
+
### Add Reviewers
|
|
418
|
+
|
|
419
|
+
Add reviewers or CCs to a change:
|
|
420
|
+
|
|
421
|
+
```bash
|
|
422
|
+
# Add a single reviewer
|
|
423
|
+
ger add-reviewer user@example.com -c 12345
|
|
424
|
+
|
|
425
|
+
# Add multiple reviewers
|
|
426
|
+
ger add-reviewer user1@example.com user2@example.com -c 12345
|
|
427
|
+
|
|
428
|
+
# Add as CC instead of reviewer
|
|
429
|
+
ger add-reviewer --cc user@example.com -c 12345
|
|
430
|
+
|
|
431
|
+
# Suppress email notifications
|
|
432
|
+
ger add-reviewer --notify none user@example.com -c 12345
|
|
433
|
+
|
|
434
|
+
# XML output for automation
|
|
435
|
+
ger add-reviewer user@example.com -c 12345 --xml
|
|
436
|
+
```
|
|
437
|
+
|
|
438
|
+
#### Options:
|
|
439
|
+
- `-c, --change <id>` - Change ID (required)
|
|
440
|
+
- `--cc` - Add as CC instead of reviewer
|
|
441
|
+
- `--notify <level>` - Notification level: `none`, `owner`, `owner_reviewers`, `all` (default: `all`)
|
|
442
|
+
- `--xml` - XML output for LLM/automation consumption
|
|
443
|
+
|
|
444
|
+
#### Notes:
|
|
445
|
+
- Both email addresses and usernames are accepted
|
|
446
|
+
- Multiple reviewers can be added in a single command
|
|
447
|
+
- Use `--cc` for carbon copy (notified but not required to review)
|
|
448
|
+
|
|
362
449
|
### AI-Powered Review
|
|
363
450
|
|
|
364
451
|
The `ger review` command provides automated code review using AI tools (claude, llm, or opencode CLI).
|
package/package.json
CHANGED
package/src/api/gerrit.ts
CHANGED
|
@@ -9,6 +9,8 @@ import {
|
|
|
9
9
|
FileInfo,
|
|
10
10
|
type GerritCredentials,
|
|
11
11
|
type ReviewInput,
|
|
12
|
+
type ReviewerInput,
|
|
13
|
+
ReviewerResult,
|
|
12
14
|
RevisionInfo,
|
|
13
15
|
} from '@/schemas/gerrit'
|
|
14
16
|
import { filterMeaningfulMessages } from '@/utils/message-filters'
|
|
@@ -50,6 +52,11 @@ export interface GerritApiServiceImpl {
|
|
|
50
52
|
revisionId?: string,
|
|
51
53
|
) => Effect.Effect<Record<string, readonly CommentInfo[]>, ApiError>
|
|
52
54
|
readonly getMessages: (changeId: string) => Effect.Effect<readonly MessageInfo[], ApiError>
|
|
55
|
+
readonly addReviewer: (
|
|
56
|
+
changeId: string,
|
|
57
|
+
reviewer: string,
|
|
58
|
+
options?: { state?: 'REVIEWER' | 'CC'; notify?: 'NONE' | 'OWNER' | 'OWNER_REVIEWERS' | 'ALL' },
|
|
59
|
+
) => Effect.Effect<ReviewerResult, ApiError>
|
|
53
60
|
}
|
|
54
61
|
|
|
55
62
|
// Export both the tag value and the type for use in Effect requirements
|
|
@@ -485,6 +492,26 @@ export const GerritApiServiceLive: Layer.Layer<GerritApiService, never, ConfigSe
|
|
|
485
492
|
return changeResponse.messages || []
|
|
486
493
|
}).pipe(Effect.map(filterMeaningfulMessages))
|
|
487
494
|
|
|
495
|
+
const addReviewer = (
|
|
496
|
+
changeId: string,
|
|
497
|
+
reviewer: string,
|
|
498
|
+
options?: {
|
|
499
|
+
state?: 'REVIEWER' | 'CC'
|
|
500
|
+
notify?: 'NONE' | 'OWNER' | 'OWNER_REVIEWERS' | 'ALL'
|
|
501
|
+
},
|
|
502
|
+
) =>
|
|
503
|
+
Effect.gen(function* () {
|
|
504
|
+
const { credentials, authHeader } = yield* getCredentialsAndAuth
|
|
505
|
+
const normalized = yield* normalizeAndValidate(changeId)
|
|
506
|
+
const url = `${credentials.host}/a/changes/${encodeURIComponent(normalized)}/reviewers`
|
|
507
|
+
const body: ReviewerInput = {
|
|
508
|
+
reviewer,
|
|
509
|
+
...(options?.state && { state: options.state }),
|
|
510
|
+
...(options?.notify && { notify: options.notify }),
|
|
511
|
+
}
|
|
512
|
+
return yield* makeRequest(url, authHeader, 'POST', body, ReviewerResult)
|
|
513
|
+
})
|
|
514
|
+
|
|
488
515
|
return {
|
|
489
516
|
getChange,
|
|
490
517
|
listChanges,
|
|
@@ -499,6 +526,7 @@ export const GerritApiServiceLive: Layer.Layer<GerritApiService, never, ConfigSe
|
|
|
499
526
|
getDiff,
|
|
500
527
|
getComments,
|
|
501
528
|
getMessages,
|
|
529
|
+
addReviewer,
|
|
502
530
|
}
|
|
503
531
|
}),
|
|
504
532
|
)
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { Effect } from 'effect'
|
|
2
|
+
import { type ApiError, GerritApiService } from '@/api/gerrit'
|
|
3
|
+
|
|
4
|
+
interface AddReviewerOptions {
|
|
5
|
+
change?: string
|
|
6
|
+
cc?: boolean
|
|
7
|
+
notify?: string
|
|
8
|
+
xml?: boolean
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
type NotifyLevel = 'NONE' | 'OWNER' | 'OWNER_REVIEWERS' | 'ALL'
|
|
12
|
+
|
|
13
|
+
const VALID_NOTIFY_LEVELS: ReadonlyArray<NotifyLevel> = ['NONE', 'OWNER', 'OWNER_REVIEWERS', 'ALL']
|
|
14
|
+
|
|
15
|
+
const escapeXml = (str: string): string =>
|
|
16
|
+
str.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
|
|
17
|
+
|
|
18
|
+
const outputXmlError = (message: string): void => {
|
|
19
|
+
console.log(`<?xml version="1.0" encoding="UTF-8"?>`)
|
|
20
|
+
console.log(`<add_reviewer_result>`)
|
|
21
|
+
console.log(` <status>error</status>`)
|
|
22
|
+
console.log(` <error><![CDATA[${message}]]></error>`)
|
|
23
|
+
console.log(`</add_reviewer_result>`)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
class ValidationError extends Error {
|
|
27
|
+
readonly _tag = 'ValidationError'
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export const addReviewerCommand = (
|
|
31
|
+
reviewers: string[],
|
|
32
|
+
options: AddReviewerOptions = {},
|
|
33
|
+
): Effect.Effect<void, ApiError | ValidationError, GerritApiService> =>
|
|
34
|
+
Effect.gen(function* () {
|
|
35
|
+
const gerritApi = yield* GerritApiService
|
|
36
|
+
|
|
37
|
+
const changeId = options.change
|
|
38
|
+
|
|
39
|
+
if (!changeId) {
|
|
40
|
+
const message =
|
|
41
|
+
'Change ID is required. Use -c <change-id> or run from a branch with an active change.'
|
|
42
|
+
if (options.xml) {
|
|
43
|
+
outputXmlError(message)
|
|
44
|
+
} else {
|
|
45
|
+
console.error(`✗ ${message}`)
|
|
46
|
+
}
|
|
47
|
+
return yield* Effect.fail(new ValidationError(message))
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (reviewers.length === 0) {
|
|
51
|
+
const message = 'At least one reviewer is required.'
|
|
52
|
+
if (options.xml) {
|
|
53
|
+
outputXmlError(message)
|
|
54
|
+
} else {
|
|
55
|
+
console.error(`✗ ${message}`)
|
|
56
|
+
}
|
|
57
|
+
return yield* Effect.fail(new ValidationError(message))
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const state: 'REVIEWER' | 'CC' = options.cc ? 'CC' : 'REVIEWER'
|
|
61
|
+
const stateLabel = options.cc ? 'cc' : 'reviewer'
|
|
62
|
+
|
|
63
|
+
let notify: NotifyLevel | undefined
|
|
64
|
+
if (options.notify) {
|
|
65
|
+
const upperNotify = options.notify.toUpperCase()
|
|
66
|
+
if (!VALID_NOTIFY_LEVELS.includes(upperNotify as NotifyLevel)) {
|
|
67
|
+
const message = `Invalid notify level: ${options.notify}. Valid values: none, owner, owner_reviewers, all`
|
|
68
|
+
if (options.xml) {
|
|
69
|
+
outputXmlError(message)
|
|
70
|
+
} else {
|
|
71
|
+
console.error(`✗ ${message}`)
|
|
72
|
+
}
|
|
73
|
+
return yield* Effect.fail(new ValidationError(message))
|
|
74
|
+
}
|
|
75
|
+
notify = upperNotify as NotifyLevel
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const results: Array<{ reviewer: string; success: boolean; name?: string; error?: string }> = []
|
|
79
|
+
|
|
80
|
+
for (const reviewer of reviewers) {
|
|
81
|
+
const result = yield* Effect.either(
|
|
82
|
+
gerritApi.addReviewer(changeId, reviewer, { state, notify }),
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
if (result._tag === 'Left') {
|
|
86
|
+
const error = result.left
|
|
87
|
+
const message = 'message' in error ? String(error.message) : String(error)
|
|
88
|
+
results.push({ reviewer, success: false, error: message })
|
|
89
|
+
continue
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const apiResult = result.right
|
|
93
|
+
|
|
94
|
+
if (apiResult.error) {
|
|
95
|
+
results.push({ reviewer, success: false, error: apiResult.error })
|
|
96
|
+
} else {
|
|
97
|
+
const added = apiResult.reviewers?.[0] || apiResult.ccs?.[0]
|
|
98
|
+
const name = added?.name || added?.email || reviewer
|
|
99
|
+
results.push({ reviewer, success: true, name })
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (options.xml) {
|
|
104
|
+
console.log(`<?xml version="1.0" encoding="UTF-8"?>`)
|
|
105
|
+
console.log(`<add_reviewer_result>`)
|
|
106
|
+
console.log(` <change_id>${escapeXml(changeId)}</change_id>`)
|
|
107
|
+
console.log(` <state>${escapeXml(stateLabel)}</state>`)
|
|
108
|
+
console.log(` <reviewers>`)
|
|
109
|
+
for (const r of results) {
|
|
110
|
+
if (r.success) {
|
|
111
|
+
console.log(` <reviewer status="added">`)
|
|
112
|
+
console.log(` <input>${escapeXml(r.reviewer)}</input>`)
|
|
113
|
+
console.log(` <name><![CDATA[${r.name}]]></name>`)
|
|
114
|
+
console.log(` </reviewer>`)
|
|
115
|
+
} else {
|
|
116
|
+
console.log(` <reviewer status="failed">`)
|
|
117
|
+
console.log(` <input>${escapeXml(r.reviewer)}</input>`)
|
|
118
|
+
console.log(` <error><![CDATA[${r.error}]]></error>`)
|
|
119
|
+
console.log(` </reviewer>`)
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
console.log(` </reviewers>`)
|
|
123
|
+
const allSuccess = results.every((r) => r.success)
|
|
124
|
+
console.log(` <status>${allSuccess ? 'success' : 'partial_failure'}</status>`)
|
|
125
|
+
console.log(`</add_reviewer_result>`)
|
|
126
|
+
} else {
|
|
127
|
+
for (const r of results) {
|
|
128
|
+
if (r.success) {
|
|
129
|
+
console.log(`✓ Added ${r.name} as ${stateLabel}`)
|
|
130
|
+
} else {
|
|
131
|
+
console.error(`✗ Failed to add ${r.reviewer}: ${r.error}`)
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
})
|
|
@@ -3,6 +3,55 @@ import { type ApiError, GerritApiService } from '@/api/gerrit'
|
|
|
3
3
|
import type { MessageInfo } from '@/schemas/gerrit'
|
|
4
4
|
import { getChangeIdFromHead, GitError, NoChangeIdError } from '@/utils/git-commit'
|
|
5
5
|
|
|
6
|
+
/** Help text for build-status command - exported to keep index.ts under line limit */
|
|
7
|
+
export const BUILD_STATUS_HELP_TEXT = `
|
|
8
|
+
This command parses Gerrit change messages to determine build status.
|
|
9
|
+
It looks for "Build Started" messages and subsequent verification labels.
|
|
10
|
+
|
|
11
|
+
Output is JSON with a "state" field that can be:
|
|
12
|
+
- pending: No build has started yet
|
|
13
|
+
- running: Build started but no verification yet
|
|
14
|
+
- success: Build completed with Verified+1
|
|
15
|
+
- failure: Build completed with Verified-1
|
|
16
|
+
- not_found: Change does not exist
|
|
17
|
+
|
|
18
|
+
Exit codes:
|
|
19
|
+
- 0: Default for all states (like gh run watch)
|
|
20
|
+
- 1: Only when --exit-status is used AND build fails
|
|
21
|
+
- 2: Timeout reached in watch mode
|
|
22
|
+
- 3: API/network errors
|
|
23
|
+
|
|
24
|
+
Examples:
|
|
25
|
+
# Single check (current behavior)
|
|
26
|
+
$ ger build-status 392385
|
|
27
|
+
{"state":"success"}
|
|
28
|
+
|
|
29
|
+
# Watch until completion (outputs JSON on each poll)
|
|
30
|
+
$ ger build-status 392385 --watch
|
|
31
|
+
{"state":"pending"}
|
|
32
|
+
{"state":"running"}
|
|
33
|
+
{"state":"running"}
|
|
34
|
+
{"state":"success"}
|
|
35
|
+
|
|
36
|
+
# Watch with custom interval (check every 5 seconds)
|
|
37
|
+
$ ger build-status --watch --interval 5
|
|
38
|
+
|
|
39
|
+
# Watch with custom timeout (60 minutes)
|
|
40
|
+
$ ger build-status --watch --timeout 3600
|
|
41
|
+
|
|
42
|
+
# Exit with code 1 on failure (for CI/CD pipelines)
|
|
43
|
+
$ ger build-status --watch --exit-status && deploy.sh
|
|
44
|
+
|
|
45
|
+
# Trigger notification when done (like gh run watch pattern)
|
|
46
|
+
$ ger build-status --watch && notify-send 'Build is done!'
|
|
47
|
+
|
|
48
|
+
# Parse final state in scripts
|
|
49
|
+
$ ger build-status --watch | tail -1 | jq -r '.state'
|
|
50
|
+
success
|
|
51
|
+
|
|
52
|
+
Note: When no change-id is provided, it will be automatically extracted from the
|
|
53
|
+
Change-ID footer in your HEAD commit.`
|
|
54
|
+
|
|
6
55
|
// Export types for external use
|
|
7
56
|
export type BuildState = 'pending' | 'running' | 'success' | 'failure' | 'not_found'
|
|
8
57
|
|