@aaronshaf/ger 3.0.1 → 3.0.2
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/docs/prd/commands.md +59 -1
- package/package.json +1 -1
- package/src/api/gerrit.ts +14 -7
- package/src/cli/commands/files.ts +86 -0
- package/src/cli/commands/reviewers.ts +95 -0
- package/src/cli/register-commands.ts +40 -0
- package/src/schemas/gerrit.ts +2 -0
- package/src/schemas/reviewer.ts +16 -0
- package/tests/files.test.ts +223 -0
- package/tests/reviewers.test.ts +259 -0
package/docs/prd/commands.md
CHANGED
|
@@ -28,7 +28,65 @@ ger show # Auto-detect from HEAD
|
|
|
28
28
|
- Full diff
|
|
29
29
|
- All comments with context
|
|
30
30
|
|
|
31
|
-
|
|
31
|
+
### files
|
|
32
|
+
|
|
33
|
+
List files changed in a change.
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
ger files [change-id]
|
|
37
|
+
ger files 12345
|
|
38
|
+
ger files # Auto-detect from HEAD
|
|
39
|
+
ger files 12345 --json
|
|
40
|
+
ger files 12345 --xml
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
| Option | Description |
|
|
44
|
+
|--------|-------------|
|
|
45
|
+
| `--json` | Output as JSON |
|
|
46
|
+
| `--xml` | Output as XML for LLM consumption |
|
|
47
|
+
|
|
48
|
+
**Output:** One file per line with status prefix (`M` modified, `A` added, `D` deleted, `R` renamed). Magic files (`/COMMIT_MSG`, `/MERGE_LIST`, `/PATCHSET_LEVEL`) are filtered out.
|
|
49
|
+
|
|
50
|
+
**JSON output:**
|
|
51
|
+
```json
|
|
52
|
+
{
|
|
53
|
+
"status": "success",
|
|
54
|
+
"change_id": "12345",
|
|
55
|
+
"files": [
|
|
56
|
+
{ "path": "src/foo.ts", "status": "M", "lines_inserted": 10, "lines_deleted": 2 }
|
|
57
|
+
]
|
|
58
|
+
}
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
### reviewers
|
|
62
|
+
|
|
63
|
+
List reviewers on a change.
|
|
64
|
+
|
|
65
|
+
```bash
|
|
66
|
+
ger reviewers [change-id]
|
|
67
|
+
ger reviewers 12345
|
|
68
|
+
ger reviewers # Auto-detect from HEAD
|
|
69
|
+
ger reviewers 12345 --json
|
|
70
|
+
ger reviewers 12345 --xml
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
| Option | Description |
|
|
74
|
+
|--------|-------------|
|
|
75
|
+
| `--json` | Output as JSON |
|
|
76
|
+
| `--xml` | Output as XML for LLM consumption |
|
|
77
|
+
|
|
78
|
+
**Output:** One reviewer per line in `Name <email>` format (or email alone for email-only accounts).
|
|
79
|
+
|
|
80
|
+
**JSON output:**
|
|
81
|
+
```json
|
|
82
|
+
{
|
|
83
|
+
"status": "success",
|
|
84
|
+
"change_id": "12345",
|
|
85
|
+
"reviewers": [
|
|
86
|
+
{ "account_id": 1001, "name": "Alice Smith", "email": "alice@example.com", "username": "alice" }
|
|
87
|
+
]
|
|
88
|
+
}
|
|
89
|
+
```
|
|
32
90
|
|
|
33
91
|
### diff
|
|
34
92
|
|
package/package.json
CHANGED
package/src/api/gerrit.ts
CHANGED
|
@@ -18,6 +18,7 @@ import {
|
|
|
18
18
|
GroupDetailInfo,
|
|
19
19
|
AccountInfo,
|
|
20
20
|
} from '@/schemas/gerrit'
|
|
21
|
+
import { ReviewerListItem } from '@/schemas/reviewer'
|
|
21
22
|
import { filterMeaningfulMessages } from '@/utils/message-filters'
|
|
22
23
|
import { convertToUnifiedDiff } from '@/utils/diff-formatters'
|
|
23
24
|
import { ConfigService } from '@/services/config'
|
|
@@ -86,6 +87,7 @@ export interface GerritApiServiceImpl {
|
|
|
86
87
|
readonly getGroup: (groupId: string) => Effect.Effect<GroupInfo, ApiError>
|
|
87
88
|
readonly getGroupDetail: (groupId: string) => Effect.Effect<GroupDetailInfo, ApiError>
|
|
88
89
|
readonly getGroupMembers: (groupId: string) => Effect.Effect<readonly AccountInfo[], ApiError>
|
|
90
|
+
readonly getReviewers: (changeId: string) => Effect.Effect<readonly ReviewerListItem[], ApiError>
|
|
89
91
|
readonly removeReviewer: (
|
|
90
92
|
changeId: string,
|
|
91
93
|
accountId: string,
|
|
@@ -227,13 +229,8 @@ export const GerritApiServiceLive: Layer.Layer<GerritApiService, never, ConfigSe
|
|
|
227
229
|
const { credentials, authHeader } = yield* getCredentialsAndAuth
|
|
228
230
|
let url = `${credentials.host}/a/projects/`
|
|
229
231
|
if (options?.pattern) url += `?p=${encodeURIComponent(options.pattern)}`
|
|
230
|
-
const
|
|
231
|
-
|
|
232
|
-
authHeader,
|
|
233
|
-
'GET',
|
|
234
|
-
undefined,
|
|
235
|
-
Schema.Record({ key: Schema.String, value: ProjectInfo }),
|
|
236
|
-
)
|
|
232
|
+
const schema = Schema.Record({ key: Schema.String, value: ProjectInfo })
|
|
233
|
+
const projectsRecord = yield* makeRequest(url, authHeader, 'GET', undefined, schema)
|
|
237
234
|
return Object.values(projectsRecord).sort((a, b) => a.name.localeCompare(b.name))
|
|
238
235
|
})
|
|
239
236
|
|
|
@@ -581,6 +578,15 @@ export const GerritApiServiceLive: Layer.Layer<GerritApiService, never, ConfigSe
|
|
|
581
578
|
return yield* makeRequest(url, authHeader, 'GET', undefined, Schema.Array(AccountInfo))
|
|
582
579
|
})
|
|
583
580
|
|
|
581
|
+
const getReviewers = (changeId: string) =>
|
|
582
|
+
Effect.gen(function* () {
|
|
583
|
+
const { credentials, authHeader } = yield* getCredentialsAndAuth
|
|
584
|
+
const normalized = yield* normalizeAndValidate(changeId)
|
|
585
|
+
const url = `${credentials.host}/a/changes/${encodeURIComponent(normalized)}/reviewers`
|
|
586
|
+
const schema = Schema.Array(ReviewerListItem)
|
|
587
|
+
return yield* makeRequest(url, authHeader, 'GET', undefined, schema)
|
|
588
|
+
})
|
|
589
|
+
|
|
584
590
|
const removeReviewer = (
|
|
585
591
|
changeId: string,
|
|
586
592
|
accountId: string,
|
|
@@ -674,6 +680,7 @@ export const GerritApiServiceLive: Layer.Layer<GerritApiService, never, ConfigSe
|
|
|
674
680
|
getComments,
|
|
675
681
|
getMessages,
|
|
676
682
|
addReviewer,
|
|
683
|
+
getReviewers,
|
|
677
684
|
listGroups,
|
|
678
685
|
getGroup,
|
|
679
686
|
getGroupDetail,
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { Effect } from 'effect'
|
|
2
|
+
import { type ApiError, GerritApiService } from '@/api/gerrit'
|
|
3
|
+
import { GitError, NoChangeIdError, getChangeIdFromHead } from '@/utils/git-commit'
|
|
4
|
+
import { escapeXML, sanitizeCDATA } from '@/utils/shell-safety'
|
|
5
|
+
|
|
6
|
+
const MAGIC_FILES = new Set(['/COMMIT_MSG', '/MERGE_LIST', '/PATCHSET_LEVEL'])
|
|
7
|
+
|
|
8
|
+
interface FilesOptions {
|
|
9
|
+
xml?: boolean
|
|
10
|
+
json?: boolean
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export const filesCommand = (
|
|
14
|
+
changeId?: string,
|
|
15
|
+
options: FilesOptions = {},
|
|
16
|
+
): Effect.Effect<void, never, GerritApiService> =>
|
|
17
|
+
Effect.gen(function* () {
|
|
18
|
+
const gerritApi = yield* GerritApiService
|
|
19
|
+
const resolvedChangeId = changeId || (yield* getChangeIdFromHead())
|
|
20
|
+
|
|
21
|
+
const filesRecord = yield* gerritApi.getFiles(resolvedChangeId)
|
|
22
|
+
const files = Object.entries(filesRecord)
|
|
23
|
+
.filter(([path]) => !MAGIC_FILES.has(path))
|
|
24
|
+
.map(([path, info]) => ({
|
|
25
|
+
path,
|
|
26
|
+
status: info.status ?? 'M',
|
|
27
|
+
lines_inserted: info.lines_inserted ?? 0,
|
|
28
|
+
lines_deleted: info.lines_deleted ?? 0,
|
|
29
|
+
}))
|
|
30
|
+
|
|
31
|
+
if (options.json) {
|
|
32
|
+
console.log(
|
|
33
|
+
JSON.stringify(
|
|
34
|
+
{
|
|
35
|
+
status: 'success',
|
|
36
|
+
change_id: resolvedChangeId,
|
|
37
|
+
files,
|
|
38
|
+
},
|
|
39
|
+
null,
|
|
40
|
+
2,
|
|
41
|
+
),
|
|
42
|
+
)
|
|
43
|
+
} else if (options.xml) {
|
|
44
|
+
console.log(`<?xml version="1.0" encoding="UTF-8"?>`)
|
|
45
|
+
console.log(`<files_result>`)
|
|
46
|
+
console.log(` <status>success</status>`)
|
|
47
|
+
console.log(` <change_id><![CDATA[${sanitizeCDATA(resolvedChangeId)}]]></change_id>`)
|
|
48
|
+
console.log(` <files>`)
|
|
49
|
+
for (const file of files) {
|
|
50
|
+
console.log(` <file>`)
|
|
51
|
+
console.log(` <path><![CDATA[${sanitizeCDATA(file.path)}]]></path>`)
|
|
52
|
+
console.log(` <status>${escapeXML(file.status)}</status>`)
|
|
53
|
+
console.log(` <lines_inserted>${file.lines_inserted}</lines_inserted>`)
|
|
54
|
+
console.log(` <lines_deleted>${file.lines_deleted}</lines_deleted>`)
|
|
55
|
+
console.log(` </file>`)
|
|
56
|
+
}
|
|
57
|
+
console.log(` </files>`)
|
|
58
|
+
console.log(`</files_result>`)
|
|
59
|
+
} else {
|
|
60
|
+
for (const file of files) {
|
|
61
|
+
console.log(`${file.status} ${file.path}`)
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}).pipe(
|
|
65
|
+
Effect.catchAll((error: ApiError | GitError | NoChangeIdError) =>
|
|
66
|
+
Effect.sync(() => {
|
|
67
|
+
const errorMessage =
|
|
68
|
+
error instanceof GitError || error instanceof NoChangeIdError || error instanceof Error
|
|
69
|
+
? error.message
|
|
70
|
+
: String(error)
|
|
71
|
+
|
|
72
|
+
if (options.json) {
|
|
73
|
+
console.log(JSON.stringify({ status: 'error', error: errorMessage }, null, 2))
|
|
74
|
+
} else if (options.xml) {
|
|
75
|
+
console.log(`<?xml version="1.0" encoding="UTF-8"?>`)
|
|
76
|
+
console.log(`<files_result>`)
|
|
77
|
+
console.log(` <status>error</status>`)
|
|
78
|
+
console.log(` <error><![CDATA[${sanitizeCDATA(errorMessage)}]]></error>`)
|
|
79
|
+
console.log(`</files_result>`)
|
|
80
|
+
} else {
|
|
81
|
+
console.error(`✗ Error: ${errorMessage}`)
|
|
82
|
+
}
|
|
83
|
+
process.exit(1)
|
|
84
|
+
}),
|
|
85
|
+
),
|
|
86
|
+
)
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { Effect } from 'effect'
|
|
2
|
+
import { type ApiError, GerritApiService } from '@/api/gerrit'
|
|
3
|
+
import { GitError, NoChangeIdError, getChangeIdFromHead } from '@/utils/git-commit'
|
|
4
|
+
import { sanitizeCDATA } from '@/utils/shell-safety'
|
|
5
|
+
import type { ReviewerListItem } from '@/schemas/reviewer'
|
|
6
|
+
|
|
7
|
+
interface ReviewersOptions {
|
|
8
|
+
xml?: boolean
|
|
9
|
+
json?: boolean
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function formatReviewer(r: ReviewerListItem): string {
|
|
13
|
+
const name =
|
|
14
|
+
r.name ?? r.username ?? (r._account_id !== undefined ? `#${r._account_id}` : undefined)
|
|
15
|
+
if (name !== undefined) return r.email ? `${name} <${r.email}>` : name
|
|
16
|
+
return r.email ?? 'unknown'
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export const reviewersCommand = (
|
|
20
|
+
changeId?: string,
|
|
21
|
+
options: ReviewersOptions = {},
|
|
22
|
+
): Effect.Effect<void, never, GerritApiService> =>
|
|
23
|
+
Effect.gen(function* () {
|
|
24
|
+
const gerritApi = yield* GerritApiService
|
|
25
|
+
const resolvedChangeId = changeId || (yield* getChangeIdFromHead())
|
|
26
|
+
|
|
27
|
+
const reviewers = yield* gerritApi.getReviewers(resolvedChangeId)
|
|
28
|
+
|
|
29
|
+
if (options.json) {
|
|
30
|
+
console.log(
|
|
31
|
+
JSON.stringify(
|
|
32
|
+
{
|
|
33
|
+
status: 'success',
|
|
34
|
+
change_id: resolvedChangeId,
|
|
35
|
+
reviewers: reviewers.map((r) => ({
|
|
36
|
+
...(r._account_id !== undefined ? { account_id: r._account_id } : {}),
|
|
37
|
+
name: r.name,
|
|
38
|
+
email: r.email,
|
|
39
|
+
username: r.username,
|
|
40
|
+
})),
|
|
41
|
+
},
|
|
42
|
+
null,
|
|
43
|
+
2,
|
|
44
|
+
),
|
|
45
|
+
)
|
|
46
|
+
} else if (options.xml) {
|
|
47
|
+
console.log(`<?xml version="1.0" encoding="UTF-8"?>`)
|
|
48
|
+
console.log(`<reviewers_result>`)
|
|
49
|
+
console.log(` <status>success</status>`)
|
|
50
|
+
console.log(` <change_id><![CDATA[${sanitizeCDATA(resolvedChangeId)}]]></change_id>`)
|
|
51
|
+
console.log(` <reviewers>`)
|
|
52
|
+
for (const r of reviewers) {
|
|
53
|
+
console.log(` <reviewer>`)
|
|
54
|
+
if (r._account_id !== undefined)
|
|
55
|
+
console.log(` <account_id>${r._account_id}</account_id>`)
|
|
56
|
+
if (r.name) console.log(` <name><![CDATA[${sanitizeCDATA(r.name)}]]></name>`)
|
|
57
|
+
if (r.email) console.log(` <email><![CDATA[${sanitizeCDATA(r.email)}]]></email>`)
|
|
58
|
+
if (r.username)
|
|
59
|
+
console.log(` <username><![CDATA[${sanitizeCDATA(r.username)}]]></username>`)
|
|
60
|
+
console.log(` </reviewer>`)
|
|
61
|
+
}
|
|
62
|
+
console.log(` </reviewers>`)
|
|
63
|
+
console.log(`</reviewers_result>`)
|
|
64
|
+
} else {
|
|
65
|
+
if (reviewers.length === 0) {
|
|
66
|
+
console.log('No reviewers')
|
|
67
|
+
} else {
|
|
68
|
+
for (const r of reviewers) {
|
|
69
|
+
console.log(formatReviewer(r))
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}).pipe(
|
|
74
|
+
Effect.catchAll((error: ApiError | GitError | NoChangeIdError) =>
|
|
75
|
+
Effect.sync(() => {
|
|
76
|
+
const errorMessage =
|
|
77
|
+
error instanceof GitError || error instanceof NoChangeIdError || error instanceof Error
|
|
78
|
+
? error.message
|
|
79
|
+
: String(error)
|
|
80
|
+
|
|
81
|
+
if (options.json) {
|
|
82
|
+
console.log(JSON.stringify({ status: 'error', error: errorMessage }, null, 2))
|
|
83
|
+
} else if (options.xml) {
|
|
84
|
+
console.log(`<?xml version="1.0" encoding="UTF-8"?>`)
|
|
85
|
+
console.log(`<reviewers_result>`)
|
|
86
|
+
console.log(` <status>error</status>`)
|
|
87
|
+
console.log(` <error><![CDATA[${sanitizeCDATA(errorMessage)}]]></error>`)
|
|
88
|
+
console.log(`</reviewers_result>`)
|
|
89
|
+
} else {
|
|
90
|
+
console.error(`✗ Error: ${errorMessage}`)
|
|
91
|
+
}
|
|
92
|
+
process.exit(1)
|
|
93
|
+
}),
|
|
94
|
+
),
|
|
95
|
+
)
|
|
@@ -28,6 +28,8 @@ import { workspaceCommand } from './commands/workspace'
|
|
|
28
28
|
import { sanitizeCDATA } from '@/utils/shell-safety'
|
|
29
29
|
import { registerGroupCommands } from './register-group-commands'
|
|
30
30
|
import { registerReviewerCommands } from './register-reviewer-commands'
|
|
31
|
+
import { filesCommand } from './commands/files'
|
|
32
|
+
import { reviewersCommand } from './commands/reviewers'
|
|
31
33
|
|
|
32
34
|
// Helper function to output error in plain text, JSON, or XML format
|
|
33
35
|
function outputError(
|
|
@@ -578,6 +580,44 @@ Note:
|
|
|
578
580
|
}
|
|
579
581
|
})
|
|
580
582
|
|
|
583
|
+
// files command
|
|
584
|
+
program
|
|
585
|
+
.command('files [change-id]')
|
|
586
|
+
.description(
|
|
587
|
+
'List files changed in a Gerrit change (auto-detects from HEAD commit if not specified)',
|
|
588
|
+
)
|
|
589
|
+
.option('--xml', 'XML output for LLM consumption')
|
|
590
|
+
.option('--json', 'JSON output for programmatic consumption')
|
|
591
|
+
.action(async (changeId, options) => {
|
|
592
|
+
await executeEffect(
|
|
593
|
+
filesCommand(changeId, options).pipe(
|
|
594
|
+
Effect.provide(GerritApiServiceLive),
|
|
595
|
+
Effect.provide(ConfigServiceLive),
|
|
596
|
+
),
|
|
597
|
+
options,
|
|
598
|
+
'files_result',
|
|
599
|
+
)
|
|
600
|
+
})
|
|
601
|
+
|
|
602
|
+
// reviewers command
|
|
603
|
+
program
|
|
604
|
+
.command('reviewers [change-id]')
|
|
605
|
+
.description(
|
|
606
|
+
'List reviewers on a Gerrit change (auto-detects from HEAD commit if not specified)',
|
|
607
|
+
)
|
|
608
|
+
.option('--xml', 'XML output for LLM consumption')
|
|
609
|
+
.option('--json', 'JSON output for programmatic consumption')
|
|
610
|
+
.action(async (changeId, options) => {
|
|
611
|
+
await executeEffect(
|
|
612
|
+
reviewersCommand(changeId, options).pipe(
|
|
613
|
+
Effect.provide(GerritApiServiceLive),
|
|
614
|
+
Effect.provide(ConfigServiceLive),
|
|
615
|
+
),
|
|
616
|
+
options,
|
|
617
|
+
'reviewers_result',
|
|
618
|
+
)
|
|
619
|
+
})
|
|
620
|
+
|
|
581
621
|
// checkout command
|
|
582
622
|
program
|
|
583
623
|
.command('checkout <change-id>')
|
package/src/schemas/gerrit.ts
CHANGED
|
@@ -570,6 +570,8 @@ const ReviewerAccountInfo = Schema.Struct({
|
|
|
570
570
|
username: Schema.optional(Schema.String),
|
|
571
571
|
})
|
|
572
572
|
|
|
573
|
+
export type { ReviewerListItem } from './reviewer'
|
|
574
|
+
|
|
573
575
|
export const ReviewerResult: Schema.Schema<{
|
|
574
576
|
readonly input: string
|
|
575
577
|
readonly reviewers?: ReadonlyArray<{
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { Schema } from '@effect/schema'
|
|
2
|
+
|
|
3
|
+
export const ReviewerListItem: Schema.Schema<{
|
|
4
|
+
readonly _account_id?: number
|
|
5
|
+
readonly name?: string
|
|
6
|
+
readonly email?: string
|
|
7
|
+
readonly username?: string
|
|
8
|
+
readonly approvals?: { readonly [x: string]: string }
|
|
9
|
+
}> = Schema.Struct({
|
|
10
|
+
_account_id: Schema.optional(Schema.Number),
|
|
11
|
+
name: Schema.optional(Schema.String),
|
|
12
|
+
email: Schema.optional(Schema.String),
|
|
13
|
+
username: Schema.optional(Schema.String),
|
|
14
|
+
approvals: Schema.optional(Schema.Record({ key: Schema.String, value: Schema.String })),
|
|
15
|
+
})
|
|
16
|
+
export type ReviewerListItem = Schema.Schema.Type<typeof ReviewerListItem>
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, mock } from 'bun:test'
|
|
2
|
+
import { Effect, Layer } from 'effect'
|
|
3
|
+
import { HttpResponse, http } from 'msw'
|
|
4
|
+
import { setupServer } from 'msw/node'
|
|
5
|
+
import { GerritApiServiceLive } from '@/api/gerrit'
|
|
6
|
+
import { filesCommand } from '@/cli/commands/files'
|
|
7
|
+
import { ConfigService } from '@/services/config'
|
|
8
|
+
import { createMockConfigService } from './helpers/config-mock'
|
|
9
|
+
|
|
10
|
+
const mockFilesResponse = {
|
|
11
|
+
'/COMMIT_MSG': { status: 'A' as const, lines_inserted: 10 },
|
|
12
|
+
'src/foo.ts': { status: 'M' as const, lines_inserted: 5, lines_deleted: 2 },
|
|
13
|
+
'src/bar.ts': { status: 'A' as const, lines_inserted: 20 },
|
|
14
|
+
'src/old.ts': { status: 'D' as const, lines_deleted: 30 },
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const server = setupServer(
|
|
18
|
+
http.get('*/a/accounts/self', ({ request }) => {
|
|
19
|
+
const auth = request.headers.get('Authorization')
|
|
20
|
+
if (!auth || !auth.startsWith('Basic ')) {
|
|
21
|
+
return HttpResponse.text('Unauthorized', { status: 401 })
|
|
22
|
+
}
|
|
23
|
+
return HttpResponse.json({ _account_id: 1000, name: 'Test User', email: 'test@example.com' })
|
|
24
|
+
}),
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
describe('files command', () => {
|
|
28
|
+
let mockConsoleLog: ReturnType<typeof mock>
|
|
29
|
+
let mockConsoleError: ReturnType<typeof mock>
|
|
30
|
+
let mockProcessExit: ReturnType<typeof mock>
|
|
31
|
+
|
|
32
|
+
beforeAll(() => {
|
|
33
|
+
server.listen({ onUnhandledRequest: 'bypass' })
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
afterAll(() => {
|
|
37
|
+
server.close()
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
beforeEach(() => {
|
|
41
|
+
mockConsoleLog = mock(() => {})
|
|
42
|
+
mockConsoleError = mock(() => {})
|
|
43
|
+
mockProcessExit = mock(() => {})
|
|
44
|
+
console.log = mockConsoleLog
|
|
45
|
+
console.error = mockConsoleError
|
|
46
|
+
process.exit = mockProcessExit as unknown as typeof process.exit
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
afterEach(() => {
|
|
50
|
+
server.resetHandlers()
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it('should list changed files with plain output', async () => {
|
|
54
|
+
server.use(
|
|
55
|
+
http.get('*/a/changes/12345/revisions/current/files', () => {
|
|
56
|
+
return HttpResponse.text(`)]}'\n${JSON.stringify(mockFilesResponse)}`)
|
|
57
|
+
}),
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
|
|
61
|
+
const program = filesCommand('12345', {}).pipe(
|
|
62
|
+
Effect.provide(GerritApiServiceLive),
|
|
63
|
+
Effect.provide(mockConfigLayer),
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
await Effect.runPromise(program)
|
|
67
|
+
|
|
68
|
+
const output = mockConsoleLog.mock.calls.map((call) => call[0]).join('\n')
|
|
69
|
+
expect(output).not.toContain('/COMMIT_MSG')
|
|
70
|
+
expect(output).toContain('M src/foo.ts')
|
|
71
|
+
expect(output).toContain('A src/bar.ts')
|
|
72
|
+
expect(output).toContain('D src/old.ts')
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it('should output JSON format', async () => {
|
|
76
|
+
server.use(
|
|
77
|
+
http.get('*/a/changes/12345/revisions/current/files', () => {
|
|
78
|
+
return HttpResponse.text(`)]}'\n${JSON.stringify(mockFilesResponse)}`)
|
|
79
|
+
}),
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
|
|
83
|
+
const program = filesCommand('12345', { json: true }).pipe(
|
|
84
|
+
Effect.provide(GerritApiServiceLive),
|
|
85
|
+
Effect.provide(mockConfigLayer),
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
await Effect.runPromise(program)
|
|
89
|
+
|
|
90
|
+
const output = mockConsoleLog.mock.calls.map((call) => call[0]).join('\n')
|
|
91
|
+
const parsed = JSON.parse(output) as { status: string; change_id: string; files: unknown[] }
|
|
92
|
+
expect(parsed.status).toBe('success')
|
|
93
|
+
expect(parsed.change_id).toBe('12345')
|
|
94
|
+
expect(parsed.files).toBeArray()
|
|
95
|
+
const paths = (parsed.files as Array<{ path: string }>).map((f) => f.path)
|
|
96
|
+
expect(paths).not.toContain('/COMMIT_MSG')
|
|
97
|
+
expect(paths).toContain('src/foo.ts')
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
it('should output XML format', async () => {
|
|
101
|
+
server.use(
|
|
102
|
+
http.get('*/a/changes/12345/revisions/current/files', () => {
|
|
103
|
+
return HttpResponse.text(`)]}'\n${JSON.stringify(mockFilesResponse)}`)
|
|
104
|
+
}),
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
|
|
108
|
+
const program = filesCommand('12345', { xml: true }).pipe(
|
|
109
|
+
Effect.provide(GerritApiServiceLive),
|
|
110
|
+
Effect.provide(mockConfigLayer),
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
await Effect.runPromise(program)
|
|
114
|
+
|
|
115
|
+
const output = mockConsoleLog.mock.calls.map((call) => call[0]).join('\n')
|
|
116
|
+
expect(output).toContain('<?xml version="1.0" encoding="UTF-8"?>')
|
|
117
|
+
expect(output).toContain('<files_result>')
|
|
118
|
+
expect(output).toContain('<status>success</status>')
|
|
119
|
+
expect(output).toContain('<change_id><![CDATA[12345]]></change_id>')
|
|
120
|
+
expect(output).toContain('<path><![CDATA[src/foo.ts]]></path>')
|
|
121
|
+
expect(output).not.toContain('/COMMIT_MSG')
|
|
122
|
+
expect(output).toContain('</files_result>')
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
it('should handle empty files response (only magic files)', async () => {
|
|
126
|
+
server.use(
|
|
127
|
+
http.get('*/a/changes/12345/revisions/current/files', () => {
|
|
128
|
+
return HttpResponse.text(`)]}'\n${JSON.stringify({ '/COMMIT_MSG': { status: 'A' } })}`)
|
|
129
|
+
}),
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
|
|
133
|
+
const program = filesCommand('12345', {}).pipe(
|
|
134
|
+
Effect.provide(GerritApiServiceLive),
|
|
135
|
+
Effect.provide(mockConfigLayer),
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
await Effect.runPromise(program)
|
|
139
|
+
|
|
140
|
+
const output = mockConsoleLog.mock.calls.map((call) => call[0]).join('\n')
|
|
141
|
+
expect(output).not.toContain('/COMMIT_MSG')
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
it('should exit 1 on API error', async () => {
|
|
145
|
+
server.use(
|
|
146
|
+
http.get('*/a/changes/99999/revisions/current/files', () => {
|
|
147
|
+
return HttpResponse.text('Not Found', { status: 404 })
|
|
148
|
+
}),
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
|
|
152
|
+
const program = filesCommand('99999', {}).pipe(
|
|
153
|
+
Effect.provide(GerritApiServiceLive),
|
|
154
|
+
Effect.provide(mockConfigLayer),
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
await Effect.runPromise(program)
|
|
158
|
+
|
|
159
|
+
const errorOutput = mockConsoleError.mock.calls.map((call) => call[0]).join('\n')
|
|
160
|
+
expect(errorOutput).toContain('Error:')
|
|
161
|
+
expect(mockProcessExit.mock.calls[0][0]).toBe(1)
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
it('should exit 1 and output XML on API error with --xml', async () => {
|
|
165
|
+
server.use(
|
|
166
|
+
http.get('*/a/changes/99999/revisions/current/files', () => {
|
|
167
|
+
return HttpResponse.text('Not Found', { status: 404 })
|
|
168
|
+
}),
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
|
|
172
|
+
const program = filesCommand('99999', { xml: true }).pipe(
|
|
173
|
+
Effect.provide(GerritApiServiceLive),
|
|
174
|
+
Effect.provide(mockConfigLayer),
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
await Effect.runPromise(program)
|
|
178
|
+
|
|
179
|
+
const output = mockConsoleLog.mock.calls.map((call) => call[0]).join('\n')
|
|
180
|
+
expect(output).toContain('<files_result>')
|
|
181
|
+
expect(output).toContain('<status>error</status>')
|
|
182
|
+
expect(output).toContain('</files_result>')
|
|
183
|
+
expect(mockProcessExit.mock.calls[0][0]).toBe(1)
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
it('should exit 1 and output JSON on API error with --json', async () => {
|
|
187
|
+
server.use(
|
|
188
|
+
http.get('*/a/changes/99999/revisions/current/files', () => {
|
|
189
|
+
return HttpResponse.text('Not Found', { status: 404 })
|
|
190
|
+
}),
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
|
|
194
|
+
const program = filesCommand('99999', { json: true }).pipe(
|
|
195
|
+
Effect.provide(GerritApiServiceLive),
|
|
196
|
+
Effect.provide(mockConfigLayer),
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
await Effect.runPromise(program)
|
|
200
|
+
|
|
201
|
+
const output = mockConsoleLog.mock.calls.map((call) => call[0]).join('\n')
|
|
202
|
+
const parsed = JSON.parse(output) as { status: string; error: string }
|
|
203
|
+
expect(parsed.status).toBe('error')
|
|
204
|
+
expect(typeof parsed.error).toBe('string')
|
|
205
|
+
expect(parsed.error.length).toBeGreaterThan(0)
|
|
206
|
+
expect(mockProcessExit.mock.calls[0][0]).toBe(1)
|
|
207
|
+
})
|
|
208
|
+
|
|
209
|
+
it('should exit 1 when no change-id and HEAD has no Change-Id', async () => {
|
|
210
|
+
const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
|
|
211
|
+
const program = filesCommand(undefined, {}).pipe(
|
|
212
|
+
Effect.provide(GerritApiServiceLive),
|
|
213
|
+
Effect.provide(mockConfigLayer),
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
await Effect.runPromise(program)
|
|
217
|
+
|
|
218
|
+
const errorOutput = mockConsoleError.mock.calls.map((call) => call[0]).join('\n')
|
|
219
|
+
expect(errorOutput).toContain('Error:')
|
|
220
|
+
expect(errorOutput).toContain('No Change-ID found in HEAD commit')
|
|
221
|
+
expect(mockProcessExit.mock.calls[0][0]).toBe(1)
|
|
222
|
+
})
|
|
223
|
+
})
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, mock } from 'bun:test'
|
|
2
|
+
import { Effect, Layer } from 'effect'
|
|
3
|
+
import { HttpResponse, http } from 'msw'
|
|
4
|
+
import { setupServer } from 'msw/node'
|
|
5
|
+
import { GerritApiServiceLive } from '@/api/gerrit'
|
|
6
|
+
import { reviewersCommand } from '@/cli/commands/reviewers'
|
|
7
|
+
import { ConfigService } from '@/services/config'
|
|
8
|
+
import { createMockConfigService } from './helpers/config-mock'
|
|
9
|
+
|
|
10
|
+
const mockReviewersResponse = [
|
|
11
|
+
{
|
|
12
|
+
_account_id: 1001,
|
|
13
|
+
name: 'Alice Smith',
|
|
14
|
+
email: 'alice@example.com',
|
|
15
|
+
username: 'alice',
|
|
16
|
+
approvals: { 'Code-Review': '0' },
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
_account_id: 1002,
|
|
20
|
+
name: 'Bob Jones',
|
|
21
|
+
email: 'bob@example.com',
|
|
22
|
+
username: 'bob',
|
|
23
|
+
approvals: { 'Code-Review': '+1' },
|
|
24
|
+
},
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
const server = setupServer(
|
|
28
|
+
http.get('*/a/accounts/self', ({ request }) => {
|
|
29
|
+
const auth = request.headers.get('Authorization')
|
|
30
|
+
if (!auth || !auth.startsWith('Basic ')) {
|
|
31
|
+
return HttpResponse.text('Unauthorized', { status: 401 })
|
|
32
|
+
}
|
|
33
|
+
return HttpResponse.json({ _account_id: 1000, name: 'Test User', email: 'test@example.com' })
|
|
34
|
+
}),
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
describe('reviewers command', () => {
|
|
38
|
+
let mockConsoleLog: ReturnType<typeof mock>
|
|
39
|
+
let mockConsoleError: ReturnType<typeof mock>
|
|
40
|
+
let mockProcessExit: ReturnType<typeof mock>
|
|
41
|
+
|
|
42
|
+
beforeAll(() => {
|
|
43
|
+
server.listen({ onUnhandledRequest: 'bypass' })
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
afterAll(() => {
|
|
47
|
+
server.close()
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
beforeEach(() => {
|
|
51
|
+
mockConsoleLog = mock(() => {})
|
|
52
|
+
mockConsoleError = mock(() => {})
|
|
53
|
+
mockProcessExit = mock(() => {})
|
|
54
|
+
console.log = mockConsoleLog
|
|
55
|
+
console.error = mockConsoleError
|
|
56
|
+
process.exit = mockProcessExit as unknown as typeof process.exit
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
afterEach(() => {
|
|
60
|
+
server.resetHandlers()
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
it('should list reviewers with plain output', async () => {
|
|
64
|
+
server.use(
|
|
65
|
+
http.get('*/a/changes/12345/reviewers', () => {
|
|
66
|
+
return HttpResponse.text(`)]}'\n${JSON.stringify(mockReviewersResponse)}`)
|
|
67
|
+
}),
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
|
|
71
|
+
const program = reviewersCommand('12345', {}).pipe(
|
|
72
|
+
Effect.provide(GerritApiServiceLive),
|
|
73
|
+
Effect.provide(mockConfigLayer),
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
await Effect.runPromise(program)
|
|
77
|
+
|
|
78
|
+
const output = mockConsoleLog.mock.calls.map((call) => call[0]).join('\n')
|
|
79
|
+
expect(output).toContain('Alice Smith')
|
|
80
|
+
expect(output).toContain('alice@example.com')
|
|
81
|
+
expect(output).toContain('Bob Jones')
|
|
82
|
+
expect(output).toContain('bob@example.com')
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
it('should handle email-only reviewer (no _account_id, no name)', async () => {
|
|
86
|
+
const emailOnlyReviewer = [{ email: 'ext@external.com', approvals: {} }]
|
|
87
|
+
server.use(
|
|
88
|
+
http.get('*/a/changes/12345/reviewers', () => {
|
|
89
|
+
return HttpResponse.text(`)]}'\n${JSON.stringify(emailOnlyReviewer)}`)
|
|
90
|
+
}),
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
|
|
94
|
+
const program = reviewersCommand('12345', {}).pipe(
|
|
95
|
+
Effect.provide(GerritApiServiceLive),
|
|
96
|
+
Effect.provide(mockConfigLayer),
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
await Effect.runPromise(program)
|
|
100
|
+
|
|
101
|
+
const lines = mockConsoleLog.mock.calls.map((call) => call[0] as string)
|
|
102
|
+
expect(lines).toContain('ext@external.com')
|
|
103
|
+
// Must not produce "ext@external.com <ext@external.com>"
|
|
104
|
+
expect(lines.join('\n')).not.toContain('ext@external.com <ext@external.com>')
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
it('should output JSON format', async () => {
|
|
108
|
+
server.use(
|
|
109
|
+
http.get('*/a/changes/12345/reviewers', () => {
|
|
110
|
+
return HttpResponse.text(`)]}'\n${JSON.stringify(mockReviewersResponse)}`)
|
|
111
|
+
}),
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
|
|
115
|
+
const program = reviewersCommand('12345', { json: true }).pipe(
|
|
116
|
+
Effect.provide(GerritApiServiceLive),
|
|
117
|
+
Effect.provide(mockConfigLayer),
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
await Effect.runPromise(program)
|
|
121
|
+
|
|
122
|
+
const output = mockConsoleLog.mock.calls.map((call) => call[0]).join('\n')
|
|
123
|
+
const parsed = JSON.parse(output) as {
|
|
124
|
+
status: string
|
|
125
|
+
change_id: string
|
|
126
|
+
reviewers: Array<{ account_id?: number; name?: string; email?: string }>
|
|
127
|
+
}
|
|
128
|
+
expect(parsed.status).toBe('success')
|
|
129
|
+
expect(parsed.change_id).toBe('12345')
|
|
130
|
+
expect(parsed.reviewers).toBeArray()
|
|
131
|
+
expect(parsed.reviewers.length).toBe(2)
|
|
132
|
+
expect(parsed.reviewers[0].name).toBe('Alice Smith')
|
|
133
|
+
expect(parsed.reviewers[1].email).toBe('bob@example.com')
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
it('should output XML format', async () => {
|
|
137
|
+
server.use(
|
|
138
|
+
http.get('*/a/changes/12345/reviewers', () => {
|
|
139
|
+
return HttpResponse.text(`)]}'\n${JSON.stringify(mockReviewersResponse)}`)
|
|
140
|
+
}),
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
|
|
144
|
+
const program = reviewersCommand('12345', { xml: true }).pipe(
|
|
145
|
+
Effect.provide(GerritApiServiceLive),
|
|
146
|
+
Effect.provide(mockConfigLayer),
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
await Effect.runPromise(program)
|
|
150
|
+
|
|
151
|
+
const output = mockConsoleLog.mock.calls.map((call) => call[0]).join('\n')
|
|
152
|
+
expect(output).toContain('<?xml version="1.0" encoding="UTF-8"?>')
|
|
153
|
+
expect(output).toContain('<reviewers_result>')
|
|
154
|
+
expect(output).toContain('<status>success</status>')
|
|
155
|
+
expect(output).toContain('<change_id><![CDATA[12345]]></change_id>')
|
|
156
|
+
expect(output).toContain('<name><![CDATA[Alice Smith]]></name>')
|
|
157
|
+
expect(output).toContain('<email><![CDATA[bob@example.com]]></email>')
|
|
158
|
+
expect(output).toContain('</reviewers_result>')
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
it('should handle empty reviewers list', async () => {
|
|
162
|
+
server.use(
|
|
163
|
+
http.get('*/a/changes/12345/reviewers', () => {
|
|
164
|
+
return HttpResponse.text(`)]}'\n${JSON.stringify([])}`)
|
|
165
|
+
}),
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
|
|
169
|
+
const program = reviewersCommand('12345', {}).pipe(
|
|
170
|
+
Effect.provide(GerritApiServiceLive),
|
|
171
|
+
Effect.provide(mockConfigLayer),
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
await Effect.runPromise(program)
|
|
175
|
+
|
|
176
|
+
const output = mockConsoleLog.mock.calls.map((call) => call[0]).join('\n')
|
|
177
|
+
expect(output).toContain('No reviewers')
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
it('should exit 1 on API error', async () => {
|
|
181
|
+
server.use(
|
|
182
|
+
http.get('*/a/changes/99999/reviewers', () => {
|
|
183
|
+
return HttpResponse.text('Not Found', { status: 404 })
|
|
184
|
+
}),
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
|
|
188
|
+
const program = reviewersCommand('99999', {}).pipe(
|
|
189
|
+
Effect.provide(GerritApiServiceLive),
|
|
190
|
+
Effect.provide(mockConfigLayer),
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
await Effect.runPromise(program)
|
|
194
|
+
|
|
195
|
+
const errorOutput = mockConsoleError.mock.calls.map((call) => call[0]).join('\n')
|
|
196
|
+
expect(errorOutput).toContain('Error:')
|
|
197
|
+
expect(mockProcessExit.mock.calls[0][0]).toBe(1)
|
|
198
|
+
})
|
|
199
|
+
|
|
200
|
+
it('should exit 1 and output XML on API error with --xml', async () => {
|
|
201
|
+
server.use(
|
|
202
|
+
http.get('*/a/changes/99999/reviewers', () => {
|
|
203
|
+
return HttpResponse.text('Forbidden', { status: 403 })
|
|
204
|
+
}),
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
|
|
208
|
+
const program = reviewersCommand('99999', { xml: true }).pipe(
|
|
209
|
+
Effect.provide(GerritApiServiceLive),
|
|
210
|
+
Effect.provide(mockConfigLayer),
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
await Effect.runPromise(program)
|
|
214
|
+
|
|
215
|
+
const output = mockConsoleLog.mock.calls.map((call) => call[0]).join('\n')
|
|
216
|
+
expect(output).toContain('<reviewers_result>')
|
|
217
|
+
expect(output).toContain('<status>error</status>')
|
|
218
|
+
expect(output).toContain('</reviewers_result>')
|
|
219
|
+
expect(mockProcessExit.mock.calls[0][0]).toBe(1)
|
|
220
|
+
})
|
|
221
|
+
|
|
222
|
+
it('should exit 1 and output JSON on API error with --json', async () => {
|
|
223
|
+
server.use(
|
|
224
|
+
http.get('*/a/changes/99999/reviewers', () => {
|
|
225
|
+
return HttpResponse.text('Not Found', { status: 404 })
|
|
226
|
+
}),
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
|
|
230
|
+
const program = reviewersCommand('99999', { json: true }).pipe(
|
|
231
|
+
Effect.provide(GerritApiServiceLive),
|
|
232
|
+
Effect.provide(mockConfigLayer),
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
await Effect.runPromise(program)
|
|
236
|
+
|
|
237
|
+
const output = mockConsoleLog.mock.calls.map((call) => call[0]).join('\n')
|
|
238
|
+
const parsed = JSON.parse(output) as { status: string; error: string }
|
|
239
|
+
expect(parsed.status).toBe('error')
|
|
240
|
+
expect(typeof parsed.error).toBe('string')
|
|
241
|
+
expect(parsed.error.length).toBeGreaterThan(0)
|
|
242
|
+
expect(mockProcessExit.mock.calls[0][0]).toBe(1)
|
|
243
|
+
})
|
|
244
|
+
|
|
245
|
+
it('should exit 1 when no change-id and HEAD has no Change-Id', async () => {
|
|
246
|
+
const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
|
|
247
|
+
const program = reviewersCommand(undefined, {}).pipe(
|
|
248
|
+
Effect.provide(GerritApiServiceLive),
|
|
249
|
+
Effect.provide(mockConfigLayer),
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
await Effect.runPromise(program)
|
|
253
|
+
|
|
254
|
+
const errorOutput = mockConsoleError.mock.calls.map((call) => call[0]).join('\n')
|
|
255
|
+
expect(errorOutput).toContain('Error:')
|
|
256
|
+
expect(errorOutput).toContain('No Change-ID found in HEAD commit')
|
|
257
|
+
expect(mockProcessExit.mock.calls[0][0]).toBe(1)
|
|
258
|
+
})
|
|
259
|
+
})
|