Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions internal/appcore/review_runtime.go
Original file line number Diff line number Diff line change
Expand Up @@ -267,6 +267,9 @@ func runReviewWithOptions(opts reviewopts.Options) error {
if strings.TrimSpace(opts.APIURL) != "" {
config.APIURL = opts.APIURL
}
if authErr := loadAuthConfigFile(config); authErr != nil {
log.Printf("Failed to load auth config: %v", authErr)
}
if verbose {
log.Printf("Fake review mode enabled (reviewMode=%s)", reviewMode)
}
Expand Down Expand Up @@ -1998,6 +2001,40 @@ func loadConfigValues(apiKeyOverride, apiURLOverride string, verbose bool) (*Con
return config, nil
}

// loadAuthConfigFile reads JWT and OrgID from ~/.lrc.toml and populates
// them on the given config. It does not require an API key, so it can be
// used in fake mode where the full config loading is skipped.
// Returns nil on success or when the config file simply doesn't exist
// (which is expected for first-time users). Returns an error only when
// the file exists but cannot be read or parsed.
func loadAuthConfigFile(config *Config) error {
configPath, err := configpath.ResolveConfigPath()
if err != nil {
return fmt.Errorf("resolve config path: %w", err)
}
if _, err := os.Stat(configPath); err != nil {
if errors.Is(err, os.ErrNotExist) {
return nil
}
return fmt.Errorf("access config file %s: %w", configPath, err)
}
k := koanf.New(".")
if err := k.Load(file.Provider(configPath), toml.Parser()); err != nil {
return fmt.Errorf("parse config file %s: %w", configPath, err)
}
if v := k.Get("jwt"); v != nil {
if s, ok := v.(string); ok {
config.JWT = strings.TrimSpace(s)
}
}
if v := k.Get("org_id"); v != nil {
if s, ok := v.(string); ok {
config.OrgID = strings.TrimSpace(s)
}
}
return nil
}

// zipEntryNames lists the file names contained in a zip archive, falling
// back to a placeholder describing the read error if the archive can't be
// read.
Expand Down
3 changes: 1 addition & 2 deletions internal/appcore/usage_chip_runtime.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@ import (
"github.com/HexmosTech/git-lrc/internal/reviewmodel"
"github.com/HexmosTech/git-lrc/internal/reviewopts"
"github.com/HexmosTech/git-lrc/network"
"github.com/HexmosTech/git-lrc/setup"
uicfg "github.com/HexmosTech/git-lrc/ui"
)

Expand Down Expand Up @@ -92,7 +91,6 @@ func buildRuntimeUsageChipPayload(config *Config, verbose bool) uicfg.UsageChipR
TopMembers: make([]uicfg.UsageChipMember, 0),
CanViewTeamBreakdown: false,
FetchedAt: time.Now().UTC().Format(time.RFC3339),
CloudURL: setup.CloudAPIURL,
}

