Skip to content
Open
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: 23 additions & 14 deletions internal/appcore/review_runtime.go
Original file line number Diff line number Diff line change
Expand Up @@ -74,11 +74,7 @@ func formatLiveReviewTechnicalDetails(rawBody string) string {
return "(empty response body)"
}

var payload struct {
Error string `json:"error"`
ErrorCode string `json:"error_code"`
Envelope *reviewmodel.PlanUsageEnvelope `json:"envelope"`
}
var payload reviewmodel.APIErrorPayload
if err := json.Unmarshal([]byte(trimmed), &payload); err != nil {
return trimmed
}
Expand Down Expand Up @@ -311,6 +307,8 @@ func runReviewWithOptions(opts reviewopts.Options) error {
}

// Submit review
var submissionFailed bool
var submissionBlockedReason string
var submitResp reviewmodel.DiffReviewCreateResponse
if fakeMode {
submitResp = buildFakeSubmitResponse()
Expand All @@ -323,7 +321,7 @@ func runReviewWithOptions(opts reviewopts.Options) error {
// Handle 413 Request Entity Too Large - prompt user to skip if interactive
var apiErr *reviewmodel.APIError
if errors.As(err, &apiErr) && apiErr.StatusCode == http.StatusUnauthorized {
return liveReviewAuthFailureError(config.APIURL, apiErr.Body)
return liveReviewAuthFailureError(config.APIURL, formatLiveReviewTechnicalDetails(apiErr.Body))
}
if errors.As(err, &apiErr) && apiErr.StatusCode == http.StatusRequestEntityTooLarge {
isInteractive := term.IsTerminal(int(os.Stdin.Fd())) && term.IsTerminal(int(os.Stdout.Fd()))
Expand All @@ -338,10 +336,6 @@ func runReviewWithOptions(opts reviewopts.Options) error {
return fmt.Errorf("failed to read input during 413 handling: %w (original error: %v)", rErr, err)
}
response = strings.ToLower(strings.TrimSpace(response))
if errors.As(err, &apiErr) && (apiErr.StatusCode == http.StatusForbidden || apiErr.StatusCode == http.StatusTooManyRequests) {
fmt.Printf("\n⚠️ Review submission blocked by LiveReview limits.\n")
fmt.Printf(" %s\n\n", formatLiveReviewTechnicalDetails(apiErr.Body))
}

if response == "y" || response == "yes" {
fmt.Println("Proceeding with skipped review...")
Expand All @@ -356,7 +350,14 @@ func runReviewWithOptions(opts reviewopts.Options) error {
return fmt.Errorf("review submission aborted by user (diff too large)")
}
}
return fmt.Errorf("failed to submit review: %w", err)
if errors.As(err, &apiErr) && (apiErr.StatusCode == http.StatusForbidden || apiErr.StatusCode == http.StatusTooManyRequests) {
submissionFailed = true
submissionBlockedReason = "Usage quota exceeded"
err = nil // Continue to UI
}
if err != nil {
return fmt.Errorf("failed to submit review: %w", err)
}
}

reviewID := submitResp.ReviewID
Expand Down Expand Up @@ -415,7 +416,7 @@ func runReviewWithOptions(opts reviewopts.Options) error {
runningDraftHub = newDraftHub(initialMsg)
}

if !useInteractive {
if !useInteractive && !submissionFailed {
fmt.Printf("Review submitted, ID: %s\n", reviewID)
if submitResp.UserEmail != "" {
fmt.Printf("Account: %s\n", submitResp.UserEmail)
Expand All @@ -441,6 +442,11 @@ func runReviewWithOptions(opts reviewopts.Options) error {
// Initialize global review state for API-based UI
reviewStateMu.Lock()
currentReviewState = NewReviewState(reviewID, filesFromDiff, useInteractive, isPostCommitReview, initialMsg, config.APIURL)
if submissionFailed {
currentReviewState.Status = "failed"
currentReviewState.ErrorSummary = submissionBlockedReason
currentReviewState.SetBlocked(true)
}
reviewStateMu.Unlock()

// Start serving immediately in background
Expand Down Expand Up @@ -903,13 +909,16 @@ func runReviewWithOptions(opts reviewopts.Options) error {
var stopPollOnce sync.Once
stopPollFn := func() { stopPollOnce.Do(func() { close(stopPoll) }) }
go func() {
defer close(pollDone)
if submissionFailed || reviewID == "" {
return
}
if fakeMode {
pollResult, pollErr = pollReviewFake(reviewID, opts.PollInterval, fakeWait, verbose, stopPoll, fakeBaseFiles, setTUIStatus)
} else {
pollUsedRecovery = true
pollResult, pollUpdatedConfig, pollErr = pollReviewWithRecovery(*config, reviewID, opts.PollInterval, opts.Timeout, verbose, stopPoll, setTUIStatus)
}
close(pollDone)
}()

var pollFinished bool
Expand Down Expand Up @@ -1015,7 +1024,7 @@ func runReviewWithOptions(opts reviewopts.Options) error {
result = pollResult
// Update review state with final result
reviewStateMu.Lock()
if currentReviewState != nil {
if currentReviewState != nil && pollResult != nil {
currentReviewState.UpdateFromResult(pollResult)
}
reviewStateMu.Unlock()
Expand Down
15 changes: 14 additions & 1 deletion internal/appcore/review_state.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,8 @@ type ReviewState struct {
StartedAt time.Time `json:"-"`

// Status
Status string `json:"status"` // "in_progress", "completed", "failed"
Status string `json:"status"` // "in_progress", "completed", "failed", "blocked"
Blocked bool `json:"blocked"`

// Content
Summary string `json:"summary"`
Expand Down Expand Up @@ -53,6 +54,7 @@ type ReviewStateSnapshot struct {
TotalComments int
StartedAt time.Time
Summary string
Blocked bool
}

// NewReviewState creates a new ReviewState with initial values
Expand All @@ -76,6 +78,9 @@ func NewReviewState(reviewID string, files []reviewmodel.DiffReviewFileResult, i
// It merges comments into existing files rather than replacing them,
// to preserve the hunk data from the initial diff parsing
func (rs *ReviewState) UpdateFromResult(result *reviewmodel.DiffReviewResponse) {
if result == nil {
return
}
rs.mu.Lock()
defer rs.mu.Unlock()

Expand Down Expand Up @@ -114,6 +119,13 @@ func (rs *ReviewState) SetFailed(errorSummary string) {
rs.ErrorSummary = errorSummary
}

// SetBlocked marks the review as blocked (e.g. quota exceeded)
func (rs *ReviewState) SetBlocked(blocked bool) {
rs.mu.Lock()
defer rs.mu.Unlock()
rs.Blocked = blocked
}

// AddComments adds comments to the total count
// Note: Comments are associated with files in the poll result,
// so full comment merging happens via UpdateFromResult
Expand All @@ -134,6 +146,7 @@ func (rs *ReviewState) Snapshot() ReviewStateSnapshot {
TotalComments: rs.TotalComments,
StartedAt: rs.StartedAt,
Summary: rs.Summary,
Blocked: rs.Blocked,
}
}

Expand Down
39 changes: 33 additions & 6 deletions internal/appcore/usage_chip_runtime.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,13 @@ import (
"fmt"
"net/http"
"strings"
"sync"
"time"

"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 @@ -90,6 +92,7 @@ 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 @@ -115,12 +118,36 @@ func buildRuntimeUsageChipPayload(config *Config, verbose bool) uicfg.UsageChipR
var myUsageResp runtimeMyUsageResponse
var membersResp runtimeMembersResponse

quotaErr := fetchRuntimeUsageEndpoint(client, config, apiURL, "/api/v1/quota/status", &quotaResp, verbose)
billingErr := fetchRuntimeUsageEndpoint(client, config, apiURL, "/api/v1/billing/status", &billingResp, verbose)
subscriptionErr := fetchRuntimeUsageEndpoint(client, config, apiURL, "/api/v1/subscriptions/current", &subscriptionResp, verbose)
upgradeErr := fetchRuntimeUsageEndpoint(client, config, apiURL, "/api/v1/billing/upgrade/request-status", &upgradeResp, verbose)
myUsageErr := fetchRuntimeUsageEndpoint(client, config, apiURL, "/api/v1/billing/usage/me", &myUsageResp, verbose)
membersErr := fetchRuntimeUsageEndpoint(client, config, apiURL, "/api/v1/billing/usage/members?limit=3&offset=0", &membersResp, verbose)
var (
quotaErr, billingErr, subscriptionErr, upgradeErr, myUsageErr, membersErr *runtimeUsageError
wg sync.WaitGroup
)
wg.Add(6)
go func() {
defer wg.Done()
quotaErr = fetchRuntimeUsageEndpoint(client, config, apiURL, "/api/v1/quota/status", &quotaResp, verbose)
}()
go func() {
defer wg.Done()
billingErr = fetchRuntimeUsageEndpoint(client, config, apiURL, "/api/v1/billing/status", &billingResp, verbose)
}()
go func() {
defer wg.Done()
subscriptionErr = fetchRuntimeUsageEndpoint(client, config, apiURL, "/api/v1/subscriptions/current", &subscriptionResp, verbose)
}()
go func() {
defer wg.Done()
upgradeErr = fetchRuntimeUsageEndpoint(client, config, apiURL, "/api/v1/billing/upgrade/request-status", &upgradeResp, verbose)
}()
go func() {
defer wg.Done()
myUsageErr = fetchRuntimeUsageEndpoint(client, config, apiURL, "/api/v1/billing/usage/me", &myUsageResp, verbose)
}()
go func() {
defer wg.Done()
membersErr = fetchRuntimeUsageEndpoint(client, config, apiURL, "/api/v1/billing/usage/members?limit=3&offset=0", &membersResp, verbose)
}()
wg.Wait()

if quotaErr == nil && quotaResp.Envelope != nil {
env := quotaResp.Envelope
Expand Down
2 changes: 2 additions & 0 deletions internal/appui/usage_chip.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"time"

"github.com/HexmosTech/git-lrc/internal/reviewmodel"
setuptpl "github.com/HexmosTech/git-lrc/setup"
uicfg "github.com/HexmosTech/git-lrc/ui"
)

Expand Down Expand Up @@ -78,6 +79,7 @@ func (s *connectorManagerServer) handleUsageChip(w http.ResponseWriter, r *http.
UsagePct: 0,
TopMembers: make([]uicfg.UsageChipMember, 0),
CanViewTeamBreakdown: false,
CloudURL: setuptpl.CloudAPIURL,
FetchedAt: time.Now().UTC().Format(time.RFC3339),
}

Expand Down
6 changes: 6 additions & 0 deletions internal/staticserve/static/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { getEventLog } from './components/EventLog.js';
import { getSeverityFilter } from './components/SeverityFilter.js';
import { getToolbar } from './components/Toolbar.js';
import { getCommentNav } from './components/CommentNav.js';
import { UsageBanner } from './components/UsageBanner.js';

// Convert API response to UI data format
// Backend uses snake_case JSON keys (file_path, old_start_line, etc.)
Expand Down Expand Up @@ -615,6 +616,9 @@ async function initApp() {

// Status display
const getStatusDisplay = () => {
if (reviewData?.blocked) {
return null;
}
if (status === 'failed') {
return html`
<div class="status-container error">
Expand Down Expand Up @@ -659,6 +663,8 @@ async function initApp() {

${getStatusDisplay()}

<${UsageBanner} endpoint="/api/runtime/usage-chip" />

${summary && summary.trim() && status !== 'in_progress' && html`
<${Summary}
markdown=${summary}
Expand Down
80 changes: 80 additions & 0 deletions internal/staticserve/static/components/UsageBanner.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import { normalizeUsagePayload } from '/static/components/usage_chip_model.mjs';

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

export function UsageBanner({ endpoint }) {
const [chip, setChip] = useState(null);
const [loading, setLoading] = useState(true);

useEffect(() => {
let cancelled = false;
const load = async () => {
try {
const response = await fetch(endpoint);
if (!response.ok) return;
const data = await response.json();
if (!cancelled) {
setChip(normalizeUsagePayload(data));
}
} catch (err) {
console.error('Failed to fetch usage for banner:', err);
} finally {
if (!cancelled) setLoading(false);
}
};
load();
return () => { cancelled = true; };
}, [endpoint]);

if (loading || !chip || !chip.available) return '';

const upgradeURL = `${chip.cloudURL}/#/settings-subscriptions-overview`;

if (chip.blocked || chip.usagePct >= 100) {
const limitStr = chip.locLimit > 0 ? chip.locLimit.toLocaleString() : 'N/A';

return html`
<div class="quota-banner-slate">
<div class="qbs-flex">
<div class="qbs-icon-wrap">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"></path>
<line x1="12" y1="9" x2="12" y2="13"></line>
<line x1="12" y1="17" x2="12.01" y2="17"></line>
</svg>
</div>
<div class="qbs-content">
<p class="qbs-title">You've reached your monthly limit</p>
<p class="qbs-text">
Your team used all <strong>${limitStr} LOC</strong> this month.
Upgrade to a higher tier and continue reviewing code without any interruption to your workflow.
</p>
<a href="${upgradeURL}" target="_blank" class="qbs-btn">
Upgrade plan
</a>
</div>
</div>
</div>
`;
}

if (chip.usagePct >= 90) {
return html`
<div class="main-alert main-alert-warn">
<div class="main-alert-content">
<div class="main-alert-text">
<span class="main-alert-title">⚠️ LOC Usage Nearing Limit</span>
<span class="main-alert-sub">
You've used ${chip.locUsed.toLocaleString()} of ${chip.locLimit > 0 ? chip.locLimit.toLocaleString() : 'N/A'} LOC (${chip.usagePct}%). Upgrade to avoid interruption.
</span>
</div>
<a href="${upgradeURL}" target="_blank" class="main-alert-btn main-alert-btn-warn">
Upgrade Plan
</a>
</div>
</div>
`;
}

return '';
}
2 changes: 2 additions & 0 deletions internal/staticserve/static/components/UsageChip.js
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,8 @@ export function UsageChip({ endpoint, refreshMs = DEFAULT_REFRESH_MS }) {
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>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ export function normalizeUsagePayload(raw, fallbackReason = 'Usage data unavaila
}))
: [],
canViewTeamBreakdown: Boolean(source.can_view_team_breakdown),
cloudURL: String(source.cloud_url || '').trim(),
fetchedAt: String(source.fetched_at || '').trim(),
};
}
Expand Down
Loading
Loading