Skip to content

Commit 2cb55dd

Browse files
committed
Merge branch 'loc-pricing'
2 parents 9fc677f + 852b5ee commit 2cb55dd

37 files changed

Lines changed: 3313 additions & 93 deletions

cmd/app.go

Lines changed: 38 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ type Handlers struct {
2121
RunAttestationTrailer cli.ActionFunc
2222
RunSetup cli.ActionFunc
2323
RunUI cli.ActionFunc
24+
RunUsageInspect cli.ActionFunc
2425
}
2526

2627
// BuildApp constructs the full CLI app with all command wiring.
@@ -177,6 +178,22 @@ func BuildApp(version, buildTime, gitCommit string, baseFlags, debugFlags []cli.
177178
},
178179
Action: h.RunSelfUpdate,
179180
},
181+
{
182+
Name: "usage",
183+
Usage: "Inspect plan and quota usage",
184+
Subcommands: []*cli.Command{
185+
{
186+
Name: "inspect",
187+
Usage: "Fetch and display current quota envelope for selected org",
188+
Flags: []cli.Flag{
189+
&cli.StringFlag{Name: "api-url", Usage: "override LiveReview API base URL"},
190+
&cli.StringFlag{Name: "output", Value: "pretty", Usage: "output format: pretty or json"},
191+
&cli.BoolFlag{Name: "verbose", Usage: "enable verbose output"},
192+
},
193+
Action: h.RunUsageInspect,
194+
},
195+
},
196+
},
180197
{
181198
Name: "review-cleanup",
182199
Usage: "Clean up review session history for the current branch (called by post-commit hook)",
@@ -196,8 +213,27 @@ func BuildApp(version, buildTime, gitCommit string, baseFlags, debugFlags []cli.
196213
Action: h.RunAttestationTrailer,
197214
},
198215
{
199-
Name: "setup",
200-
Usage: "Guided onboarding — authenticate with Hexmos and configure LiveReview + AI",
216+
Name: "setup",
217+
Usage: "Guided onboarding — authenticate with Hexmos and configure LiveReview + AI",
218+
Flags: []cli.Flag{
219+
&cli.StringFlag{
220+
Name: "api-url",
221+
Aliases: []string{"base-url"},
222+
Usage: "override LiveReview API base URL for setup",
223+
},
224+
&cli.BoolFlag{
225+
Name: "yes",
226+
Usage: "run non-interactively; requires explicit --keep-api-url or --replace-api-url when config already exists",
227+
},
228+
&cli.BoolFlag{
229+
Name: "keep-api-url",
230+
Usage: "when config exists, preserve existing api_url",
231+
},
232+
&cli.BoolFlag{
233+
Name: "replace-api-url",
234+
Usage: "when config exists, replace api_url with setup target URL",
235+
},
236+
},
201237
Action: h.RunSetup,
202238
},
203239
{

cmd/app_test.go

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
package cmd
2+
3+
import "testing"
4+
5+
func TestSetupCommandIncludesAPIURLChoiceFlags(t *testing.T) {
6+
app := BuildApp("dev", "now", "none", nil, nil, Handlers{})
7+
8+
var setupCommandFound bool
9+
var setupCommandFlags map[string]bool
10+
11+
for _, command := range app.Commands {
12+
if command.Name != "setup" {
13+
continue
14+
}
15+
setupCommandFound = true
16+
setupCommandFlags = map[string]bool{}
17+
for _, flag := range command.Flags {
18+
for _, name := range flag.Names() {
19+
setupCommandFlags[name] = true
20+
}
21+
}
22+
break
23+
}
24+
25+
if !setupCommandFound {
26+
t.Fatalf("setup command not found")
27+
}
28+
29+
for _, expected := range []string{"api-url", "base-url", "yes", "keep-api-url", "replace-api-url"} {
30+
if !setupCommandFlags[expected] {
31+
t.Fatalf("setup command missing flag %q", expected)
32+
}
33+
}
34+
}

config/ai_connectors.go

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,31 @@ func UpsertQuotedConfigValue(content, key, value string) string {
3232
return strings.Join(lines, "\n")
3333
}
3434

35+
func ReadQuotedConfigValue(content, key string) (string, bool) {
36+
lines := strings.Split(content, "\n")
37+
prefix := key + " ="
38+
39+
for _, line := range lines {
40+
trimmed := strings.TrimSpace(line)
41+
if !strings.HasPrefix(trimmed, prefix) {
42+
continue
43+
}
44+
45+
rawValue := strings.TrimSpace(strings.TrimPrefix(trimmed, prefix))
46+
if rawValue == "" {
47+
return "", true
48+
}
49+
50+
if unquoted, err := strconv.Unquote(rawValue); err == nil {
51+
return unquoted, true
52+
}
53+
54+
return rawValue, true
55+
}
56+
57+
return "", false
58+
}
59+
3560
func StripManagedAIConnectorsSection(content, sectionBegin, sectionEnd string) string {
3661
start := strings.Index(content, sectionBegin)
3762
if start == -1 {

internal/appcore/auth_recovery.go

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -166,7 +166,7 @@ func recoverAPIKeyAndTokens(config Config, phase string) (Config, error) {
166166
if err == nil {
167167
updated := config
168168
updated.APIKey = newKey
169-
if persistErr := persistConfigUpdates(updated.ConfigPath, updated.APIURL, map[string]string{"api_key": newKey}); persistErr != nil {
169+
if persistErr := persistConfigUpdates(updated.ConfigPath, map[string]string{"api_key": newKey}); persistErr != nil {
170170
diag.FailureReason = fmt.Sprintf("persist api_key failed: %v", persistErr)
171171
reportDiagnosticWriteError(persistAuthRecoveryDiagnostic(&diag, time.Since(started)))
172172
return config, fmt.Errorf("generated a new API key but failed to persist config: %w", persistErr)
@@ -203,7 +203,7 @@ func recoverAPIKeyAndTokens(config Config, phase string) (Config, error) {
203203
if strings.TrimSpace(newRefresh) != "" {
204204
updated.RefreshToken = newRefresh
205205
}
206-
if persistErr := persistConfigUpdates(updated.ConfigPath, updated.APIURL, map[string]string{"jwt": updated.JWT, "refresh_token": updated.RefreshToken}); persistErr != nil {
206+
if persistErr := persistConfigUpdates(updated.ConfigPath, map[string]string{"jwt": updated.JWT, "refresh_token": updated.RefreshToken}); persistErr != nil {
207207
diag.FailureReason = fmt.Sprintf("persist refreshed tokens failed: %v", persistErr)
208208
reportDiagnosticWriteError(persistAuthRecoveryDiagnostic(&diag, time.Since(started)))
209209
return config, fmt.Errorf("refreshed session tokens but failed to persist them: %w", persistErr)
@@ -224,7 +224,7 @@ func recoverAPIKeyAndTokens(config Config, phase string) (Config, error) {
224224
}
225225

226226
updated.APIKey = newKey
227-
if persistErr := persistConfigUpdates(updated.ConfigPath, updated.APIURL, map[string]string{"api_key": updated.APIKey}); persistErr != nil {
227+
if persistErr := persistConfigUpdates(updated.ConfigPath, map[string]string{"api_key": updated.APIKey}); persistErr != nil {
228228
diag.FailureReason = fmt.Sprintf("persist recovered api_key failed: %v", persistErr)
229229
reportDiagnosticWriteError(persistAuthRecoveryDiagnostic(&diag, time.Since(started)))
230230
return config, fmt.Errorf("recovered API key but failed to persist config: %w", persistErr)
@@ -297,7 +297,7 @@ func resolveConfigPath(configPath string) (string, error) {
297297
return configpath.ResolveConfigPath()
298298
}
299299

300-
func persistConfigUpdates(configPath, apiURL string, updates map[string]string) error {
300+
func persistConfigUpdates(configPath string, updates map[string]string) error {
301301
resolvedConfigPath, err := resolveConfigPath(configPath)
302302
if err != nil {
303303
return err
@@ -310,11 +310,10 @@ func persistConfigUpdates(configPath, apiURL string, updates map[string]string)
310310
return err
311311
}
312312

313-
if strings.TrimSpace(content) == "" && strings.TrimSpace(apiURL) != "" {
314-
content = cfgutil.UpsertQuotedConfigValue(content, "api_url", apiURL)
315-
}
316-
317313
for key, value := range updates {
314+
if strings.EqualFold(strings.TrimSpace(key), "api_url") {
315+
return fmt.Errorf("api_url updates are not allowed in auth recovery persistence")
316+
}
318317
if strings.TrimSpace(value) == "" {
319318
continue
320319
}
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
package appcore
2+
3+
import (
4+
"net/http"
5+
"os"
6+
"path/filepath"
7+
"strings"
8+
"testing"
9+
10+
"github.com/HexmosTech/git-lrc/internal/reviewmodel"
11+
)
12+
13+
func TestIsLiveReviewAPIKeyInvalid(t *testing.T) {
14+
tests := []struct {
15+
name string
16+
err error
17+
want bool
18+
}{
19+
{
20+
name: "valid unauthorized code",
21+
err: &reviewmodel.APIError{
22+
StatusCode: http.StatusUnauthorized,
23+
Body: `{"error_code":"LIVE_REVIEW_API_KEY_INVALID","error":"invalid"}`,
24+
},
25+
want: true,
26+
},
27+
{
28+
name: "unauthorized but different error code",
29+
err: &reviewmodel.APIError{
30+
StatusCode: http.StatusUnauthorized,
31+
Body: `{"error_code":"SOMETHING_ELSE","error":"nope"}`,
32+
},
33+
want: false,
34+
},
35+
{
36+
name: "non-401 should not trigger recovery",
37+
err: &reviewmodel.APIError{
38+
StatusCode: http.StatusTooManyRequests,
39+
Body: `{"error_code":"LIVE_REVIEW_API_KEY_INVALID"}`,
40+
},
41+
want: false,
42+
},
43+
{
44+
name: "malformed json should not trigger recovery",
45+
err: &reviewmodel.APIError{
46+
StatusCode: http.StatusUnauthorized,
47+
Body: `not-json`,
48+
},
49+
want: false,
50+
},
51+
}
52+
53+
for _, tt := range tests {
54+
t.Run(tt.name, func(t *testing.T) {
55+
got := isLiveReviewAPIKeyInvalid(tt.err)
56+
if got != tt.want {
57+
t.Fatalf("isLiveReviewAPIKeyInvalid() = %v, want %v", got, tt.want)
58+
}
59+
})
60+
}
61+
}
62+
63+
func TestParseAPIErrorCode(t *testing.T) {
64+
code, err := parseAPIErrorCode(`{"error_code":"LIVE_REVIEW_API_KEY_INVALID"}`)
65+
if err != nil {
66+
t.Fatalf("expected no error, got %v", err)
67+
}
68+
if code != "LIVE_REVIEW_API_KEY_INVALID" {
69+
t.Fatalf("unexpected code: %s", code)
70+
}
71+
72+
if _, err := parseAPIErrorCode(`{not-json}`); err == nil {
73+
t.Fatal("expected parse error for malformed json")
74+
}
75+
}
76+
77+
func TestPersistConfigUpdatesPreservesExistingAPIURL(t *testing.T) {
78+
tmpDir := t.TempDir()
79+
configPath := filepath.Join(tmpDir, ".lrc.toml")
80+
original := "api_url = \"http://localhost:8888\"\njwt = \"old\"\n"
81+
if err := os.WriteFile(configPath, []byte(original), 0600); err != nil {
82+
t.Fatalf("write config: %v", err)
83+
}
84+
85+
if err := persistConfigUpdates(configPath, map[string]string{"jwt": "new"}); err != nil {
86+
t.Fatalf("persist config updates: %v", err)
87+
}
88+
89+
updatedBytes, err := os.ReadFile(configPath)
90+
if err != nil {
91+
t.Fatalf("read config: %v", err)
92+
}
93+
updated := string(updatedBytes)
94+
95+
if !strings.Contains(updated, `api_url = "http://localhost:8888"`) {
96+
t.Fatalf("expected api_url to remain unchanged: %s", updated)
97+
}
98+
if !strings.Contains(updated, `jwt = "new"`) {
99+
t.Fatalf("expected jwt to be updated: %s", updated)
100+
}
101+
}
102+
103+
func TestPersistConfigUpdatesRejectsAPIURLMutation(t *testing.T) {
104+
tmpDir := t.TempDir()
105+
configPath := filepath.Join(tmpDir, ".lrc.toml")
106+
if err := os.WriteFile(configPath, []byte("jwt = \"old\"\n"), 0600); err != nil {
107+
t.Fatalf("write config: %v", err)
108+
}
109+
110+
err := persistConfigUpdates(configPath, map[string]string{"api_url": "https://livereview.hexmos.com"})
111+
if err == nil {
112+
t.Fatal("expected api_url mutation guard to fail")
113+
}
114+
if !strings.Contains(err.Error(), "api_url updates are not allowed") {
115+
t.Fatalf("unexpected error: %v", err)
116+
}
117+
}

0 commit comments

Comments
 (0)