if config == nil {
Expand All @@ -104,6 +102,7 @@ func buildRuntimeUsageChipPayload(config *Config, verbose bool) uicfg.UsageChipR
if apiURL == "" {
apiURL = reviewopts.DefaultAPIURL
}
payload.CloudURL = apiURL
if strings.TrimSpace(config.JWT) == "" || strings.TrimSpace(config.OrgID) == "" {
payload.UnavailableReason = "Not authenticated. Run lrc ui to sign in and select an organization."
return payload
Expand Down
9 changes: 8 additions & 1 deletion internal/appui/usage_chip.go
Original file line number Diff line number Diff line change
Expand Up @@ -74,12 +74,19 @@ func (s *connectorManagerServer) handleUsageChip(w http.ResponseWriter, r *http.
return
}

s.mu.Lock()
apiURL := strings.TrimSpace(s.cfg.APIURL)
s.mu.Unlock()
if apiURL == "" {
apiURL = setuptpl.CloudAPIURL
}

payload := uicfg.UsageChipResponse{
Available: false,
UsagePct: 0,
TopMembers: make([]uicfg.UsageChipMember, 0),
CanViewTeamBreakdown: false,
CloudURL: setuptpl.CloudAPIURL,
CloudURL: apiURL,
FetchedAt: time.Now().UTC().Format(time.RFC3339),
}

Expand Down
32 changes: 21 additions & 11 deletions internal/staticserve/static/components/UsageChip.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { formatResetAt, normalizeUsagePayload, planLabel, usageTone } from '/static/components/usage_chip_model.mjs';
import { formatResetAt, isEnterpriseSelfhostedPlan, normalizeUsagePayload, planLabel, usageTone } from '/static/components/usage_chip_model.mjs';

const { html, useEffect, useMemo, useRef, useState } = window.preact;

Expand Down Expand Up @@ -118,7 +118,9 @@ export function UsageChip({ endpoint, refreshMs = DEFAULT_REFRESH_MS }) {
const buttonLabel = loading
? 'Usage...'
: chip.available
? `${planLabel(chip.planCode)} ${chip.usagePct}%`
? isEnterpriseSelfhostedPlan(chip.planCode)
? `${planLabel(chip.planCode)} • ${chip.locUsed.toLocaleString()} LOC used`
: `${planLabel(chip.planCode)} ${chip.usagePct}%`
: 'Usage unavailable';

const title = chip.available
Expand Down Expand Up @@ -147,16 +149,21 @@ export function UsageChip({ endpoint, refreshMs = DEFAULT_REFRESH_MS }) {
: chip.available
? html`
<p class="usage-chip-title">Billing Usage Detail</p>
<p class="usage-chip-help">
Scope: organization usage in current billing period. Attribution is charged to the triggering actor.
</p>

${!isEnterpriseSelfhostedPlan(chip.planCode) && html`
<p class="usage-chip-help">
Scope: organization usage in current billing period. Attribution is charged to the triggering actor.
</p>
`}

${''}

<div class="usage-chip-reset-card">
<p class="usage-chip-reset-title">Usage resets on ${formatResetAt(chip.resetAt)}</p>
<p class="usage-chip-reset-sub">Local timezone. New cycle usage starts immediately after this time.</p>
</div>
${!isEnterpriseSelfhostedPlan(chip.planCode) && html`
<div class="usage-chip-reset-card">
<p class="usage-chip-reset-title">Usage resets on ${formatResetAt(chip.resetAt)}</p>
<p class="usage-chip-reset-sub">Local timezone. New cycle usage starts immediately after this time.</p>
</div>
`}

<div class="usage-chip-grid">
<div class="usage-chip-cell">
Expand All @@ -165,7 +172,9 @@ export function UsageChip({ endpoint, refreshMs = DEFAULT_REFRESH_MS }) {
</div>
<div class="usage-chip-cell">
<p class="usage-chip-cell-label">Org Usage</p>
<p class="usage-chip-cell-value">${chip.locUsed.toLocaleString()} / ${chip.locLimit > 0 ? chip.locLimit.toLocaleString() : 'Unlimited'} LOC</p>
${isEnterpriseSelfhostedPlan(chip.planCode)
? html`<p class="usage-chip-cell-value">${chip.locUsed.toLocaleString()} LOC</p>`
: html`<p class="usage-chip-cell-value">${chip.locUsed.toLocaleString()} LOC / ${chip.locLimit > 0 ? chip.locLimit.toLocaleString() : 'Unlimited'} LOC</p>`}
</div>
<div class="usage-chip-cell">
<p class="usage-chip-cell-label">My Usage</p>
Expand All @@ -181,8 +190,9 @@ export function UsageChip({ endpoint, refreshMs = DEFAULT_REFRESH_MS }) {
`}
</div>
</div>

${!isEnterpriseSelfhostedPlan(chip.planCode) && html`
<p class="usage-chip-footnote">Operations are billable actions. Share is your LOC contribution percentage out of org usage.</p>
`}

${chip.canViewTeamBreakdown && chip.topMembers.length > 0
? html`
Expand Down
6 changes: 6 additions & 0 deletions internal/staticserve/static/components/usage_chip_model.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,15 @@ export function planLabel(planCode) {
if (normalized === 'loc_800k') return 'Team 800k';
if (normalized === 'loc_1600k') return 'Team 1.6M';
if (normalized === 'loc_3200k') return 'Team 3.2M';
if (normalized === 'enterprise-selfhosted') return 'Enterprise - Selfhosted';
return planCode;
}

export function isEnterpriseSelfhostedPlan(planCode) {
const normalized = String(planCode || '').trim().toLowerCase();
return normalized === 'enterprise-selfhosted';
}

export function clampUsagePercent(value) {
const parsed = Number(value);
if (!Number.isFinite(parsed)) return 0;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import test from 'node:test';
import assert from 'node:assert/strict';

import { clampUsagePercent, normalizeUsagePayload, planLabel, usageTone } from './usage_chip_model.mjs';
import { clampUsagePercent, isEnterpriseSelfhostedPlan, normalizeUsagePayload, planLabel, usageTone } from './usage_chip_model.mjs';

test('planLabel maps known plan codes', () => {
assert.equal(planLabel('free'), 'Free 30k');
Expand All @@ -10,6 +10,14 @@ test('planLabel maps known plan codes', () => {
assert.equal(planLabel('team_32usd'), 'Team 100k');
assert.equal(planLabel('loc_1600k'), 'Team 1.6M');
assert.equal(planLabel('custom_plan'), 'custom_plan');
assert.equal(planLabel('enterprise-selfhosted'), 'Enterprise - Selfhosted');
});

test('isEnterpriseSelfhostedPlan detects enterprise-selfhosted plan', () => {
assert.equal(isEnterpriseSelfhostedPlan('enterprise-selfhosted'), true);
assert.equal(isEnterpriseSelfhostedPlan('free_30k'), false);
assert.equal(isEnterpriseSelfhostedPlan('team_32usd'), false);
assert.equal(isEnterpriseSelfhostedPlan(''), false);
});

test('clampUsagePercent normalizes numeric values', () => {
Expand Down
Loading