@aaronshaf/ger 1.2.10 → 2.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/.ast-grep/rules/no-as-casting.yml +13 -0
- package/.claude-plugin/plugin.json +22 -0
- package/.github/workflows/ci-simple.yml +53 -0
- package/.github/workflows/ci.yml +171 -0
- package/.github/workflows/claude-code-review.yml +83 -0
- package/.github/workflows/claude.yml +50 -0
- package/.github/workflows/dependency-update.yml +84 -0
- package/.github/workflows/release.yml +166 -0
- package/.github/workflows/security-scan.yml +113 -0
- package/.github/workflows/security.yml +96 -0
- package/.husky/pre-commit +16 -0
- package/.husky/pre-push +25 -0
- package/.lintstagedrc.json +6 -0
- package/.tool-versions +1 -0
- package/CLAUDE.md +105 -0
- package/DEVELOPMENT.md +361 -0
- package/EXAMPLES.md +457 -0
- package/README.md +831 -16
- package/bin/ger +3 -18
- package/biome.json +36 -0
- package/bun.lock +678 -0
- package/bunfig.toml +8 -0
- package/docs/adr/0001-use-effect-for-side-effects.md +65 -0
- package/docs/adr/0002-use-bun-runtime.md +64 -0
- package/docs/adr/0003-store-credentials-in-home-directory.md +75 -0
- package/docs/adr/0004-use-commander-for-cli.md +76 -0
- package/docs/adr/0005-use-effect-schema-for-validation.md +93 -0
- package/docs/adr/0006-use-msw-for-api-mocking.md +89 -0
- package/docs/adr/0007-git-hooks-for-quality.md +94 -0
- package/docs/adr/0008-no-as-typecasting.md +83 -0
- package/docs/adr/0009-file-size-limits.md +82 -0
- package/docs/adr/0010-llm-friendly-xml-output.md +93 -0
- package/docs/adr/0011-ai-tool-strategy-pattern.md +102 -0
- package/docs/adr/0012-build-status-message-parsing.md +94 -0
- package/docs/adr/0013-git-subprocess-integration.md +98 -0
- package/docs/adr/0014-group-management-support.md +95 -0
- package/docs/adr/0015-batch-comment-processing.md +111 -0
- package/docs/adr/0016-flexible-change-identifiers.md +94 -0
- package/docs/adr/0017-git-worktree-support.md +102 -0
- package/docs/adr/0018-auto-install-commit-hook.md +103 -0
- package/docs/adr/0019-sdk-package-exports.md +95 -0
- package/docs/adr/0020-code-coverage-enforcement.md +105 -0
- package/docs/adr/0021-typescript-isolated-declarations.md +83 -0
- package/docs/adr/0022-biome-oxlint-tooling.md +124 -0
- package/docs/adr/README.md +30 -0
- package/docs/prd/README.md +12 -0
- package/docs/prd/architecture.md +325 -0
- package/docs/prd/commands.md +425 -0
- package/docs/prd/data-model.md +349 -0
- package/docs/prd/overview.md +124 -0
- package/index.ts +219 -0
- package/oxlint.json +24 -0
- package/package.json +82 -15
- package/scripts/check-coverage.ts +69 -0
- package/scripts/check-file-size.ts +38 -0
- package/scripts/fix-test-mocks.ts +55 -0
- package/skills/gerrit-workflow/SKILL.md +247 -0
- package/skills/gerrit-workflow/examples.md +572 -0
- package/skills/gerrit-workflow/reference.md +728 -0
- package/src/api/gerrit.ts +696 -0
- package/src/cli/commands/abandon.ts +65 -0
- package/src/cli/commands/add-reviewer.ts +156 -0
- package/src/cli/commands/build-status.ts +282 -0
- package/src/cli/commands/checkout.ts +422 -0
- package/src/cli/commands/comment.ts +460 -0
- package/src/cli/commands/comments.ts +85 -0
- package/src/cli/commands/diff.ts +71 -0
- package/src/cli/commands/extract-url.ts +266 -0
- package/src/cli/commands/groups-members.ts +104 -0
- package/src/cli/commands/groups-show.ts +169 -0
- package/src/cli/commands/groups.ts +137 -0
- package/src/cli/commands/incoming.ts +226 -0
- package/src/cli/commands/init.ts +164 -0
- package/src/cli/commands/mine.ts +115 -0
- package/src/cli/commands/open.ts +57 -0
- package/src/cli/commands/projects.ts +68 -0
- package/src/cli/commands/push.ts +430 -0
- package/src/cli/commands/rebase.ts +52 -0
- package/src/cli/commands/remove-reviewer.ts +123 -0
- package/src/cli/commands/restore.ts +50 -0
- package/src/cli/commands/review.ts +486 -0
- package/src/cli/commands/search.ts +162 -0
- package/src/cli/commands/setup.ts +286 -0
- package/src/cli/commands/show.ts +491 -0
- package/src/cli/commands/status.ts +35 -0
- package/src/cli/commands/submit.ts +108 -0
- package/src/cli/commands/vote.ts +119 -0
- package/src/cli/commands/workspace.ts +200 -0
- package/src/cli/index.ts +53 -0
- package/src/cli/register-commands.ts +659 -0
- package/src/cli/register-group-commands.ts +88 -0
- package/src/cli/register-reviewer-commands.ts +97 -0
- package/src/prompts/default-review.md +86 -0
- package/src/prompts/system-inline-review.md +135 -0
- package/src/prompts/system-overall-review.md +206 -0
- package/src/schemas/config.test.ts +245 -0
- package/src/schemas/config.ts +84 -0
- package/src/schemas/gerrit.ts +681 -0
- package/src/services/commit-hook.ts +314 -0
- package/src/services/config.test.ts +150 -0
- package/src/services/config.ts +250 -0
- package/src/services/git-worktree.ts +342 -0
- package/src/services/review-strategy.ts +292 -0
- package/src/test-utils/mock-generator.ts +138 -0
- package/src/utils/change-id.test.ts +98 -0
- package/src/utils/change-id.ts +63 -0
- package/src/utils/comment-formatters.ts +153 -0
- package/src/utils/diff-context.ts +103 -0
- package/src/utils/diff-formatters.ts +141 -0
- package/src/utils/formatters.ts +85 -0
- package/src/utils/git-commit.test.ts +277 -0
- package/src/utils/git-commit.ts +122 -0
- package/src/utils/index.ts +55 -0
- package/src/utils/message-filters.ts +26 -0
- package/src/utils/review-formatters.ts +89 -0
- package/src/utils/review-prompt-builder.ts +110 -0
- package/src/utils/shell-safety.ts +117 -0
- package/src/utils/status-indicators.ts +100 -0
- package/src/utils/url-parser.test.ts +271 -0
- package/src/utils/url-parser.ts +118 -0
- package/tests/abandon.test.ts +230 -0
- package/tests/add-reviewer.test.ts +579 -0
- package/tests/build-status-watch.test.ts +344 -0
- package/tests/build-status.test.ts +789 -0
- package/tests/change-id-formats.test.ts +268 -0
- package/tests/checkout/integration.test.ts +653 -0
- package/tests/checkout/parse-input.test.ts +55 -0
- package/tests/checkout/validation.test.ts +178 -0
- package/tests/comment-batch-advanced.test.ts +431 -0
- package/tests/comment-gerrit-api-compliance.test.ts +414 -0
- package/tests/comment.test.ts +708 -0
- package/tests/comments.test.ts +323 -0
- package/tests/config-service-simple.test.ts +100 -0
- package/tests/diff.test.ts +419 -0
- package/tests/extract-url.test.ts +517 -0
- package/tests/groups-members.test.ts +256 -0
- package/tests/groups-show.test.ts +323 -0
- package/tests/groups.test.ts +334 -0
- package/tests/helpers/build-status-test-setup.ts +83 -0
- package/tests/helpers/config-mock.ts +27 -0
- package/tests/incoming.test.ts +357 -0
- package/tests/init.test.ts +70 -0
- package/tests/integration/commit-hook.test.ts +246 -0
- package/tests/interactive-incoming.test.ts +173 -0
- package/tests/mine.test.ts +285 -0
- package/tests/mocks/msw-handlers.ts +80 -0
- package/tests/open.test.ts +233 -0
- package/tests/projects.test.ts +259 -0
- package/tests/rebase.test.ts +271 -0
- package/tests/remove-reviewer.test.ts +357 -0
- package/tests/restore.test.ts +237 -0
- package/tests/review.test.ts +135 -0
- package/tests/search.test.ts +712 -0
- package/tests/setup.test.ts +63 -0
- package/tests/show-auto-detect.test.ts +324 -0
- package/tests/show.test.ts +813 -0
- package/tests/status.test.ts +145 -0
- package/tests/submit.test.ts +316 -0
- package/tests/unit/commands/push.test.ts +194 -0
- package/tests/unit/git-branch-detection.test.ts +82 -0
- package/tests/unit/git-worktree.test.ts +55 -0
- package/tests/unit/patterns/push-patterns.test.ts +148 -0
- package/tests/unit/schemas/gerrit.test.ts +85 -0
- package/tests/unit/services/commit-hook.test.ts +132 -0
- package/tests/unit/services/review-strategy.test.ts +349 -0
- package/tests/unit/test-utils/mock-generator.test.ts +154 -0
- package/tests/unit/utils/comment-formatters.test.ts +415 -0
- package/tests/unit/utils/diff-context.test.ts +171 -0
- package/tests/unit/utils/diff-formatters.test.ts +165 -0
- package/tests/unit/utils/formatters.test.ts +411 -0
- package/tests/unit/utils/message-filters.test.ts +227 -0
- package/tests/unit/utils/shell-safety.test.ts +230 -0
- package/tests/unit/utils/status-indicators.test.ts +137 -0
- package/tests/vote.test.ts +317 -0
- package/tests/workspace.test.ts +295 -0
- package/tsconfig.json +36 -5
- package/src/commands/branch.ts +0 -180
- package/src/ger.ts +0 -22
- package/src/types.d.ts +0 -35
- package/src/utils.ts +0 -130
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { Effect } from 'effect'
|
|
2
|
+
import { type ApiError, GerritApiService } from '@/api/gerrit'
|
|
3
|
+
|
|
4
|
+
interface AbandonOptions {
|
|
5
|
+
message?: string
|
|
6
|
+
xml?: boolean
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export const abandonCommand = (
|
|
10
|
+
changeId?: string,
|
|
11
|
+
options: AbandonOptions = {},
|
|
12
|
+
): Effect.Effect<void, ApiError, GerritApiService> =>
|
|
13
|
+
Effect.gen(function* () {
|
|
14
|
+
const gerritApi = yield* GerritApiService
|
|
15
|
+
|
|
16
|
+
if (!changeId) {
|
|
17
|
+
console.error('✗ Change ID is required')
|
|
18
|
+
console.error(' Usage: ger abandon <change-id>')
|
|
19
|
+
return
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
try {
|
|
23
|
+
// First get the change details to show what we're abandoning
|
|
24
|
+
const change = yield* gerritApi.getChange(changeId)
|
|
25
|
+
|
|
26
|
+
// Perform the abandon
|
|
27
|
+
yield* gerritApi.abandonChange(changeId, options.message)
|
|
28
|
+
|
|
29
|
+
if (options.xml) {
|
|
30
|
+
console.log(`<?xml version="1.0" encoding="UTF-8"?>`)
|
|
31
|
+
console.log(`<abandon_result>`)
|
|
32
|
+
console.log(` <status>success</status>`)
|
|
33
|
+
console.log(` <change_number>${change._number}</change_number>`)
|
|
34
|
+
console.log(` <subject><![CDATA[${change.subject}]]></subject>`)
|
|
35
|
+
if (options.message) {
|
|
36
|
+
console.log(` <message><![CDATA[${options.message}]]></message>`)
|
|
37
|
+
}
|
|
38
|
+
console.log(`</abandon_result>`)
|
|
39
|
+
} else {
|
|
40
|
+
console.log(`✓ Abandoned change ${change._number}: ${change.subject}`)
|
|
41
|
+
if (options.message) {
|
|
42
|
+
console.log(` Message: ${options.message}`)
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
} catch {
|
|
46
|
+
// If we can't get change details, still try to abandon with just the ID
|
|
47
|
+
yield* gerritApi.abandonChange(changeId, options.message)
|
|
48
|
+
|
|
49
|
+
if (options.xml) {
|
|
50
|
+
console.log(`<?xml version="1.0" encoding="UTF-8"?>`)
|
|
51
|
+
console.log(`<abandon_result>`)
|
|
52
|
+
console.log(` <status>success</status>`)
|
|
53
|
+
console.log(` <change_id>${changeId}</change_id>`)
|
|
54
|
+
if (options.message) {
|
|
55
|
+
console.log(` <message><![CDATA[${options.message}]]></message>`)
|
|
56
|
+
}
|
|
57
|
+
console.log(`</abandon_result>`)
|
|
58
|
+
} else {
|
|
59
|
+
console.log(`✓ Abandoned change ${changeId}`)
|
|
60
|
+
if (options.message) {
|
|
61
|
+
console.log(` Message: ${options.message}`)
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
})
|
|
@@ -0,0 +1,156 @@
|
|
|
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
|
+
group?: boolean
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
type NotifyLevel = 'NONE' | 'OWNER' | 'OWNER_REVIEWERS' | 'ALL'
|
|
13
|
+
|
|
14
|
+
const VALID_NOTIFY_LEVELS: ReadonlyArray<NotifyLevel> = ['NONE', 'OWNER', 'OWNER_REVIEWERS', 'ALL']
|
|
15
|
+
|
|
16
|
+
const escapeXml = (str: string): string =>
|
|
17
|
+
str.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
|
|
18
|
+
|
|
19
|
+
const outputXmlError = (message: string): void => {
|
|
20
|
+
console.log(`<?xml version="1.0" encoding="UTF-8"?>`)
|
|
21
|
+
console.log(`<add_reviewer_result>`)
|
|
22
|
+
console.log(` <status>error</status>`)
|
|
23
|
+
console.log(` <error><![CDATA[${message}]]></error>`)
|
|
24
|
+
console.log(`</add_reviewer_result>`)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
class ValidationError extends Error {
|
|
28
|
+
readonly _tag = 'ValidationError'
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export const addReviewerCommand = (
|
|
32
|
+
reviewers: string[],
|
|
33
|
+
options: AddReviewerOptions = {},
|
|
34
|
+
): Effect.Effect<void, ApiError | ValidationError, GerritApiService> =>
|
|
35
|
+
Effect.gen(function* () {
|
|
36
|
+
const gerritApi = yield* GerritApiService
|
|
37
|
+
|
|
38
|
+
const changeId = options.change
|
|
39
|
+
|
|
40
|
+
if (!changeId) {
|
|
41
|
+
const message =
|
|
42
|
+
'Change ID is required. Use -c <change-id> or run from a branch with an active change.'
|
|
43
|
+
if (options.xml) {
|
|
44
|
+
outputXmlError(message)
|
|
45
|
+
} else {
|
|
46
|
+
console.error(`✗ ${message}`)
|
|
47
|
+
}
|
|
48
|
+
return yield* Effect.fail(new ValidationError(message))
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (reviewers.length === 0) {
|
|
52
|
+
const entityType = options.group ? 'group' : 'reviewer'
|
|
53
|
+
const message = `At least one ${entityType} is required.`
|
|
54
|
+
if (options.xml) {
|
|
55
|
+
outputXmlError(message)
|
|
56
|
+
} else {
|
|
57
|
+
console.error(`✗ ${message}`)
|
|
58
|
+
}
|
|
59
|
+
return yield* Effect.fail(new ValidationError(message))
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Validate that email-like inputs aren't used with --group flag
|
|
63
|
+
// Note: This uses a simple heuristic (presence of '@') to detect likely email addresses.
|
|
64
|
+
// While Gerrit group names could theoretically contain '@', this is rare in practice
|
|
65
|
+
// and the validation serves as a helpful UX guardrail against common mistakes.
|
|
66
|
+
if (options.group) {
|
|
67
|
+
const emailLikeInputs = reviewers.filter((r) => r.includes('@'))
|
|
68
|
+
if (emailLikeInputs.length > 0) {
|
|
69
|
+
const message = `The --group flag expects group identifiers, but received email-like input: ${emailLikeInputs.join(', ')}. Did you mean to omit --group?`
|
|
70
|
+
if (options.xml) {
|
|
71
|
+
outputXmlError(message)
|
|
72
|
+
} else {
|
|
73
|
+
console.error(`✗ ${message}`)
|
|
74
|
+
}
|
|
75
|
+
return yield* Effect.fail(new ValidationError(message))
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const state: 'REVIEWER' | 'CC' = options.cc ? 'CC' : 'REVIEWER'
|
|
80
|
+
const entityType = options.group ? 'group' : 'individual'
|
|
81
|
+
const stateLabel = options.cc ? 'cc' : options.group ? 'group' : 'reviewer'
|
|
82
|
+
|
|
83
|
+
let notify: NotifyLevel | undefined
|
|
84
|
+
if (options.notify) {
|
|
85
|
+
const upperNotify = options.notify.toUpperCase()
|
|
86
|
+
if (!VALID_NOTIFY_LEVELS.includes(upperNotify as NotifyLevel)) {
|
|
87
|
+
const message = `Invalid notify level: ${options.notify}. Valid values: none, owner, owner_reviewers, all`
|
|
88
|
+
if (options.xml) {
|
|
89
|
+
outputXmlError(message)
|
|
90
|
+
} else {
|
|
91
|
+
console.error(`✗ ${message}`)
|
|
92
|
+
}
|
|
93
|
+
return yield* Effect.fail(new ValidationError(message))
|
|
94
|
+
}
|
|
95
|
+
notify = upperNotify as NotifyLevel
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const results: Array<{ reviewer: string; success: boolean; name?: string; error?: string }> = []
|
|
99
|
+
|
|
100
|
+
for (const reviewer of reviewers) {
|
|
101
|
+
const result = yield* Effect.either(
|
|
102
|
+
gerritApi.addReviewer(changeId, reviewer, { state, notify }),
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
if (result._tag === 'Left') {
|
|
106
|
+
const error = result.left
|
|
107
|
+
const message = 'message' in error ? String(error.message) : String(error)
|
|
108
|
+
results.push({ reviewer, success: false, error: message })
|
|
109
|
+
continue
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const apiResult = result.right
|
|
113
|
+
|
|
114
|
+
if (apiResult.error) {
|
|
115
|
+
results.push({ reviewer, success: false, error: apiResult.error })
|
|
116
|
+
} else {
|
|
117
|
+
const added = apiResult.reviewers?.[0] || apiResult.ccs?.[0]
|
|
118
|
+
const name = added?.name || added?.email || reviewer
|
|
119
|
+
results.push({ reviewer, success: true, name })
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (options.xml) {
|
|
124
|
+
console.log(`<?xml version="1.0" encoding="UTF-8"?>`)
|
|
125
|
+
console.log(`<add_reviewer_result>`)
|
|
126
|
+
console.log(` <change_id>${escapeXml(changeId)}</change_id>`)
|
|
127
|
+
console.log(` <state>${escapeXml(state)}</state>`)
|
|
128
|
+
console.log(` <entity_type>${escapeXml(entityType)}</entity_type>`)
|
|
129
|
+
console.log(` <reviewers>`)
|
|
130
|
+
for (const r of results) {
|
|
131
|
+
if (r.success) {
|
|
132
|
+
console.log(` <reviewer status="added">`)
|
|
133
|
+
console.log(` <input>${escapeXml(r.reviewer)}</input>`)
|
|
134
|
+
console.log(` <name><![CDATA[${r.name}]]></name>`)
|
|
135
|
+
console.log(` </reviewer>`)
|
|
136
|
+
} else {
|
|
137
|
+
console.log(` <reviewer status="failed">`)
|
|
138
|
+
console.log(` <input>${escapeXml(r.reviewer)}</input>`)
|
|
139
|
+
console.log(` <error><![CDATA[${r.error}]]></error>`)
|
|
140
|
+
console.log(` </reviewer>`)
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
console.log(` </reviewers>`)
|
|
144
|
+
const allSuccess = results.every((r) => r.success)
|
|
145
|
+
console.log(` <status>${allSuccess ? 'success' : 'partial_failure'}</status>`)
|
|
146
|
+
console.log(`</add_reviewer_result>`)
|
|
147
|
+
} else {
|
|
148
|
+
for (const r of results) {
|
|
149
|
+
if (r.success) {
|
|
150
|
+
console.log(`✓ Added ${r.name} as ${stateLabel}`)
|
|
151
|
+
} else {
|
|
152
|
+
console.error(`✗ Failed to add ${r.reviewer}: ${r.error}`)
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
})
|
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
import { Effect, Schema } from 'effect'
|
|
2
|
+
import { type ApiError, GerritApiService } from '@/api/gerrit'
|
|
3
|
+
import type { MessageInfo } from '@/schemas/gerrit'
|
|
4
|
+
import { getChangeIdFromHead, GitError, NoChangeIdError } from '@/utils/git-commit'
|
|
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
|
+
|
|
55
|
+
// Export types for external use
|
|
56
|
+
export type BuildState = 'pending' | 'running' | 'success' | 'failure' | 'not_found'
|
|
57
|
+
|
|
58
|
+
// Watch options (matches gh run watch pattern)
|
|
59
|
+
export type WatchOptions = {
|
|
60
|
+
readonly watch: boolean
|
|
61
|
+
readonly interval: number // seconds
|
|
62
|
+
readonly timeout: number // seconds
|
|
63
|
+
readonly exitStatus: boolean
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Timeout error for watch mode
|
|
67
|
+
export class TimeoutError extends Error {
|
|
68
|
+
readonly _tag = 'TimeoutError'
|
|
69
|
+
constructor(message: string) {
|
|
70
|
+
super(message)
|
|
71
|
+
this.name = 'TimeoutError'
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Effect Schema for BuildStatus (follows project patterns)
|
|
76
|
+
export const BuildStatus: Schema.Schema<{
|
|
77
|
+
readonly state: 'pending' | 'running' | 'success' | 'failure' | 'not_found'
|
|
78
|
+
}> = Schema.Struct({
|
|
79
|
+
state: Schema.Literal('pending', 'running', 'success', 'failure', 'not_found'),
|
|
80
|
+
})
|
|
81
|
+
export type BuildStatus = Schema.Schema.Type<typeof BuildStatus>
|
|
82
|
+
|
|
83
|
+
// Message patterns for precise matching
|
|
84
|
+
const BUILD_STARTED_PATTERN = /Build\s+Started/i
|
|
85
|
+
const VERIFIED_PLUS_PATTERN = /Verified\s*[+]\s*1/
|
|
86
|
+
const VERIFIED_MINUS_PATTERN = /Verified\s*[-]\s*1/
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Parse messages to determine build status based on "Build Started" and verification messages.
|
|
90
|
+
* Only considers verification messages for the same patchset as the latest build.
|
|
91
|
+
*/
|
|
92
|
+
const parseBuildStatus = (messages: readonly MessageInfo[]): BuildStatus => {
|
|
93
|
+
// Empty messages means change exists but has no activity yet - return pending
|
|
94
|
+
if (messages.length === 0) {
|
|
95
|
+
return { state: 'pending' }
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Find the most recent "Build Started" message and its revision number
|
|
99
|
+
let lastBuildDate: string | null = null
|
|
100
|
+
let lastBuildRevision: number | undefined = undefined
|
|
101
|
+
for (const msg of messages) {
|
|
102
|
+
if (BUILD_STARTED_PATTERN.test(msg.message)) {
|
|
103
|
+
lastBuildDate = msg.date
|
|
104
|
+
lastBuildRevision = msg._revision_number
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// If no build has started, state is "pending"
|
|
109
|
+
if (!lastBuildDate) {
|
|
110
|
+
return { state: 'pending' }
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Check for verification messages after the build started AND for the same revision
|
|
114
|
+
for (const msg of messages) {
|
|
115
|
+
const date = msg.date
|
|
116
|
+
// Gerrit timestamps are ISO 8601 strings (lexicographically sortable)
|
|
117
|
+
if (date <= lastBuildDate) continue
|
|
118
|
+
|
|
119
|
+
// Only consider verification messages for the same patchset
|
|
120
|
+
// If revision numbers are available, they must match
|
|
121
|
+
if (lastBuildRevision !== undefined && msg._revision_number !== undefined) {
|
|
122
|
+
if (msg._revision_number !== lastBuildRevision) continue
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (VERIFIED_PLUS_PATTERN.test(msg.message)) {
|
|
126
|
+
return { state: 'success' }
|
|
127
|
+
} else if (VERIFIED_MINUS_PATTERN.test(msg.message)) {
|
|
128
|
+
return { state: 'failure' }
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Build started but no verification yet, state is "running"
|
|
133
|
+
return { state: 'running' }
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Get messages for a change
|
|
138
|
+
*/
|
|
139
|
+
const getMessagesForChange = (
|
|
140
|
+
changeId: string,
|
|
141
|
+
): Effect.Effect<readonly MessageInfo[], ApiError, GerritApiService> =>
|
|
142
|
+
Effect.gen(function* () {
|
|
143
|
+
const gerritApi = yield* GerritApiService
|
|
144
|
+
const messages = yield* gerritApi.getMessages(changeId)
|
|
145
|
+
return messages
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Poll build status until terminal state or timeout
|
|
150
|
+
* Outputs JSON status on each iteration (mimics gh run watch)
|
|
151
|
+
*/
|
|
152
|
+
const pollBuildStatus = (
|
|
153
|
+
changeId: string,
|
|
154
|
+
options: WatchOptions,
|
|
155
|
+
): Effect.Effect<BuildStatus, ApiError | TimeoutError, GerritApiService> =>
|
|
156
|
+
Effect.gen(function* () {
|
|
157
|
+
const startTime = Date.now()
|
|
158
|
+
const timeoutMs = options.timeout * 1000
|
|
159
|
+
|
|
160
|
+
while (true) {
|
|
161
|
+
// Check timeout
|
|
162
|
+
const elapsed = Date.now() - startTime
|
|
163
|
+
if (elapsed > timeoutMs) {
|
|
164
|
+
yield* Effect.sync(() => {
|
|
165
|
+
console.error(`Timeout: Build status check exceeded ${options.timeout}s`)
|
|
166
|
+
})
|
|
167
|
+
yield* Effect.fail(
|
|
168
|
+
new TimeoutError(`Build status check timed out after ${options.timeout}s`),
|
|
169
|
+
)
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Fetch and parse status
|
|
173
|
+
const messages = yield* getMessagesForChange(changeId)
|
|
174
|
+
const status = parseBuildStatus(messages)
|
|
175
|
+
|
|
176
|
+
// Check timeout again after API call (in case it took longer than expected)
|
|
177
|
+
const elapsedAfterFetch = Date.now() - startTime
|
|
178
|
+
if (elapsedAfterFetch > timeoutMs) {
|
|
179
|
+
yield* Effect.sync(() => {
|
|
180
|
+
console.error(`Timeout: Build status check exceeded ${options.timeout}s`)
|
|
181
|
+
})
|
|
182
|
+
yield* Effect.fail(
|
|
183
|
+
new TimeoutError(`Build status check timed out after ${options.timeout}s`),
|
|
184
|
+
)
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Output current status to stdout (JSON, like single-check mode)
|
|
188
|
+
yield* Effect.sync(() => {
|
|
189
|
+
process.stdout.write(JSON.stringify(status) + '\n')
|
|
190
|
+
})
|
|
191
|
+
|
|
192
|
+
// Terminal states - wait for interval before returning to allow logs to be written
|
|
193
|
+
if (status.state === 'success' || status.state === 'not_found') {
|
|
194
|
+
return status
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
if (status.state === 'failure') {
|
|
198
|
+
// Wait for interval seconds to allow build failure logs to be fully written
|
|
199
|
+
yield* Effect.sleep(options.interval * 1000)
|
|
200
|
+
return status
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// Non-terminal states - sleep for interval duration
|
|
204
|
+
yield* Effect.sleep(options.interval * 1000)
|
|
205
|
+
}
|
|
206
|
+
})
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Build status command with optional watch mode (mimics gh run watch)
|
|
210
|
+
*/
|
|
211
|
+
export const buildStatusCommand = (
|
|
212
|
+
changeId: string | undefined,
|
|
213
|
+
options: Partial<WatchOptions> = {},
|
|
214
|
+
): Effect.Effect<
|
|
215
|
+
void,
|
|
216
|
+
ApiError | Error | GitError | NoChangeIdError | TimeoutError,
|
|
217
|
+
GerritApiService
|
|
218
|
+
> =>
|
|
219
|
+
Effect.gen(function* () {
|
|
220
|
+
// Auto-detect Change-ID from HEAD commit if not provided
|
|
221
|
+
const resolvedChangeId = changeId || (yield* getChangeIdFromHead())
|
|
222
|
+
|
|
223
|
+
// Set defaults (matching gh run watch patterns)
|
|
224
|
+
const watchOpts: WatchOptions = {
|
|
225
|
+
watch: options.watch ?? false,
|
|
226
|
+
interval: Math.max(1, options.interval ?? 10), // Min 1 second
|
|
227
|
+
timeout: Math.max(1, options.timeout ?? 1800), // Min 1 second, default 30 minutes
|
|
228
|
+
exitStatus: options.exitStatus ?? false,
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
let status: BuildStatus
|
|
232
|
+
|
|
233
|
+
if (watchOpts.watch) {
|
|
234
|
+
// Polling mode - outputs JSON on each iteration
|
|
235
|
+
status = yield* pollBuildStatus(resolvedChangeId, watchOpts)
|
|
236
|
+
} else {
|
|
237
|
+
// Single check mode (existing behavior)
|
|
238
|
+
const messages = yield* getMessagesForChange(resolvedChangeId)
|
|
239
|
+
status = parseBuildStatus(messages)
|
|
240
|
+
|
|
241
|
+
// Output JSON to stdout
|
|
242
|
+
yield* Effect.sync(() => {
|
|
243
|
+
process.stdout.write(JSON.stringify(status) + '\n')
|
|
244
|
+
})
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// Handle exit codes (only non-zero when explicitly requested)
|
|
248
|
+
if (watchOpts.exitStatus && status.state === 'failure') {
|
|
249
|
+
yield* Effect.sync(() => process.exit(1))
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// Default: exit 0 for all states (success, failure, pending, etc.)
|
|
253
|
+
}).pipe(
|
|
254
|
+
Effect.catchAll((error) => {
|
|
255
|
+
// Timeout error
|
|
256
|
+
if (error instanceof TimeoutError) {
|
|
257
|
+
return Effect.sync(() => {
|
|
258
|
+
console.error(`Error: ${error.message}`)
|
|
259
|
+
process.exit(2)
|
|
260
|
+
})
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// 404 - change not found
|
|
264
|
+
if (error && typeof error === 'object' && 'status' in error && error.status === 404) {
|
|
265
|
+
const status: BuildStatus = { state: 'not_found' }
|
|
266
|
+
return Effect.sync(() => {
|
|
267
|
+
process.stdout.write(JSON.stringify(status) + '\n')
|
|
268
|
+
})
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// Other errors - exit 3
|
|
272
|
+
const errorMessage =
|
|
273
|
+
error instanceof GitError || error instanceof NoChangeIdError || error instanceof Error
|
|
274
|
+
? error.message
|
|
275
|
+
: String(error)
|
|
276
|
+
|
|
277
|
+
return Effect.sync(() => {
|
|
278
|
+
console.error(`Error: ${errorMessage}`)
|
|
279
|
+
process.exit(3)
|
|
280
|
+
})
|
|
281
|
+
}),
|
|
282
|
+
)
|