@aaronshaf/ger 3.0.2 → 4.0.1
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/package.json +1 -1
- package/skills/gerrit-workflow/SKILL.md +228 -141
- package/skills/gerrit-workflow/examples.md +133 -426
- package/skills/gerrit-workflow/reference.md +470 -408
- package/src/api/gerrit-types.ts +121 -0
- package/src/api/gerrit.ts +55 -96
- package/src/cli/commands/analyze.ts +284 -0
- package/src/cli/commands/cherry.ts +268 -0
- package/src/cli/commands/failures.ts +74 -0
- package/src/cli/commands/list.ts +239 -0
- package/src/cli/commands/rebase.ts +5 -1
- package/src/cli/commands/retrigger.ts +91 -0
- package/src/cli/commands/setup.ts +19 -6
- package/src/cli/commands/tree-cleanup.ts +139 -0
- package/src/cli/commands/tree-rebase.ts +168 -0
- package/src/cli/commands/tree-setup.ts +202 -0
- package/src/cli/commands/trees.ts +107 -0
- package/src/cli/commands/update.ts +73 -0
- package/src/cli/index.ts +1 -1
- package/src/cli/register-analytics-commands.ts +90 -0
- package/src/cli/register-commands.ts +56 -40
- package/src/cli/register-list-commands.ts +105 -0
- package/src/cli/register-tree-commands.ts +128 -0
- package/src/schemas/config.ts +3 -0
- package/src/services/config.ts +15 -0
- package/tests/analyze.test.ts +197 -0
- package/tests/cherry.test.ts +208 -0
- package/tests/failures.test.ts +212 -0
- package/tests/helpers/config-mock.ts +4 -0
- package/tests/list.test.ts +220 -0
- package/tests/retrigger.test.ts +159 -0
- package/tests/tree.test.ts +517 -0
- package/tests/update.test.ts +86 -0
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import { describe, test, expect, beforeAll, afterAll, afterEach } from 'bun:test'
|
|
2
|
+
import { HttpResponse, http } from 'msw'
|
|
3
|
+
import { setupServer } from 'msw/node'
|
|
4
|
+
import { Effect, Layer } from 'effect'
|
|
5
|
+
import { analyzeCommand } from '@/cli/commands/analyze'
|
|
6
|
+
import { GerritApiServiceLive } from '@/api/gerrit'
|
|
7
|
+
import { ConfigService } from '@/services/config'
|
|
8
|
+
import { createMockConfigService } from './helpers/config-mock'
|
|
9
|
+
import type { ChangeInfo } from '@/schemas/gerrit'
|
|
10
|
+
|
|
11
|
+
const makeChange = (overrides: Partial<ChangeInfo> = {}): ChangeInfo => ({
|
|
12
|
+
id: 'project~main~I123',
|
|
13
|
+
_number: 12345,
|
|
14
|
+
project: 'my-project',
|
|
15
|
+
branch: 'main',
|
|
16
|
+
change_id: 'I123',
|
|
17
|
+
subject: 'Test change',
|
|
18
|
+
status: 'MERGED',
|
|
19
|
+
created: '2025-01-15 10:00:00.000000000',
|
|
20
|
+
updated: '2025-01-15 12:00:00.000000000',
|
|
21
|
+
submitted: '2025-01-15 12:00:00.000000000',
|
|
22
|
+
owner: { _account_id: 1001, name: 'Alice Smith', email: 'alice@example.com' },
|
|
23
|
+
...overrides,
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
const mockChanges: ChangeInfo[] = [
|
|
27
|
+
makeChange({
|
|
28
|
+
_number: 1,
|
|
29
|
+
project: 'repo-a',
|
|
30
|
+
owner: { _account_id: 1, name: 'Alice', email: 'alice@x.com' },
|
|
31
|
+
}),
|
|
32
|
+
makeChange({
|
|
33
|
+
_number: 2,
|
|
34
|
+
project: 'repo-a',
|
|
35
|
+
owner: { _account_id: 1, name: 'Alice', email: 'alice@x.com' },
|
|
36
|
+
}),
|
|
37
|
+
makeChange({
|
|
38
|
+
_number: 3,
|
|
39
|
+
project: 'repo-b',
|
|
40
|
+
owner: { _account_id: 2, name: 'Bob', email: 'bob@x.com' },
|
|
41
|
+
submitted: '2025-02-10 10:00:00.000000000',
|
|
42
|
+
}),
|
|
43
|
+
]
|
|
44
|
+
|
|
45
|
+
const server = setupServer(
|
|
46
|
+
http.get('*/a/accounts/self', () =>
|
|
47
|
+
HttpResponse.json({ _account_id: 1, name: 'User', email: 'u@example.com' }),
|
|
48
|
+
),
|
|
49
|
+
http.get('*/a/changes/', () => HttpResponse.json(mockChanges)),
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
beforeAll(() => server.listen({ onUnhandledRequest: 'bypass' }))
|
|
53
|
+
afterAll(() => server.close())
|
|
54
|
+
afterEach(() => server.resetHandlers())
|
|
55
|
+
|
|
56
|
+
const mockConfig = createMockConfigService()
|
|
57
|
+
|
|
58
|
+
describe('analyze command', () => {
|
|
59
|
+
test('runs without error and outputs terminal UI', async () => {
|
|
60
|
+
const logs: string[] = []
|
|
61
|
+
const origLog = console.log
|
|
62
|
+
console.log = (...args: unknown[]) => logs.push(args.join(' '))
|
|
63
|
+
|
|
64
|
+
try {
|
|
65
|
+
await Effect.runPromise(
|
|
66
|
+
analyzeCommand({}).pipe(
|
|
67
|
+
Effect.provide(GerritApiServiceLive),
|
|
68
|
+
Effect.provide(Layer.succeed(ConfigService, mockConfig)),
|
|
69
|
+
),
|
|
70
|
+
)
|
|
71
|
+
} finally {
|
|
72
|
+
console.log = origLog
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const output = logs.join('\n')
|
|
76
|
+
expect(output).toContain('repo-a')
|
|
77
|
+
expect(output).toContain('Alice')
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
test('outputs JSON', async () => {
|
|
81
|
+
const logs: string[] = []
|
|
82
|
+
const origLog = console.log
|
|
83
|
+
console.log = (...args: unknown[]) => logs.push(args.join(' '))
|
|
84
|
+
|
|
85
|
+
try {
|
|
86
|
+
await Effect.runPromise(
|
|
87
|
+
analyzeCommand({ json: true }).pipe(
|
|
88
|
+
Effect.provide(GerritApiServiceLive),
|
|
89
|
+
Effect.provide(Layer.succeed(ConfigService, mockConfig)),
|
|
90
|
+
),
|
|
91
|
+
)
|
|
92
|
+
} finally {
|
|
93
|
+
console.log = origLog
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const jsonStr = logs.find((l) => l.startsWith('{'))
|
|
97
|
+
expect(jsonStr).toBeDefined()
|
|
98
|
+
const parsed = JSON.parse(jsonStr as string) as { totalMerged: number }
|
|
99
|
+
expect(parsed.totalMerged).toBe(3)
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
test('outputs XML', async () => {
|
|
103
|
+
const logs: string[] = []
|
|
104
|
+
const origLog = console.log
|
|
105
|
+
console.log = (...args: unknown[]) => logs.push(args.join(' '))
|
|
106
|
+
|
|
107
|
+
try {
|
|
108
|
+
await Effect.runPromise(
|
|
109
|
+
analyzeCommand({ xml: true }).pipe(
|
|
110
|
+
Effect.provide(GerritApiServiceLive),
|
|
111
|
+
Effect.provide(Layer.succeed(ConfigService, mockConfig)),
|
|
112
|
+
),
|
|
113
|
+
)
|
|
114
|
+
} finally {
|
|
115
|
+
console.log = origLog
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const output = logs.join('\n')
|
|
119
|
+
expect(output).toContain('<analytics>')
|
|
120
|
+
expect(output).toContain('<total_merged>3</total_merged>')
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
test('outputs markdown', async () => {
|
|
124
|
+
const logs: string[] = []
|
|
125
|
+
const origLog = console.log
|
|
126
|
+
console.log = (...args: unknown[]) => logs.push(args.join(' '))
|
|
127
|
+
|
|
128
|
+
try {
|
|
129
|
+
await Effect.runPromise(
|
|
130
|
+
analyzeCommand({ markdown: true }).pipe(
|
|
131
|
+
Effect.provide(GerritApiServiceLive),
|
|
132
|
+
Effect.provide(Layer.succeed(ConfigService, mockConfig)),
|
|
133
|
+
),
|
|
134
|
+
)
|
|
135
|
+
} finally {
|
|
136
|
+
console.log = origLog
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const output = logs.join('\n')
|
|
140
|
+
expect(output).toContain('# Contribution Analytics')
|
|
141
|
+
expect(output).toContain('| Repository | Count |')
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
test('outputs CSV', async () => {
|
|
145
|
+
const logs: string[] = []
|
|
146
|
+
const origLog = console.log
|
|
147
|
+
console.log = (...args: unknown[]) => logs.push(args.join(' '))
|
|
148
|
+
|
|
149
|
+
try {
|
|
150
|
+
await Effect.runPromise(
|
|
151
|
+
analyzeCommand({ csv: true }).pipe(
|
|
152
|
+
Effect.provide(GerritApiServiceLive),
|
|
153
|
+
Effect.provide(Layer.succeed(ConfigService, mockConfig)),
|
|
154
|
+
),
|
|
155
|
+
)
|
|
156
|
+
} finally {
|
|
157
|
+
console.log = origLog
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const output = logs.join('\n')
|
|
161
|
+
expect(output).toContain('section,key,count')
|
|
162
|
+
expect(output).toContain('repo,"repo-a",2')
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
test('filters by repo via query', async () => {
|
|
166
|
+
let capturedUrl = ''
|
|
167
|
+
server.use(
|
|
168
|
+
http.get('*/a/changes/', ({ request }) => {
|
|
169
|
+
capturedUrl = request.url
|
|
170
|
+
return HttpResponse.json([mockChanges[0]])
|
|
171
|
+
}),
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
await Effect.runPromise(
|
|
175
|
+
analyzeCommand({ repo: 'my-repo', json: true }).pipe(
|
|
176
|
+
Effect.provide(GerritApiServiceLive),
|
|
177
|
+
Effect.provide(Layer.succeed(ConfigService, mockConfig)),
|
|
178
|
+
),
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
expect(capturedUrl).toContain('project%3Amy-repo')
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
test('fails gracefully when API returns error', async () => {
|
|
185
|
+
server.use(http.get('*/a/changes/', () => HttpResponse.json({}, { status: 500 })))
|
|
186
|
+
|
|
187
|
+
const result = await Effect.runPromise(
|
|
188
|
+
analyzeCommand({}).pipe(
|
|
189
|
+
Effect.provide(GerritApiServiceLive),
|
|
190
|
+
Effect.provide(Layer.succeed(ConfigService, mockConfig)),
|
|
191
|
+
Effect.either,
|
|
192
|
+
),
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
expect(result._tag).toBe('Left')
|
|
196
|
+
})
|
|
197
|
+
})
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
import { describe, test, expect, beforeAll, afterAll, afterEach, mock, spyOn } from 'bun:test'
|
|
2
|
+
import { HttpResponse, http } from 'msw'
|
|
3
|
+
import { setupServer } from 'msw/node'
|
|
4
|
+
import { Effect, Layer } from 'effect'
|
|
5
|
+
import { cherryCommand } from '@/cli/commands/cherry'
|
|
6
|
+
import { GerritApiServiceLive } from '@/api/gerrit'
|
|
7
|
+
import { ConfigService } from '@/services/config'
|
|
8
|
+
import { createMockConfigService } from './helpers/config-mock'
|
|
9
|
+
import type { ChangeInfo, RevisionInfo } from '@/schemas/gerrit'
|
|
10
|
+
import * as childProcess from 'node:child_process'
|
|
11
|
+
import type { SpawnSyncReturns } from 'node:child_process'
|
|
12
|
+
|
|
13
|
+
const mockChange: ChangeInfo = {
|
|
14
|
+
id: 'test-project~main~I123',
|
|
15
|
+
_number: 12345,
|
|
16
|
+
project: 'test-project',
|
|
17
|
+
branch: 'main',
|
|
18
|
+
change_id: 'I123',
|
|
19
|
+
subject: 'Test cherry-pick change',
|
|
20
|
+
status: 'NEW',
|
|
21
|
+
created: '2024-01-15 10:00:00.000000000',
|
|
22
|
+
updated: '2024-01-15 10:00:00.000000000',
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const mockRevision: RevisionInfo = {
|
|
26
|
+
_number: 1,
|
|
27
|
+
ref: 'refs/changes/45/12345/1',
|
|
28
|
+
created: '2024-01-15 10:00:00.000000000',
|
|
29
|
+
uploader: { _account_id: 1000, name: 'Test User', email: 'test@example.com' },
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const server = setupServer(
|
|
33
|
+
http.get('*/a/accounts/self', ({ request }) => {
|
|
34
|
+
const auth = request.headers.get('Authorization')
|
|
35
|
+
if (!auth?.startsWith('Basic ')) return HttpResponse.text('Unauthorized', { status: 401 })
|
|
36
|
+
return HttpResponse.json({ _account_id: 1000, name: 'Test User', email: 'test@example.com' })
|
|
37
|
+
}),
|
|
38
|
+
http.get('*/a/changes/12345', () => HttpResponse.json(mockChange)),
|
|
39
|
+
http.get('*/a/changes/12345/revisions/current', () => HttpResponse.json(mockRevision)),
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
beforeAll(() => server.listen({ onUnhandledRequest: 'bypass' }))
|
|
43
|
+
afterAll(() => server.close())
|
|
44
|
+
|
|
45
|
+
const mockConfig = createMockConfigService({
|
|
46
|
+
host: 'https://test.gerrit.com',
|
|
47
|
+
username: 'testuser',
|
|
48
|
+
password: 'testpass',
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
describe('cherry command', () => {
|
|
52
|
+
let mockExecSync: ReturnType<typeof spyOn>
|
|
53
|
+
let mockSpawnSync: ReturnType<typeof spyOn>
|
|
54
|
+
|
|
55
|
+
afterEach(() => {
|
|
56
|
+
server.resetHandlers()
|
|
57
|
+
mockExecSync?.mockRestore()
|
|
58
|
+
mockSpawnSync?.mockRestore()
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
const setupGitMocks = (spawnOverrides: { failFetch?: boolean; failCherry?: boolean } = {}) => {
|
|
62
|
+
mockExecSync = spyOn(childProcess, 'execSync').mockImplementation(((command: string) => {
|
|
63
|
+
if (command.includes('rev-parse --git-dir')) return Buffer.from('.git')
|
|
64
|
+
if (command.includes('remote -v'))
|
|
65
|
+
return Buffer.from('origin\thttps://test.gerrit.com/project\t(fetch)\n')
|
|
66
|
+
return Buffer.from('')
|
|
67
|
+
}) as typeof childProcess.execSync)
|
|
68
|
+
|
|
69
|
+
mockSpawnSync = spyOn(childProcess, 'spawnSync').mockImplementation(((
|
|
70
|
+
_cmd: string,
|
|
71
|
+
args: string[],
|
|
72
|
+
) => {
|
|
73
|
+
const isCherry = args.includes('cherry-pick')
|
|
74
|
+
if (isCherry && spawnOverrides.failCherry) {
|
|
75
|
+
return {
|
|
76
|
+
status: 1,
|
|
77
|
+
stderr: Buffer.from('conflict during cherry-pick'),
|
|
78
|
+
} as unknown as SpawnSyncReturns<Buffer>
|
|
79
|
+
}
|
|
80
|
+
if (!isCherry && spawnOverrides.failFetch) {
|
|
81
|
+
return {
|
|
82
|
+
status: 1,
|
|
83
|
+
stderr: Buffer.from('fetch failed'),
|
|
84
|
+
} as unknown as SpawnSyncReturns<Buffer>
|
|
85
|
+
}
|
|
86
|
+
return { status: 0, stderr: Buffer.from('') } as unknown as SpawnSyncReturns<Buffer>
|
|
87
|
+
}) as typeof childProcess.spawnSync)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
test('cherry-picks a change successfully', async () => {
|
|
91
|
+
setupGitMocks()
|
|
92
|
+
|
|
93
|
+
await Effect.runPromise(
|
|
94
|
+
cherryCommand('12345', {}).pipe(
|
|
95
|
+
Effect.provide(GerritApiServiceLive),
|
|
96
|
+
Effect.provide(Layer.succeed(ConfigService, mockConfig)),
|
|
97
|
+
),
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
const spawnCalls = mockSpawnSync.mock.calls as unknown as [string, string[]][]
|
|
101
|
+
expect(spawnCalls.some(([, args]) => args.includes('fetch'))).toBe(true)
|
|
102
|
+
expect(
|
|
103
|
+
spawnCalls.some(([, args]) => args.includes('cherry-pick') && args.includes('FETCH_HEAD')),
|
|
104
|
+
).toBe(true)
|
|
105
|
+
expect(spawnCalls.some(([, args]) => args.includes('cherry-pick') && args.includes('-n'))).toBe(
|
|
106
|
+
false,
|
|
107
|
+
)
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
test('cherry-picks with --no-commit flag', async () => {
|
|
111
|
+
setupGitMocks()
|
|
112
|
+
|
|
113
|
+
await Effect.runPromise(
|
|
114
|
+
cherryCommand('12345', { noCommit: true }).pipe(
|
|
115
|
+
Effect.provide(GerritApiServiceLive),
|
|
116
|
+
Effect.provide(Layer.succeed(ConfigService, mockConfig)),
|
|
117
|
+
),
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
const spawnCalls = mockSpawnSync.mock.calls as unknown as [string, string[]][]
|
|
121
|
+
expect(spawnCalls.some(([, args]) => args.includes('cherry-pick') && args.includes('-n'))).toBe(
|
|
122
|
+
true,
|
|
123
|
+
)
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
test('parses 12345/3 patchset syntax', async () => {
|
|
127
|
+
setupGitMocks()
|
|
128
|
+
|
|
129
|
+
server.use(
|
|
130
|
+
http.get('*/a/changes/12345/revisions/3', () =>
|
|
131
|
+
HttpResponse.json({ ...mockRevision, _number: 3, ref: 'refs/changes/45/12345/3' }),
|
|
132
|
+
),
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
await Effect.runPromise(
|
|
136
|
+
cherryCommand('12345/3', {}).pipe(
|
|
137
|
+
Effect.provide(GerritApiServiceLive),
|
|
138
|
+
Effect.provide(Layer.succeed(ConfigService, mockConfig)),
|
|
139
|
+
),
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
const spawnCalls = mockSpawnSync.mock.calls as unknown as [string, string[]][]
|
|
143
|
+
expect(spawnCalls.some(([, args]) => args.includes('refs/changes/45/12345/3'))).toBe(true)
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
test('fails when not in a git repo', async () => {
|
|
147
|
+
mockExecSync = spyOn(childProcess, 'execSync').mockImplementation((() => {
|
|
148
|
+
throw new Error('not a git repo')
|
|
149
|
+
}) as typeof childProcess.execSync)
|
|
150
|
+
|
|
151
|
+
const result = await Effect.runPromise(
|
|
152
|
+
cherryCommand('12345', {}).pipe(
|
|
153
|
+
Effect.provide(GerritApiServiceLive),
|
|
154
|
+
Effect.provide(Layer.succeed(ConfigService, mockConfig)),
|
|
155
|
+
Effect.either,
|
|
156
|
+
),
|
|
157
|
+
)
|
|
158
|
+
expect(result._tag).toBe('Left')
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
test('fails when change not found', async () => {
|
|
162
|
+
setupGitMocks()
|
|
163
|
+
server.use(http.get('*/a/changes/99999', () => HttpResponse.json({}, { status: 404 })))
|
|
164
|
+
|
|
165
|
+
const result = await Effect.runPromise(
|
|
166
|
+
cherryCommand('99999', {}).pipe(
|
|
167
|
+
Effect.provide(GerritApiServiceLive),
|
|
168
|
+
Effect.provide(Layer.succeed(ConfigService, mockConfig)),
|
|
169
|
+
Effect.either,
|
|
170
|
+
),
|
|
171
|
+
)
|
|
172
|
+
expect(result._tag).toBe('Left')
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
test('fails when git cherry-pick fails', async () => {
|
|
176
|
+
setupGitMocks({ failCherry: true })
|
|
177
|
+
|
|
178
|
+
let threw = false
|
|
179
|
+
try {
|
|
180
|
+
await Effect.runPromise(
|
|
181
|
+
cherryCommand('12345', {}).pipe(
|
|
182
|
+
Effect.provide(GerritApiServiceLive),
|
|
183
|
+
Effect.provide(Layer.succeed(ConfigService, mockConfig)),
|
|
184
|
+
),
|
|
185
|
+
)
|
|
186
|
+
} catch (e) {
|
|
187
|
+
threw = true
|
|
188
|
+
expect(String(e)).toContain('Cherry-pick failed')
|
|
189
|
+
}
|
|
190
|
+
expect(threw).toBe(true)
|
|
191
|
+
})
|
|
192
|
+
|
|
193
|
+
test('uses --remote option when provided', async () => {
|
|
194
|
+
setupGitMocks()
|
|
195
|
+
|
|
196
|
+
await Effect.runPromise(
|
|
197
|
+
cherryCommand('12345', { remote: 'upstream' }).pipe(
|
|
198
|
+
Effect.provide(GerritApiServiceLive),
|
|
199
|
+
Effect.provide(Layer.succeed(ConfigService, mockConfig)),
|
|
200
|
+
),
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
const spawnCalls = mockSpawnSync.mock.calls as unknown as [string, string[]][]
|
|
204
|
+
expect(spawnCalls.some(([, args]) => args.includes('fetch') && args.includes('upstream'))).toBe(
|
|
205
|
+
true,
|
|
206
|
+
)
|
|
207
|
+
})
|
|
208
|
+
})
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
import { describe, test, expect, beforeAll, afterAll, afterEach } from 'bun:test'
|
|
2
|
+
import { HttpResponse, http } from 'msw'
|
|
3
|
+
import { setupServer } from 'msw/node'
|
|
4
|
+
import { Effect, Layer } from 'effect'
|
|
5
|
+
import { failuresCommand } from '@/cli/commands/failures'
|
|
6
|
+
import { GerritApiServiceLive } from '@/api/gerrit'
|
|
7
|
+
import { ConfigService } from '@/services/config'
|
|
8
|
+
import { createMockConfigService } from './helpers/config-mock'
|
|
9
|
+
|
|
10
|
+
const JENKINS_URL = 'https://jenkins.inst-ci.net/job/Canvas/job/main/123//build-summary-report/'
|
|
11
|
+
|
|
12
|
+
const makeMessage = (id: string, message: string, authorName = 'Test User') => ({
|
|
13
|
+
id,
|
|
14
|
+
message,
|
|
15
|
+
date: '2025-01-15 10:00:00.000000000',
|
|
16
|
+
author: { _account_id: 1, name: authorName, email: 'test@example.com' },
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
const makeMessagesResponse = (messages: ReturnType<typeof makeMessage>[]) => ({
|
|
20
|
+
messages,
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
const defaultMessages = [
|
|
24
|
+
makeMessage('m1', 'Build started', 'Service Cloud Jenkins'),
|
|
25
|
+
makeMessage(
|
|
26
|
+
'm2',
|
|
27
|
+
`Patch Set 1: Verified-1\n\nBuild failed. See ${JENKINS_URL}`,
|
|
28
|
+
'Service Cloud Jenkins',
|
|
29
|
+
),
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
const server = setupServer(
|
|
33
|
+
http.get('*/a/accounts/self', () =>
|
|
34
|
+
HttpResponse.json({ _account_id: 1, name: 'User', email: 'u@example.com' }),
|
|
35
|
+
),
|
|
36
|
+
http.get('*/a/changes/12345', () => HttpResponse.json(makeMessagesResponse(defaultMessages))),
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
beforeAll(() => server.listen({ onUnhandledRequest: 'bypass' }))
|
|
40
|
+
afterAll(() => server.close())
|
|
41
|
+
afterEach(() => server.resetHandlers())
|
|
42
|
+
|
|
43
|
+
const mockConfig = createMockConfigService()
|
|
44
|
+
|
|
45
|
+
describe('failures command', () => {
|
|
46
|
+
test('outputs the Jenkins failure URL', async () => {
|
|
47
|
+
const logs: string[] = []
|
|
48
|
+
const origLog = console.log
|
|
49
|
+
console.log = (...args: unknown[]) => logs.push(String(args[0]))
|
|
50
|
+
|
|
51
|
+
try {
|
|
52
|
+
await Effect.runPromise(
|
|
53
|
+
failuresCommand('12345', {}).pipe(
|
|
54
|
+
Effect.provide(GerritApiServiceLive),
|
|
55
|
+
Effect.provide(Layer.succeed(ConfigService, mockConfig)),
|
|
56
|
+
),
|
|
57
|
+
)
|
|
58
|
+
} finally {
|
|
59
|
+
console.log = origLog
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
expect(logs.join('\n')).toContain(JENKINS_URL)
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
test('outputs JSON with url field', async () => {
|
|
66
|
+
const logs: string[] = []
|
|
67
|
+
const origLog = console.log
|
|
68
|
+
console.log = (...args: unknown[]) => logs.push(String(args[0]))
|
|
69
|
+
|
|
70
|
+
try {
|
|
71
|
+
await Effect.runPromise(
|
|
72
|
+
failuresCommand('12345', { json: true }).pipe(
|
|
73
|
+
Effect.provide(GerritApiServiceLive),
|
|
74
|
+
Effect.provide(Layer.succeed(ConfigService, mockConfig)),
|
|
75
|
+
),
|
|
76
|
+
)
|
|
77
|
+
} finally {
|
|
78
|
+
console.log = origLog
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const parsed = JSON.parse(logs.join('')) as { status: string; url: string }
|
|
82
|
+
expect(parsed.status).toBe('found')
|
|
83
|
+
expect(parsed.url).toBe(JENKINS_URL)
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
test('outputs XML with url element', async () => {
|
|
87
|
+
const logs: string[] = []
|
|
88
|
+
const origLog = console.log
|
|
89
|
+
console.log = (...args: unknown[]) => logs.push(String(args[0]))
|
|
90
|
+
|
|
91
|
+
try {
|
|
92
|
+
await Effect.runPromise(
|
|
93
|
+
failuresCommand('12345', { xml: true }).pipe(
|
|
94
|
+
Effect.provide(GerritApiServiceLive),
|
|
95
|
+
Effect.provide(Layer.succeed(ConfigService, mockConfig)),
|
|
96
|
+
),
|
|
97
|
+
)
|
|
98
|
+
} finally {
|
|
99
|
+
console.log = origLog
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const output = logs.join('\n')
|
|
103
|
+
expect(output).toContain('<failures>')
|
|
104
|
+
expect(output).toContain(`<url>${JENKINS_URL}</url>`)
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
test('ignores messages not from Service Cloud Jenkins', async () => {
|
|
108
|
+
server.use(
|
|
109
|
+
http.get('*/a/changes/12345', () =>
|
|
110
|
+
HttpResponse.json(
|
|
111
|
+
makeMessagesResponse([
|
|
112
|
+
makeMessage('m1', `Verified-1\n\nFailed: ${JENKINS_URL}`, 'Some Other Bot'),
|
|
113
|
+
]),
|
|
114
|
+
),
|
|
115
|
+
),
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
const logs: string[] = []
|
|
119
|
+
const origLog = console.log
|
|
120
|
+
console.log = (...args: unknown[]) => logs.push(String(args[0]))
|
|
121
|
+
|
|
122
|
+
try {
|
|
123
|
+
await Effect.runPromise(
|
|
124
|
+
failuresCommand('12345', {}).pipe(
|
|
125
|
+
Effect.provide(GerritApiServiceLive),
|
|
126
|
+
Effect.provide(Layer.succeed(ConfigService, mockConfig)),
|
|
127
|
+
),
|
|
128
|
+
)
|
|
129
|
+
} finally {
|
|
130
|
+
console.log = origLog
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
expect(logs.join('\n')).not.toContain(JENKINS_URL)
|
|
134
|
+
expect(logs.join('\n')).toContain('No build failure links found')
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
test('ignores Service Cloud Jenkins messages without Verified-1', async () => {
|
|
138
|
+
server.use(
|
|
139
|
+
http.get('*/a/changes/12345', () =>
|
|
140
|
+
HttpResponse.json(
|
|
141
|
+
makeMessagesResponse([
|
|
142
|
+
makeMessage('m1', `Build started: ${JENKINS_URL}`, 'Service Cloud Jenkins'),
|
|
143
|
+
]),
|
|
144
|
+
),
|
|
145
|
+
),
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
const logs: string[] = []
|
|
149
|
+
const origLog = console.log
|
|
150
|
+
console.log = (...args: unknown[]) => logs.push(String(args[0]))
|
|
151
|
+
|
|
152
|
+
try {
|
|
153
|
+
await Effect.runPromise(
|
|
154
|
+
failuresCommand('12345', {}).pipe(
|
|
155
|
+
Effect.provide(GerritApiServiceLive),
|
|
156
|
+
Effect.provide(Layer.succeed(ConfigService, mockConfig)),
|
|
157
|
+
),
|
|
158
|
+
)
|
|
159
|
+
} finally {
|
|
160
|
+
console.log = origLog
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
expect(logs.join('\n')).not.toContain(JENKINS_URL)
|
|
164
|
+
expect(logs.join('\n')).toContain('No build failure links found')
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
test('returns most recent failure when multiple exist', async () => {
|
|
168
|
+
const NEWER_URL = 'https://jenkins.inst-ci.net/job/Canvas/job/main/456//build-summary-report/'
|
|
169
|
+
server.use(
|
|
170
|
+
http.get('*/a/changes/12345', () =>
|
|
171
|
+
HttpResponse.json(
|
|
172
|
+
makeMessagesResponse([
|
|
173
|
+
makeMessage('m1', `Verified-1\n\nFailed: ${JENKINS_URL}`, 'Service Cloud Jenkins'),
|
|
174
|
+
makeMessage('m2', `Verified-1\n\nFailed: ${NEWER_URL}`, 'Service Cloud Jenkins'),
|
|
175
|
+
]),
|
|
176
|
+
),
|
|
177
|
+
),
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
const logs: string[] = []
|
|
181
|
+
const origLog = console.log
|
|
182
|
+
console.log = (...args: unknown[]) => logs.push(String(args[0]))
|
|
183
|
+
|
|
184
|
+
try {
|
|
185
|
+
await Effect.runPromise(
|
|
186
|
+
failuresCommand('12345', {}).pipe(
|
|
187
|
+
Effect.provide(GerritApiServiceLive),
|
|
188
|
+
Effect.provide(Layer.succeed(ConfigService, mockConfig)),
|
|
189
|
+
),
|
|
190
|
+
)
|
|
191
|
+
} finally {
|
|
192
|
+
console.log = origLog
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
expect(logs.join('\n')).toContain(NEWER_URL)
|
|
196
|
+
expect(logs.join('\n')).not.toContain(JENKINS_URL)
|
|
197
|
+
})
|
|
198
|
+
|
|
199
|
+
test('fails when change not found', async () => {
|
|
200
|
+
server.use(http.get('*/a/changes/99999', () => HttpResponse.json({}, { status: 404 })))
|
|
201
|
+
|
|
202
|
+
const result = await Effect.runPromise(
|
|
203
|
+
failuresCommand('99999', {}).pipe(
|
|
204
|
+
Effect.provide(GerritApiServiceLive),
|
|
205
|
+
Effect.provide(Layer.succeed(ConfigService, mockConfig)),
|
|
206
|
+
Effect.either,
|
|
207
|
+
),
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
expect(result._tag).toBe('Left')
|
|
211
|
+
})
|
|
212
|
+
})
|
|
@@ -10,6 +10,7 @@ export const createMockConfigService = (
|
|
|
10
10
|
password: 'testpass',
|
|
11
11
|
},
|
|
12
12
|
aiConfig: AiConfig = { autoDetect: true },
|
|
13
|
+
retriggerComment?: string,
|
|
13
14
|
): ConfigServiceImpl => ({
|
|
14
15
|
getCredentials: Effect.succeed(credentials),
|
|
15
16
|
saveCredentials: () => Effect.succeed(undefined as void),
|
|
@@ -22,6 +23,9 @@ export const createMockConfigService = (
|
|
|
22
23
|
password: credentials.password,
|
|
23
24
|
aiTool: aiConfig.tool,
|
|
24
25
|
aiAutoDetect: aiConfig.autoDetect ?? true,
|
|
26
|
+
retriggerComment,
|
|
25
27
|
} as AppConfig),
|
|
26
28
|
saveFullConfig: () => Effect.succeed(undefined as void),
|
|
29
|
+
getRetriggerComment: Effect.succeed(retriggerComment),
|
|
30
|
+
saveRetriggerComment: () => Effect.succeed(undefined as void),
|
|
27
31
|
})
|