mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-07 02:59:05 +00:00
chore!: retire the integrated ROI calculator (#44477)
* chore!: retire the integrated ROI calculator * chore(ui): remove unused ROI demo notice
This commit is contained in:
parent
902736bfe7
commit
eeb192d4a7
33 changed files with 30 additions and 11029 deletions
|
|
@ -139,11 +139,6 @@ LAZY_FEATURES: Final[tuple[LazyFeature, ...]] = (
|
|||
module_path="litellm.proxy.management_endpoints.model_insights_endpoints",
|
||||
path_prefixes=("/model-insights",),
|
||||
),
|
||||
LazyFeature(
|
||||
name="roi_calculator",
|
||||
module_path="litellm.proxy.management_endpoints.roi_calculator_endpoints",
|
||||
path_prefixes=("/roi-calculator",),
|
||||
),
|
||||
LazyFeature(
|
||||
name="search_tools",
|
||||
module_path="litellm.proxy.search_endpoints.search_tool_management",
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -1604,11 +1604,6 @@ async def proxy_startup_event(app: FastAPI) -> AsyncGenerator[ProxyLifespanState
|
|||
if not model_info_scheduler.running:
|
||||
model_info_scheduler.start()
|
||||
|
||||
if scheduler is not None and prisma_client is not None:
|
||||
from litellm.proxy.management_endpoints.roi_calculator_endpoints import register_scheduled_sync
|
||||
|
||||
register_scheduled_sync(scheduler)
|
||||
|
||||
tracing_settings: Final = cast( # cast-ok: Pydantic validates the legacy untyped settings value
|
||||
dict[str, object] | None,
|
||||
TypeAdapter(dict[str, object] | None).validate_python(general_settings.get("tracing")),
|
||||
|
|
|
|||
|
|
@ -1,54 +1,5 @@
|
|||
# ROI Calculator
|
||||
# ROI Calculator (retired)
|
||||
|
||||
The dashboard compares merged pull or merge requests, elapsed time from opening to merge, new bug and regression issues, and recorded gateway spend over 7, 28, or 90 complete UTC days. Compare against the immediately preceding period of the same length or the same-length period last year
|
||||
The integrated ROI Calculator is retired. Its dashboard page, API endpoints, OAuth callbacks, and scheduled refresh are no longer available
|
||||
|
||||
The calculator combines repository activity with spend recorded by the gateway. Spend per merged change is a person's recorded gateway spend during the period divided by their matched merged changes. To track a branch's AI cost, send repository and branch tags with each request
|
||||
|
||||
## Connect repositories
|
||||
|
||||
Use **Preview sample report** beside the title to explore the dashboard before connecting repositories. Sample periods, engineer details, quality signals, and branch spend work without changing your connections or live report. **Exit demo** returns to your report or setup
|
||||
|
||||
Open `/ui/roi-calculator/`, choose GitHub or GitLab, then connect with an app or access token. Select several repositories and start the sync. Use **Add connection** to keep both providers connected. Each provider and API host retains its credentials, repositories, and identity mappings, and the report combines their activity while counting each person’s gateway spend once. Public repositories also accept an empty token, subject to the provider's anonymous API limits
|
||||
|
||||
For GitHub tokens, grant read access to metadata, pull requests and issues. GitLab tokens require `read_api`. Self-hosted instances use their API URL, for example `https://git.example.com/api/v4`
|
||||
|
||||
## Configure app authorization
|
||||
|
||||
Register a GitHub App with read-only repository permissions for metadata, pull requests and issues. Enable expiring user access tokens and leave authorization during installation disabled, since the gateway starts authorization after installation. Generate a private key in the app settings to allow installation and store it securely. The gateway uses a generated client secret for authorization and does not need the private key
|
||||
|
||||
Register a confidential GitLab OAuth application with `read_api` and `read_user` scopes
|
||||
|
||||
Set `PROXY_BASE_URL` to the gateway's public URL. The callback URLs are `<PROXY_BASE_URL>/roi-calculator/observed/oauth/github/callback` and `<PROXY_BASE_URL>/roi-calculator/observed/oauth/gitlab/callback`
|
||||
|
||||
Set the GitHub App setup URL to `<PROXY_BASE_URL>/roi-calculator/observed/oauth/github/installed`, enable **Redirect on update**, and set `LITELLM_ROI_GITHUB_APP_SLUG` to its URL slug. The first connection then starts with repository installation and continues to user authorization
|
||||
|
||||
Set `LITELLM_ROI_GITHUB_CLIENT_ID` and `LITELLM_ROI_GITHUB_CLIENT_SECRET` for GitHub, or `LITELLM_ROI_GITLAB_CLIENT_ID` and `LITELLM_ROI_GITLAB_CLIENT_SECRET` for GitLab. For a self-hosted provider, set `LITELLM_ROI_GITHUB_URL` or `LITELLM_ROI_GITLAB_URL` to its base URL without the API suffix
|
||||
|
||||
The gateway encrypts access and refresh tokens using its configured encryption key. Authorization uses PKCE and an expiring, single-use state tied to an HTTP-only browser cookie. Refreshes are coordinated across gateway workers
|
||||
|
||||
## Link people
|
||||
|
||||
Use **Link accounts** to associate several current or historical usernames with one internal email. Each connection has a separate username field, so a GitHub username never matches a GitLab user implicitly. Saving immediately recalculates the report without fetching repositories again. Public profile emails match automatically when they resolve unambiguously to an internal user
|
||||
|
||||
**Matched people only** is on by default for people, merged changes, and branch lists. Turn it off to include outside contributors and their branches. Matching depends on the linked internal account, even when no spend was recorded. This switch filters the lists; summary metrics and quality signals still cover all selected repositories
|
||||
|
||||
Agent-authored changes count for a person only when the supported agent metadata explicitly names a requester. Repository issue counts and revert titles are quality signals, not an individual defect score
|
||||
|
||||
Bug and regression counts combine repositories with issue tracking enabled. They remain unavailable when none of the selected repositories has issue tracking enabled
|
||||
|
||||
## Sync behavior
|
||||
|
||||
The default refresh interval is daily and applies to every connection in the workspace. Adding or editing a connection preserves it unless `update_interval_minutes` is supplied. The observed settings API accepts `update_interval_minutes: 0` for manual updates. A cancelled or failed sync preserves the last complete report
|
||||
|
||||
Existing settings retain `report_mode: legacy` and their scheduled reports until an administrator saves a connection, authorizes an app, or starts an observed sync. Reading the new dashboard alone does not change the mode. The legacy settings API can explicitly select `report_mode: legacy` again
|
||||
|
||||
GitHub collection splits large searches into smaller date ranges to avoid its search-result limit. Both providers validate pagination and reject incomplete responses instead of publishing partial counts
|
||||
|
||||
|
||||
## Branch request tags
|
||||
|
||||
Send `repo:github.com/owner/repo` or `repo:gitlab.com/group/project` together with `branch:feature/name` in `metadata.tags`, top-level `tags`, or the comma-separated `x-litellm-tags` header. The tags must identify the source repository and branch, including forks
|
||||
|
||||
The report sums recorded requests inside its UTC dates. A branch cost is assigned to a merged change only when that source branch matches one change in the period. Reused branches stay visible in Branch spend without duplicating costs across changes. No retained tagged requests means unknown cost; a recorded zero remains zero
|
||||
|
||||
An empty repository produces a successful report with zero merged changes and no merge duration or spend-per-change ratio
|
||||
The backend implementation and stored configuration are retained. Reverting the retirement change restores the dashboard and registration points. The standalone [litellm-roi-calculator](https://github.com/BerriAI/litellm-roi-calculator) project is separate and remains available
|
||||
|
|
|
|||
|
|
@ -50,6 +50,33 @@ def _has_lazy_middleware(app: FastAPI) -> bool:
|
|||
return any(middleware.cls is LazyFeatureMiddleware for middleware in app.user_middleware)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("disable_lazy_routes", ("false", "true"))
|
||||
def test_retired_roi_calculator_cannot_be_loaded_or_called(
|
||||
monkeypatch: pytest.MonkeyPatch, disable_lazy_routes: str
|
||||
) -> None:
|
||||
monkeypatch.setenv(FLAG, disable_lazy_routes)
|
||||
app: Final = FastAPI()
|
||||
attach_lazy_features(app)
|
||||
|
||||
with TestClient(app) as client:
|
||||
for method, path in (
|
||||
("POST", "/lazy/warm/roi_calculator"),
|
||||
("GET", "/roi-calculator/settings"),
|
||||
("GET", "/roi-calculator/report?mode=demo"),
|
||||
("POST", "/roi-calculator/sync"),
|
||||
("GET", "/roi-calculator/observed/report"),
|
||||
("GET", "/roi-calculator/observed/apps"),
|
||||
("POST", "/roi-calculator/observed/sync"),
|
||||
("POST", "/roi-calculator/observed/oauth/github/start"),
|
||||
("GET", "/roi-calculator/observed/oauth/github/callback"),
|
||||
("GET", "/roi-calculator/observed/oauth/github/installed"),
|
||||
("POST", "/roi-calculator/observed/oauth/gitlab/start"),
|
||||
("GET", "/roi-calculator/observed/oauth/gitlab/callback"),
|
||||
):
|
||||
response: Final = client.request(method, path)
|
||||
assert response.status_code == 404, f"{method} {path}: {response.text}"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value", ("1", "true", "TRUE", "yes", "on"))
|
||||
def test_flag_registers_every_feature_at_startup(monkeypatch: pytest.MonkeyPatch, value: str) -> None:
|
||||
monkeypatch.setenv(FLAG, value)
|
||||
|
|
|
|||
|
|
@ -37,7 +37,6 @@ const LEGACY_PAGE_ROUTES: ReadonlyMap<string, string> = new Map(
|
|||
usage: "old-usage",
|
||||
"cost-optimization": "cost-optimization",
|
||||
"model-insights": "model-insights",
|
||||
"roi-calculator": "roi-calculator",
|
||||
agents: "agents",
|
||||
"router-settings": "router-settings",
|
||||
users: "users",
|
||||
|
|
|
|||
|
|
@ -1,12 +0,0 @@
|
|||
import { useId } from "react";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
|
||||
export function MatchedPeopleToggle({ checked, onChange }: { checked: boolean; onChange: (checked: boolean) => void }) {
|
||||
const id = useId();
|
||||
return (
|
||||
<label htmlFor={id} className="flex shrink-0 cursor-pointer items-center gap-2 text-xs text-muted-foreground">
|
||||
<Switch id={id} size="sm" checked={checked} onCheckedChange={onChange} />
|
||||
Matched people only
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,88 +0,0 @@
|
|||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, expect, it, vi } from "vitest";
|
||||
import ObservedAccounts from "./ObservedAccounts";
|
||||
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
it("links several usernames on both providers to one email in a single save", async () => {
|
||||
const writes: unknown[] = [];
|
||||
const saved = vi.fn();
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (_input: string, init: RequestInit) => {
|
||||
if (init.method === "PUT") {
|
||||
writes.push(JSON.parse(String(init.body)));
|
||||
return Response.json({ report: null });
|
||||
}
|
||||
const identities = {
|
||||
gateway_emails: ["ari@example.test"],
|
||||
identity_map: { old: "ari@example.test" },
|
||||
unmatched_logins: ["new"],
|
||||
connections: [
|
||||
{
|
||||
id: "github-id",
|
||||
source_provider: "github",
|
||||
api_url: "https://api.github.com",
|
||||
identity_map: { old: "ari@example.test" },
|
||||
unmatched_logins: ["new"],
|
||||
},
|
||||
{
|
||||
id: "gitlab-id",
|
||||
source_provider: "gitlab",
|
||||
api_url: "https://gitlab.com/api/v4",
|
||||
identity_map: {},
|
||||
unmatched_logins: ["new"],
|
||||
},
|
||||
],
|
||||
};
|
||||
return Response.json(identities);
|
||||
}),
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
render(<ObservedAccounts accessToken="gateway-test-token" people={[]} onClose={vi.fn()} onSaved={saved} />);
|
||||
await screen.findByLabelText(/GitHub usernames/);
|
||||
fireEvent.change(screen.getByLabelText("Internal email"), { target: { value: "ari@example.test" } });
|
||||
expect(screen.getByLabelText(/GitHub usernames/)).toHaveValue("old");
|
||||
fireEvent.change(screen.getByLabelText(/GitHub usernames/), { target: { value: "@Old, new, NEW" } });
|
||||
fireEvent.change(screen.getByLabelText(/GitLab usernames/), { target: { value: "new" } });
|
||||
await user.click(screen.getByRole("button", { name: "Save accounts" }));
|
||||
await waitFor(() => expect(saved).toHaveBeenCalledOnce());
|
||||
expect(writes).toEqual([
|
||||
{
|
||||
email: "ari@example.test",
|
||||
accounts: [
|
||||
{ connection_id: "github-id", login: "old" },
|
||||
{ connection_id: "github-id", login: "new" },
|
||||
{ connection_id: "gitlab-id", login: "new" },
|
||||
],
|
||||
},
|
||||
]);
|
||||
});
|
||||
it("keeps a conflicting link editable", async () => {
|
||||
const saved = vi.fn();
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (_input: string, init: RequestInit) =>
|
||||
init.method === "PUT"
|
||||
? Response.json({ detail: "An account is already linked to another email. Unlink it first" }, { status: 409 })
|
||||
: Response.json({ gateway_emails: ["ari@example.test"], identity_map: {}, unmatched_logins: [] }),
|
||||
),
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<ObservedAccounts
|
||||
accessToken="gateway-test-token"
|
||||
people={[]}
|
||||
initialEmail="ari@example.test"
|
||||
onClose={vi.fn()}
|
||||
onSaved={saved}
|
||||
/>,
|
||||
);
|
||||
fireEvent.change(screen.getByLabelText("Source usernames"), { target: { value: "old, new" } });
|
||||
const button = screen.getByRole("button", { name: "Save accounts" });
|
||||
await waitFor(() => expect(button).toBeEnabled());
|
||||
await user.click(button);
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent("An account is already linked to another email");
|
||||
expect(screen.getByLabelText("Source usernames")).toHaveValue("old, new");
|
||||
expect(saved).not.toHaveBeenCalled();
|
||||
});
|
||||
|
|
@ -1,209 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { z } from "zod";
|
||||
import { apiClient } from "@/components/networking";
|
||||
import { extractProxyErrorMessage } from "@/lib/http/client";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog";
|
||||
import { accountLogins, type ObservedPerson } from "./observedData";
|
||||
|
||||
const connectionIdentityFields = {
|
||||
id: z.string(),
|
||||
source_provider: z.enum(["github", "gitlab"]),
|
||||
api_url: z.string(),
|
||||
identity_map: z.record(z.string(), z.string()),
|
||||
unmatched_logins: z.array(z.string()),
|
||||
};
|
||||
const identitiesFields = {
|
||||
gateway_emails: z.array(z.string()),
|
||||
identity_map: z.record(z.string(), z.string()),
|
||||
unmatched_logins: z.array(z.string()),
|
||||
connections: z.array(z.object(connectionIdentityFields)).optional(),
|
||||
};
|
||||
const identitiesSchema = z.object(identitiesFields);
|
||||
|
||||
function matches(identities: z.infer<typeof identitiesSchema>, email: string, people: ObservedPerson[]) {
|
||||
const person = people.find((entry) => entry.email === email);
|
||||
return Object.fromEntries(
|
||||
(identities.connections ?? []).map((entry) => [
|
||||
entry.id,
|
||||
[
|
||||
...new Set([
|
||||
...Object.entries(entry.identity_map)
|
||||
.filter(([, address]) => address === email)
|
||||
.map(([login]) => login),
|
||||
...(person?.accounts ?? [])
|
||||
.filter((account) => account.connection_id === entry.id)
|
||||
.map((account) => account.login),
|
||||
]),
|
||||
].join(", "),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
export default function ObservedAccounts({
|
||||
accessToken,
|
||||
people,
|
||||
initialEmail = "",
|
||||
onClose,
|
||||
onSaved,
|
||||
}: {
|
||||
accessToken: string;
|
||||
people: ObservedPerson[];
|
||||
initialEmail?: string;
|
||||
onClose: () => void;
|
||||
onSaved: () => void;
|
||||
}) {
|
||||
const [identities, setIdentities] = useState<z.infer<typeof identitiesSchema> | null>(null);
|
||||
const [email, setEmail] = useState(initialEmail);
|
||||
const [logins, setLogins] = useState(people.find((person) => person.email === initialEmail)?.logins.join(", ") ?? "");
|
||||
const [linked, setLinked] = useState<Record<string, string>>({});
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
apiClient
|
||||
.get<unknown>("/roi-calculator/observed/identities", { accessToken, signal: controller.signal })
|
||||
.then((data) => {
|
||||
if (!controller.signal.aborted) {
|
||||
const parsed = identitiesSchema.parse(data);
|
||||
setIdentities(parsed);
|
||||
setLinked(matches(parsed, initialEmail, people));
|
||||
}
|
||||
})
|
||||
.catch((reason: unknown) => {
|
||||
if (!controller.signal.aborted) setError(extractProxyErrorMessage(reason));
|
||||
});
|
||||
return () => controller.abort();
|
||||
}, [accessToken, initialEmail, people]);
|
||||
function selectEmail(value: string) {
|
||||
setEmail(value);
|
||||
if (identities) setLinked(matches(identities, value, people));
|
||||
const automatic = people.find((person) => person.email === value)?.logins ?? [];
|
||||
const manual = Object.entries(identities?.identity_map ?? {})
|
||||
.filter(([, address]) => address === value)
|
||||
.map(([login]) => login);
|
||||
setLogins([...new Set([...automatic, ...manual])].join(", "));
|
||||
}
|
||||
async function save() {
|
||||
setSaving(true);
|
||||
setError("");
|
||||
try {
|
||||
await apiClient.put<unknown>("/roi-calculator/observed/identities", {
|
||||
accessToken,
|
||||
body: {
|
||||
email: email.trim().toLowerCase(),
|
||||
...(identities?.connections?.length
|
||||
? {
|
||||
accounts: identities.connections.flatMap((entry) =>
|
||||
accountLogins(linked[entry.id] ?? "").map((login) => ({ connection_id: entry.id, login })),
|
||||
),
|
||||
}
|
||||
: { logins: accountLogins(logins) }),
|
||||
},
|
||||
});
|
||||
onSaved();
|
||||
onClose();
|
||||
} catch (reason) {
|
||||
setError(extractProxyErrorMessage(reason));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
return (
|
||||
<Dialog
|
||||
open
|
||||
onOpenChange={(open) => {
|
||||
if (!open) onClose();
|
||||
}}
|
||||
>
|
||||
<DialogContent className="sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Link accounts</DialogTitle>
|
||||
<DialogDescription>Match one internal user to all their source accounts</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-2">
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="identity-email" className="text-sm font-medium">
|
||||
Internal email
|
||||
</label>
|
||||
<Input
|
||||
id="identity-email"
|
||||
disabled={!identities}
|
||||
list="roi-internal-emails"
|
||||
value={email}
|
||||
placeholder="Choose an internal user"
|
||||
onChange={(event) => selectEmail(event.target.value)}
|
||||
/>
|
||||
<datalist id="roi-internal-emails">
|
||||
{identities?.gateway_emails.map((address) => <option key={address} value={address} />)}
|
||||
</datalist>
|
||||
</div>
|
||||
{identities?.connections?.length ? (
|
||||
identities.connections.map((entry) => (
|
||||
<div key={entry.id} className="space-y-2">
|
||||
<label htmlFor={`identity-${entry.id}`} className="text-sm font-medium">
|
||||
{entry.source_provider === "github" ? "GitHub" : "GitLab"} usernames
|
||||
<span className="ml-2 text-xs font-normal text-muted-foreground">{new URL(entry.api_url).host}</span>
|
||||
</label>
|
||||
<Input
|
||||
id={`identity-${entry.id}`}
|
||||
value={linked[entry.id] ?? ""}
|
||||
onChange={(event) => setLinked({ ...linked, [entry.id]: event.target.value })}
|
||||
placeholder="current-account, old-account"
|
||||
/>
|
||||
{entry.unmatched_logins.length > 0 && (
|
||||
<details className="text-xs text-muted-foreground">
|
||||
<summary className="cursor-pointer">{entry.unmatched_logins.length} unmatched accounts</summary>
|
||||
<div className="mt-2 max-h-32 overflow-auto flex flex-wrap gap-1">
|
||||
{entry.unmatched_logins.map((login) => (
|
||||
<Button
|
||||
key={login}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() =>
|
||||
setLinked({
|
||||
...linked,
|
||||
[entry.id]: [...new Set([...accountLogins(linked[entry.id] ?? ""), login])].join(", "),
|
||||
})
|
||||
}
|
||||
>
|
||||
{login}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="identity-logins" className="text-sm font-medium">
|
||||
Source usernames
|
||||
</label>
|
||||
<Input
|
||||
id="identity-logins"
|
||||
value={logins}
|
||||
onChange={(event) => setLogins(event.target.value)}
|
||||
placeholder="current-account, old-account"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Separate accounts with commas. Their merged changes are combined, and gateway spend is counted once
|
||||
</p>
|
||||
{error && (
|
||||
<p role="alert" className="text-sm text-destructive">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
<Button className="w-full" disabled={!identities || !email.trim() || saving} onClick={save}>
|
||||
{saving ? "Saving…" : "Save accounts"}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,198 +0,0 @@
|
|||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import ObservedConnections from "./ObservedConnections";
|
||||
import type { ObservedSettings } from "./observedData";
|
||||
|
||||
const settings: ObservedSettings = {
|
||||
id: "initial-github",
|
||||
source_provider: "github",
|
||||
api_url: "https://api.github.com",
|
||||
repos: [],
|
||||
has_token: false,
|
||||
connection_type: "token",
|
||||
update_interval_minutes: 1440,
|
||||
ready: false,
|
||||
};
|
||||
const app = { configured: true, api_url: null, callback_url: null };
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
describe("observed ROI connections", () => {
|
||||
it("identifies the saved connection when editing its host", async () => {
|
||||
const writes: unknown[] = [];
|
||||
const existing = { ...settings, id: "saved-github", has_token: true, repos: ["org/service"], ready: true };
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (input: string, init: RequestInit) => {
|
||||
const path = new URL(input, "http://localhost").pathname;
|
||||
if (path.endsWith("/apps")) return Response.json({ github: app, gitlab: app });
|
||||
if (path.endsWith("/repositories")) return Response.json({ repositories: [], has_more: false });
|
||||
if (path.endsWith("/settings")) {
|
||||
writes.push(JSON.parse(String(init.body)));
|
||||
return Response.json({ ...existing, id: "enterprise-github", api_url: "https://git.example.test/api/v3" });
|
||||
}
|
||||
throw new Error(path);
|
||||
}),
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<ObservedConnections
|
||||
accessToken="gateway-test-token"
|
||||
settings={{ ...existing, connections: [existing] }}
|
||||
onClose={vi.fn()}
|
||||
onSaved={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
await user.click(screen.getByRole("button", { name: "Edit GitHub api.github.com" }));
|
||||
await user.click(screen.getByRole("button", { name: "Change connection" }));
|
||||
await user.click(screen.getByText("Self-hosted instance"));
|
||||
fireEvent.change(screen.getByLabelText("API URL"), { target: { value: "https://git.example.test/api/v3" } });
|
||||
fireEvent.change(screen.getByLabelText("GitHub access token"), { target: { value: "enterprise-test-token" } });
|
||||
await user.click(screen.getByRole("button", { name: "Continue" }));
|
||||
expect(await screen.findByRole("heading", { name: "Choose repositories" })).toBeInTheDocument();
|
||||
expect(writes).toEqual([
|
||||
{
|
||||
connection_id: "saved-github",
|
||||
source_provider: "github",
|
||||
api_url: "https://git.example.test/api/v3",
|
||||
token: "enterprise-test-token",
|
||||
repos: [],
|
||||
update_interval_minutes: 1440,
|
||||
},
|
||||
]);
|
||||
});
|
||||
it("starts a GitHub installation when switching from a GitLab app connection", async () => {
|
||||
const starts = vi.fn();
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (input: string, init: RequestInit) => {
|
||||
const url = new URL(input, "http://localhost");
|
||||
if (url.pathname.endsWith("/apps"))
|
||||
return Response.json({ github: { ...app, can_install: true }, gitlab: app });
|
||||
if (url.pathname.endsWith("/repositories")) return Response.json({ repositories: [], has_more: false });
|
||||
starts(url.searchParams.get("install"), init.credentials);
|
||||
return Response.json({ detail: "Authorization test stopped before redirect" }, { status: 502 });
|
||||
}),
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
const connected: ObservedSettings = {
|
||||
...settings,
|
||||
source_provider: "gitlab",
|
||||
connection_type: "app",
|
||||
has_token: true,
|
||||
};
|
||||
render(<ObservedConnections accessToken="test-token" settings={connected} onClose={vi.fn()} onSaved={vi.fn()} />);
|
||||
await user.click(screen.getByRole("button", { name: "Change connection" }));
|
||||
await user.click(screen.getByRole("button", { name: "GitHub", exact: true }));
|
||||
const connect = screen.getByRole("button", { name: "Connect GitHub", exact: true });
|
||||
await waitFor(() => expect(connect).toBeEnabled());
|
||||
await user.click(connect);
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent("Authorization test stopped before redirect");
|
||||
expect(starts).toHaveBeenCalledWith("true", "include");
|
||||
});
|
||||
it.each(["GitHub", "GitLab"] as const)(
|
||||
"connects %s with a token, saves repositories, and starts a sync",
|
||||
async (label) => {
|
||||
const provider = label === "GitHub" ? "github" : "gitlab";
|
||||
const apiUrl = provider === "github" ? "https://api.github.com" : "https://gitlab.com/api/v4";
|
||||
const saved = vi.fn();
|
||||
const closed = vi.fn();
|
||||
const writes: { path: string; body: unknown }[] = [];
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (input: string, init: RequestInit) => {
|
||||
const path = new URL(input, "http://localhost").pathname;
|
||||
if (init.method === "PUT" || init.method === "POST")
|
||||
writes.push({ path, body: init.body ? JSON.parse(String(init.body)) : undefined });
|
||||
if (path.endsWith("/apps")) return Response.json({ github: app, gitlab: app });
|
||||
if (path.endsWith("/repositories"))
|
||||
return Response.json({
|
||||
repositories: [{ name: "org/service", visibility: "private", archived: false }],
|
||||
has_more: false,
|
||||
});
|
||||
if (path.endsWith("/settings")) {
|
||||
const request = JSON.parse(String(init.body)) as { repos: string[] };
|
||||
const connected = {
|
||||
...settings,
|
||||
id: `saved-${provider}`,
|
||||
source_provider: provider,
|
||||
api_url: apiUrl,
|
||||
has_token: true,
|
||||
repos: request.repos,
|
||||
ready: request.repos.length > 0,
|
||||
};
|
||||
return Response.json(connected);
|
||||
}
|
||||
if (path.endsWith("/sync")) return Response.json({ running: true }, { status: 202 });
|
||||
throw new Error(path);
|
||||
}),
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<ObservedConnections accessToken="gateway-test-token" settings={settings} onClose={closed} onSaved={saved} />,
|
||||
);
|
||||
await user.click(screen.getByRole("button", { name: label, exact: true }));
|
||||
await user.click(screen.getByRole("button", { name: "Access token", exact: true }));
|
||||
fireEvent.change(screen.getByLabelText(`${label} access token`), { target: { value: "source-test-token" } });
|
||||
await user.click(screen.getByRole("button", { name: "Continue" }));
|
||||
await user.click(await screen.findByRole("checkbox", { name: /org\/service/ }));
|
||||
await user.click(screen.getByRole("button", { name: "Save and sync" }));
|
||||
await waitFor(() => expect(saved).toHaveBeenCalledOnce());
|
||||
expect(closed).toHaveBeenCalledOnce();
|
||||
expect(writes).toEqual([
|
||||
{
|
||||
path: "/roi-calculator/observed/settings",
|
||||
body: {
|
||||
source_provider: provider,
|
||||
api_url: apiUrl,
|
||||
token: "source-test-token",
|
||||
repos: [],
|
||||
update_interval_minutes: 1440,
|
||||
},
|
||||
},
|
||||
{
|
||||
path: "/roi-calculator/observed/settings",
|
||||
body: {
|
||||
connection_id: `saved-${provider}`,
|
||||
source_provider: provider,
|
||||
api_url: apiUrl,
|
||||
repos: ["org/service"],
|
||||
update_interval_minutes: 1440,
|
||||
},
|
||||
},
|
||||
{ path: "/roi-calculator/observed/sync", body: undefined },
|
||||
]);
|
||||
},
|
||||
);
|
||||
it.each(["GitHub", "GitLab"] as const)(
|
||||
"starts %s app authorization with a browser cookie and displays provider failures",
|
||||
async (label) => {
|
||||
const provider = label.toLowerCase();
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (input: string, init: RequestInit) => {
|
||||
if (String(input).endsWith("/apps")) return Response.json({ github: app, gitlab: app });
|
||||
expect(String(input)).toContain(`/oauth/${provider}/start`);
|
||||
expect(init.credentials).toBe("include");
|
||||
expect(init.method).toBe("POST");
|
||||
return Response.json({ detail: "Provider unavailable. Try again" }, { status: 502 });
|
||||
}),
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<ObservedConnections
|
||||
accessToken="gateway-test-token"
|
||||
settings={settings}
|
||||
onClose={vi.fn()}
|
||||
onSaved={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
await user.click(screen.getByRole("button", { name: label, exact: true }));
|
||||
const button = await screen.findByRole("button", { name: `Connect ${label}`, exact: true });
|
||||
await waitFor(() => expect(button).toBeEnabled());
|
||||
await user.click(button);
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent("Provider unavailable. Try again");
|
||||
expect(button).toBeEnabled();
|
||||
},
|
||||
);
|
||||
});
|
||||
|
|
@ -1,606 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Github, Gitlab, ArrowLeft, KeyRound } from "lucide-react";
|
||||
import { z } from "zod";
|
||||
import { apiClient } from "@/components/networking";
|
||||
import { extractProxyErrorMessage } from "@/lib/http/client";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog";
|
||||
import {
|
||||
observedSettingsSchema,
|
||||
repositoryNames,
|
||||
type ObservedSettings,
|
||||
type ObservedConnection,
|
||||
} from "./observedData";
|
||||
|
||||
const appFields = {
|
||||
configured: z.boolean(),
|
||||
can_install: z.boolean().optional().default(false),
|
||||
api_url: z.string().nullable(),
|
||||
callback_url: z.string().nullable(),
|
||||
};
|
||||
const appSchema = z.object(appFields);
|
||||
const appsSchema = z.object({ github: appSchema, gitlab: appSchema });
|
||||
const repositoriesSchema = z.object({
|
||||
repositories: z.array(z.object({ name: z.string(), visibility: z.string(), archived: z.boolean() })),
|
||||
has_more: z.boolean(),
|
||||
});
|
||||
const defaultUrl = { github: "https://api.github.com", gitlab: "https://gitlab.com/api/v4" };
|
||||
|
||||
function preferredConnectionMethod(selected: "app" | "token" | null, configured: boolean | undefined) {
|
||||
return selected ?? (configured ? "app" : "token");
|
||||
}
|
||||
|
||||
function TokenFields({
|
||||
label,
|
||||
provider,
|
||||
token,
|
||||
onToken,
|
||||
apiUrl,
|
||||
onUrl,
|
||||
hasToken,
|
||||
}: {
|
||||
label: string;
|
||||
provider: ObservedSettings["source_provider"];
|
||||
token: string;
|
||||
onToken: (value: string) => void;
|
||||
apiUrl: string;
|
||||
onUrl: (value: string) => void;
|
||||
hasToken: boolean;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<label htmlFor="roi-source-token" className="block text-sm font-medium">
|
||||
{label} access token
|
||||
</label>
|
||||
<Input
|
||||
id="roi-source-token"
|
||||
type="password"
|
||||
autoComplete="off"
|
||||
value={token}
|
||||
onChange={(event) => onToken(event.target.value)}
|
||||
placeholder={hasToken ? "Leave blank to keep the saved token" : "Optional for public repositories"}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{provider === "github"
|
||||
? "Fine-grained token: read access to pull requests, issues, and metadata"
|
||||
: "Token with read_api scope"}
|
||||
</p>
|
||||
<details className="text-sm">
|
||||
<summary className="cursor-pointer">Self-hosted instance</summary>
|
||||
<label htmlFor="roi-source-url" className="mt-3 block text-xs">
|
||||
API URL
|
||||
</label>
|
||||
<Input id="roi-source-url" value={apiUrl} onChange={(event) => onUrl(event.target.value)} />
|
||||
</details>
|
||||
</>
|
||||
);
|
||||
}
|
||||
function AppMessage({ configured, label }: { configured: boolean; label: string }) {
|
||||
return (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{configured
|
||||
? `You’ll authorize ${label}, then choose repositories`
|
||||
: `Register the ${label} app in gateway settings, or connect with a token`}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
function RepositoryChoices({
|
||||
available,
|
||||
repos,
|
||||
setRepos,
|
||||
query,
|
||||
setQuery,
|
||||
page,
|
||||
setPage,
|
||||
}: {
|
||||
available: z.infer<typeof repositoriesSchema> | null;
|
||||
repos: string;
|
||||
setRepos: (value: string) => void;
|
||||
query: string;
|
||||
setQuery: (value: string) => void;
|
||||
page: number;
|
||||
setPage: (value: number) => void;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<Input
|
||||
aria-label="Find repositories"
|
||||
value={query}
|
||||
onChange={(event) => {
|
||||
setQuery(event.target.value);
|
||||
setPage(1);
|
||||
}}
|
||||
placeholder="Find repositories…"
|
||||
/>
|
||||
<div className="max-h-48 overflow-y-auto rounded-lg border divide-y">
|
||||
{!available && (
|
||||
<p role="status" className="p-3 text-sm text-muted-foreground">
|
||||
Loading repositories…
|
||||
</p>
|
||||
)}
|
||||
{available?.repositories.length === 0 && (
|
||||
<p className="p-3 text-sm text-muted-foreground">No repositories found</p>
|
||||
)}
|
||||
{available?.repositories
|
||||
.filter((repo) => !repo.archived)
|
||||
.map((repo) => (
|
||||
<label key={repo.name} className="flex cursor-pointer items-center gap-3 p-3 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={repositoryNames(repos).includes(repo.name)}
|
||||
onChange={(event) =>
|
||||
setRepos(
|
||||
(event.target.checked
|
||||
? [...new Set([...repositoryNames(repos), repo.name])]
|
||||
: repositoryNames(repos).filter((name) => name !== repo.name)
|
||||
).join(", "),
|
||||
)
|
||||
}
|
||||
/>
|
||||
<span className="flex-1">{repo.name}</span>
|
||||
<span className="text-xs text-muted-foreground">{repo.visibility}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<Button size="sm" variant="ghost" disabled={page === 1} onClick={() => setPage(page - 1)}>
|
||||
Previous
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" disabled={!available?.has_more} onClick={() => setPage(page + 1)}>
|
||||
Next
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ConnectionMethod({
|
||||
method,
|
||||
setMethod,
|
||||
label,
|
||||
provider,
|
||||
token,
|
||||
setToken,
|
||||
apiUrl,
|
||||
setApiUrl,
|
||||
connected,
|
||||
apps,
|
||||
busy,
|
||||
connect,
|
||||
}: {
|
||||
method: "app" | "token";
|
||||
setMethod: (value: "app" | "token") => void;
|
||||
label: string;
|
||||
provider: ObservedSettings["source_provider"];
|
||||
token: string;
|
||||
setToken: (value: string) => void;
|
||||
apiUrl: string;
|
||||
setApiUrl: (value: string) => void;
|
||||
connected: ObservedConnection;
|
||||
apps: z.infer<typeof appsSchema> | null;
|
||||
busy: boolean;
|
||||
connect: () => void;
|
||||
}) {
|
||||
const connectLabel = method === "app" ? `Connect ${label}` : "Continue";
|
||||
const sameSource = connected.source_provider === provider && connected.api_url === apiUrl;
|
||||
const hasSavedToken = sameSource && connected.has_token && connected.connection_type === "token";
|
||||
return (
|
||||
<>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Button
|
||||
variant={method === "app" ? "secondary" : "outline"}
|
||||
aria-pressed={method === "app"}
|
||||
onClick={() => setMethod("app")}
|
||||
>
|
||||
Connect with app
|
||||
</Button>
|
||||
<Button
|
||||
variant={method === "token" ? "secondary" : "outline"}
|
||||
aria-pressed={method === "token"}
|
||||
onClick={() => setMethod("token")}
|
||||
>
|
||||
<KeyRound />
|
||||
Access token
|
||||
</Button>
|
||||
</div>
|
||||
{method === "token" && (
|
||||
<TokenFields
|
||||
label={label}
|
||||
provider={provider}
|
||||
token={token}
|
||||
onToken={setToken}
|
||||
apiUrl={apiUrl}
|
||||
onUrl={setApiUrl}
|
||||
hasToken={hasSavedToken}
|
||||
/>
|
||||
)}
|
||||
{method === "app" && <AppMessage configured={Boolean(apps?.[provider].configured)} label={label} />}
|
||||
<Button
|
||||
className="w-full"
|
||||
disabled={busy || (method === "app" && !apps?.[provider].configured)}
|
||||
onClick={connect}
|
||||
>
|
||||
{busy ? "Connecting…" : connectLabel}
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
type ConnectionStep = "list" | "connect" | "repos";
|
||||
const stepTitles = { list: "Connections", connect: "Connect your code", repos: "Choose repositories" };
|
||||
|
||||
function initialStep(settings: ObservedSettings, afterAuthorization: boolean): ConnectionStep {
|
||||
if (afterAuthorization) return "repos";
|
||||
if (settings.connections?.length) return "list";
|
||||
return settings.has_token || settings.ready ? "repos" : "connect";
|
||||
}
|
||||
|
||||
function stepDescription(step: ConnectionStep, label: string) {
|
||||
if (step === "list") return "All selected repositories appear in one report";
|
||||
if (step === "connect") return "Connect GitHub and GitLab with an app or access token";
|
||||
return `Select ${label} repositories to compare`;
|
||||
}
|
||||
|
||||
function connectionMethodLabel(entry: ObservedConnection) {
|
||||
if (entry.connection_type === "app") return "App";
|
||||
return entry.has_token ? "Token" : "Public access";
|
||||
}
|
||||
|
||||
function ConnectionList({
|
||||
connections,
|
||||
onEdit,
|
||||
onAdd,
|
||||
}: {
|
||||
connections: ObservedConnection[];
|
||||
onEdit: (entry: ObservedConnection) => void;
|
||||
onAdd: () => void;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
{connections.map((entry) => (
|
||||
<div key={entry.id} className="flex items-center justify-between gap-3 rounded-lg border p-4">
|
||||
<div className="min-w-0 text-sm">
|
||||
<p className="flex items-center gap-2 font-medium">
|
||||
{entry.source_provider === "github" ? <Github className="size-4" /> : <Gitlab className="size-4" />}
|
||||
{entry.source_provider === "github" ? "GitHub" : "GitLab"}
|
||||
</p>
|
||||
<p className="mt-1 truncate text-xs text-muted-foreground">{new URL(entry.api_url).host}</p>
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
{entry.repos.length} repositories · {connectionMethodLabel(entry)}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onEdit(entry)}
|
||||
aria-label={`Edit ${entry.source_provider === "github" ? "GitHub" : "GitLab"} ${new URL(entry.api_url).host}`}
|
||||
>
|
||||
Edit
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
<Button className="w-full" variant="outline" onClick={onAdd}>
|
||||
Add connection
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function initialMethod(settings: ObservedSettings) {
|
||||
return settings.has_token ? settings.connection_type : null;
|
||||
}
|
||||
|
||||
function hasConnections(settings: ObservedSettings) {
|
||||
return Boolean(settings.connections?.length);
|
||||
}
|
||||
|
||||
function canManageApp(connected: ObservedConnection, apps: z.infer<typeof appsSchema> | null) {
|
||||
return (
|
||||
connected.connection_type === "app" && connected.source_provider === "github" && Boolean(apps?.github.can_install)
|
||||
);
|
||||
}
|
||||
|
||||
export default function ObservedConnections({
|
||||
accessToken,
|
||||
settings,
|
||||
onClose,
|
||||
onSaved,
|
||||
initialError = "",
|
||||
afterAuthorization = false,
|
||||
}: {
|
||||
accessToken: string;
|
||||
settings: ObservedSettings;
|
||||
onClose: () => void;
|
||||
onSaved: () => void;
|
||||
initialError?: string;
|
||||
afterAuthorization?: boolean;
|
||||
}) {
|
||||
const [savedSettings, setSavedSettings] = useState(settings);
|
||||
const [connected, setConnected] = useState<ObservedConnection>(settings);
|
||||
const [provider, setProvider] = useState(settings.source_provider);
|
||||
const [apiUrl, setApiUrl] = useState(settings.api_url);
|
||||
const [selectedMethod, setMethod] = useState<"app" | "token" | null>(initialMethod(settings));
|
||||
const [step, setStep] = useState<ConnectionStep>(() => initialStep(settings, afterAuthorization));
|
||||
const [token, setToken] = useState("");
|
||||
const [repos, setRepos] = useState(settings.repos.join(", "));
|
||||
const [apps, setApps] = useState<z.infer<typeof appsSchema> | null>(null);
|
||||
const method = preferredConnectionMethod(selectedMethod, apps?.[provider].configured);
|
||||
const [available, setAvailable] = useState<z.infer<typeof repositoriesSchema> | null>(null);
|
||||
const [query, setQuery] = useState("");
|
||||
const [page, setPage] = useState(1);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState(initialError);
|
||||
const label = provider === "github" ? "GitHub" : "GitLab";
|
||||
const saveLabel = repositoryNames(repos).length ? "Save and sync" : "Save repositories";
|
||||
const manageApp = canManageApp(connected, apps);
|
||||
const showConnections = hasConnections(savedSettings);
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
apiClient
|
||||
.get<unknown>("/roi-calculator/observed/apps", { accessToken, signal: controller.signal })
|
||||
.then((data) => {
|
||||
if (!controller.signal.aborted) setApps(appsSchema.parse(data));
|
||||
})
|
||||
.catch((reason: unknown) => {
|
||||
if (!controller.signal.aborted) setError(extractProxyErrorMessage(reason));
|
||||
});
|
||||
return () => controller.abort();
|
||||
}, [accessToken]);
|
||||
useEffect(() => {
|
||||
if (step !== "repos" || (!connected.has_token && connected.source_provider === "github")) return;
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => {
|
||||
apiClient
|
||||
.get<unknown>("/roi-calculator/observed/repositories", {
|
||||
accessToken,
|
||||
signal: controller.signal,
|
||||
query: { query, page, connection: connected.id },
|
||||
})
|
||||
.then((data) => {
|
||||
if (!controller.signal.aborted) setAvailable(repositoriesSchema.parse(data));
|
||||
})
|
||||
.catch((reason: unknown) => {
|
||||
if (!controller.signal.aborted) setError(extractProxyErrorMessage(reason));
|
||||
});
|
||||
}, 250);
|
||||
return () => {
|
||||
clearTimeout(timer);
|
||||
controller.abort();
|
||||
};
|
||||
}, [accessToken, step, connected, query, page]);
|
||||
function selectProvider(value: ObservedSettings["source_provider"]) {
|
||||
setProvider(value);
|
||||
const existing = savedSettings.connections?.find(
|
||||
(entry) => entry.source_provider === value && entry.api_url === defaultUrl[value],
|
||||
);
|
||||
setApiUrl(existing?.api_url ?? defaultUrl[value]);
|
||||
setConnected(
|
||||
existing ?? {
|
||||
...settings,
|
||||
source_provider: value,
|
||||
api_url: defaultUrl[value],
|
||||
repos: [],
|
||||
has_token: false,
|
||||
ready: false,
|
||||
connection_type: "token",
|
||||
id: undefined,
|
||||
},
|
||||
);
|
||||
setMethod(existing?.connection_type ?? null);
|
||||
setToken("");
|
||||
setError("");
|
||||
}
|
||||
function searchRepositories(value: string) {
|
||||
setAvailable(null);
|
||||
setError("");
|
||||
setQuery(value);
|
||||
}
|
||||
function changePage(value: number) {
|
||||
setAvailable(null);
|
||||
setError("");
|
||||
setPage(value);
|
||||
}
|
||||
async function connect(install = false) {
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
if (method === "app" || install) {
|
||||
const sameApp = connected.source_provider === provider && connected.connection_type === "app";
|
||||
const firstInstallation = provider === "github" && !sameApp && apps?.github.can_install;
|
||||
const result = z.object({ url: z.string().url() }).parse(
|
||||
await apiClient.post<unknown>(`/roi-calculator/observed/oauth/${provider}/start`, {
|
||||
accessToken,
|
||||
credentials: "include",
|
||||
query: { install: install || Boolean(firstInstallation) },
|
||||
}),
|
||||
);
|
||||
window.location.assign(result.url);
|
||||
return;
|
||||
}
|
||||
const same = provider === connected.source_provider && apiUrl === connected.api_url;
|
||||
const keepToken = same && connected.has_token && connected.connection_type === "token";
|
||||
const result = observedSettingsSchema.parse(
|
||||
await apiClient.put<unknown>("/roi-calculator/observed/settings", {
|
||||
accessToken,
|
||||
body: {
|
||||
connection_id: savedSettings.connections?.find((entry) => entry.id === connected.id)?.id,
|
||||
source_provider: provider,
|
||||
api_url: apiUrl,
|
||||
token: token || (keepToken ? undefined : ""),
|
||||
repos: same ? connected.repos : [],
|
||||
update_interval_minutes: connected.update_interval_minutes,
|
||||
},
|
||||
}),
|
||||
);
|
||||
setSavedSettings(result);
|
||||
setConnected(result);
|
||||
setToken("");
|
||||
setRepos(result.repos.join(", "));
|
||||
setAvailable(null);
|
||||
setStep("repos");
|
||||
} catch (reason) {
|
||||
setError(extractProxyErrorMessage(reason));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
async function save() {
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
const result = observedSettingsSchema.parse(
|
||||
await apiClient.put<unknown>("/roi-calculator/observed/settings", {
|
||||
accessToken,
|
||||
body: {
|
||||
connection_id: connected.id,
|
||||
source_provider: connected.source_provider,
|
||||
api_url: connected.api_url,
|
||||
repos: repositoryNames(repos),
|
||||
update_interval_minutes: connected.update_interval_minutes,
|
||||
},
|
||||
}),
|
||||
);
|
||||
if (result.ready) await apiClient.post<unknown>("/roi-calculator/observed/sync", { accessToken });
|
||||
onSaved();
|
||||
onClose();
|
||||
} catch (reason) {
|
||||
setError(extractProxyErrorMessage(reason));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
function edit(entry: ObservedConnection) {
|
||||
setConnected(entry);
|
||||
setProvider(entry.source_provider);
|
||||
setApiUrl(entry.api_url);
|
||||
setMethod(entry.connection_type);
|
||||
setRepos(entry.repos.join(", "));
|
||||
setToken("");
|
||||
setQuery("");
|
||||
setPage(1);
|
||||
setAvailable(null);
|
||||
setError("");
|
||||
setStep("repos");
|
||||
}
|
||||
return (
|
||||
<Dialog
|
||||
open
|
||||
onOpenChange={(open) => {
|
||||
if (!open) onClose();
|
||||
}}
|
||||
>
|
||||
<DialogContent className="sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{stepTitles[step]}</DialogTitle>
|
||||
<DialogDescription>{stepDescription(step, label)}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-2">
|
||||
{step === "list" && (
|
||||
<ConnectionList
|
||||
connections={savedSettings.connections ?? []}
|
||||
onEdit={edit}
|
||||
onAdd={() => {
|
||||
selectProvider(
|
||||
savedSettings.connections?.some((entry) => entry.source_provider === "github") ? "gitlab" : "github",
|
||||
);
|
||||
setStep("connect");
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{step === "connect" && (
|
||||
<>
|
||||
{showConnections && (
|
||||
<Button variant="ghost" size="sm" onClick={() => setStep("list")}>
|
||||
<ArrowLeft />
|
||||
All connections
|
||||
</Button>
|
||||
)}
|
||||
<div className="grid grid-cols-2 gap-3" role="group" aria-label="Code provider">
|
||||
{(["github", "gitlab"] as const).map((value) => (
|
||||
<Button
|
||||
key={value}
|
||||
variant={provider === value ? "default" : "outline"}
|
||||
aria-pressed={provider === value}
|
||||
onClick={() => selectProvider(value)}
|
||||
>
|
||||
{value === "github" ? <Github /> : <Gitlab />}
|
||||
{value === "github" ? "GitHub" : "GitLab"}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
<ConnectionMethod
|
||||
method={method}
|
||||
setMethod={setMethod}
|
||||
label={label}
|
||||
provider={provider}
|
||||
token={token}
|
||||
setToken={setToken}
|
||||
apiUrl={apiUrl}
|
||||
setApiUrl={setApiUrl}
|
||||
connected={connected}
|
||||
apps={apps}
|
||||
busy={busy}
|
||||
connect={() => connect()}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{step === "repos" && (
|
||||
<>
|
||||
{showConnections && (
|
||||
<Button variant="ghost" size="sm" onClick={() => setStep("list")}>
|
||||
<ArrowLeft />
|
||||
All connections
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="ghost" size="sm" onClick={() => setStep("connect")}>
|
||||
<ArrowLeft />
|
||||
Change connection
|
||||
</Button>
|
||||
{manageApp && (
|
||||
<Button variant="outline" size="sm" disabled={busy} onClick={() => connect(true)}>
|
||||
Manage GitHub repositories
|
||||
</Button>
|
||||
)}
|
||||
<label htmlFor="roi-repositories" className="block text-sm font-medium">
|
||||
Repositories
|
||||
</label>
|
||||
<Input
|
||||
id="roi-repositories"
|
||||
value={repos}
|
||||
onChange={(event) => setRepos(event.target.value)}
|
||||
placeholder={
|
||||
provider === "github" ? "owner/repo, owner/another-repo" : "group/project, group/subgroup/project"
|
||||
}
|
||||
/>
|
||||
{(connected.has_token || connected.source_provider === "gitlab") && (
|
||||
<>
|
||||
<RepositoryChoices
|
||||
available={available}
|
||||
repos={repos}
|
||||
setRepos={setRepos}
|
||||
query={query}
|
||||
setQuery={searchRepositories}
|
||||
page={page}
|
||||
setPage={changePage}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<Button className="w-full" disabled={busy} onClick={save}>
|
||||
{busy ? "Saving…" : saveLabel}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{error && (
|
||||
<p role="alert" className="text-sm text-destructive">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,239 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { ExternalLink, GitPullRequest } from "lucide-react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetDescription } from "@/components/ui/sheet";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import {
|
||||
dateRange,
|
||||
changeTerms,
|
||||
duration,
|
||||
money,
|
||||
number,
|
||||
recordedBranches,
|
||||
type Comparison,
|
||||
type ObservedPerson,
|
||||
type ObservedPull,
|
||||
type ObservedSnapshot,
|
||||
type Period,
|
||||
} from "./observedData";
|
||||
|
||||
export function PullList({
|
||||
pulls,
|
||||
provider,
|
||||
matchedOnly = false,
|
||||
}: {
|
||||
matchedOnly?: boolean;
|
||||
pulls: ObservedPull[];
|
||||
provider: ObservedSnapshot["source_provider"];
|
||||
}) {
|
||||
const terms = changeTerms(provider);
|
||||
const emptyMessage = matchedOnly
|
||||
? "No merged changes from matched people in this period"
|
||||
: `No ${terms.lower} in this period`;
|
||||
const [query, setQuery] = useState("");
|
||||
const [limit, setLimit] = useState(20);
|
||||
const filtered = pulls.filter((pull) =>
|
||||
`${pull.number} ${pull.title} ${pull.author} ${pull.repo} ${pull.source_repo} ${pull.source_branch}`
|
||||
.toLowerCase()
|
||||
.includes(query.toLowerCase()),
|
||||
);
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<Input
|
||||
aria-label={`Search ${terms.lower}`}
|
||||
placeholder={`Search ${terms.lower}…`}
|
||||
value={query}
|
||||
onChange={(event) => {
|
||||
setQuery(event.target.value);
|
||||
setLimit(20);
|
||||
}}
|
||||
className="max-w-sm"
|
||||
/>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{terms.requests}</TableHead>
|
||||
<TableHead>Author</TableHead>
|
||||
<TableHead className="text-right">Opened to merged</TableHead>
|
||||
<TableHead className="text-right">Tagged spend</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filtered.slice(0, limit).map((pull) => (
|
||||
<TableRow key={pull.url}>
|
||||
<TableCell className="max-w-lg whitespace-normal py-3">
|
||||
<a
|
||||
className="group flex items-start gap-2 hover:underline"
|
||||
href={pull.url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
<GitPullRequest className="mt-0.5 size-4 shrink-0 text-violet-500" />
|
||||
<span>
|
||||
<span className="mr-2 text-muted-foreground">#{pull.number}</span>
|
||||
{pull.title}
|
||||
<span className="mt-1 block text-xs text-muted-foreground">{pull.repo}</span>
|
||||
</span>
|
||||
<ExternalLink className="mt-0.5 size-3 shrink-0 text-muted-foreground" />
|
||||
</a>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="text-xs">{pull.author || "Deleted author"}</span>
|
||||
{pull.agent && (
|
||||
<Badge variant="secondary" className="ml-2">
|
||||
Agent
|
||||
</Badge>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{duration(pull.merge_hours)}</TableCell>
|
||||
<TableCell
|
||||
className="text-right tabular-nums"
|
||||
title={
|
||||
pull.branch_cost.status === "ambiguous"
|
||||
? "Branch is shared by multiple changes"
|
||||
: "Spend tagged to this branch during the period"
|
||||
}
|
||||
>
|
||||
{money(pull.branch_cost.spend)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Elapsed time from opening to merge, not engineering effort or time saved
|
||||
</p>
|
||||
{filtered.length === 0 && (
|
||||
<p className="py-8 text-center text-sm text-muted-foreground">
|
||||
{query ? `No ${terms.lower} match this search` : emptyMessage}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
||||
<span>
|
||||
{number(Math.min(limit, filtered.length))} of {number(filtered.length)} {terms.lower}
|
||||
</span>
|
||||
{limit < filtered.length && (
|
||||
<Button variant="outline" size="sm" onClick={() => setLimit(limit + 40)}>
|
||||
Show more
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function PersonDetails({
|
||||
person,
|
||||
snapshot,
|
||||
comparison,
|
||||
onClose,
|
||||
onEdit,
|
||||
}: {
|
||||
person: ObservedPerson;
|
||||
snapshot: ObservedSnapshot;
|
||||
comparison: Comparison;
|
||||
onClose: () => void;
|
||||
onEdit?: () => void;
|
||||
}) {
|
||||
const terms = changeTerms(snapshot.source_provider);
|
||||
const [period, setPeriod] = useState<Period>("current");
|
||||
const current = person.periods.current;
|
||||
const baseline = person.periods[comparison];
|
||||
const urls = new Set(person.periods[period].pr_urls);
|
||||
const pulls = snapshot.pulls[period].filter((pull) => urls.has(pull.url));
|
||||
return (
|
||||
<Sheet
|
||||
open
|
||||
onOpenChange={(open) => {
|
||||
if (!open) onClose();
|
||||
}}
|
||||
>
|
||||
<SheetContent className="overflow-y-auto p-6 data-[side=right]:w-full data-[side=right]:sm:max-w-3xl">
|
||||
<SheetHeader className="p-0 pr-8">
|
||||
<SheetTitle className="text-xl">{person.name}</SheetTitle>
|
||||
<SheetDescription>{[person.email, person.logins.join(", ")].filter(Boolean).join(" · ")}</SheetDescription>
|
||||
</SheetHeader>
|
||||
{onEdit && (
|
||||
<Button variant="outline" className="w-fit" onClick={onEdit}>
|
||||
Edit linked accounts
|
||||
</Button>
|
||||
)}
|
||||
<div className="mt-2 grid grid-cols-1 gap-y-4 divide-y rounded-lg border py-5 sm:grid-cols-3 sm:divide-x sm:divide-y-0">
|
||||
<div className="px-4">
|
||||
<p className="text-xs text-muted-foreground">Merged {terms.plural}</p>
|
||||
<p className="mt-2 text-2xl font-semibold">{number(current.merged_prs)}</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">{number(baseline.merged_prs)} in comparison</p>
|
||||
</div>
|
||||
<div className="px-4">
|
||||
<p className="text-xs text-muted-foreground">Recorded spend</p>
|
||||
<p className="mt-2 text-2xl font-semibold">
|
||||
{money(current.spend_observation === "no_records" ? null : current.gateway_recorded_spend)}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">Gateway only</p>
|
||||
</div>
|
||||
<div className="px-4">
|
||||
<p className="text-xs text-muted-foreground">Spend / matched {terms.singular}</p>
|
||||
<p className="mt-2 text-2xl font-semibold">{money(current.recorded_spend_per_attributed_pr)}</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">Period average</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 flex flex-wrap items-center justify-between gap-3">
|
||||
<h3 className="font-medium">Merged {terms.plural}</h3>
|
||||
<div className="flex gap-1 rounded-lg bg-muted p-1">
|
||||
<Button size="sm" variant={period === "current" ? "outline" : "ghost"} onClick={() => setPeriod("current")}>
|
||||
Current
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant={period === comparison ? "outline" : "ghost"}
|
||||
onClick={() => setPeriod(comparison)}
|
||||
>
|
||||
{comparison === "previous" ? "Previous period" : "Last year"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">{dateRange(snapshot.periods[period].window)} · UTC</p>
|
||||
<PullList key={period} pulls={pulls} provider={snapshot.source_provider} />
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
|
||||
export function BranchSpend({ snapshot, matchedOnly = true }: { snapshot: ObservedSnapshot; matchedOnly?: boolean }) {
|
||||
const rows = recordedBranches(snapshot, matchedOnly);
|
||||
return (
|
||||
<div className="rounded-xl border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Repository</TableHead>
|
||||
<TableHead>Branch</TableHead>
|
||||
<TableHead className="text-right">Requests</TableHead>
|
||||
<TableHead className="text-right">Tagged spend</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{rows.map((row) => (
|
||||
<TableRow key={`${row.repo}/${row.branch}`}>
|
||||
<TableCell>{row.repo}</TableCell>
|
||||
<TableCell>{row.branch}</TableCell>
|
||||
<TableCell className="text-right">{number(row.requests)}</TableCell>
|
||||
<TableCell className="text-right">{money(row.spend)}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
{rows.length === 0 && (
|
||||
<p className="p-8 text-center text-sm text-muted-foreground">
|
||||
{matchedOnly
|
||||
? "No tagged branch spend for matched people in this period"
|
||||
: "No tagged branch spend in this period"}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,384 +0,0 @@
|
|||
import { fireEvent, render as renderWithoutNuqs, screen, waitFor, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { NuqsAdapter } from "nuqs/adapters/react";
|
||||
import ObservedROIView from "./ObservedROIView";
|
||||
import { createObservedDemo } from "./observedDemo";
|
||||
import type { ObservedSettings, ObservedSnapshot, ObservedStatus } from "./observedData";
|
||||
|
||||
const render = (ui: Parameters<typeof renderWithoutNuqs>[0]) => renderWithoutNuqs(ui, { wrapper: NuqsAdapter });
|
||||
|
||||
const settings: ObservedSettings = {
|
||||
source_provider: "gitlab",
|
||||
api_url: "https://gitlab.com/api/v4",
|
||||
repos: ["org/service"],
|
||||
has_token: true,
|
||||
connection_type: "app",
|
||||
update_interval_minutes: 1440,
|
||||
ready: true,
|
||||
};
|
||||
const idle: ObservedStatus = {
|
||||
running: false,
|
||||
phase: "complete",
|
||||
stage: "",
|
||||
done: 0,
|
||||
total: 0,
|
||||
error: null,
|
||||
finished_at: null,
|
||||
};
|
||||
const period = {
|
||||
window: { start: "2026-09-01", end: "2026-09-28" },
|
||||
merged_prs: 1,
|
||||
median_merge_hours: 16 / 3600,
|
||||
human_authored: 1,
|
||||
agent_authored: 0,
|
||||
missing_author: 0,
|
||||
agents_without_requester: 0,
|
||||
matched_internal_prs: 0,
|
||||
new_bug_labeled_issues: 0,
|
||||
new_regression_labeled_issues: 0,
|
||||
explicitly_titled_revert_prs: 0,
|
||||
matched_users_recorded_spend: 0,
|
||||
spend_observation: "no_records" as const,
|
||||
human_summary: { median_merge_hours: 16 / 3600 },
|
||||
};
|
||||
const report: ObservedSnapshot = {
|
||||
source_provider: "gitlab",
|
||||
repos: ["org/service"],
|
||||
unmatched_logins: [],
|
||||
unlinked_branches: [],
|
||||
captured_at: "2026-09-29T00:00:00Z",
|
||||
periods: { current: period, previous: period, last_year: period },
|
||||
people: [],
|
||||
pulls: { current: [], previous: [], last_year: [] },
|
||||
};
|
||||
const app = { configured: true, api_url: null, callback_url: null };
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
window.history.replaceState(null, "", "/");
|
||||
});
|
||||
|
||||
describe("observed ROI dashboard", () => {
|
||||
it("defaults every contributor list to matched people and keeps the switch across tabs", async () => {
|
||||
const sample = createObservedDemo(7);
|
||||
const outside = {
|
||||
...sample.pulls.current[0],
|
||||
url: "https://gitlab.com/outside/api/-/merge_requests/999",
|
||||
author: "outside",
|
||||
agent: false,
|
||||
title: "Outside change",
|
||||
source_branch: "outside-only",
|
||||
branch_cost: {
|
||||
repo: "gitlab.com/outside/api",
|
||||
branch: "outside-only",
|
||||
spend: 123,
|
||||
requests: 5,
|
||||
status: "matched" as const,
|
||||
},
|
||||
};
|
||||
const report = {
|
||||
...sample,
|
||||
pulls: { ...sample.pulls, current: [...sample.pulls.current, outside] },
|
||||
unlinked_branches: [{ repo: "gitlab.com/outside/api", branch: "orphan-only", spend: 5, requests: 1 }],
|
||||
};
|
||||
const requests = vi.fn(async (input: string, _init: RequestInit) => {
|
||||
const path = new URL(input, "http://localhost").pathname;
|
||||
if (path.endsWith("/settings")) return Response.json(settings);
|
||||
if (path.endsWith("/report")) return Response.json({ report });
|
||||
if (path.endsWith("/sync")) return Response.json(idle);
|
||||
throw new Error(path);
|
||||
});
|
||||
vi.stubGlobal("fetch", requests);
|
||||
const user = userEvent.setup();
|
||||
render(<ObservedROIView accessToken="test-only-gateway-token" isViewOnly />);
|
||||
expect(await screen.findByRole("switch", { name: "Matched people only" })).toBeChecked();
|
||||
expect(screen.getByRole("tab", { name: "Engineers 3" })).toBeInTheDocument();
|
||||
expect(screen.queryByText("outside")).not.toBeInTheDocument();
|
||||
await user.click(screen.getByRole("switch", { name: "Matched people only" }));
|
||||
expect(screen.getByRole("tab", { name: "Engineers 4" })).toBeInTheDocument();
|
||||
await user.click(screen.getByRole("button", { name: "View outside's merged changes" }));
|
||||
expect(await screen.findByRole("dialog", { name: "outside" })).toHaveTextContent("Outside change");
|
||||
expect(screen.queryByRole("button", { name: "Edit linked accounts" })).not.toBeInTheDocument();
|
||||
await user.click(screen.getByRole("button", { name: "Close" }));
|
||||
await user.click(screen.getByRole("switch", { name: "Matched people only" }));
|
||||
await user.click(screen.getByRole("tab", { name: "Merged changes" }));
|
||||
expect(screen.queryByText("Outside change")).not.toBeInTheDocument();
|
||||
await user.click(screen.getByRole("tab", { name: "Branch spend" }));
|
||||
expect(screen.getAllByText(/feature\/sample-/).length).toBeGreaterThan(0);
|
||||
expect(screen.queryByText("outside-only")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("orphan-only")).not.toBeInTheDocument();
|
||||
await user.click(screen.getByRole("switch", { name: "Matched people only" }));
|
||||
expect(screen.getByText("outside-only")).toBeInTheDocument();
|
||||
expect(screen.getByText("orphan-only")).toBeInTheDocument();
|
||||
await user.click(screen.getByRole("tab", { name: "Merged changes" }));
|
||||
expect(screen.getByText("Outside change")).toBeInTheDocument();
|
||||
expect(requests.mock.calls.every(([, init]) => init.method === "GET")).toBe(true);
|
||||
});
|
||||
|
||||
it("shows a useful empty matched view and exposes all changes when the filter is off", async () => {
|
||||
const sample = { ...createObservedDemo(7), people: [] };
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (input: string) => {
|
||||
const path = new URL(input, "http://localhost").pathname;
|
||||
if (path.endsWith("/settings")) return Response.json(settings);
|
||||
if (path.endsWith("/report")) return Response.json({ report: sample });
|
||||
return Response.json(idle);
|
||||
}),
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
render(<ObservedROIView accessToken="test-only-gateway-token" />);
|
||||
expect(await screen.findByText("No merged changes from matched people in this period")).toBeInTheDocument();
|
||||
await user.click(screen.getByRole("switch", { name: "Matched people only" }));
|
||||
expect(screen.queryByText("No merged changes from matched people in this period")).not.toBeInTheDocument();
|
||||
expect(screen.getAllByRole("link", { name: /Add repository search/ }).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("previews every sample view before setup, changes sample periods without writes, and exits back to setup", async () => {
|
||||
const requests = vi.fn(async (input: string, _init: RequestInit) => {
|
||||
const path = new URL(input, "http://localhost").pathname;
|
||||
const disconnected = { ...settings, ready: false, repos: [], has_token: false };
|
||||
if (path.endsWith("/settings")) return Response.json(disconnected);
|
||||
if (path.endsWith("/report")) return Response.json({ report: null });
|
||||
if (path.endsWith("/sync")) return Response.json(idle);
|
||||
throw new Error(path);
|
||||
});
|
||||
vi.stubGlobal("fetch", requests);
|
||||
const user = userEvent.setup();
|
||||
render(<ObservedROIView accessToken="test-only-gateway-token" />);
|
||||
expect(await screen.findByRole("heading", { name: "Connect your repositories" })).toBeInTheDocument();
|
||||
expect(screen.queryByText("Ready to sync")).not.toBeInTheDocument();
|
||||
await user.click(screen.getByRole("button", { name: "Preview sample report" }));
|
||||
expect(screen.getByRole("status")).toHaveTextContent("You’re viewing demo data");
|
||||
expect(screen.getByRole("tab", { name: "Engineers 3", selected: true })).toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "Connections" })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "Link accounts" })).not.toBeInTheDocument();
|
||||
await waitFor(() => expect(window.location.search).toBe("?demo=1"));
|
||||
await user.click(screen.getByRole("button", { name: "View Alex Rivera's merged changes" }));
|
||||
expect(await screen.findByRole("dialog", { name: "Alex Rivera" })).toHaveTextContent("alex-demo@example.com");
|
||||
expect(screen.getByRole("heading", { name: "Merged changes" })).toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "Edit linked accounts" })).not.toBeInTheDocument();
|
||||
await user.click(screen.getByRole("button", { name: "Close" }));
|
||||
await user.click(screen.getByRole("tab", { name: "Merged changes" }));
|
||||
expect(screen.getByRole("img", { name: /Merged changes by week/ })).toBeInTheDocument();
|
||||
await user.click(screen.getByRole("tab", { name: "Quality" }));
|
||||
expect(screen.getByText("New regression-labeled issues")).toBeInTheDocument();
|
||||
await user.click(screen.getByRole("tab", { name: "Branch spend" }));
|
||||
expect(screen.getAllByText(/feature\/sample-/).length).toBeGreaterThan(0);
|
||||
await user.click(screen.getByRole("combobox", { name: "Reporting period" }));
|
||||
await user.click(await screen.findByRole("option", { name: "Last 7 days" }));
|
||||
expect(screen.getByRole("combobox", { name: "Reporting period" })).toHaveTextContent("Last 7 days");
|
||||
await user.click(screen.getByRole("combobox", { name: "Comparison period" }));
|
||||
await user.click(await screen.findByRole("option", { name: "vs. same period last year" }));
|
||||
expect(screen.getByRole("combobox", { name: "Comparison period" })).toHaveTextContent("vs. same period last year");
|
||||
await user.click(screen.getByRole("button", { name: "Exit demo" }));
|
||||
expect(screen.getByRole("heading", { name: "Connect your repositories" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Connect GitHub or GitLab" })).toBeEnabled();
|
||||
await waitFor(() => expect(window.location.search).toBe(""));
|
||||
expect(requests.mock.calls.every(([, init]) => init.method === "GET")).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps live sync and its report intact when entering and exiting the demo", async () => {
|
||||
const requests = vi.fn(async (input: string, _init: RequestInit) => {
|
||||
const path = new URL(input, "http://localhost").pathname;
|
||||
if (path.endsWith("/settings")) return Response.json(settings);
|
||||
if (path.endsWith("/report")) return Response.json({ report });
|
||||
if (path.endsWith("/sync")) return Response.json({ ...idle, running: true, stage: "Reading changes" });
|
||||
throw new Error(path);
|
||||
});
|
||||
vi.stubGlobal("fetch", requests);
|
||||
window.history.replaceState(null, "", "/roi-calculator/?from=review#report");
|
||||
const user = userEvent.setup();
|
||||
render(<ObservedROIView accessToken="test-only-gateway-token" />);
|
||||
expect(await screen.findByRole("button", { name: "Cancel sync" })).toBeEnabled();
|
||||
await user.click(screen.getByRole("button", { name: "Preview sample report" }));
|
||||
expect(screen.queryByRole("button", { name: "Cancel sync" })).not.toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "2 repositories" })).toBeInTheDocument();
|
||||
await waitFor(() => expect(window.location.search).toBe("?from=review&demo=1"));
|
||||
await user.click(screen.getByRole("button", { name: "Exit demo" }));
|
||||
expect(screen.getByRole("button", { name: "Cancel sync" })).toBeEnabled();
|
||||
expect(screen.getByRole("button", { name: "1 repository" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("tab", { name: "Merge requests", selected: true })).toBeInTheDocument();
|
||||
await waitFor(() => expect(window.location.search).toBe("?from=review"));
|
||||
expect(window.location.hash).toBe("#report");
|
||||
expect(requests.mock.calls.every(([, init]) => init.method === "GET")).toBe(true);
|
||||
});
|
||||
|
||||
it.each(["failed", "pending"])("opens a demo URL even when live requests are %s", async (state) => {
|
||||
window.history.replaceState(null, "", "/roi-calculator/?demo=1");
|
||||
const pending = Promise.withResolvers<Response>();
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(() => (state === "failed" ? Promise.reject(new Error("Live data unavailable")) : pending.promise)),
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
render(<ObservedROIView accessToken="test-only-gateway-token" isViewOnly />);
|
||||
expect(screen.getByRole("status")).toHaveTextContent("You’re viewing demo data");
|
||||
expect(screen.getByText("Alex Rivera")).toBeInTheDocument();
|
||||
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
|
||||
await user.click(screen.getByRole("button", { name: "Exit demo" }));
|
||||
expect(screen.queryByText("Alex Rivera")).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("heading", { name: "Connect your repositories" })).not.toBeInTheDocument();
|
||||
await waitFor(() => expect(window.location.search).toBe(""));
|
||||
if (state === "failed") expect(await screen.findByRole("alert")).toHaveTextContent("Live data unavailable");
|
||||
});
|
||||
|
||||
it("retries failures, keeps the report during cancellation, and refreshes after completion", async () => {
|
||||
let status: ObservedStatus = { ...idle, phase: "error", error: "Provider temporarily unavailable" };
|
||||
let completeOnPoll = false;
|
||||
let currentReport = { ...report, repos: ["org/service", "org/docs"] };
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (input: string, init: RequestInit) => {
|
||||
const path = new URL(input, "http://localhost").pathname;
|
||||
if (path.endsWith("/settings")) return Response.json(settings);
|
||||
if (path.endsWith("/report")) return Response.json({ report: currentReport });
|
||||
if (path.endsWith("/sync")) {
|
||||
if (init.method === "POST") status = { ...idle, running: true, phase: "pulls" };
|
||||
if (init.method === "DELETE") status = { ...idle, phase: "cancelled" };
|
||||
if (init.method === "GET" && completeOnPoll) {
|
||||
status = { ...idle, finished_at: "2026-09-29T00:01:00Z" };
|
||||
currentReport = { ...report, repos: ["org/updated"] };
|
||||
}
|
||||
return Response.json(status);
|
||||
}
|
||||
throw new Error(path);
|
||||
}),
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
render(<ObservedROIView accessToken="test-only-gateway-token" />);
|
||||
expect(await screen.findByRole("tab", { name: "Merge requests", selected: true })).toBeInTheDocument();
|
||||
await user.click(screen.getByRole("tab", { name: "Quality" }));
|
||||
expect(screen.getByRole("alert")).toHaveTextContent("Provider temporarily unavailable");
|
||||
await user.click(screen.getByRole("button", { name: "Retry" }));
|
||||
await user.click(await screen.findByRole("button", { name: "Cancel sync" }));
|
||||
expect(await screen.findByRole("button", { name: "Sync now" })).toBeEnabled();
|
||||
expect(screen.queryByText("org/service")).not.toBeInTheDocument();
|
||||
await user.click(screen.getByRole("button", { name: "2 repositories" }));
|
||||
const repositories = await screen.findByRole("dialog", { name: "Repositories" });
|
||||
expect(within(repositories).getByText("org/service")).toBeInTheDocument();
|
||||
expect(within(repositories).getByText("org/docs")).toBeInTheDocument();
|
||||
await user.keyboard("{Escape}");
|
||||
await waitFor(() => expect(screen.queryByRole("dialog", { name: "Repositories" })).not.toBeInTheDocument());
|
||||
await user.click(screen.getByRole("button", { name: "Sync now" }));
|
||||
expect(await screen.findByRole("button", { name: "Cancel sync" })).toBeEnabled();
|
||||
completeOnPoll = true;
|
||||
await user.click(await screen.findByRole("button", { name: "1 repository" }, { timeout: 4000 }));
|
||||
expect(
|
||||
await within(screen.getByRole("dialog", { name: "Repositories" })).findByText("org/updated"),
|
||||
).toBeInTheDocument();
|
||||
await user.keyboard("{Escape}");
|
||||
expect(screen.getByRole("tab", { name: "Quality", selected: true })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Sync now" })).toBeEnabled();
|
||||
});
|
||||
|
||||
it("syncs the selected range and labels its equal-length comparison", async () => {
|
||||
let currentReport = report;
|
||||
const requested: string[] = [];
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (input: string, init: RequestInit) => {
|
||||
const url = new URL(input, "http://localhost");
|
||||
if (url.pathname.endsWith("/settings")) return Response.json(settings);
|
||||
if (url.pathname.endsWith("/report")) return Response.json({ report: currentReport });
|
||||
if (url.pathname.endsWith("/sync")) {
|
||||
if (init.method === "POST") {
|
||||
requested.push(url.searchParams.get("days") ?? "");
|
||||
currentReport = {
|
||||
...report,
|
||||
periods: {
|
||||
...report.periods,
|
||||
current: { ...period, window: { start: "2026-09-22", end: "2026-09-28" } },
|
||||
previous: { ...period, window: { start: "2026-09-15", end: "2026-09-21" } },
|
||||
},
|
||||
};
|
||||
}
|
||||
return Response.json(idle);
|
||||
}
|
||||
throw new Error(url.pathname);
|
||||
}),
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
render(<ObservedROIView accessToken="test-only-gateway-token" />);
|
||||
await user.click(await screen.findByRole("combobox", { name: "Reporting period" }));
|
||||
await user.click(await screen.findByRole("option", { name: "Last 7 days" }));
|
||||
await waitFor(() => expect(requested).toEqual(["7"]));
|
||||
expect(await screen.findByText(/Comparing with Sep 15.*Sep 21/)).toBeInTheDocument();
|
||||
expect(screen.getByRole("combobox", { name: "Reporting period" })).toHaveTextContent("Last 7 days");
|
||||
expect(screen.getByRole("combobox", { name: "Comparison period" })).toHaveTextContent("vs. previous period");
|
||||
});
|
||||
|
||||
it("shows a successful empty repository without a setup prompt or invented durations", async () => {
|
||||
const emptyPeriod = {
|
||||
...period,
|
||||
merged_prs: 0,
|
||||
human_authored: 0,
|
||||
median_merge_hours: null,
|
||||
human_summary: { median_merge_hours: null },
|
||||
};
|
||||
const empty = { ...report, periods: { current: emptyPeriod, previous: emptyPeriod, last_year: emptyPeriod } };
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (input: string) => {
|
||||
const path = new URL(input, "http://localhost").pathname;
|
||||
if (path.endsWith("/settings")) return Response.json(settings);
|
||||
if (path.endsWith("/report")) return Response.json({ report: empty });
|
||||
if (path.endsWith("/sync")) return Response.json(idle);
|
||||
throw new Error(path);
|
||||
}),
|
||||
);
|
||||
render(<ObservedROIView accessToken="test-only-gateway-token" />);
|
||||
expect(await screen.findByRole("heading", { name: "No merged changes yet" })).toBeInTheDocument();
|
||||
expect(screen.getByText("No merges")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Sync now" })).toBeEnabled();
|
||||
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("heading", { name: "Connect your repositories" })).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("0h")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ query: "connected=gitlab", alerts: [] },
|
||||
{ query: "connection_failed=1", alerts: ["Connection failed or expired. Try again or use a token"] },
|
||||
{ query: "connection_cancelled=1", alerts: ["Connection cancelled. Choose an app or token to try again"] },
|
||||
])("resumes setup after $query and refreshes saved changes after closing", async ({ query, alerts }) => {
|
||||
window.history.replaceState(null, "", `/roi-calculator/?${query}`);
|
||||
let currentSettings = settings;
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (input: string, init: RequestInit) => {
|
||||
const path = new URL(input, "http://localhost").pathname;
|
||||
if (path.endsWith("/apps")) return Response.json({ github: app, gitlab: app });
|
||||
if (path.endsWith("/repositories")) return Response.json({ repositories: [], has_more: false });
|
||||
if (path.endsWith("/settings")) {
|
||||
if (init.method === "PUT") currentSettings = { ...settings, repos: ["org/changed"] };
|
||||
return Response.json(currentSettings);
|
||||
}
|
||||
if (path.endsWith("/report")) return Response.json({ report: { ...report, repos: currentSettings.repos } });
|
||||
if (path.endsWith("/sync"))
|
||||
return init.method === "POST"
|
||||
? Response.json({ detail: "Provider unavailable" }, { status: 502 })
|
||||
: Response.json(idle);
|
||||
throw new Error(path);
|
||||
}),
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
render(<ObservedROIView accessToken="test-only-gateway-token" />);
|
||||
const dialog = await screen.findByRole("dialog", { name: "Choose repositories" });
|
||||
expect(
|
||||
within(dialog)
|
||||
.queryAllByRole("alert")
|
||||
.map((alert) => alert.textContent),
|
||||
).toEqual(alerts);
|
||||
await waitFor(() => expect(window.location.search).toBe(""));
|
||||
fireEvent.change(within(dialog).getByLabelText("Repositories"), { target: { value: "org/changed" } });
|
||||
await user.click(within(dialog).getByRole("button", { name: "Save and sync" }));
|
||||
expect(await within(dialog).findByRole("alert")).toHaveTextContent("Provider unavailable");
|
||||
await user.click(within(dialog).getByRole("button", { name: "Close" }));
|
||||
await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument());
|
||||
await user.click(await screen.findByRole("button", { name: "1 repository" }));
|
||||
expect(
|
||||
await within(screen.getByRole("dialog", { name: "Repositories" })).findByText("org/changed"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
@ -1,252 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { parseAsString, useQueryStates } from "nuqs";
|
||||
import { Link2, RefreshCw } from "lucide-react";
|
||||
import { apiClient } from "@/components/networking";
|
||||
import { extractProxyErrorMessage } from "@/lib/http/client";
|
||||
import { Page } from "@/components/shared/Page";
|
||||
import { PageHeader, PageHeaderDescription, PageHeaderTitle } from "@/components/shared/PageHeader";
|
||||
import { DemoNotice } from "@/components/shared/DemoNotice";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import ObservedConnections from "./ObservedConnections";
|
||||
import ObservedReport from "./ObservedReport";
|
||||
import { useObservedReport, type ObservedViewData } from "./useObservedReport";
|
||||
import { syncMessage, type ObservedSnapshot } from "./observedData";
|
||||
import { createObservedDemo } from "./observedDemo";
|
||||
import { parseAsDemoFlag } from "./demoUrlState";
|
||||
|
||||
const OBSERVED_ROI_QUERY_PARSERS = {
|
||||
demo: parseAsDemoFlag,
|
||||
connected: parseAsString,
|
||||
connection_cancelled: parseAsString,
|
||||
connection_failed: parseAsString,
|
||||
};
|
||||
|
||||
function SyncActions({
|
||||
data,
|
||||
error,
|
||||
busy,
|
||||
readOnly,
|
||||
compact = false,
|
||||
onSync,
|
||||
onRetry,
|
||||
}: {
|
||||
data: ObservedViewData | null;
|
||||
error: string;
|
||||
busy: boolean;
|
||||
readOnly: boolean;
|
||||
compact?: boolean;
|
||||
onSync: (cancel: boolean) => void;
|
||||
onRetry: () => void;
|
||||
}) {
|
||||
const message = error || data?.status.error;
|
||||
const statusMessage = data ? syncMessage(data.status, data.report) : "";
|
||||
const canSync = data?.settings.ready && !readOnly;
|
||||
if (!message && !statusMessage && !canSync) return null;
|
||||
return (
|
||||
<div className={compact ? "contents" : "space-y-2"}>
|
||||
{message && (
|
||||
<div
|
||||
role="alert"
|
||||
className="flex basis-full items-center justify-between gap-3 rounded-lg border border-destructive/30 p-3 text-sm text-destructive"
|
||||
>
|
||||
<span>{message}</span>
|
||||
<Button size="sm" variant="outline" onClick={onRetry}>
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{data && (
|
||||
<div className={compact ? "contents" : "flex flex-wrap items-center justify-between gap-2"}>
|
||||
{statusMessage && (
|
||||
<span role="status" className="text-xs text-muted-foreground">
|
||||
{statusMessage}
|
||||
</span>
|
||||
)}
|
||||
{!readOnly && data.settings.ready && (
|
||||
<Button size="sm" variant="outline" disabled={busy} onClick={() => onSync(data.status.running)}>
|
||||
<RefreshCw className={data.status.running ? "animate-spin" : ""} />
|
||||
{data.status.running ? "Cancel sync" : "Sync now"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyReport({
|
||||
data,
|
||||
readOnly,
|
||||
onConnect,
|
||||
}: {
|
||||
data: ObservedViewData;
|
||||
readOnly: boolean;
|
||||
onConnect: () => void;
|
||||
}) {
|
||||
function title() {
|
||||
if (data.status.running) return "Reading repository activity";
|
||||
return data.settings.ready ? "Ready for your first report" : "Connect your repositories";
|
||||
}
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-4 px-6 py-12 text-center">
|
||||
<h2 className="text-lg font-medium">{title()}</h2>
|
||||
<p className="max-w-md text-sm text-muted-foreground">
|
||||
{data.status.running
|
||||
? "Your report will appear here when the first sync finishes"
|
||||
: "Compare merged changes, issue trends, and recorded AI spend across your team"}
|
||||
</p>
|
||||
{!readOnly && !data.status.running && (
|
||||
<Button onClick={onConnect}>
|
||||
<Link2 />
|
||||
{data.settings.ready ? "Connections" : "Connect GitHub or GitLab"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ObservedROIView({
|
||||
accessToken,
|
||||
isViewOnly = false,
|
||||
}: {
|
||||
accessToken: string;
|
||||
isViewOnly?: boolean;
|
||||
}) {
|
||||
const { data, error, refresh } = useObservedReport(accessToken);
|
||||
const [{ demo, connected, connection_cancelled, connection_failed }, setQueryParams] =
|
||||
useQueryStates(OBSERVED_ROI_QUERY_PARSERS);
|
||||
const [sample, setSample] = useState<ObservedSnapshot | null>(() => (demo === true ? createObservedDemo(28) : null));
|
||||
const [connections, setConnections] = useState(
|
||||
["github", "gitlab"].includes(connected ?? "") || connection_cancelled !== null || connection_failed !== null,
|
||||
);
|
||||
const [connectionError, setConnectionError] = useState(() => {
|
||||
if (connection_failed !== null) return "Connection failed or expired. Try again or use a token";
|
||||
if (connection_cancelled !== null) return "Connection cancelled. Choose an app or token to try again";
|
||||
return "";
|
||||
});
|
||||
const [afterAuthorization, setAfterAuthorization] = useState(Boolean(connected));
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [actionError, setActionError] = useState("");
|
||||
useEffect(() => {
|
||||
setQueryParams({ connected: null, connection_cancelled: null, connection_failed: null });
|
||||
}, [setQueryParams]);
|
||||
function previewSample(enabled: boolean) {
|
||||
setQueryParams({ demo: enabled ? true : null });
|
||||
setSample(enabled ? createObservedDemo(28) : null);
|
||||
}
|
||||
function closeConnections() {
|
||||
setConnections(false);
|
||||
setConnectionError("");
|
||||
setAfterAuthorization(false);
|
||||
refresh();
|
||||
}
|
||||
async function sync(cancel: boolean, days?: number) {
|
||||
setBusy(true);
|
||||
setActionError("");
|
||||
try {
|
||||
if (cancel) await apiClient.delete("/roi-calculator/observed/sync", { accessToken });
|
||||
else await apiClient.post("/roi-calculator/observed/sync", { accessToken, query: { days } });
|
||||
refresh();
|
||||
} catch (reason) {
|
||||
setActionError(extractProxyErrorMessage(reason));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
function retry() {
|
||||
if (error || isViewOnly || !data?.settings.ready) refresh();
|
||||
else void sync(data.status.running);
|
||||
}
|
||||
if (sample) {
|
||||
return (
|
||||
<ObservedReport
|
||||
key="demo"
|
||||
snapshot={sample}
|
||||
accessToken={accessToken}
|
||||
readOnly
|
||||
onRefresh={refresh}
|
||||
onConnect={() => setConnections(true)}
|
||||
actions={null}
|
||||
notice={<DemoNotice onExit={() => previewSample(false)} />}
|
||||
syncing={false}
|
||||
onPeriod={(days) => setSample(createObservedDemo(days))}
|
||||
/>
|
||||
);
|
||||
}
|
||||
const previewButton = (
|
||||
<Button size="sm" variant="ghost" onClick={() => previewSample(true)}>
|
||||
Preview sample report
|
||||
</Button>
|
||||
);
|
||||
const actions = (
|
||||
<>
|
||||
{previewButton}
|
||||
<SyncActions
|
||||
data={data}
|
||||
error={error || actionError}
|
||||
busy={busy}
|
||||
readOnly={isViewOnly}
|
||||
compact={Boolean(data?.report)}
|
||||
onSync={sync}
|
||||
onRetry={retry}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
const content = data?.report ? (
|
||||
<ObservedReport
|
||||
key="live"
|
||||
snapshot={data.report}
|
||||
accessToken={accessToken}
|
||||
readOnly={isViewOnly}
|
||||
onRefresh={refresh}
|
||||
onConnect={() => setConnections(true)}
|
||||
actions={actions}
|
||||
syncing={busy || data.status.running}
|
||||
onPeriod={isViewOnly ? undefined : (days) => void sync(false, days)}
|
||||
/>
|
||||
) : (
|
||||
<Page>
|
||||
<PageHeader>
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<PageHeaderTitle>ROI Calculator</PageHeaderTitle>
|
||||
{previewButton}
|
||||
</div>
|
||||
<PageHeaderDescription>Are we shipping more, with fewer bugs, at a better cost?</PageHeaderDescription>
|
||||
</PageHeader>
|
||||
<SyncActions
|
||||
data={data}
|
||||
error={error || actionError}
|
||||
busy={busy}
|
||||
readOnly={isViewOnly}
|
||||
onSync={sync}
|
||||
onRetry={retry}
|
||||
/>
|
||||
{!data && !error && (
|
||||
<>
|
||||
<Skeleton className="h-32 w-full" />
|
||||
<Skeleton className="h-96 w-full" />
|
||||
</>
|
||||
)}
|
||||
{data && <EmptyReport data={data} readOnly={isViewOnly} onConnect={() => setConnections(true)} />}
|
||||
</Page>
|
||||
);
|
||||
const showConnections = connections && data && !isViewOnly;
|
||||
return (
|
||||
<>
|
||||
{content}
|
||||
{showConnections && (
|
||||
<ObservedConnections
|
||||
accessToken={accessToken}
|
||||
settings={data.settings}
|
||||
initialError={connectionError}
|
||||
afterAuthorization={afterAuthorization}
|
||||
onClose={closeConnections}
|
||||
onSaved={refresh}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,631 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { ArrowDown, ArrowUp, CalendarDays, ChevronDown, ChevronRight, Link2, Search, Users } from "lucide-react";
|
||||
import { Page, PageTabsList, PageTabsTrigger } from "@/components/shared/Page";
|
||||
import { PageHeader, PageHeaderTitle } from "@/components/shared/PageHeader";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Popover, PopoverContent, PopoverTitle, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Tabs, TabsContent } from "@/components/ui/tabs";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import ObservedAccounts from "./ObservedAccounts";
|
||||
import { MatchedPeopleToggle } from "./MatchedPeopleToggle";
|
||||
import { BranchSpend, PersonDetails, PullList } from "./ObservedDetails";
|
||||
import {
|
||||
change,
|
||||
dateRange,
|
||||
changeTerms,
|
||||
duration,
|
||||
money,
|
||||
number,
|
||||
visiblePeople,
|
||||
reportPeople,
|
||||
filterObservedPulls,
|
||||
weeklyMerges,
|
||||
type Comparison,
|
||||
type ReportPerson,
|
||||
type ObservedSnapshot,
|
||||
type PeopleSort,
|
||||
} from "./observedData";
|
||||
|
||||
function Delta({
|
||||
current,
|
||||
baseline,
|
||||
neutral = false,
|
||||
}: {
|
||||
current: number | null;
|
||||
baseline: number | null;
|
||||
neutral?: boolean;
|
||||
}) {
|
||||
const delta = current === null || baseline === null ? null : change(current, baseline);
|
||||
if (delta === null) return <span className="text-xs text-muted-foreground">No baseline</span>;
|
||||
const Icon = delta >= 0 ? ArrowUp : ArrowDown;
|
||||
return (
|
||||
<span
|
||||
aria-label={`${number(Math.abs(delta))}% ${delta >= 0 ? "increase" : "decrease"}`}
|
||||
className={`inline-flex items-center gap-1 text-xs tabular-nums ${neutral ? "text-muted-foreground" : "text-foreground"}`}
|
||||
>
|
||||
<Icon className="size-3" />
|
||||
{number(Math.abs(delta))}%
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function Metric({
|
||||
label,
|
||||
value,
|
||||
detail,
|
||||
current,
|
||||
baseline,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
detail: string;
|
||||
current?: number | null;
|
||||
baseline?: number | null;
|
||||
}) {
|
||||
return (
|
||||
<div className="min-w-0 bg-background px-4 py-3">
|
||||
<div className="flex items-center gap-1.5 text-xs font-medium text-muted-foreground">{label}</div>
|
||||
<div className="mt-1 flex flex-wrap items-baseline gap-2">
|
||||
<span className="text-2xl font-semibold tracking-tight tabular-nums">{value}</span>
|
||||
{current !== undefined && baseline !== undefined && <Delta current={current} baseline={baseline} />}
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-muted-foreground">{detail}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ShippingTrend({ snapshot, comparison }: { snapshot: ObservedSnapshot; comparison: Comparison }) {
|
||||
const terms = changeTerms(snapshot.source_provider);
|
||||
const current = weeklyMerges(snapshot, "current");
|
||||
const baseline = weeklyMerges(snapshot, comparison);
|
||||
const max = Math.max(1, ...current, ...baseline);
|
||||
return (
|
||||
<div className="rounded-xl border p-5">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<h2 className="text-sm font-medium">Repository shipping activity</h2>
|
||||
<div className="flex gap-4 text-xs text-muted-foreground">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className="size-2 rounded-sm bg-blue-500" />
|
||||
Current period
|
||||
</span>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className="size-2 rounded-sm bg-slate-300 dark:bg-slate-600" />
|
||||
{comparison === "previous" ? "Previous period" : "Last year"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="mt-5 grid gap-2"
|
||||
style={{ gridTemplateColumns: `repeat(${current.length}, minmax(0, 1fr))` }}
|
||||
role="img"
|
||||
aria-label={`Merged ${terms.plural} by week. Current: ${current.join(", ")}. Comparison: ${baseline.join(", ")}`}
|
||||
>
|
||||
{current.map((value, week) => (
|
||||
<div key={week} className="min-w-0">
|
||||
<div className="flex h-28 items-end justify-center gap-2 border-b border-border/60">
|
||||
<div
|
||||
className="group relative w-10 rounded-t bg-slate-200 dark:bg-slate-700"
|
||||
style={{ height: `${Math.max(2, (baseline[week] / max) * 85)}%` }}
|
||||
>
|
||||
<span className="absolute -top-5 left-1/2 -translate-x-1/2 text-[10px] text-muted-foreground">
|
||||
{baseline[week]}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
className="relative w-10 rounded-t bg-blue-500/90"
|
||||
style={{ height: `${Math.max(2, (value / max) * 85)}%` }}
|
||||
>
|
||||
<span className="absolute -top-5 left-1/2 -translate-x-1/2 text-[10px] font-medium">{value}</span>
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-2 text-center text-[11px] text-muted-foreground">W{week + 1}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PeopleTable({
|
||||
rows,
|
||||
provider,
|
||||
matchedOnly,
|
||||
comparison,
|
||||
onSelect,
|
||||
}: {
|
||||
rows: ReportPerson[];
|
||||
provider: ObservedSnapshot["source_provider"];
|
||||
matchedOnly: boolean;
|
||||
comparison: Comparison;
|
||||
onSelect: (person: ReportPerson) => void;
|
||||
}) {
|
||||
const terms = changeTerms(provider);
|
||||
const [query, setQuery] = useState("");
|
||||
const [sort, setSort] = useState<PeopleSort>("merged");
|
||||
const people = visiblePeople(rows, query, sort);
|
||||
const emptyMessage = matchedOnly
|
||||
? "No matched people. Link accounts or turn off the filter to see all contributors"
|
||||
: "No contributors in these periods";
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="relative w-72">
|
||||
<Search className="absolute top-2.5 left-3 size-4 text-muted-foreground" />
|
||||
<Input
|
||||
aria-label="Search engineers"
|
||||
placeholder="Search engineers…"
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{people.length} {people.length === 1 ? "engineer" : "engineers"}
|
||||
</span>
|
||||
<Select
|
||||
value={sort}
|
||||
onValueChange={(value) => {
|
||||
if (value) setSort(value);
|
||||
}}
|
||||
items={[
|
||||
{ value: "merged", label: `Most merged ${terms.plural}` },
|
||||
{ value: "spend", label: "Highest spend" },
|
||||
{ value: "cost", label: `Highest cost / ${terms.singular}` },
|
||||
{ value: "name", label: "Name" },
|
||||
]}
|
||||
>
|
||||
<SelectTrigger aria-label="Sort engineers">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="merged">Most merged {terms.plural}</SelectItem>
|
||||
<SelectItem value="spend">Highest spend</SelectItem>
|
||||
<SelectItem value="cost">Highest cost / {terms.singular}</SelectItem>
|
||||
<SelectItem value="name">Name</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="overflow-hidden rounded-xl border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="bg-muted/30">
|
||||
<TableHead className="pl-5">Engineer</TableHead>
|
||||
<TableHead className="text-right">Merged {terms.plural}</TableHead>
|
||||
<TableHead>Authored / agent</TableHead>
|
||||
<TableHead className="text-right">
|
||||
{comparison === "previous" ? "vs. previous" : "vs. last year"}
|
||||
</TableHead>
|
||||
<TableHead className="text-right">Median merge</TableHead>
|
||||
<TableHead className="text-right">Recorded spend</TableHead>
|
||||
<TableHead className="text-right">Spend / {terms.singular}</TableHead>
|
||||
<TableHead>
|
||||
<span className="sr-only">Details</span>
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{people.map((person) => {
|
||||
const current = person.periods.current;
|
||||
const baseline = person.periods[comparison];
|
||||
return (
|
||||
<TableRow key={person.id}>
|
||||
<TableCell className="py-3 pl-5">
|
||||
<button
|
||||
className="flex items-center gap-3 rounded text-left hover:underline"
|
||||
onClick={() => onSelect(person)}
|
||||
>
|
||||
<span className="flex size-8 shrink-0 items-center justify-center rounded-full bg-muted text-xs font-medium text-muted-foreground">
|
||||
{person.name.slice(0, 2).toUpperCase()}
|
||||
</span>
|
||||
<span>
|
||||
<span className="block font-medium">{person.name}</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{person.email || `Not linked · ${person.host}`}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
</TableCell>
|
||||
<TableCell className="text-right font-medium tabular-nums">{number(current.merged_prs)}</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex w-28 items-center gap-2">
|
||||
<div className="flex h-1.5 w-16 overflow-hidden rounded-full bg-muted">
|
||||
<span
|
||||
className="bg-blue-500"
|
||||
style={{
|
||||
width: `${current.merged_prs ? (current.direct_authored / current.merged_prs) * 100 : 0}%`,
|
||||
}}
|
||||
/>
|
||||
<span
|
||||
className="bg-violet-400"
|
||||
style={{
|
||||
width: `${current.merged_prs ? (current.declared_agent_owned / current.merged_prs) * 100 : 0}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-[11px] text-muted-foreground">
|
||||
{current.direct_authored}/{current.declared_agent_owned}
|
||||
</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Delta current={current.merged_prs} baseline={baseline.merged_prs} neutral />
|
||||
<span className="ml-2 text-xs text-muted-foreground">({baseline.merged_prs})</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{duration(current.median_merge_hours)}</TableCell>
|
||||
<TableCell className="text-right tabular-nums">
|
||||
{money(current.spend_observation === "no_records" ? null : current.gateway_recorded_spend)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right font-medium tabular-nums">
|
||||
{money(current.recorded_spend_per_attributed_pr)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Button
|
||||
size="icon-xs"
|
||||
variant="ghost"
|
||||
aria-label={`View ${person.name}'s ${terms.lower}`}
|
||||
onClick={() => onSelect(person)}
|
||||
>
|
||||
<ChevronRight />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
{people.length === 0 && (
|
||||
<div className="p-10 text-center text-sm text-muted-foreground">
|
||||
{query ? `No engineers match “${query}”` : emptyMessage}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<span className="mr-3 inline-flex items-center gap-1.5">
|
||||
<span className="size-2 rounded-sm bg-blue-500" />
|
||||
Authored
|
||||
</span>
|
||||
<span className="mr-3 inline-flex items-center gap-1.5">
|
||||
<span className="size-2 rounded-sm bg-violet-400" />
|
||||
Agent, explicit requester
|
||||
</span>
|
||||
Spend / {terms.singular} is recorded period spend divided by matched {terms.plural}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Quality({ snapshot, comparison }: { snapshot: ObservedSnapshot; comparison: Comparison }) {
|
||||
const terms = changeTerms(snapshot.source_provider);
|
||||
const current = snapshot.periods.current;
|
||||
const baseline = snapshot.periods[comparison];
|
||||
const rows = [
|
||||
{
|
||||
label: "New bug-labeled issues",
|
||||
current: current.new_bug_labeled_issues,
|
||||
baseline: baseline.new_bug_labeled_issues,
|
||||
detail: "Opened during the period, with bug or kind:bug labels at collection",
|
||||
},
|
||||
{
|
||||
label: "New regression-labeled issues",
|
||||
current: current.new_regression_labeled_issues,
|
||||
baseline: baseline.new_regression_labeled_issues,
|
||||
detail: "Opened during the period and labeled as regressions",
|
||||
},
|
||||
{
|
||||
label: `Revert-titled ${terms.plural}`,
|
||||
current: current.explicitly_titled_revert_prs,
|
||||
baseline: baseline.explicitly_titled_revert_prs,
|
||||
detail: `Merged ${terms.plural} whose titles explicitly indicate a revert`,
|
||||
},
|
||||
];
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-xl border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="pl-5">Repository signal</TableHead>
|
||||
<TableHead className="text-right">Current</TableHead>
|
||||
<TableHead className="text-right">Comparison</TableHead>
|
||||
<TableHead className="pr-5 text-right">Change</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{rows.map((row) => (
|
||||
<TableRow key={row.label}>
|
||||
<TableCell className="py-5 pl-5">
|
||||
<p className="font-medium">{row.label}</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">{row.detail}</p>
|
||||
</TableCell>
|
||||
<TableCell className="text-right font-medium">{number(row.current)}</TableCell>
|
||||
<TableCell className="text-right text-muted-foreground">{number(row.baseline)}</TableCell>
|
||||
<TableCell className="pr-5 text-right">
|
||||
<Delta current={row.current} baseline={row.baseline} neutral />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
<p className="max-w-3xl text-xs leading-relaxed text-muted-foreground">
|
||||
These signals help check whether more shipping comes with more bugs. Labels and revert titles are incomplete
|
||||
proxies; they do not establish a change-failure rate or attribute bugs to an engineer.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function costPerChange(period: ObservedSnapshot["periods"]["current"]) {
|
||||
if (period.spend_observation !== "records_present" || period.matched_internal_prs === 0) return null;
|
||||
return period.matched_users_recorded_spend / period.matched_internal_prs;
|
||||
}
|
||||
|
||||
export default function ObservedReport({
|
||||
snapshot,
|
||||
accessToken,
|
||||
readOnly,
|
||||
onRefresh,
|
||||
onConnect,
|
||||
actions,
|
||||
notice,
|
||||
syncing,
|
||||
onPeriod,
|
||||
}: {
|
||||
snapshot: ObservedSnapshot;
|
||||
accessToken: string;
|
||||
readOnly: boolean;
|
||||
onRefresh: () => void;
|
||||
onConnect: () => void;
|
||||
actions: React.ReactNode;
|
||||
notice?: React.ReactNode;
|
||||
syncing: boolean;
|
||||
onPeriod?: (days: number) => void;
|
||||
}) {
|
||||
const [comparison, setComparison] = useState<Comparison>("previous");
|
||||
const [activeTab, setActiveTab] = useState(
|
||||
snapshot.people.length && snapshot.periods.current.merged_prs > 0 ? "people" : "pulls",
|
||||
);
|
||||
const [accountEmail, setAccountEmail] = useState<string | null>(null);
|
||||
const [matchedOnly, setMatchedOnly] = useState(true);
|
||||
const people = useMemo(() => reportPeople(snapshot, matchedOnly), [snapshot, matchedOnly]);
|
||||
const pulls = useMemo(() => filterObservedPulls(snapshot, "current", matchedOnly), [snapshot, matchedOnly]);
|
||||
const [personId, setPersonId] = useState<string | null>(null);
|
||||
const person = people.find((entry) => entry.id === personId) ?? null;
|
||||
const terms = changeTerms(snapshot.source_provider);
|
||||
const current = snapshot.periods.current;
|
||||
const baseline = snapshot.periods[comparison];
|
||||
const days = Math.round((Date.parse(current.window.end) - Date.parse(current.window.start)) / 86400000) + 1;
|
||||
const rangeOptions = [...new Set([7, 28, 90, days])].sort((a, b) => a - b);
|
||||
const cost = costPerChange(current);
|
||||
const baselineCost = costPerChange(baseline);
|
||||
return (
|
||||
<Page className="mx-auto max-w-[1500px] gap-3 pb-10 sm:pt-4">
|
||||
<PageHeader className="flex flex-wrap items-center justify-between gap-3">
|
||||
<PageHeaderTitle className="text-xl">ROI Calculator</PageHeaderTitle>
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-2">
|
||||
{actions}
|
||||
{!readOnly && (
|
||||
<Button size="sm" variant="outline" onClick={onConnect}>
|
||||
<Link2 />
|
||||
Connections
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</PageHeader>
|
||||
{notice}
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<Popover>
|
||||
<PopoverTrigger render={<Button size="sm" variant="outline" className="self-start" />}>
|
||||
{number(snapshot.repos.length)} {snapshot.repos.length === 1 ? "repository" : "repositories"}
|
||||
<ChevronDown className="size-3.5" />
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="start" className="w-80 max-w-[calc(100vw-2rem)] gap-2 p-3">
|
||||
<PopoverTitle>Repositories</PopoverTitle>
|
||||
<ul className="max-h-64 space-y-2 overflow-y-auto text-xs break-words">
|
||||
{snapshot.repos.map((repo) => (
|
||||
<li key={repo}>{repo}</li>
|
||||
))}
|
||||
</ul>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Select
|
||||
value={String(days)}
|
||||
disabled={!onPeriod || syncing}
|
||||
onValueChange={(value) => {
|
||||
if (value && Number(value) !== days) onPeriod?.(Number(value));
|
||||
}}
|
||||
items={rangeOptions.map((value) => ({ value: String(value), label: `Last ${value} days` }))}
|
||||
>
|
||||
<SelectTrigger size="sm" aria-label="Reporting period">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{rangeOptions.map((value) => (
|
||||
<SelectItem key={value} value={String(value)}>
|
||||
Last {value} days
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<span className="flex h-8 items-center gap-2 text-xs text-muted-foreground">
|
||||
<CalendarDays className="size-3.5 text-muted-foreground" />
|
||||
{dateRange(current.window)}
|
||||
</span>
|
||||
<Select
|
||||
value={comparison}
|
||||
onValueChange={(value) => {
|
||||
if (value) setComparison(value);
|
||||
}}
|
||||
items={[
|
||||
{ value: "previous", label: "vs. previous period" },
|
||||
{ value: "last_year", label: "vs. same period last year" },
|
||||
]}
|
||||
>
|
||||
<SelectTrigger size="sm" aria-label="Comparison period">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="previous">vs. previous period</SelectItem>
|
||||
<SelectItem value="last_year">vs. same period last year</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-px overflow-hidden rounded-xl border bg-border lg:grid-cols-4">
|
||||
<Metric
|
||||
label={`Merged ${terms.plural}`}
|
||||
value={number(current.merged_prs)}
|
||||
current={current.merged_prs}
|
||||
baseline={baseline.merged_prs}
|
||||
detail={`${number(baseline.merged_prs)} in comparison`}
|
||||
/>
|
||||
<Metric
|
||||
label="Median time to merge"
|
||||
value={current.merged_prs === 0 ? "No merges" : duration(current.median_merge_hours)}
|
||||
current={current.median_merge_hours ?? undefined}
|
||||
baseline={baseline.median_merge_hours ?? undefined}
|
||||
detail={`${duration(baseline.median_merge_hours)} in comparison`}
|
||||
/>
|
||||
<Metric
|
||||
label="New bugs"
|
||||
value={number(current.new_bug_labeled_issues)}
|
||||
current={current.new_bug_labeled_issues}
|
||||
baseline={baseline.new_bug_labeled_issues}
|
||||
detail={`${number(baseline.new_bug_labeled_issues)} in comparison · bug-labeled issues`}
|
||||
/>
|
||||
<Metric
|
||||
label={`Recorded spend / matched ${terms.singular}`}
|
||||
value={money(cost)}
|
||||
detail={
|
||||
baselineCost === null
|
||||
? "No gateway records for comparison"
|
||||
: `${money(baselineCost)} in comparison · gateway only`
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="gap-3">
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-x-4 gap-y-2 border-b">
|
||||
<PageTabsList className="min-w-0 flex-1 basis-full gap-4 border-0 xl:basis-auto">
|
||||
<PageTabsTrigger value="people">
|
||||
Engineers <span className="ml-1.5 text-muted-foreground">{people.length}</span>
|
||||
</PageTabsTrigger>
|
||||
<PageTabsTrigger value="pulls">{terms.requests}</PageTabsTrigger>
|
||||
<PageTabsTrigger value="quality">Quality</PageTabsTrigger>
|
||||
<PageTabsTrigger value="branches">Branch spend</PageTabsTrigger>
|
||||
</PageTabsList>
|
||||
{activeTab !== "quality" && <MatchedPeopleToggle checked={matchedOnly} onChange={setMatchedOnly} />}
|
||||
{!readOnly && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
aria-label="Link accounts"
|
||||
title="Link accounts"
|
||||
onClick={() => setAccountEmail("")}
|
||||
>
|
||||
<Users />
|
||||
<span className="hidden sm:inline">Link accounts</span>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<TabsContent value="people">
|
||||
<PeopleTable
|
||||
rows={people}
|
||||
provider={snapshot.source_provider}
|
||||
matchedOnly={matchedOnly}
|
||||
comparison={comparison}
|
||||
onSelect={(selected) => setPersonId(selected.id)}
|
||||
/>
|
||||
</TabsContent>
|
||||
<TabsContent value="pulls" className="space-y-5">
|
||||
{current.merged_prs > 0 || baseline.merged_prs > 0 ? (
|
||||
<div className="grid gap-4 lg:grid-cols-[1.6fr_1fr]">
|
||||
<ShippingTrend snapshot={snapshot} comparison={comparison} />
|
||||
<div className="flex flex-col justify-between rounded-xl border p-5">
|
||||
<div>
|
||||
<h2 className="text-sm font-medium">Behind the numbers</h2>
|
||||
<p className="mt-3 text-sm leading-relaxed text-muted-foreground">
|
||||
{number(current.agent_authored)} of {number(current.merged_prs)} {terms.plural} were authored by
|
||||
agents or bots.
|
||||
</p>
|
||||
<p className="mt-3 text-xs leading-relaxed text-muted-foreground">
|
||||
Human-authored median merge time:{" "}
|
||||
<span className="font-medium text-foreground">
|
||||
{duration(current.human_summary.median_merge_hours)}
|
||||
</span>
|
||||
, compared with {duration(baseline.human_summary.median_merge_hours)}.
|
||||
</p>
|
||||
</div>
|
||||
<div className="mt-5 flex flex-wrap items-center justify-between gap-2 border-t pt-4">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{number(current.agents_without_requester)} agent {terms.plural} have no requester
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-xl border p-8 text-center">
|
||||
<h2 className="text-base font-medium">No merged changes yet</h2>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
Your repositories are connected. New activity will appear after the next sync
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="mb-4 text-xs text-muted-foreground">
|
||||
{matchedOnly
|
||||
? "Changes from people linked to internal accounts"
|
||||
: `All repository ${terms.plural}, including agent work without a known requester`}
|
||||
</p>
|
||||
<PullList pulls={pulls} provider={snapshot.source_provider} matchedOnly={matchedOnly} />
|
||||
</TabsContent>
|
||||
<TabsContent value="quality">
|
||||
<Quality snapshot={snapshot} comparison={comparison} />
|
||||
</TabsContent>
|
||||
<TabsContent value="branches">
|
||||
<BranchSpend snapshot={snapshot} matchedOnly={matchedOnly} />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 border-t pt-3 text-xs text-muted-foreground">
|
||||
<span className="w-full">
|
||||
{number(current.matched_internal_prs)} {terms.plural} matched to {snapshot.people.length} engineers ·{" "}
|
||||
{number(current.agents_without_requester)} agent {terms.plural} without a requester
|
||||
</span>
|
||||
<span>Comparing with {dateRange(baseline.window)} · All dates UTC</span>
|
||||
<span>Spend recorded by this gateway · Merge time is elapsed time, not effort</span>
|
||||
</div>
|
||||
{accountEmail !== null && (
|
||||
<ObservedAccounts
|
||||
accessToken={accessToken}
|
||||
people={snapshot.people}
|
||||
initialEmail={accountEmail}
|
||||
onClose={() => setAccountEmail(null)}
|
||||
onSaved={onRefresh}
|
||||
/>
|
||||
)}
|
||||
{person && (
|
||||
<PersonDetails
|
||||
key={person.id}
|
||||
person={person}
|
||||
snapshot={snapshot}
|
||||
comparison={comparison}
|
||||
onClose={() => setPersonId(null)}
|
||||
onEdit={
|
||||
readOnly || !person.matched
|
||||
? undefined
|
||||
: () => {
|
||||
setAccountEmail(person.email);
|
||||
setPersonId(null);
|
||||
}
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,217 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import React from "react";
|
||||
|
||||
import { extractErrorMessage } from "@/utils/errorUtils";
|
||||
import { Button, buttonVariants } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { effortNote, estimateLabel, branchCostLabel } from "./roiCalculatorData";
|
||||
import type { ROIIdentityMapUpdate, ROIPull, ROISummary } from "./roiCalculatorData";
|
||||
import type { ROIPerson } from "./roiCalculatorData";
|
||||
|
||||
export type PersonMatchSelection = { person: ROIPerson; login: string };
|
||||
|
||||
export function PullReasoningDialog({
|
||||
pull,
|
||||
summary,
|
||||
onClose,
|
||||
}: {
|
||||
pull: ROIPull | null;
|
||||
summary: ROISummary | null;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const titleRef = React.useRef<HTMLHeadingElement>(null);
|
||||
return (
|
||||
<Dialog open={Boolean(pull)} onOpenChange={(open) => !open && onClose()}>
|
||||
<DialogContent className="max-h-[85dvh] overflow-y-auto sm:max-w-2xl" initialFocus={titleRef}>
|
||||
{pull && (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle ref={titleRef} tabIndex={-1} className="pr-8 text-lg leading-6">
|
||||
{pull.title}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{pull.repo} #{pull.number} · {pull.login}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3">
|
||||
<dl className="grid grid-cols-2 gap-4 rounded-lg border bg-muted/20 p-4">
|
||||
<div className="space-y-2">
|
||||
<dt className="text-xs text-muted-foreground">Estimated effort</dt>
|
||||
<dd className="text-2xl font-semibold tabular-nums">{estimateLabel(pull.estimate)}</dd>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<dt className="text-xs text-muted-foreground">Recorded AI cost</dt>
|
||||
<dd className="text-xl font-semibold tabular-nums">{branchCostLabel(pull)}</dd>
|
||||
{pull.branch_cost?.status === "matched" && (
|
||||
<dd className="text-xs text-muted-foreground">{pull.branch_cost.requests} requests</dd>
|
||||
)}
|
||||
</div>
|
||||
</dl>
|
||||
<p className="text-xs leading-5 text-muted-foreground">
|
||||
{effortNote(pull.estimate.effort_basis ?? summary?.effort_basis)}
|
||||
</p>
|
||||
</div>
|
||||
<section>
|
||||
<h3 className="mb-2 font-medium">Reasoning</h3>
|
||||
<p className="whitespace-pre-wrap leading-relaxed">
|
||||
{pull.estimate.reasoning || "No estimate available."}
|
||||
</p>
|
||||
</section>
|
||||
<dl className="grid grid-cols-[auto_1fr] gap-x-5 gap-y-2 text-xs">
|
||||
<dt className="text-muted-foreground">Model</dt>
|
||||
<dd className="break-all">{pull.estimate.model || summary?.estimator_model}</dd>
|
||||
<dt className="text-muted-foreground">Merged</dt>
|
||||
<dd>{new Date(pull.merged_at).toLocaleDateString(undefined, { timeZone: "UTC" })}</dd>
|
||||
<dt className="text-muted-foreground">Email match</dt>
|
||||
<dd>{pull.email || "Not matched"}</dd>
|
||||
</dl>
|
||||
<section className="space-y-2 border-t pt-4 text-sm">
|
||||
<h3 className="font-medium">Track costs for this branch</h3>
|
||||
{pull.branch_cost?.status === "matched" && (
|
||||
<p className="text-muted-foreground">
|
||||
{pull.branch_cost.spend?.toFixed(8)} USD across {pull.branch_cost.requests} requests
|
||||
</p>
|
||||
)}
|
||||
{pull.source_repo && pull.source_branch ? (
|
||||
<>
|
||||
<p className="text-muted-foreground">Send both tags with each gateway request from this branch:</p>
|
||||
<pre className="overflow-x-auto rounded-md bg-muted p-3 text-xs">
|
||||
{JSON.stringify(
|
||||
{ metadata: { tags: [`repo:${pull.source_repo}`, `branch:${pull.source_branch}`] } },
|
||||
null,
|
||||
2,
|
||||
)}
|
||||
</pre>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Retained requests in the report’s UTC period. Branch names are case-sensitive. Reused branches
|
||||
cannot be split between changes.
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<p className="text-muted-foreground">
|
||||
The source repository or branch is unavailable. Sync again to refresh its metadata.
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
{summary?.estimator_prompt && (
|
||||
<details className="rounded-md border p-3">
|
||||
<summary className="cursor-pointer font-medium">Estimator prompt</summary>
|
||||
<p className="mt-2 whitespace-pre-wrap text-sm">{summary.estimator_prompt}</p>
|
||||
</details>
|
||||
)}
|
||||
<DialogFooter>
|
||||
{pull.url && (
|
||||
<a
|
||||
className={buttonVariants({ variant: "outline" })}
|
||||
href={pull.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
View on {summary?.source_provider === "gitlab" ? "GitLab" : "GitHub"}
|
||||
</a>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export function IdentityMatchDialog({
|
||||
selection,
|
||||
identityMap,
|
||||
gatewayEmails,
|
||||
onClose,
|
||||
onSave,
|
||||
}: {
|
||||
selection: PersonMatchSelection | null;
|
||||
identityMap: Record<string, string>;
|
||||
gatewayEmails: string[];
|
||||
onClose: () => void;
|
||||
onSave: (payload: ROIIdentityMapUpdate) => Promise<void>;
|
||||
}) {
|
||||
const [email, setEmail] = React.useState(() =>
|
||||
selection ? identityMap[selection.login.toLowerCase()] ?? selection.person.email ?? "" : "",
|
||||
);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
const [busy, setBusy] = React.useState(false);
|
||||
const person = selection?.person ?? null;
|
||||
const login = selection?.login ?? "";
|
||||
const existingEmail = identityMap[login.toLowerCase()];
|
||||
|
||||
const save = async (value: string | null) => {
|
||||
if (!login) return;
|
||||
try {
|
||||
setBusy(true);
|
||||
await onSave({ github_login: login, email: value });
|
||||
setError(null);
|
||||
onClose();
|
||||
} catch (reason) {
|
||||
setError(extractErrorMessage(reason));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={Boolean(person)} onOpenChange={(open) => !open && onClose()}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Match email</DialogTitle>
|
||||
<DialogDescription>Link {login} to their gateway email. Manual matches take priority.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form
|
||||
className="space-y-4"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
void save(email.trim());
|
||||
}}
|
||||
>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="roi-match-email">Gateway email</Label>
|
||||
<Input
|
||||
id="roi-match-email"
|
||||
autoComplete="off"
|
||||
list="roi-gateway-emails"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(event) => setEmail(event.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<datalist id="roi-gateway-emails">
|
||||
{Array.from(new Set(gatewayEmails)).map((address) => (
|
||||
<option key={address} value={address} />
|
||||
))}
|
||||
</datalist>
|
||||
{error && (
|
||||
<p role="alert" className="text-sm text-destructive">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
<DialogFooter>
|
||||
{existingEmail && (
|
||||
<Button disabled={busy} type="button" variant="outline" onClick={() => void save(null)}>
|
||||
Use automatic match
|
||||
</Button>
|
||||
)}
|
||||
<Button disabled={busy || !email.trim()} type="submit">
|
||||
{busy ? "Saving…" : "Save match"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,814 +0,0 @@
|
|||
import userEvent from "@testing-library/user-event";
|
||||
import { act, fireEvent, render as renderWithoutNuqs, screen, waitFor } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { NuqsAdapter } from "nuqs/adapters/react";
|
||||
|
||||
import { apiClient } from "@/components/networking";
|
||||
import ROICalculatorView from "./ROICalculatorView";
|
||||
|
||||
const render = (ui: Parameters<typeof renderWithoutNuqs>[0]) => renderWithoutNuqs(ui, { wrapper: NuqsAdapter });
|
||||
|
||||
vi.mock("@/components/networking", () => ({
|
||||
apiClient: {
|
||||
delete: vi.fn(),
|
||||
get: vi.fn(),
|
||||
post: vi.fn(),
|
||||
put: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
const summary = {
|
||||
id: null,
|
||||
mode: "live",
|
||||
start: "2026-09-01",
|
||||
end: "2026-09-30",
|
||||
synced_at: "2026-09-30T12:00:00Z",
|
||||
repos: ["org/repo"],
|
||||
estimator_model: "estimator",
|
||||
estimator_prompt: "Estimate hours.",
|
||||
warnings: [],
|
||||
effort_basis: "without_ai",
|
||||
metrics: {
|
||||
matched_spend: 12,
|
||||
output_hours: 4,
|
||||
total_spend: 20,
|
||||
total_output_hours: 4,
|
||||
excluded_spend: 8,
|
||||
cost_per_hour: 3,
|
||||
hours_per_dollar: 1 / 3,
|
||||
merged_prs: 1,
|
||||
estimated_prs: 1,
|
||||
matched_prs: 1,
|
||||
cohort_people: 1,
|
||||
people_with_prs: 1,
|
||||
pending_prs: 0,
|
||||
},
|
||||
people: [
|
||||
{
|
||||
id: "alice@example.com",
|
||||
email: "alice@example.com",
|
||||
logins: ["alice", "alice-work"],
|
||||
spend: 12,
|
||||
hours: 4,
|
||||
prs: 1,
|
||||
estimated_prs: 1,
|
||||
pending_prs: 0,
|
||||
match_methods: ["profile email"],
|
||||
eligible: true,
|
||||
cost_per_hour: 3,
|
||||
},
|
||||
],
|
||||
pulls: [
|
||||
{
|
||||
repo: "org/repo",
|
||||
source_repo: "github.com/org/repo",
|
||||
source_branch: "feature/routing",
|
||||
branch_cost: {
|
||||
repo: "github.com/org/repo",
|
||||
branch: "feature/routing",
|
||||
status: "matched",
|
||||
spend: 8,
|
||||
requests: 12,
|
||||
},
|
||||
number: 42,
|
||||
title: "Improve request routing",
|
||||
url: "https://github.com/org/repo/pull/42",
|
||||
login: "alice",
|
||||
emails: ["alice@example.com"],
|
||||
profile_email: "alice@example.com",
|
||||
merged_at: "2026-09-12T00:00:00Z",
|
||||
head_sha: "abc",
|
||||
additions: 10,
|
||||
deletions: 2,
|
||||
changed_files: 1,
|
||||
commit_count: 1,
|
||||
incomplete_metadata: false,
|
||||
estimate: {
|
||||
status: "estimated",
|
||||
hours: 4,
|
||||
reasoning: "Updated routing and added a regression test.",
|
||||
model: "estimator",
|
||||
evidence_source: "pr_metadata",
|
||||
effort_basis: "without_ai",
|
||||
cached: false,
|
||||
},
|
||||
cache_key: "cache",
|
||||
email: "alice@example.com",
|
||||
match_method: "profile email",
|
||||
matched: true,
|
||||
},
|
||||
],
|
||||
trend: [{ date: "2026-09-12", spend: 12, hours: 4, prs: 1 }],
|
||||
} as const;
|
||||
|
||||
const settings = {
|
||||
github_api_url: "https://api.github.com",
|
||||
repos: ["org/repo"],
|
||||
estimator_model: "estimator",
|
||||
estimator_prompt: "Estimate hours.",
|
||||
backfill_days: 30,
|
||||
identity_map: {},
|
||||
has_github_token: true,
|
||||
default_prompt: "Estimate hours.",
|
||||
available_models: ["estimator"],
|
||||
ready: true,
|
||||
};
|
||||
|
||||
const idleStatus = {
|
||||
running: false,
|
||||
phase: "idle",
|
||||
stage: "Idle",
|
||||
done: 0,
|
||||
total: 0,
|
||||
estimated: 0,
|
||||
reused: 0,
|
||||
needs_attention: 0,
|
||||
error: null,
|
||||
};
|
||||
|
||||
describe("ROICalculatorView", () => {
|
||||
beforeEach(() => {
|
||||
window.history.replaceState(null, "", "/roi-calculator/");
|
||||
vi.mocked(apiClient.get).mockReset();
|
||||
vi.mocked(apiClient.put).mockReset();
|
||||
vi.mocked(apiClient.post).mockReset();
|
||||
vi.mocked(apiClient.get).mockImplementation((path: string) => {
|
||||
if (path === "/roi-calculator/settings") return Promise.resolve(settings);
|
||||
if (path === "/roi-calculator/report") return Promise.resolve({ report: summary });
|
||||
return Promise.resolve(idleStatus);
|
||||
});
|
||||
vi.mocked(apiClient.put).mockResolvedValue({ report: summary, identity_map: { alice: "alice@example.com" } });
|
||||
});
|
||||
|
||||
it.each(["github", "gitlab"])("filters people and branches by linked accounts for %s", async (provider) => {
|
||||
const outsidePerson = {
|
||||
...summary.people[0],
|
||||
id: "outside",
|
||||
email: "",
|
||||
logins: ["outside"],
|
||||
match_methods: ["no gateway match"],
|
||||
spend: null,
|
||||
};
|
||||
const outsidePull = {
|
||||
...summary.pulls[0],
|
||||
number: 43,
|
||||
title: "External contribution",
|
||||
login: "outside",
|
||||
email: "",
|
||||
match_method: "no gateway match",
|
||||
matched: false,
|
||||
};
|
||||
const linkedPerson = { ...summary.people[0], spend: null, match_methods: ["manual"], eligible: false };
|
||||
const spendOnlyPerson = {
|
||||
...summary.people[0],
|
||||
id: "internal@example.test",
|
||||
email: "internal@example.test",
|
||||
logins: [],
|
||||
spend: 8.5,
|
||||
prs: 0,
|
||||
match_methods: [],
|
||||
estimated_prs: 0,
|
||||
hours: 0,
|
||||
eligible: false,
|
||||
cost_per_hour: null,
|
||||
};
|
||||
const linkedPull = { ...summary.pulls[0], matched: false, match_method: "manual" };
|
||||
const report = {
|
||||
...summary,
|
||||
source_provider: provider,
|
||||
people: [linkedPerson, spendOnlyPerson, outsidePerson],
|
||||
pulls: [linkedPull, outsidePull],
|
||||
};
|
||||
vi.mocked(apiClient.get).mockImplementation((path: string) => {
|
||||
if (path.endsWith("/settings")) return Promise.resolve(settings);
|
||||
if (path.endsWith("/report")) return Promise.resolve({ report });
|
||||
return Promise.resolve(idleStatus);
|
||||
});
|
||||
const user = userEvent.setup();
|
||||
render(<ROICalculatorView accessToken="token" />);
|
||||
await user.click(await screen.findByRole("tab", { name: "People" }));
|
||||
expect(screen.getByRole("switch", { name: "Matched people only" })).toBeChecked();
|
||||
expect(screen.getByRole("button", { name: "alice" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("row", { name: /internal@example.test/ })).toHaveTextContent("$8.50");
|
||||
expect(screen.queryByRole("button", { name: "outside" })).not.toBeInTheDocument();
|
||||
await user.click(screen.getByRole("switch", { name: "Matched people only" }));
|
||||
expect(screen.getByRole("button", { name: "outside" })).toBeInTheDocument();
|
||||
await user.click(screen.getByRole("tab", { name: "Branches" }));
|
||||
expect(screen.getByRole("switch", { name: "Matched people only" })).not.toBeChecked();
|
||||
expect(screen.getByText("External contribution")).toBeInTheDocument();
|
||||
await user.click(screen.getByRole("switch", { name: "Matched people only" }));
|
||||
expect(screen.queryByText("External contribution")).not.toBeInTheDocument();
|
||||
expect(screen.getByText("Improve request routing")).toBeInTheDocument();
|
||||
expect(apiClient.put).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows the spend summary and opens an accessible pull reasoning dialog", async () => {
|
||||
render(<ROICalculatorView accessToken="token" />);
|
||||
|
||||
expect(await screen.findByText("Gateway AI cost")).toBeInTheDocument();
|
||||
expect(screen.getByText("$20.00")).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Open estimate for org/repo pull request 42" }));
|
||||
|
||||
expect(await screen.findByRole("dialog")).toBeInTheDocument();
|
||||
expect(screen.getByText("Updated routing and added a regression test.")).toBeInTheDocument();
|
||||
expect(screen.getByRole("link", { name: "View on GitHub" })).toHaveAttribute(
|
||||
"href",
|
||||
"https://github.com/org/repo/pull/42",
|
||||
);
|
||||
});
|
||||
|
||||
it("separates the overview, people, and branch reports into three tabs", async () => {
|
||||
render(<ROICalculatorView accessToken="token" />);
|
||||
expect(await screen.findByRole("heading", { name: "Where AI costs are matched" })).toBeVisible();
|
||||
fireEvent.click(screen.getByRole("tab", { name: "Branches" }));
|
||||
expect(screen.getByRole("heading", { name: "Costs by branch" })).toBeVisible();
|
||||
expect(screen.getByRole("cell", { name: "$8.00" })).toBeVisible();
|
||||
fireEvent.click(screen.getByRole("tab", { name: "People" }));
|
||||
expect(screen.getByRole("heading", { name: "People and account matches" })).toBeVisible();
|
||||
fireEvent.click(screen.getByRole("tab", { name: "Overview" }));
|
||||
expect(screen.getByRole("heading", { name: "Highest-cost changes" })).toBeVisible();
|
||||
expect(screen.queryByRole("radio")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows incomplete repository results without a spend-per-hour figure", async () => {
|
||||
const warning = "Incomplete report: could not read org/unavailable. Spend-per-hour figures are unavailable.";
|
||||
vi.mocked(apiClient.get).mockImplementation((path: string) => {
|
||||
if (path === "/roi-calculator/settings") return Promise.resolve(settings);
|
||||
if (path === "/roi-calculator/report") {
|
||||
return Promise.resolve({
|
||||
report: {
|
||||
...summary,
|
||||
warnings: [warning],
|
||||
metrics: { ...summary.metrics, cost_per_hour: null, hours_per_dollar: null },
|
||||
people: summary.people.map((person) => ({ ...person, cost_per_hour: null })),
|
||||
},
|
||||
});
|
||||
}
|
||||
return Promise.resolve(idleStatus);
|
||||
});
|
||||
|
||||
render(<ROICalculatorView accessToken="token" />);
|
||||
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent(warning);
|
||||
expect(screen.getByRole("button", { name: "Open estimate for org/repo pull request 42" })).toBeInTheDocument();
|
||||
expect(screen.queryByText("$3.00")).not.toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole("tab", { name: "People" }));
|
||||
fireEvent.click(screen.getByText("How this is calculated"));
|
||||
expect(
|
||||
screen.getByText("Spend per estimated hour is unavailable until all selected repositories can be read."),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
it("lets a view-only admin read the report without write controls", async () => {
|
||||
const runningStatus = {
|
||||
...idleStatus,
|
||||
running: true,
|
||||
phase: "estimating",
|
||||
stage: "Estimating pull requests",
|
||||
total: 1,
|
||||
};
|
||||
vi.mocked(apiClient.get).mockImplementation((path: string) => {
|
||||
if (path === "/roi-calculator/settings") return Promise.resolve(settings);
|
||||
if (path === "/roi-calculator/report") return Promise.resolve({ report: summary });
|
||||
return Promise.resolve(runningStatus);
|
||||
});
|
||||
|
||||
render(<ROICalculatorView accessToken="token" userRole="Admin" isViewOnly />);
|
||||
|
||||
expect(await screen.findByText("Gateway AI cost")).toBeInTheDocument();
|
||||
expect(screen.getByRole("note")).toHaveTextContent("Read-only access");
|
||||
expect(screen.queryByRole("button", { name: "Run analysis" })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "Cancel sync" })).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole("tab", { name: "People" }));
|
||||
expect(screen.getByText("alice-work")).toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "alice-work" })).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Settings" }));
|
||||
expect(screen.getByLabelText("GitHub token (optional for public repositories)")).toBeDisabled();
|
||||
expect(screen.queryByRole("button", { name: "Save settings" })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "Run analysis" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("lets an admin open the people view and save a manual email match", async () => {
|
||||
render(<ROICalculatorView accessToken="token" />);
|
||||
|
||||
fireEvent.click(await screen.findByRole("tab", { name: "People" }));
|
||||
fireEvent.click(await screen.findByRole("button", { name: "alice-work" }));
|
||||
fireEvent.change(screen.getByLabelText("Gateway email"), {
|
||||
target: { value: "alice+work@example.com" },
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: "Save match" }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(apiClient.put).toHaveBeenCalledWith("/roi-calculator/identity-map", {
|
||||
accessToken: "token",
|
||||
body: { github_login: "alice-work", email: "alice+work@example.com" },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("presents onboarding settings once when no report exists", async () => {
|
||||
const emptySettings = { ...settings, has_github_token: false, ready: false, repos: [], estimator_model: "" };
|
||||
vi.mocked(apiClient.get).mockImplementation((path: string) => {
|
||||
if (path === "/roi-calculator/settings") return Promise.resolve(emptySettings);
|
||||
if (path === "/roi-calculator/report") return Promise.resolve({ report: null });
|
||||
return Promise.resolve(idleStatus);
|
||||
});
|
||||
|
||||
render(<ROICalculatorView accessToken="token" />);
|
||||
|
||||
expect(await screen.findByRole("heading", { name: "Connect your repositories" })).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("GitHub token (optional for public repositories)")).toHaveAttribute(
|
||||
"type",
|
||||
"password",
|
||||
);
|
||||
expect(screen.getAllByText("Connect your repositories")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it.each(["github", "gitlab"])("only permits unauthenticated repository browsing for GitLab: %s", async (provider) => {
|
||||
const publicSettings = {
|
||||
...settings,
|
||||
source_provider: provider,
|
||||
gitlab_api_url: "https://gitlab.com/api/v4",
|
||||
has_github_token: false,
|
||||
has_gitlab_token: false,
|
||||
};
|
||||
vi.mocked(apiClient.get).mockImplementation((path: string) => {
|
||||
if (path === "/roi-calculator/settings") {
|
||||
return Promise.resolve(publicSettings);
|
||||
}
|
||||
if (path === "/roi-calculator/report") return Promise.resolve({ report: summary });
|
||||
return Promise.resolve(idleStatus);
|
||||
});
|
||||
render(<ROICalculatorView accessToken="token" />);
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Settings" }));
|
||||
const load = screen.getByRole("button", { name: "Load repositories" });
|
||||
if (provider === "github") expect(load).toBeDisabled();
|
||||
else expect(load).toBeEnabled();
|
||||
expect(screen.getByRole("textbox", { name: "Repository name" })).toBeEnabled();
|
||||
});
|
||||
|
||||
it("searches by the real model name and saves the selected gateway alias", async () => {
|
||||
const user = userEvent.setup();
|
||||
const modelSettings = {
|
||||
...settings,
|
||||
available_models: ["estimator", "fast-estimator"],
|
||||
estimator_models: [
|
||||
{ model_name: "estimator", provider_models: ["custom-model"] },
|
||||
{ model_name: "fast-estimator", provider_models: ["openai/gpt-6-luna"] },
|
||||
],
|
||||
};
|
||||
vi.mocked(apiClient.get).mockImplementation((path: string) => {
|
||||
if (path === "/roi-calculator/settings") return Promise.resolve(modelSettings);
|
||||
if (path === "/roi-calculator/report") return Promise.resolve({ report: summary });
|
||||
return Promise.resolve(idleStatus);
|
||||
});
|
||||
vi.mocked(apiClient.put).mockResolvedValue({ ...modelSettings, estimator_model: "fast-estimator" });
|
||||
render(<ROICalculatorView accessToken="token" />);
|
||||
await user.click(await screen.findByRole("button", { name: "Settings" }));
|
||||
const search = screen.getByRole("combobox", { name: "Estimator model" });
|
||||
await user.clear(search);
|
||||
await user.type(search, "Luna");
|
||||
expect(screen.queryByRole("option", { name: /custom-model/ })).not.toBeInTheDocument();
|
||||
await user.click(await screen.findByRole("option", { name: /GPT-6 Luna.*Recommended/ }));
|
||||
expect(search).toHaveValue("GPT-6 Luna");
|
||||
await user.click(screen.getByRole("button", { name: "Save settings" }));
|
||||
expect(apiClient.put).toHaveBeenCalledWith(
|
||||
"/roi-calculator/settings",
|
||||
expect.objectContaining({
|
||||
body: expect.objectContaining({ estimator_model: "fast-estimator" }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("closes the old settings dialog when saving a different source", async () => {
|
||||
const gitlabSettings = {
|
||||
...settings,
|
||||
source_provider: "gitlab",
|
||||
gitlab_api_url: "https://gitlab.com/api/v4",
|
||||
has_gitlab_token: false,
|
||||
repos: [],
|
||||
ready: false,
|
||||
};
|
||||
vi.mocked(apiClient.put).mockResolvedValue(gitlabSettings);
|
||||
render(<ROICalculatorView accessToken="token" />);
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Settings" }));
|
||||
fireEvent.change(screen.getByLabelText("Repository source"), { target: { value: "gitlab" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Save settings" }));
|
||||
expect(await screen.findByRole("heading", { name: "Connect your repositories" })).toBeVisible();
|
||||
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
|
||||
expect(screen.getAllByLabelText("Repository source")).toHaveLength(1);
|
||||
expect(screen.getByLabelText("Repository source")).toHaveValue("gitlab");
|
||||
});
|
||||
|
||||
it("keeps a running analysis visible when a source change finishes saving", async () => {
|
||||
const gitlabSettings = { ...settings, source_provider: "gitlab", repos: ["group/project"] };
|
||||
const runningStatus = { ...idleStatus, running: true, phase: "estimating", total: 1 };
|
||||
const saveRequest = Promise.withResolvers<typeof gitlabSettings>();
|
||||
vi.mocked(apiClient.put).mockReturnValue(saveRequest.promise);
|
||||
render(<ROICalculatorView accessToken="token" />);
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Settings" }));
|
||||
fireEvent.change(screen.getByLabelText("Repository source"), { target: { value: "gitlab" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Save settings" }));
|
||||
vi.mocked(apiClient.get).mockResolvedValue(runningStatus);
|
||||
expect(await screen.findByRole("progressbar", { hidden: true }, { timeout: 3000 })).toBeInTheDocument();
|
||||
|
||||
saveRequest.resolve(gitlabSettings);
|
||||
await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument());
|
||||
expect(screen.getByRole("progressbar", { name: "Sync progress" })).toBeVisible();
|
||||
expect(screen.getByRole("button", { name: "Cancel sync" })).toBeEnabled();
|
||||
expect(screen.queryByRole("button", { name: "Run analysis" })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("heading", { name: "Connect your repositories" })).not.toBeInTheDocument();
|
||||
expect(apiClient.post).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each(["success", "failure"])("ignores an old report refresh %s after switching sources", async (outcome) => {
|
||||
const gitlabSettings = { ...settings, source_provider: "gitlab", repos: [], ready: false };
|
||||
const oldRequest = Promise.withResolvers<{ report: typeof summary }>();
|
||||
const complete = { ...idleStatus, phase: "complete", finished_at: "2026-09-30T12:00:00Z" };
|
||||
vi.mocked(apiClient.put).mockResolvedValue(gitlabSettings);
|
||||
render(<ROICalculatorView accessToken="token" />);
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Settings" }));
|
||||
vi.mocked(apiClient.get)
|
||||
.mockClear()
|
||||
.mockImplementation((path: string) =>
|
||||
path === "/roi-calculator/report" ? oldRequest.promise : Promise.resolve(complete),
|
||||
);
|
||||
await waitFor(
|
||||
() => expect(apiClient.get).toHaveBeenCalledWith("/roi-calculator/report", { accessToken: "token" }),
|
||||
{
|
||||
timeout: 3000,
|
||||
},
|
||||
);
|
||||
fireEvent.change(screen.getByLabelText("Repository source"), { target: { value: "gitlab" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Save settings" }));
|
||||
expect(await screen.findByRole("heading", { name: "Connect your repositories" })).toBeVisible();
|
||||
|
||||
await act(async () => {
|
||||
if (outcome === "success") oldRequest.resolve({ report: summary });
|
||||
else oldRequest.reject(new Error("The old source is unavailable"));
|
||||
});
|
||||
expect(screen.getByRole("heading", { name: "Connect your repositories" })).toBeVisible();
|
||||
expect(screen.getByLabelText("Repository source")).toHaveValue("gitlab");
|
||||
expect(screen.queryByText("Improve request routing")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("The old source is unavailable")).not.toBeInTheDocument();
|
||||
|
||||
const nextReport = { ...summary, pulls: [{ ...summary.pulls[0], title: "New source merge request" }] };
|
||||
vi.mocked(apiClient.get).mockImplementation((path: string) =>
|
||||
Promise.resolve(
|
||||
path === "/roi-calculator/report"
|
||||
? { report: nextReport }
|
||||
: { ...complete, finished_at: "2026-09-30T13:00:00Z" },
|
||||
),
|
||||
);
|
||||
expect(await screen.findByText("New source merge request", {}, { timeout: 3000 })).toBeVisible();
|
||||
expect(screen.queryByText("Improve request routing")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("clearly identifies the sample report and returns to setup when exiting", async () => {
|
||||
const emptySettings = { ...settings, has_github_token: false, ready: false, repos: [], estimator_model: "" };
|
||||
vi.mocked(apiClient.get).mockImplementation((path: string, options) => {
|
||||
if (path === "/roi-calculator/settings") return Promise.resolve(emptySettings);
|
||||
if (path === "/roi-calculator/report") {
|
||||
return Promise.resolve({ report: options?.query?.mode === "demo" ? { ...summary, mode: "demo" } : null });
|
||||
}
|
||||
return Promise.resolve(idleStatus);
|
||||
});
|
||||
|
||||
render(<ROICalculatorView accessToken="token" />);
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Preview sample report" }));
|
||||
|
||||
expect(await screen.findByText("You’re viewing demo data")).toBeVisible();
|
||||
expect(screen.getByRole("tab", { name: "Branches" })).toHaveAttribute("aria-selected", "true");
|
||||
expect(screen.getByText("Cost / estimated hour")).toBeVisible();
|
||||
expect(screen.queryByRole("button", { name: "Run analysis" })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("tab", { name: "Settings" })).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Exit demo" }));
|
||||
|
||||
expect(screen.getByRole("heading", { name: "Connect your repositories" })).toBeVisible();
|
||||
expect(screen.queryByText("You’re viewing demo data")).not.toBeInTheDocument();
|
||||
expect(apiClient.post).not.toHaveBeenCalled();
|
||||
expect(apiClient.put).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("opens sample PR costs from a live report and restores the live data on exit", async () => {
|
||||
const samplePull = {
|
||||
...summary.pulls[0],
|
||||
title: "Sample usage breakdown",
|
||||
source_repo: "github.com/org/repo",
|
||||
source_branch: "feature/usage",
|
||||
branch_cost: {
|
||||
status: "matched",
|
||||
spend: 9.1,
|
||||
requests: 75,
|
||||
repo: "github.com/org/repo",
|
||||
branch: "feature/usage",
|
||||
},
|
||||
};
|
||||
vi.mocked(apiClient.get).mockImplementation((path: string, options) => {
|
||||
if (path === "/roi-calculator/settings") return Promise.resolve(settings);
|
||||
if (path === "/roi-calculator/report") {
|
||||
return Promise.resolve({
|
||||
report: options?.query?.mode === "demo" ? { ...summary, mode: "demo", pulls: [samplePull] } : summary,
|
||||
});
|
||||
}
|
||||
return Promise.resolve(idleStatus);
|
||||
});
|
||||
|
||||
render(<ROICalculatorView accessToken="token" />);
|
||||
fireEvent.click(await screen.findByRole("tab", { name: "Branches" }));
|
||||
fireEvent.change(screen.getByRole("searchbox"), { target: { value: "no matching PR" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Preview sample report" }));
|
||||
|
||||
expect(await screen.findByText("You’re viewing demo data")).toBeVisible();
|
||||
await waitFor(() => expect(window.location.search).toBe("?demo=1"));
|
||||
expect(screen.getByRole("tab", { name: "Branches" })).toHaveAttribute("aria-selected", "true");
|
||||
expect(screen.getByRole("searchbox")).toHaveValue("");
|
||||
expect(screen.getByRole("cell", { name: "$9.10" })).toBeVisible();
|
||||
expect(screen.queryByText("Improve request routing")).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "Run analysis" })).not.toBeInTheDocument();
|
||||
|
||||
const runningStatus = { ...idleStatus, running: true, phase: "estimating", total: 1 };
|
||||
vi.mocked(apiClient.get).mockClear().mockResolvedValue(runningStatus);
|
||||
await waitFor(() => expect(apiClient.get).toHaveBeenCalledWith("/roi-calculator/sync", { accessToken: "token" }), {
|
||||
timeout: 3000,
|
||||
});
|
||||
expect(screen.queryByRole("progressbar")).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Open estimate for org/repo pull request 42" }));
|
||||
expect(await screen.findByRole("dialog")).toBeVisible();
|
||||
expect(screen.getByText("75 requests")).toBeVisible();
|
||||
expect(screen.getByText(/branch:feature\/usage/)).toBeVisible();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Close" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Exit demo" }));
|
||||
|
||||
expect(screen.getByText("Improve request routing")).toBeVisible();
|
||||
expect(screen.queryByText("Sample usage breakdown")).not.toBeInTheDocument();
|
||||
await waitFor(() => expect(window.location.search).toBe(""));
|
||||
expect(screen.getByRole("button", { name: "Syncing…" })).toBeDisabled();
|
||||
expect(apiClient.post).not.toHaveBeenCalled();
|
||||
expect(apiClient.put).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("opens a demo link with sample data even while live analysis is running", async () => {
|
||||
window.history.replaceState(null, "", "/roi-calculator/?demo=1");
|
||||
const demoSummary = { ...summary, mode: "demo", metrics: { ...summary.metrics, total_spend: 38.4 } };
|
||||
const runningStatus = { ...idleStatus, running: true, phase: "estimating", total: 1 };
|
||||
vi.mocked(apiClient.get).mockImplementation((path: string, options) => {
|
||||
if (path === "/roi-calculator/settings") return Promise.resolve(settings);
|
||||
if (path === "/roi-calculator/report") {
|
||||
return Promise.resolve({ report: options?.query?.mode === "demo" ? demoSummary : summary });
|
||||
}
|
||||
return Promise.resolve(runningStatus);
|
||||
});
|
||||
render(<ROICalculatorView accessToken="token" />);
|
||||
expect(await screen.findByText("You’re viewing demo data")).toBeVisible();
|
||||
expect(screen.getByText("$38.40")).toBeVisible();
|
||||
expect(screen.queryByRole("progressbar")).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "Run analysis" })).not.toBeInTheDocument();
|
||||
expect(apiClient.post).not.toHaveBeenCalled();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Exit demo" }));
|
||||
expect(screen.getByRole("progressbar")).toBeVisible();
|
||||
expect(screen.getByText("$20.00")).toBeVisible();
|
||||
await waitFor(() => expect(window.location.search).toBe(""));
|
||||
});
|
||||
|
||||
it.each(["report", "sync"])("loads a demo link when the live %s request fails", async (failedRequest) => {
|
||||
window.history.replaceState(null, "", "/roi-calculator/?demo=1");
|
||||
vi.mocked(apiClient.get).mockImplementation((path: string, options) => {
|
||||
if (path === "/roi-calculator/settings") return Promise.resolve(settings);
|
||||
if (options?.query?.mode === "demo") return Promise.resolve({ report: { ...summary, mode: "demo" } });
|
||||
if (path === `/roi-calculator/${failedRequest}`) return Promise.reject(new Error("Live data unavailable"));
|
||||
if (path === "/roi-calculator/report") return Promise.resolve({ report: summary });
|
||||
return Promise.resolve(idleStatus);
|
||||
});
|
||||
render(<ROICalculatorView accessToken="token" />);
|
||||
expect(await screen.findByText("You’re viewing demo data")).toBeVisible();
|
||||
expect(screen.getByText("Gateway AI cost")).toBeVisible();
|
||||
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
|
||||
expect(apiClient.post).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows the demo without waiting for a stalled live request", async () => {
|
||||
window.history.replaceState(null, "", "/roi-calculator/?demo=1");
|
||||
vi.mocked(apiClient.get).mockImplementation((path: string, options) => {
|
||||
if (path === "/roi-calculator/settings") return Promise.resolve(settings);
|
||||
if (options?.query?.mode === "demo") return Promise.resolve({ report: { ...summary, mode: "demo" } });
|
||||
return new Promise(() => {});
|
||||
});
|
||||
render(<ROICalculatorView accessToken="token" />);
|
||||
expect(await screen.findByText("You’re viewing demo data")).toBeVisible();
|
||||
expect(screen.getByText("Gateway AI cost")).toBeVisible();
|
||||
});
|
||||
|
||||
it("keeps the live calculator usable when a demo link cannot load sample data", async () => {
|
||||
window.history.replaceState(null, "", "/roi-calculator/?demo=1&from=review#overview");
|
||||
vi.mocked(apiClient.get).mockImplementation((path: string, options) => {
|
||||
if (path === "/roi-calculator/settings") return Promise.resolve(settings);
|
||||
if (options?.query?.mode === "demo") return Promise.reject(new Error("Sample data unavailable"));
|
||||
if (path === "/roi-calculator/report") return Promise.resolve({ report: summary });
|
||||
return Promise.resolve(idleStatus);
|
||||
});
|
||||
render(<ROICalculatorView accessToken="token" />);
|
||||
expect(await screen.findByText("Gateway AI cost")).toBeVisible();
|
||||
expect(screen.getByText("$20.00")).toBeVisible();
|
||||
expect(screen.getByRole("alert")).toHaveTextContent("Sample data unavailable");
|
||||
expect(screen.getByRole("button", { name: "Settings" })).toBeEnabled();
|
||||
expect(screen.queryByText("You’re viewing demo data")).not.toBeInTheDocument();
|
||||
expect(apiClient.post).not.toHaveBeenCalled();
|
||||
await waitFor(() => expect(window.location.search).toBe("?from=review"));
|
||||
expect(window.location.hash).toBe("#overview");
|
||||
});
|
||||
|
||||
it.each(["report", "sync"])("waits for the live %s when exiting a demo", async (pendingRequest) => {
|
||||
window.history.replaceState(null, "", "/roi-calculator/?demo=1");
|
||||
const pending = Promise.withResolvers<{ report: typeof summary } | typeof idleStatus>();
|
||||
vi.mocked(apiClient.get).mockImplementation((path: string, options) => {
|
||||
if (path === "/roi-calculator/settings") return Promise.resolve(settings);
|
||||
if (options?.query?.mode === "demo") return Promise.resolve({ report: { ...summary, mode: "demo" } });
|
||||
if (path === `/roi-calculator/${pendingRequest}`) return pending.promise;
|
||||
if (path === "/roi-calculator/report") return Promise.resolve({ report: summary });
|
||||
return Promise.resolve(idleStatus);
|
||||
});
|
||||
render(<ROICalculatorView accessToken="token" />);
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Exit demo" }));
|
||||
expect(screen.getByText("Loading ROI Calculator…")).toBeVisible();
|
||||
expect(screen.queryByRole("heading", { name: "Connect your repositories" })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "Run analysis" })).not.toBeInTheDocument();
|
||||
await waitFor(() => expect(window.location.search).toBe(""));
|
||||
|
||||
pending.resolve(pendingRequest === "report" ? { report: summary } : idleStatus);
|
||||
expect(await screen.findByText("Gateway AI cost")).toBeVisible();
|
||||
expect(screen.getByText("$20.00")).toBeVisible();
|
||||
expect(screen.queryByText("Loading ROI Calculator…")).not.toBeInTheDocument();
|
||||
expect(apiClient.post).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each(["report", "demo"])("retains a failed %s load after a successful sync poll", async (failedRequest) => {
|
||||
if (failedRequest === "demo") window.history.replaceState(null, "", "/roi-calculator/?demo=1");
|
||||
const message = `The ${failedRequest} is unavailable`;
|
||||
vi.mocked(apiClient.get).mockImplementation((path: string, options) => {
|
||||
if (path === "/roi-calculator/settings") return Promise.resolve(settings);
|
||||
if (options?.query?.mode === "demo") return Promise.reject(new Error(message));
|
||||
if (path === "/roi-calculator/report") {
|
||||
return failedRequest === "report" ? Promise.reject(new Error(message)) : Promise.resolve({ report: summary });
|
||||
}
|
||||
return Promise.resolve(idleStatus);
|
||||
});
|
||||
render(<ROICalculatorView accessToken="token" />);
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent(message);
|
||||
const running = { ...idleStatus, running: true, phase: "estimating", total: 1 };
|
||||
vi.mocked(apiClient.get).mockImplementation((path: string) => {
|
||||
if (path === "/roi-calculator/report") return Promise.reject(new Error(message));
|
||||
return Promise.resolve(running);
|
||||
});
|
||||
expect(await screen.findByRole("progressbar", { name: "Sync progress" }, { timeout: 3000 })).toBeVisible();
|
||||
expect(screen.getByRole("alert")).toHaveTextContent(message);
|
||||
});
|
||||
|
||||
it("retries a failed initial report and clears its error only when the report recovers", async () => {
|
||||
vi.mocked(apiClient.get).mockImplementation((path: string) => {
|
||||
if (path === "/roi-calculator/settings") return Promise.resolve(settings);
|
||||
if (path === "/roi-calculator/report") return Promise.reject(new Error("Report unavailable"));
|
||||
return Promise.resolve(idleStatus);
|
||||
});
|
||||
render(<ROICalculatorView accessToken="token" />);
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent("Report unavailable");
|
||||
expect(screen.getByRole("button", { name: "Settings" })).toBeEnabled();
|
||||
expect(screen.getByRole("button", { name: "Run analysis" })).toBeEnabled();
|
||||
vi.mocked(apiClient.get).mockImplementation((path: string) =>
|
||||
Promise.resolve(path === "/roi-calculator/report" ? { report: summary } : idleStatus),
|
||||
);
|
||||
expect(await screen.findByText("Gateway AI cost", {}, { timeout: 3000 })).toBeVisible();
|
||||
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("heading", { name: "Connect your repositories" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("returns to Overview and shows the last sync time when completion is polled from Settings", async () => {
|
||||
const runningStatus = {
|
||||
...idleStatus,
|
||||
running: true,
|
||||
phase: "estimating",
|
||||
stage: "Estimating pull requests",
|
||||
total: 1,
|
||||
};
|
||||
const completedStatus = { ...idleStatus, phase: "complete", done: 57, total: 57, reused: 57 };
|
||||
vi.mocked(apiClient.get)
|
||||
.mockResolvedValueOnce(settings)
|
||||
.mockResolvedValueOnce({ report: null })
|
||||
.mockResolvedValueOnce(runningStatus)
|
||||
.mockResolvedValueOnce(completedStatus)
|
||||
.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
window.setTimeout(() => resolve({ report: summary }), 25);
|
||||
}),
|
||||
);
|
||||
|
||||
render(<ROICalculatorView accessToken="token" />);
|
||||
|
||||
expect(await screen.findByRole("progressbar", { name: "Sync progress" })).toBeInTheDocument();
|
||||
expect(await screen.findByText("Gateway AI cost", {}, { timeout: 5000 })).toBeInTheDocument();
|
||||
expect(screen.queryByRole("heading", { name: "Connect your repositories" })).not.toBeInTheDocument();
|
||||
expect(screen.getByRole("status")).toHaveTextContent("Last synced Sep 30, 2026, 12:00 PM UTC");
|
||||
});
|
||||
|
||||
it("shows the sync error returned by the status endpoint", async () => {
|
||||
const runningStatus = {
|
||||
...idleStatus,
|
||||
running: true,
|
||||
phase: "estimating",
|
||||
stage: "Estimating pull requests",
|
||||
total: 1,
|
||||
};
|
||||
const errorStatus = {
|
||||
...idleStatus,
|
||||
phase: "error",
|
||||
error: "The estimator could not score a pull request.",
|
||||
};
|
||||
vi.mocked(apiClient.get)
|
||||
.mockResolvedValueOnce(settings)
|
||||
.mockResolvedValueOnce({ report: null })
|
||||
.mockResolvedValueOnce(runningStatus)
|
||||
.mockResolvedValueOnce(errorStatus);
|
||||
|
||||
render(<ROICalculatorView accessToken="token" />);
|
||||
|
||||
expect(await screen.findByRole("alert", {}, { timeout: 5000 })).toHaveTextContent(
|
||||
"The estimator could not score a pull request.",
|
||||
);
|
||||
expect(screen.getByText("Sync failed")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows a report error and ends progress when the completed report cannot load", async () => {
|
||||
const runningStatus = {
|
||||
...idleStatus,
|
||||
running: true,
|
||||
phase: "estimating",
|
||||
stage: "Estimating pull requests",
|
||||
total: 1,
|
||||
};
|
||||
const completedStatus = { ...idleStatus, phase: "complete", done: 1, total: 1 };
|
||||
vi.mocked(apiClient.get)
|
||||
.mockResolvedValueOnce(settings)
|
||||
.mockResolvedValueOnce({ report: null })
|
||||
.mockResolvedValueOnce(runningStatus)
|
||||
.mockResolvedValueOnce(completedStatus)
|
||||
.mockRejectedValueOnce(new Error("The report could not be loaded."));
|
||||
|
||||
render(<ROICalculatorView accessToken="token" />);
|
||||
|
||||
expect(await screen.findByRole("progressbar", { name: "Sync progress" })).toBeInTheDocument();
|
||||
expect(await screen.findByRole("alert", {}, { timeout: 5000 })).toHaveTextContent(
|
||||
"The report could not be loaded.",
|
||||
);
|
||||
expect(screen.queryByRole("progressbar", { name: "Sync progress" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("clears a transient poll error when the next poll completes and loads the report", async () => {
|
||||
const runningStatus = {
|
||||
...idleStatus,
|
||||
running: true,
|
||||
phase: "estimating",
|
||||
stage: "Estimating pull requests",
|
||||
total: 1,
|
||||
};
|
||||
const completedStatus = { ...idleStatus, phase: "complete", done: 1, total: 1 };
|
||||
vi.mocked(apiClient.get)
|
||||
.mockResolvedValueOnce(settings)
|
||||
.mockResolvedValueOnce({ report: null })
|
||||
.mockResolvedValueOnce(runningStatus)
|
||||
.mockRejectedValueOnce(new Error("The sync status could not be loaded."))
|
||||
.mockResolvedValueOnce(completedStatus)
|
||||
.mockResolvedValueOnce({ report: summary });
|
||||
|
||||
render(<ROICalculatorView accessToken="token" />);
|
||||
|
||||
expect(await screen.findByRole("progressbar", { name: "Sync progress" })).toBeInTheDocument();
|
||||
expect(await screen.findByRole("alert", {}, { timeout: 5000 })).toHaveTextContent(
|
||||
"The sync status could not be loaded.",
|
||||
);
|
||||
expect(await screen.findByText("Gateway AI cost", {}, { timeout: 7000 })).toBeInTheDocument();
|
||||
expect(screen.queryByText("The sync status could not be loaded.")).not.toBeInTheDocument();
|
||||
});
|
||||
it("saves the edited schedule before running from Settings", async () => {
|
||||
vi.mocked(apiClient.put).mockResolvedValue(settings);
|
||||
vi.mocked(apiClient.post).mockResolvedValue({ ...idleStatus, running: true });
|
||||
render(<ROICalculatorView accessToken="token" />);
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Settings" }));
|
||||
fireEvent.change(screen.getByLabelText("Update interval (hours)"), { target: { value: "6" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Save and run analysis" }));
|
||||
await waitFor(() => expect(apiClient.post).toHaveBeenCalledWith("/roi-calculator/sync", { accessToken: "token" }));
|
||||
expect(apiClient.put).toHaveBeenCalledWith(
|
||||
"/roi-calculator/settings",
|
||||
expect.objectContaining({
|
||||
body: expect.objectContaining({ update_interval_minutes: 360, estimator_model: "estimator" }),
|
||||
}),
|
||||
);
|
||||
expect(vi.mocked(apiClient.put).mock.invocationCallOrder[0]).toBeLessThan(
|
||||
vi.mocked(apiClient.post).mock.invocationCallOrder[0],
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,508 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { Page, PageTabsList, PageTabsTrigger } from "@/components/shared/Page";
|
||||
import React from "react";
|
||||
import { Calculator, RefreshCw, Settings2 } from "lucide-react";
|
||||
|
||||
import { apiClient } from "@/components/networking";
|
||||
import { DemoNotice } from "@/components/shared/DemoNotice";
|
||||
import { PageHeader, PageHeaderDescription, PageHeaderTitle } from "@/components/shared/PageHeader";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Tabs } from "@/components/ui/tabs";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog";
|
||||
import { extractErrorMessage } from "@/utils/errorUtils";
|
||||
import { isProxyAdminTierRole } from "@/utils/roles";
|
||||
import { useQueryState } from "nuqs";
|
||||
import ROISettingsPanel from "./ROISettingsPanel";
|
||||
import { MatchedPeopleToggle } from "./MatchedPeopleToggle";
|
||||
import { IdentityMatchDialog, type PersonMatchSelection, PullReasoningDialog } from "./ROICalculatorDialogs";
|
||||
import { ROIBranches, ROIOverview, ROIPeopleView } from "./ROICalculatorViews";
|
||||
import { filterPulls, formatSyncedAt } from "./roiCalculatorData";
|
||||
import type {
|
||||
ROIIdentityMapResponse,
|
||||
ROIIdentityMapUpdate,
|
||||
ROIPull,
|
||||
ROIReportResponse,
|
||||
ROISettings,
|
||||
ROISummary,
|
||||
ROISyncStatus,
|
||||
} from "./roiCalculatorData";
|
||||
import { parseAsDemoFlag } from "./demoUrlState";
|
||||
|
||||
type View = "overview" | "people" | "branches";
|
||||
|
||||
const IDLE_STATUS: ROISyncStatus = {
|
||||
running: false,
|
||||
elapsed_seconds: 0,
|
||||
phase: "idle",
|
||||
stage: "Idle",
|
||||
done: 0,
|
||||
total: 0,
|
||||
estimated: 0,
|
||||
reused: 0,
|
||||
needs_attention: 0,
|
||||
error: null,
|
||||
};
|
||||
|
||||
export default function ROICalculatorView({
|
||||
accessToken,
|
||||
userRole = null,
|
||||
isViewOnly = false,
|
||||
}: {
|
||||
accessToken: string | null;
|
||||
userRole?: string | null;
|
||||
isViewOnly?: boolean;
|
||||
}) {
|
||||
const [demo, setDemo] = useQueryState("demo", parseAsDemoFlag);
|
||||
const [demoRequestedOnLoad] = React.useState(demo === true);
|
||||
const [sampleSummary, setSampleSummary] = React.useState<ROISummary | null>(null);
|
||||
const adminReadOnly = isViewOnly && isProxyAdminTierRole(userRole ?? "");
|
||||
const readOnly = adminReadOnly || sampleSummary !== null;
|
||||
const [settingsOpen, setSettingsOpen] = React.useState(false);
|
||||
const [view, setView] = React.useState<View>("overview");
|
||||
const [settings, setSettings] = React.useState<ROISettings | null>(null);
|
||||
const [liveSummary, setSummary] = React.useState<ROISummary | null>(null);
|
||||
const summary = sampleSummary ?? liveSummary;
|
||||
const [status, setStatus] = React.useState<ROISyncStatus>(IDLE_STATUS);
|
||||
const [selectedPull, setSelectedPull] = React.useState<ROIPull | null>(null);
|
||||
const [matchingPerson, setMatchingPerson] = React.useState<PersonMatchSelection | null>(null);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
const [demoError, setDemoError] = React.useState<string | null>(null);
|
||||
const [reportError, setReportError] = React.useState<string | null>(null);
|
||||
const [syncError, setSyncError] = React.useState<string | null>(null);
|
||||
const [loadingInitialData, setLoadingInitialData] = React.useState(true);
|
||||
const [loadingLiveData, setLoadingLiveData] = React.useState(true);
|
||||
const statusRef = React.useRef<ROISyncStatus>(IDLE_STATUS);
|
||||
const reportNeedsRefresh = React.useRef(false);
|
||||
const sourceRevision = React.useRef(0);
|
||||
const settingsLoaded = settings !== null && !loadingInitialData && !loadingLiveData;
|
||||
const requestError = [error, demoError, reportError, syncError].filter(Boolean).join(" ");
|
||||
const [query, setQuery] = React.useState("");
|
||||
const [matchedOnly, setMatchedOnly] = React.useState(true);
|
||||
|
||||
const loadReport = React.useCallback(async () => {
|
||||
if (!accessToken) return null;
|
||||
const response: ROIReportResponse = await apiClient.get("/roi-calculator/report", { accessToken });
|
||||
return response.report;
|
||||
}, [accessToken]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!accessToken) return;
|
||||
let cancelled = false;
|
||||
const settingsRequest = apiClient.get<ROISettings>("/roi-calculator/settings", { accessToken });
|
||||
const reportRequest = apiClient
|
||||
.get<ROIReportResponse>("/roi-calculator/report", { accessToken })
|
||||
.then((response) => {
|
||||
if (cancelled) return;
|
||||
setSummary(response.report);
|
||||
setReportError(null);
|
||||
reportNeedsRefresh.current = false;
|
||||
})
|
||||
.catch((reason: unknown) => {
|
||||
if (cancelled) return;
|
||||
setReportError(extractErrorMessage(reason));
|
||||
reportNeedsRefresh.current = true;
|
||||
});
|
||||
const statusRequest = apiClient
|
||||
.get<ROISyncStatus>("/roi-calculator/sync", { accessToken })
|
||||
.then((syncStatus) => {
|
||||
if (cancelled) return;
|
||||
setStatus(syncStatus);
|
||||
statusRef.current = syncStatus;
|
||||
setSyncError(null);
|
||||
})
|
||||
.catch((reason: unknown) => {
|
||||
if (!cancelled) setSyncError(extractErrorMessage(reason));
|
||||
});
|
||||
const liveData = Promise.all([reportRequest, statusRequest])
|
||||
.then(() => null)
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoadingLiveData(false);
|
||||
});
|
||||
Promise.all([
|
||||
settingsRequest,
|
||||
demoRequestedOnLoad
|
||||
? apiClient
|
||||
.get<ROIReportResponse>("/roi-calculator/report", { accessToken, query: { mode: "demo" } })
|
||||
.catch((reason: unknown) => {
|
||||
if (!cancelled) {
|
||||
setDemoError(`Could not load demo data: ${extractErrorMessage(reason)}`);
|
||||
setDemo(null);
|
||||
}
|
||||
return liveData;
|
||||
})
|
||||
: liveData,
|
||||
])
|
||||
.then(([nextSettings, sampleResponse]) => {
|
||||
if (cancelled) return;
|
||||
setSettings(nextSettings);
|
||||
setSampleSummary(sampleResponse?.report ?? null);
|
||||
setError(null);
|
||||
if (sampleResponse) setDemoError(null);
|
||||
})
|
||||
.catch((reason: unknown) => {
|
||||
if (!cancelled) setError(extractErrorMessage(reason));
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoadingInitialData(false);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [accessToken, demoRequestedOnLoad, setDemo]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!accessToken || !settingsLoaded) return;
|
||||
let cancelled = false;
|
||||
let requestInFlight = false;
|
||||
const interval = window.setInterval(() => {
|
||||
if (requestInFlight) return;
|
||||
requestInFlight = true;
|
||||
const revision = sourceRevision.current;
|
||||
const isCurrent = () => !cancelled && revision === sourceRevision.current;
|
||||
apiClient
|
||||
.get<ROISyncStatus>("/roi-calculator/sync", { accessToken })
|
||||
.then(async (nextStatus) => {
|
||||
if (!isCurrent()) return;
|
||||
const previousStatus = statusRef.current;
|
||||
statusRef.current = nextStatus;
|
||||
setStatus(nextStatus);
|
||||
setSyncError(null);
|
||||
const finished = !nextStatus.running && nextStatus.phase === "complete";
|
||||
const reportChanged = previousStatus.running || nextStatus.finished_at !== previousStatus.finished_at;
|
||||
if (reportNeedsRefresh.current || (finished && reportChanged)) {
|
||||
reportNeedsRefresh.current = true;
|
||||
try {
|
||||
const report = await loadReport();
|
||||
if (!isCurrent()) return;
|
||||
setSummary(report);
|
||||
setReportError(null);
|
||||
reportNeedsRefresh.current = false;
|
||||
} catch (reason: unknown) {
|
||||
if (isCurrent()) setReportError(extractErrorMessage(reason));
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch((reason: unknown) => {
|
||||
if (isCurrent()) setSyncError(extractErrorMessage(reason));
|
||||
})
|
||||
.finally(() => {
|
||||
requestInFlight = false;
|
||||
});
|
||||
}, 1500);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
window.clearInterval(interval);
|
||||
};
|
||||
}, [accessToken, loadReport, settingsLoaded]);
|
||||
|
||||
const startSync = React.useCallback(async () => {
|
||||
if (!accessToken || readOnly) return;
|
||||
try {
|
||||
setError(null);
|
||||
const nextStatus = await apiClient.post<ROISyncStatus>("/roi-calculator/sync", { accessToken });
|
||||
statusRef.current = nextStatus;
|
||||
setStatus(nextStatus);
|
||||
setSyncError(null);
|
||||
setSettingsOpen(false);
|
||||
} catch (reason) {
|
||||
setError(extractErrorMessage(reason));
|
||||
}
|
||||
}, [accessToken, readOnly]);
|
||||
|
||||
const cancelSync = React.useCallback(async () => {
|
||||
if (!accessToken || readOnly) return;
|
||||
try {
|
||||
const nextStatus = await apiClient.delete<ROISyncStatus>("/roi-calculator/sync", { accessToken });
|
||||
setStatus(nextStatus);
|
||||
statusRef.current = nextStatus;
|
||||
setSyncError(null);
|
||||
setError(null);
|
||||
} catch (reason) {
|
||||
setError(extractErrorMessage(reason));
|
||||
}
|
||||
}, [accessToken, readOnly]);
|
||||
|
||||
const updateIdentity = React.useCallback(
|
||||
async (payload: ROIIdentityMapUpdate) => {
|
||||
if (!accessToken || readOnly) return;
|
||||
const response: ROIIdentityMapResponse = await apiClient.put("/roi-calculator/identity-map", {
|
||||
accessToken,
|
||||
body: payload,
|
||||
});
|
||||
setSummary(response.report);
|
||||
setReportError(null);
|
||||
reportNeedsRefresh.current = false;
|
||||
setSettings((current) => (current ? { ...current, identity_map: response.identity_map } : current));
|
||||
},
|
||||
[accessToken, readOnly],
|
||||
);
|
||||
|
||||
const filteredPulls = React.useMemo(
|
||||
() => (summary ? filterPulls(summary.pulls, query, matchedOnly) : []),
|
||||
[query, summary, matchedOnly],
|
||||
);
|
||||
|
||||
if (error && !settings && !loadingInitialData) {
|
||||
return (
|
||||
<div className="p-8">
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>Could not load ROI Calculator</AlertTitle>
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const awaitingLiveData = !sampleSummary && loadingLiveData;
|
||||
if (!settings || loadingInitialData || awaitingLiveData) {
|
||||
return (
|
||||
<div className="space-y-6 p-8">
|
||||
<p role="status">Loading ROI Calculator…</p>
|
||||
<Skeleton className="h-16 w-96" />
|
||||
<Skeleton className="h-96 w-full" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const previewSample = async () => {
|
||||
try {
|
||||
const response = await apiClient.get<ROIReportResponse>("/roi-calculator/report", {
|
||||
accessToken,
|
||||
query: { mode: "demo" },
|
||||
});
|
||||
setSampleSummary(response.report);
|
||||
setDemoError(null);
|
||||
setDemo(true);
|
||||
setView("branches");
|
||||
setQuery("");
|
||||
} catch (reason) {
|
||||
setDemoError(`Could not load demo data: ${extractErrorMessage(reason)}`);
|
||||
}
|
||||
};
|
||||
const resetView = (updated: ROISettings, resetSyncStatus = true) => {
|
||||
sourceRevision.current += 1;
|
||||
setSettings(updated);
|
||||
setSummary(null);
|
||||
setReportError(null);
|
||||
reportNeedsRefresh.current = false;
|
||||
setSettingsOpen(false);
|
||||
setView("overview");
|
||||
if (resetSyncStatus) {
|
||||
setStatus(IDLE_STATUS);
|
||||
statusRef.current = IDLE_STATUS;
|
||||
setSyncError(null);
|
||||
setError(null);
|
||||
}
|
||||
};
|
||||
const showLiveStatus = !sampleSummary && !status.running;
|
||||
const showReportActions = summary !== null || reportError !== null;
|
||||
const progress = status.total > 0 ? Math.min(100, (status.done / status.total) * 100) : 0;
|
||||
const statusIsIdleOrComplete = status.phase === "idle" || status.phase === "complete";
|
||||
const syncIsUpToDate = !status.running && statusIsIdleOrComplete;
|
||||
const syncedAt = sampleSummary?.synced_at ?? (syncIsUpToDate ? summary?.synced_at : null);
|
||||
|
||||
return (
|
||||
<Page className="gap-6">
|
||||
<PageHeader className="space-y-3">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<PageHeaderTitle>
|
||||
<Calculator />
|
||||
ROI Calculator
|
||||
</PageHeaderTitle>
|
||||
{!sampleSummary && (
|
||||
<div className="flex items-center gap-2">
|
||||
{showLiveStatus && (
|
||||
<Button variant="ghost" onClick={() => void previewSample()}>
|
||||
Preview sample report
|
||||
</Button>
|
||||
)}
|
||||
{showReportActions && (
|
||||
<Button variant="outline" onClick={() => setSettingsOpen(true)}>
|
||||
<Settings2 />
|
||||
Settings
|
||||
</Button>
|
||||
)}
|
||||
{showReportActions && !readOnly && (
|
||||
<Button onClick={() => void startSync()} disabled={status.running || !settings.ready}>
|
||||
<RefreshCw className={status.running ? "animate-spin" : ""} />
|
||||
{status.running ? "Syncing…" : "Run analysis"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<PageHeaderDescription className="flex flex-wrap items-center gap-x-4 gap-y-1">
|
||||
<span>
|
||||
{summary
|
||||
? `${summary.start} through ${summary.end} · UTC`
|
||||
: "Compare AI costs with estimated engineering effort"}
|
||||
</span>
|
||||
{syncedAt && (
|
||||
<span className="text-xs" role="status">
|
||||
Last synced {formatSyncedAt(syncedAt)}
|
||||
</span>
|
||||
)}
|
||||
</PageHeaderDescription>
|
||||
</PageHeader>
|
||||
{sampleSummary && (
|
||||
<DemoNotice
|
||||
onExit={() => {
|
||||
setDemo(null);
|
||||
setSampleSummary(null);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{adminReadOnly && (
|
||||
<p className="text-sm text-muted-foreground" role="note">
|
||||
Read-only access. Settings, analysis runs, and email matches are unavailable.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{summary && (
|
||||
<Tabs value={view} onValueChange={(value) => setView(value as View)}>
|
||||
<PageTabsList aria-label="ROI Calculator views">
|
||||
<PageTabsTrigger value="overview">Overview</PageTabsTrigger>
|
||||
<PageTabsTrigger value="people">People</PageTabsTrigger>
|
||||
<PageTabsTrigger value="branches">Branches</PageTabsTrigger>
|
||||
</PageTabsList>
|
||||
{view !== "overview" && (
|
||||
<div className="flex justify-end">
|
||||
<MatchedPeopleToggle checked={matchedOnly} onChange={setMatchedOnly} />
|
||||
</div>
|
||||
)}
|
||||
</Tabs>
|
||||
)}
|
||||
|
||||
{!sampleSummary && requestError && (
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>ROI Calculator request failed</AlertTitle>
|
||||
<AlertDescription>{requestError}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
{!sampleSummary && status.error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>Sync failed</AlertTitle>
|
||||
<AlertDescription>{status.error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
{summary?.warnings.map((warning) => (
|
||||
<Alert key={warning}>
|
||||
<AlertTitle>Sync note</AlertTitle>
|
||||
<AlertDescription>{warning}</AlertDescription>
|
||||
</Alert>
|
||||
))}
|
||||
{!sampleSummary && status.running && (
|
||||
<Card>
|
||||
<CardContent className="flex flex-wrap items-center justify-between gap-4">
|
||||
<div
|
||||
aria-label="Sync progress"
|
||||
aria-valuemax={100}
|
||||
aria-valuemin={0}
|
||||
aria-valuenow={progress}
|
||||
className="min-w-0 flex-1 space-y-2"
|
||||
role="progressbar"
|
||||
>
|
||||
<p className="font-medium">{status.stage}</p>
|
||||
<div className="h-2 overflow-hidden rounded-full bg-muted">
|
||||
<div className="h-full bg-primary transition-all" style={{ width: `${progress}%` }} />
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{status.done} of {status.total} changes processed · {status.reused} reused
|
||||
{` · ${status.elapsed_seconds ?? 0}s elapsed`}
|
||||
{status.remaining_seconds != null ? ` · about ${status.remaining_seconds}s remaining` : ""}
|
||||
</p>
|
||||
</div>
|
||||
{!readOnly && (
|
||||
<Button variant="outline" onClick={() => void cancelSync()}>
|
||||
Cancel sync
|
||||
</Button>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{!summary && !status.running && !reportError ? (
|
||||
<ROISettingsPanel
|
||||
accessToken={accessToken}
|
||||
initialSettings={settings}
|
||||
onboarding={!summary}
|
||||
onSaved={setSettings}
|
||||
onReset={resetView}
|
||||
onStartSync={startSync}
|
||||
readOnly={readOnly}
|
||||
syncDisabled={status.running}
|
||||
/>
|
||||
) : null}
|
||||
{view === "overview" && summary && (
|
||||
<ROIOverview
|
||||
summary={summary}
|
||||
onSelectPull={setSelectedPull}
|
||||
onViewPeople={() => setView("people")}
|
||||
onViewBranches={() => setView("branches")}
|
||||
/>
|
||||
)}
|
||||
{view === "branches" && summary && (
|
||||
<ROIBranches
|
||||
summary={summary}
|
||||
pulls={filteredPulls}
|
||||
matchedOnly={matchedOnly}
|
||||
query={query}
|
||||
onQueryChange={setQuery}
|
||||
onSelectPull={setSelectedPull}
|
||||
/>
|
||||
)}
|
||||
{view === "people" && summary && (
|
||||
<ROIPeopleView
|
||||
summary={summary}
|
||||
identityMap={settings.identity_map}
|
||||
matchedOnly={matchedOnly}
|
||||
onMatch={(person, login) => setMatchingPerson({ person, login })}
|
||||
readOnly={readOnly}
|
||||
/>
|
||||
)}
|
||||
<Dialog open={settingsOpen} onOpenChange={setSettingsOpen}>
|
||||
<DialogContent className="max-h-[90dvh] overflow-y-auto sm:max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Calculator settings</DialogTitle>
|
||||
<DialogDescription>Connect repositories and choose how to estimate effort.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<ROISettingsPanel
|
||||
accessToken={accessToken}
|
||||
initialSettings={settings}
|
||||
onboarding={false}
|
||||
onSaved={(updated) => {
|
||||
if (
|
||||
updated.source_provider !== settings.source_provider ||
|
||||
updated.github_api_url !== settings.github_api_url ||
|
||||
updated.gitlab_api_url !== settings.gitlab_api_url
|
||||
) {
|
||||
resetView(updated, false);
|
||||
return;
|
||||
}
|
||||
setSettings(updated);
|
||||
}}
|
||||
onReset={resetView}
|
||||
onStartSync={startSync}
|
||||
readOnly={readOnly}
|
||||
syncDisabled={status.running}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<PullReasoningDialog pull={selectedPull} summary={summary} onClose={() => setSelectedPull(null)} />
|
||||
{!readOnly && (
|
||||
<IdentityMatchDialog
|
||||
key={matchingPerson?.login.toLowerCase() ?? "closed"}
|
||||
selection={matchingPerson}
|
||||
identityMap={settings.identity_map}
|
||||
gatewayEmails={liveSummary?.people.flatMap((person) => (person.email ? [person.email] : [])) ?? []}
|
||||
onClose={() => setMatchingPerson(null)}
|
||||
onSave={updateIdentity}
|
||||
/>
|
||||
)}
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,569 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { ChevronDown, Download, GitBranch, Search } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import {
|
||||
peopleCsv,
|
||||
isMatchedPerson,
|
||||
effortNote,
|
||||
estimateLabel,
|
||||
formatMoney,
|
||||
formatNumber,
|
||||
branchCostLabel,
|
||||
highestCostPulls,
|
||||
} from "./roiCalculatorData";
|
||||
import type { ROIPerson, ROIPull, ROISummary } from "./roiCalculatorData";
|
||||
|
||||
type PullSelection = { summary: ROISummary; onSelectPull: (pull: ROIPull) => void };
|
||||
|
||||
export function ROIOverview({
|
||||
summary,
|
||||
onSelectPull,
|
||||
onViewPeople,
|
||||
onViewBranches,
|
||||
}: PullSelection & {
|
||||
onViewPeople: () => void;
|
||||
onViewBranches: () => void;
|
||||
}) {
|
||||
const metrics = summary.metrics;
|
||||
const branches = summary.branch_metrics;
|
||||
const topPulls = highestCostPulls(summary.pulls);
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<div className="@container overflow-hidden rounded-xl border">
|
||||
<dl aria-label="Report overview" className="grid grid-cols-2 @min-[760px]:grid-cols-4">
|
||||
<MetricCard
|
||||
title="Gateway AI cost"
|
||||
value={formatMoney(metrics.total_spend)}
|
||||
description="All gateway usage in this period"
|
||||
primary
|
||||
/>
|
||||
<MetricCard
|
||||
title="Estimated effort"
|
||||
value={`${formatNumber(metrics.total_output_hours)} hrs`}
|
||||
description={summary.effort_basis === "without_ai" ? "Estimated without AI" : "Check estimate assumptions"}
|
||||
/>
|
||||
<MetricCard
|
||||
title="Merged changes"
|
||||
value={formatNumber(metrics.merged_prs)}
|
||||
description={`${metrics.estimated_prs} estimated`}
|
||||
/>
|
||||
<MetricCard
|
||||
title="People"
|
||||
value={formatNumber(metrics.people_with_prs)}
|
||||
description="Contributors to merged work"
|
||||
/>
|
||||
</dl>
|
||||
</div>
|
||||
<section aria-label="Cost coverage" className="space-y-4">
|
||||
<div className="space-y-1">
|
||||
<h2 className="text-base font-semibold">Where AI costs are matched</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
People use gateway account costs. Branches use tagged requests for these repositories.
|
||||
</p>
|
||||
</div>
|
||||
<div className="overflow-hidden rounded-xl border">
|
||||
<Table className="min-w-[600px]">
|
||||
<TableHeader className="bg-muted/40">
|
||||
<TableRow className="hover:bg-transparent">
|
||||
<TableHead className="px-4 text-xs">View</TableHead>
|
||||
<TableHead className="px-4 text-right text-xs">Matched cost</TableHead>
|
||||
<TableHead className="px-4 text-right text-xs">Unmatched cost</TableHead>
|
||||
<TableHead className="px-4 text-right text-xs">Changes matched</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<TableRow>
|
||||
<TableCell className="px-4 py-3">
|
||||
<Button variant="link" className="h-auto p-0" onClick={onViewPeople}>
|
||||
People
|
||||
</Button>
|
||||
</TableCell>
|
||||
<TableCell className="px-4 py-3 text-right font-medium tabular-nums">
|
||||
{formatMoney(metrics.matched_spend)}
|
||||
</TableCell>
|
||||
<TableCell className="px-4 py-3 text-right text-muted-foreground tabular-nums">
|
||||
{formatMoney(metrics.excluded_spend)}
|
||||
</TableCell>
|
||||
<TableCell className="px-4 py-3 text-right tabular-nums">
|
||||
{metrics.matched_prs} / {metrics.merged_prs}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell className="px-4 py-3">
|
||||
<Button variant="link" className="h-auto p-0" onClick={onViewBranches}>
|
||||
Branches
|
||||
</Button>
|
||||
</TableCell>
|
||||
<TableCell className="px-4 py-3 text-right font-medium tabular-nums">
|
||||
{formatMoney(branches?.spend)}
|
||||
</TableCell>
|
||||
<TableCell className="px-4 py-3 text-right text-muted-foreground tabular-nums">
|
||||
{formatMoney(branches?.unlinked_spend)}
|
||||
</TableCell>
|
||||
<TableCell className="px-4 py-3 text-right tabular-nums">
|
||||
{branches?.matched_pulls ?? 0} / {metrics.merged_prs}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</section>
|
||||
<ROIPulls
|
||||
summary={summary}
|
||||
pulls={topPulls}
|
||||
onSelectPull={onSelectPull}
|
||||
compact
|
||||
onViewBranches={onViewBranches}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ROIBranches({
|
||||
summary,
|
||||
onSelectPull,
|
||||
pulls,
|
||||
query,
|
||||
onQueryChange,
|
||||
matchedOnly,
|
||||
}: PullSelection & {
|
||||
pulls: ROIPull[];
|
||||
query: string;
|
||||
matchedOnly: boolean;
|
||||
onQueryChange: (query: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<section aria-label="Branch cost analysis" className="space-y-4">
|
||||
<div className="@container overflow-hidden rounded-xl border">
|
||||
<ROIMetrics summary={summary} branchMode />
|
||||
<ROIComparison summary={summary} branchMode />
|
||||
</div>
|
||||
</section>
|
||||
<ROIPulls
|
||||
summary={summary}
|
||||
pulls={pulls}
|
||||
query={query}
|
||||
matchedOnly={matchedOnly}
|
||||
onQueryChange={onQueryChange}
|
||||
onSelectPull={onSelectPull}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ROIPulls({
|
||||
summary,
|
||||
onSelectPull,
|
||||
pulls,
|
||||
query = "",
|
||||
onQueryChange,
|
||||
compact = false,
|
||||
matchedOnly = false,
|
||||
onViewBranches,
|
||||
}: PullSelection & {
|
||||
pulls: ROIPull[];
|
||||
query?: string;
|
||||
onQueryChange?: (query: string) => void;
|
||||
compact?: boolean;
|
||||
matchedOnly?: boolean;
|
||||
onViewBranches?: () => void;
|
||||
}) {
|
||||
const changeName = summary.source_provider === "gitlab" ? "merge request" : "pull request";
|
||||
const [pagination, setPagination] = React.useState({ query, visibleCount: 10 });
|
||||
const visibleCount = pagination.query === query ? pagination.visibleCount : 10;
|
||||
const metrics = summary.metrics;
|
||||
const emptyMessage = compact
|
||||
? "No merged changes with tagged costs yet. Open Branches to see how to add tags."
|
||||
: `No merged ${changeName}s ${matchedOnly ? "from matched people " : ""}in this period.`;
|
||||
return (
|
||||
<section aria-label={`Merged ${changeName}s`} className="space-y-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-4">
|
||||
<div className="space-y-1">
|
||||
<h2 className="text-base font-semibold">{compact ? "Highest-cost changes" : "Costs by branch"}</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{compact
|
||||
? "Merged work ranked by tagged AI cost"
|
||||
: `${pulls.length} of ${metrics.merged_prs} ${changeName}s`}
|
||||
</p>
|
||||
</div>
|
||||
{compact ? (
|
||||
<Button variant="outline" onClick={onViewBranches}>
|
||||
View all branches
|
||||
</Button>
|
||||
) : (
|
||||
<div className="relative w-full sm:w-64">
|
||||
<Search
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground"
|
||||
/>
|
||||
<Input
|
||||
aria-label={`Search ${changeName}s`}
|
||||
className="pl-9"
|
||||
placeholder={`Search ${changeName}s`}
|
||||
type="search"
|
||||
value={query}
|
||||
onChange={(event) => onQueryChange?.(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="overflow-hidden rounded-xl border">
|
||||
<Table className="min-w-[600px] table-fixed">
|
||||
<TableHeader className="bg-muted/40">
|
||||
<TableRow className="hover:bg-transparent">
|
||||
<TableHead className="w-3/5 px-4 text-xs text-muted-foreground">
|
||||
{changeName === "merge request" ? "Merge request" : "Pull request"}
|
||||
</TableHead>
|
||||
<TableHead className="px-4 text-right text-xs text-muted-foreground">AI cost</TableHead>
|
||||
<TableHead className="px-4 text-right text-xs text-muted-foreground">Estimated effort</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{pulls.slice(0, visibleCount).map((pull) => (
|
||||
<TableRow key={`${pull.repo}#${pull.number}`}>
|
||||
<TableCell className="whitespace-normal px-4 py-3">
|
||||
<Button
|
||||
aria-label={`Open estimate for ${pull.repo} ${changeName} ${pull.number}`}
|
||||
className="h-auto w-full justify-start whitespace-normal p-0 text-left"
|
||||
variant="link"
|
||||
onClick={() => onSelectPull(pull)}
|
||||
>
|
||||
<span className="min-w-0 space-y-1">
|
||||
<span className="block break-words font-medium leading-5">{pull.title}</span>
|
||||
<span className="block break-all text-xs font-normal text-muted-foreground">
|
||||
{pull.repo} #{pull.number} · {pull.login}
|
||||
</span>
|
||||
{!compact && pull.source_branch && (
|
||||
<span className="flex items-start gap-1.5 text-xs font-normal text-muted-foreground">
|
||||
<GitBranch aria-hidden="true" className="mt-0.5 size-3 shrink-0" />
|
||||
<span className="break-all">{pull.source_branch}</span>
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</Button>
|
||||
</TableCell>
|
||||
<TableCell
|
||||
className={`px-4 py-3 text-right tabular-nums ${pull.branch_cost?.status === "matched" ? "font-medium" : "whitespace-normal text-xs text-muted-foreground"}`}
|
||||
>
|
||||
{branchCostLabel(pull)}
|
||||
</TableCell>
|
||||
<TableCell className="px-4 py-3 text-right tabular-nums">{estimateLabel(pull.estimate)}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
{pulls.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell className="h-32 text-center text-muted-foreground" colSpan={3}>
|
||||
{query ? `No matching ${changeName}s. Try another search.` : emptyMessage}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
{pulls.length > visibleCount && (
|
||||
<div className="flex items-center justify-between gap-4 border-t px-4 py-3">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Showing {Math.min(visibleCount, pulls.length)} of {pulls.length}
|
||||
</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
setPagination((current) => ({
|
||||
query,
|
||||
visibleCount: (current.query === query ? current.visibleCount : 10) + 25,
|
||||
}))
|
||||
}
|
||||
>
|
||||
Load more {changeName}s
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function ROIMetrics({ summary, branchMode }: { summary: ROISummary; branchMode: boolean }) {
|
||||
const branches = summary.branch_metrics;
|
||||
const metrics = summary.metrics;
|
||||
return (
|
||||
<dl aria-label="Spend and estimated engineering effort" className="grid grid-cols-2 @min-[760px]:grid-cols-4">
|
||||
<MetricCard
|
||||
title="Cost / estimated hour"
|
||||
value={formatMoney(branchMode ? branches?.cost_per_hour : metrics.cost_per_hour)}
|
||||
description="AI cost ÷ estimated effort"
|
||||
primary
|
||||
/>
|
||||
<MetricCard
|
||||
title="Matched AI costs"
|
||||
value={formatMoney(branchMode ? branches?.spend : metrics.matched_spend)}
|
||||
description={branchMode ? "Recorded branch requests" : "Matched gateway accounts"}
|
||||
/>
|
||||
<MetricCard
|
||||
title="Estimated effort"
|
||||
value={`${formatNumber(branchMode ? branches?.hours : metrics.output_hours)} hrs`}
|
||||
description={summary.effort_basis === "without_ai" ? "Estimated without AI" : "Check estimate assumptions"}
|
||||
/>
|
||||
<MetricCard
|
||||
title={branchMode ? "Branch coverage" : "Email coverage"}
|
||||
value={`${branchMode ? branches?.matched_pulls ?? 0 : metrics.matched_prs} / ${metrics.merged_prs}`}
|
||||
description={branchMode ? "Changes with recorded costs" : "Changes with email matches"}
|
||||
/>
|
||||
</dl>
|
||||
);
|
||||
}
|
||||
|
||||
function MetricCard({
|
||||
title,
|
||||
value,
|
||||
description,
|
||||
primary = false,
|
||||
}: {
|
||||
title: string;
|
||||
value: string;
|
||||
description: string;
|
||||
primary?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className={`min-w-0 space-y-2 p-4 sm:p-5 ${primary ? "bg-muted/30" : ""}`}>
|
||||
<dt className="min-h-8 text-xs font-medium text-muted-foreground @min-[360px]:min-h-0">{title}</dt>
|
||||
<dd className="whitespace-nowrap text-xl font-semibold tracking-tight tabular-nums @min-[600px]:text-2xl">
|
||||
{value}
|
||||
</dd>
|
||||
<dd className="text-xs leading-5 text-muted-foreground">{description}</dd>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ROIComparison({
|
||||
summary,
|
||||
branchMode,
|
||||
onViewPeople,
|
||||
}: {
|
||||
summary: ROISummary;
|
||||
branchMode: boolean;
|
||||
onViewPeople?: () => void;
|
||||
}) {
|
||||
const branches = summary.branch_metrics;
|
||||
const metrics = summary.metrics;
|
||||
const unavailableRate =
|
||||
metrics.output_hours > 0
|
||||
? "Spend per estimated hour is unavailable until all selected repositories can be read."
|
||||
: "Match gateway accounts to calculate costs per estimated hour.";
|
||||
return (
|
||||
<div className="border-t">
|
||||
{!branchMode && metrics.cohort_people === 0 && onViewPeople && (
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 border-b px-5 py-3">
|
||||
<p className="text-sm text-muted-foreground">Match people to gateway accounts to see their AI costs.</p>
|
||||
<Button variant="outline" size="sm" onClick={onViewPeople}>
|
||||
Match people
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<details className="group">
|
||||
<summary className="flex cursor-pointer list-none flex-wrap items-center gap-x-3 gap-y-1 px-5 py-3 text-xs [&::-webkit-details-marker]:hidden">
|
||||
<span className="flex items-center gap-2 font-medium">
|
||||
<ChevronDown aria-hidden="true" className="size-3.5 shrink-0 transition-transform group-open:rotate-180" />
|
||||
How this is calculated
|
||||
</span>
|
||||
<span className="text-muted-foreground sm:ml-auto">
|
||||
{formatMoney(branchMode ? branches?.unlinked_spend : metrics.excluded_spend)}{" "}
|
||||
{branchMode ? "in unmatched costs" : "excluded from calculation"}
|
||||
</span>
|
||||
</summary>
|
||||
<div className="space-y-3 border-t bg-muted/20 px-5 py-4 text-sm leading-6 text-muted-foreground">
|
||||
<p>{effortNote(summary.effort_basis)}</p>
|
||||
{branchMode ? (
|
||||
<>
|
||||
<p>
|
||||
Only branches with matched request costs and complete effort estimates enter the calculation. Costs
|
||||
cover retained requests in this report’s UTC dates, not the branch’s lifetime.
|
||||
</p>
|
||||
<p>
|
||||
Open a change below to find its repository and branch tags. Send both with each gateway request. Email
|
||||
matching is not required. Shared branches stay ambiguous so their costs are not counted twice.
|
||||
</p>
|
||||
<p>
|
||||
{formatMoney(branches?.total_tagged_spend)} in tagged costs was found for these repositories. Costs
|
||||
without a unique, fully estimated change stay unmatched.
|
||||
</p>
|
||||
{(summary.unlinked_branches?.length ?? 0) > 0 && (
|
||||
<div className="space-y-2 border-t pt-3">
|
||||
<h3 className="font-medium text-foreground">Unmatched branches</h3>
|
||||
<ul className="divide-y">
|
||||
{summary.unlinked_branches?.map((row) => (
|
||||
<li
|
||||
key={`${row.repo}:${row.branch}`}
|
||||
className="flex items-start justify-between gap-4 py-2 text-xs"
|
||||
>
|
||||
<span className="min-w-0 break-all">
|
||||
{row.repo}
|
||||
<br />
|
||||
{row.branch}
|
||||
</span>
|
||||
<span className="shrink-0 tabular-nums">{formatMoney(row.spend)}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<p>
|
||||
{metrics.cost_per_hour != null
|
||||
? `${formatMoney(metrics.matched_spend)} AI costs ÷ ${formatNumber(metrics.output_hours)} estimated hours = ${formatMoney(metrics.cost_per_hour)} per estimated hour.`
|
||||
: unavailableRate}
|
||||
</p>
|
||||
<p>
|
||||
Includes {metrics.cohort_people} matched {metrics.cohort_people === 1 ? "person" : "people"} with
|
||||
complete estimates. Costs include each person’s full gateway usage across repositories during this UTC
|
||||
period.
|
||||
</p>
|
||||
{onViewPeople && (
|
||||
<Button variant="link" className="h-auto p-0" onClick={onViewPeople}>
|
||||
Review email matches
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ROIPeopleView({
|
||||
summary,
|
||||
identityMap,
|
||||
onMatch,
|
||||
readOnly = false,
|
||||
matchedOnly = true,
|
||||
}: {
|
||||
summary: ROISummary;
|
||||
identityMap: Record<string, string>;
|
||||
onMatch: (person: ROIPerson, login: string) => void;
|
||||
readOnly?: boolean;
|
||||
matchedOnly?: boolean;
|
||||
}) {
|
||||
const people = matchedOnly ? summary.people.filter(isMatchedPerson) : summary.people;
|
||||
const exportCsv = () => {
|
||||
const url = URL.createObjectURL(new Blob([peopleCsv({ ...summary, people })], { type: "text/csv;charset=utf-8" }));
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = "litellm-roi.csv";
|
||||
link.click();
|
||||
window.setTimeout(() => URL.revokeObjectURL(url), 1000);
|
||||
};
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="@container overflow-hidden rounded-xl border">
|
||||
<ROIMetrics summary={summary} branchMode={false} />
|
||||
<ROIComparison summary={summary} branchMode={false} />
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center justify-between gap-4">
|
||||
<div className="space-y-1">
|
||||
<h2 className="text-base font-semibold">People and account matches</h2>
|
||||
<p className="text-sm text-muted-foreground">Select a person to match their gateway email.</p>
|
||||
</div>
|
||||
<Button variant="outline" onClick={exportCsv}>
|
||||
<Download />
|
||||
Export CSV
|
||||
</Button>
|
||||
</div>
|
||||
<div className="overflow-hidden rounded-xl border">
|
||||
<Table className="min-w-[640px]">
|
||||
<TableHeader className="bg-muted/40">
|
||||
<TableRow className="hover:bg-transparent">
|
||||
<TableHead className="px-4 text-xs text-muted-foreground">Person</TableHead>
|
||||
<TableHead className="px-4 text-right text-xs text-muted-foreground">AI cost</TableHead>
|
||||
<TableHead className="px-4 text-right text-xs text-muted-foreground">Estimated effort</TableHead>
|
||||
<TableHead className="px-4 text-right text-xs text-muted-foreground">Cost / est. hour</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{people.map((person) => (
|
||||
<TableRow key={person.id}>
|
||||
<TableCell className="px-4 py-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{person.logins.length ? (
|
||||
person.logins.map((login) =>
|
||||
readOnly ? (
|
||||
<span key={login}>{login}</span>
|
||||
) : (
|
||||
<Button
|
||||
key={login}
|
||||
variant="link"
|
||||
className="h-auto p-0"
|
||||
onClick={() => onMatch(person, login)}
|
||||
>
|
||||
{login}
|
||||
</Button>
|
||||
),
|
||||
)
|
||||
) : (
|
||||
<span>Unassigned gateway spend</span>
|
||||
)}
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{isMatchedPerson(person) ? "Matched" : "Unmatched"}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{person.email || "No public email"}
|
||||
{person.logins.some((login) => identityMap[login.toLowerCase()]) ? " · Manual match" : ""}
|
||||
</p>
|
||||
</TableCell>
|
||||
<TableCell className="px-4 py-3 text-right tabular-nums">{formatMoney(person.spend)}</TableCell>
|
||||
<TableCell className="px-4 py-3 text-right tabular-nums">
|
||||
{person.estimated_prs > 0 ? `${formatNumber(person.hours)} hrs` : "—"}
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{person.prs} {person.prs === 1 ? "change" : "changes"}
|
||||
{person.pending_prs > 0 ? ` · ${person.pending_prs} pending` : ""}
|
||||
</p>
|
||||
</TableCell>
|
||||
<TableCell className="px-4 py-3 text-right tabular-nums">
|
||||
{formatMoney(person.cost_per_hour)}
|
||||
{!person.eligible && <p className="mt-1 text-xs text-muted-foreground">Not included</p>}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
{people.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell className="h-32 text-center text-muted-foreground" colSpan={4}>
|
||||
{matchedOnly
|
||||
? "No matched people in this period. Turn off the filter to see all contributors"
|
||||
: "No people in this period"}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
<details className="group rounded-xl border">
|
||||
<summary className="flex cursor-pointer list-none items-center gap-2 px-5 py-3 text-xs font-medium [&::-webkit-details-marker]:hidden">
|
||||
<ChevronDown aria-hidden="true" className="size-3.5 transition-transform group-open:rotate-180" />
|
||||
How email matching works
|
||||
</summary>
|
||||
<div className="space-y-3 border-t px-5 py-4 text-sm leading-6 text-muted-foreground">
|
||||
<p>
|
||||
Matches use the author’s public profile email
|
||||
{summary.source_provider === "gitlab"
|
||||
? "."
|
||||
: " or commit emails associated with their GitHub account."}{" "}
|
||||
Private, noreply, and ambiguous emails stay unmatched. Manual matches take priority.
|
||||
</p>
|
||||
<p>
|
||||
Costs include each person’s full gateway usage for this period. People without a spend record or with
|
||||
incomplete estimates are not included in the calculation.
|
||||
</p>
|
||||
<p>{effortNote(summary.effort_basis)}</p>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,566 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import React from "react";
|
||||
|
||||
import { SearchSelect } from "@/components/shared/SearchSelect";
|
||||
import { estimatorModelOptions } from "./roiCalculatorData";
|
||||
|
||||
import { apiClient } from "@/components/networking";
|
||||
import { extractErrorMessage } from "@/utils/errorUtils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import type { ROIRepository, ROIRepositoriesResponse, ROISettings, ROISettingsUpdate } from "./roiCalculatorData";
|
||||
|
||||
export default function ROISettingsPanel({
|
||||
accessToken,
|
||||
initialSettings,
|
||||
onboarding,
|
||||
onSaved,
|
||||
onReset,
|
||||
onStartSync,
|
||||
readOnly,
|
||||
syncDisabled,
|
||||
}: {
|
||||
accessToken: string | null;
|
||||
initialSettings: ROISettings;
|
||||
onboarding: boolean;
|
||||
onSaved: (settings: ROISettings) => void;
|
||||
onReset: (settings: ROISettings) => void;
|
||||
onStartSync: () => Promise<void>;
|
||||
readOnly: boolean;
|
||||
syncDisabled: boolean;
|
||||
}) {
|
||||
const [provider, setProvider] = React.useState<"github" | "gitlab">(initialSettings.source_provider ?? "github");
|
||||
const sourceName = provider === "gitlab" ? "GitLab" : "GitHub";
|
||||
const savedToken = provider === "gitlab" ? initialSettings.has_gitlab_token : initialSettings.has_github_token;
|
||||
const savedUrl = provider === "gitlab" ? initialSettings.gitlab_api_url : initialSettings.github_api_url;
|
||||
const initialStep = savedToken ? 1 : 0;
|
||||
const [step, setStep] = React.useState(initialSettings.ready ? 2 : initialStep);
|
||||
const [apiUrl, setApiUrl] = React.useState(savedUrl ?? "https://gitlab.com/api/v4");
|
||||
const [token, setToken] = React.useState("");
|
||||
const [clearToken, setClearToken] = React.useState(false);
|
||||
const [repos, setRepos] = React.useState(initialSettings.repos);
|
||||
const [model, setModel] = React.useState(initialSettings.estimator_model);
|
||||
const [prompt, setPrompt] = React.useState(initialSettings.estimator_prompt);
|
||||
const [backfillDays, setBackfillDays] = React.useState(String(initialSettings.backfill_days));
|
||||
const [intervalHours, setIntervalHours] = React.useState(
|
||||
String((initialSettings.update_interval_minutes ?? 1440) / 60),
|
||||
);
|
||||
const [estimatorKey, setEstimatorKey] = React.useState("");
|
||||
const [clearEstimatorKey, setClearEstimatorKey] = React.useState(false);
|
||||
const [repositoryName, setRepositoryName] = React.useState("");
|
||||
const [resetOpen, setResetOpen] = React.useState(false);
|
||||
const [repositoryQuery, setRepositoryQuery] = React.useState("");
|
||||
const [repositoryPage, setRepositoryPage] = React.useState(1);
|
||||
const [availableRepos, setAvailableRepos] = React.useState<ROIRepository[]>([]);
|
||||
const [hasMoreRepos, setHasMoreRepos] = React.useState(false);
|
||||
const [busy, setBusy] = React.useState(false);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
const [message, setMessage] = React.useState<string | null>(null);
|
||||
|
||||
const sourceUnchanged = provider === (initialSettings.source_provider ?? "github") && apiUrl === savedUrl;
|
||||
const credentialsSaved = sourceUnchanged && !token.trim() && !clearToken;
|
||||
const canLoadRepositories = credentialsSaved && (provider === "gitlab" || savedToken);
|
||||
const tokenHelp =
|
||||
provider === "gitlab"
|
||||
? "For private projects, use a token with read_api scope and project access."
|
||||
: "For private repositories, use a token with read access to contents and pull requests.";
|
||||
|
||||
const changeProvider = (next: "github" | "gitlab") => {
|
||||
setProvider(next);
|
||||
setApiUrl(
|
||||
next === "gitlab"
|
||||
? initialSettings.gitlab_api_url ?? "https://gitlab.com/api/v4"
|
||||
: initialSettings.github_api_url,
|
||||
);
|
||||
setToken("");
|
||||
setClearToken(false);
|
||||
setRepos([]);
|
||||
setAvailableRepos([]);
|
||||
setHasMoreRepos(false);
|
||||
};
|
||||
|
||||
const loadRepositories = async (page: number) => {
|
||||
if (!accessToken || !canLoadRepositories) return;
|
||||
try {
|
||||
setBusy(true);
|
||||
const response: ROIRepositoriesResponse = await apiClient.get("/roi-calculator/repositories", {
|
||||
accessToken,
|
||||
query: { query: repositoryQuery, page },
|
||||
});
|
||||
setAvailableRepos((current) => (page === 1 ? response.repositories : [...current, ...response.repositories]));
|
||||
setHasMoreRepos(response.has_more);
|
||||
setRepositoryPage(page);
|
||||
setError(null);
|
||||
} catch (reason) {
|
||||
setError(extractErrorMessage(reason));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const saveSettings = async () => {
|
||||
if (!accessToken || readOnly) return false;
|
||||
const tokenValue = clearToken ? null : token.trim() || undefined;
|
||||
const body: ROISettingsUpdate = {
|
||||
source_provider: provider,
|
||||
...(provider === "gitlab" ? { gitlab_api_url: apiUrl } : { github_api_url: apiUrl }),
|
||||
repos,
|
||||
estimator_model: model,
|
||||
estimator_prompt: prompt,
|
||||
backfill_days: Number(backfillDays),
|
||||
update_interval_minutes: Number(intervalHours) * 60,
|
||||
...(clearEstimatorKey ? { estimator_key: null } : {}),
|
||||
...(estimatorKey.trim() ? { estimator_key: estimatorKey.trim() } : {}),
|
||||
...(provider === "gitlab" ? { gitlab_token: tokenValue } : { github_token: tokenValue }),
|
||||
};
|
||||
try {
|
||||
setBusy(true);
|
||||
const updated: ROISettings = await apiClient.put("/roi-calculator/settings", { accessToken, body });
|
||||
onSaved(updated);
|
||||
setToken("");
|
||||
setEstimatorKey("");
|
||||
setClearEstimatorKey(false);
|
||||
setClearToken(false);
|
||||
setMessage("Settings saved.");
|
||||
setError(null);
|
||||
return true;
|
||||
} catch (reason) {
|
||||
setError(extractErrorMessage(reason));
|
||||
setMessage(null);
|
||||
return false;
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const submit = async (event: React.FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
if (!(await saveSettings())) return;
|
||||
if (onboarding && step === 0) {
|
||||
setStep(1);
|
||||
if (!(token.trim() || savedToken) || clearToken) return;
|
||||
try {
|
||||
const result = await apiClient.get<ROIRepositoriesResponse>("/roi-calculator/repositories", { accessToken });
|
||||
setAvailableRepos(result.repositories);
|
||||
setHasMoreRepos(result.has_more);
|
||||
setRepositoryPage(1);
|
||||
setStep(1);
|
||||
} catch (reason) {
|
||||
setError(extractErrorMessage(reason));
|
||||
}
|
||||
} else if (onboarding && step === 1) setStep(2);
|
||||
else if (onboarding) await onStartSync();
|
||||
};
|
||||
|
||||
const saveAndRun = async () => {
|
||||
if (await saveSettings()) await onStartSync();
|
||||
};
|
||||
|
||||
const testConnections = async () => {
|
||||
if (!(await saveSettings())) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
await apiClient.post("/roi-calculator/connections/test", { accessToken });
|
||||
setMessage("Gateway model and selected repositories are available.");
|
||||
} catch (reason) {
|
||||
setError(extractErrorMessage(reason));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const resetSetup = async () => {
|
||||
setBusy(true);
|
||||
try {
|
||||
const updated = await apiClient.post<ROISettings>("/roi-calculator/setup/reset", { accessToken });
|
||||
setRepos([]);
|
||||
setStep(0);
|
||||
setResetOpen(false);
|
||||
onReset(updated);
|
||||
} catch (reason) {
|
||||
setError(extractErrorMessage(reason));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleRepository = (name: string) => {
|
||||
setRepos((current) => (current.includes(name) ? current.filter((repo) => repo !== name) : [...current, name]));
|
||||
};
|
||||
|
||||
const formDisabled = busy || syncDisabled;
|
||||
const runDisabled = formDisabled || !repos.length || !model;
|
||||
const sourceUrlChanged = apiUrl !== savedUrl;
|
||||
const missingReplacementToken = savedToken && sourceUrlChanged && !token.trim();
|
||||
const stepReady = [true, repos.length > 0, Boolean(model)][step];
|
||||
const onboardingLabel = step < 2 ? "Continue" : "Start backfill";
|
||||
const submitLabel = onboarding ? onboardingLabel : "Save settings";
|
||||
|
||||
return (
|
||||
<Card className={onboarding ? "max-w-2xl" : "border-0 py-0 shadow-none ring-0"}>
|
||||
{onboarding && (
|
||||
<CardHeader>
|
||||
<h2 className="text-base leading-normal font-medium">
|
||||
{["Connect your repositories", "Choose repositories", "Choose an estimator"][step]}
|
||||
</h2>
|
||||
<CardDescription>
|
||||
Your gateway is already connected. Choose a source and an estimator for your first report.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
)}
|
||||
<CardContent className={onboarding ? "space-y-5" : "space-y-5 px-0"}>
|
||||
{error && (
|
||||
<p className="text-sm text-destructive" role="alert">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
{message && (
|
||||
<p className="text-sm text-emerald-700" role="status">
|
||||
{message}
|
||||
</p>
|
||||
)}
|
||||
{onboarding && (
|
||||
<p className="text-sm text-muted-foreground">Step {step + 1} of 3 · Source / Repositories / Estimator</p>
|
||||
)}
|
||||
<form className="space-y-5" onSubmit={(event) => void submit(event)}>
|
||||
<fieldset disabled={busy || syncDisabled || readOnly} className="space-y-5">
|
||||
{(!onboarding || step === 0) && (
|
||||
<section className="space-y-4">
|
||||
{!onboarding && <h3 className="font-semibold">Connection</h3>}
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="roi-source">Repository source</Label>
|
||||
<select
|
||||
id="roi-source"
|
||||
className="h-9 min-w-0 rounded-md border bg-background pl-3 pr-9 text-sm"
|
||||
value={provider}
|
||||
onChange={(event) => changeProvider(event.target.value as "github" | "gitlab")}
|
||||
>
|
||||
<option value="github">GitHub</option>
|
||||
<option value="gitlab">GitLab</option>
|
||||
</select>
|
||||
{provider !== (initialSettings.source_provider ?? "github") && !onboarding && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Switching source starts a new report and resets email matches.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<details>
|
||||
<summary className="cursor-pointer text-sm text-muted-foreground">Self-hosted {sourceName}</summary>
|
||||
<div className="mt-3 grid gap-2">
|
||||
<Label htmlFor="roi-github-url">{sourceName} API URL</Label>
|
||||
<Input
|
||||
disabled={readOnly}
|
||||
id="roi-github-url"
|
||||
value={apiUrl}
|
||||
onChange={(event) => setApiUrl(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</details>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="roi-github-token">{sourceName} token (optional for public repositories)</Label>
|
||||
<Input
|
||||
autoComplete="new-password"
|
||||
disabled={readOnly}
|
||||
id="roi-github-token"
|
||||
type="password"
|
||||
value={token}
|
||||
onChange={(event) => {
|
||||
setToken(event.target.value);
|
||||
setClearToken(false);
|
||||
}}
|
||||
placeholder={savedToken ? "Token saved" : `Enter a ${sourceName} token`}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{savedToken ? "A token is saved securely and is never shown here." : tokenHelp}
|
||||
</p>
|
||||
{missingReplacementToken && (
|
||||
<p className="text-xs text-amber-700">
|
||||
Changing the API URL clears the saved token. Enter a replacement token to keep access.
|
||||
</p>
|
||||
)}
|
||||
{savedToken && (
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
aria-label={`Clear saved ${sourceName} token`}
|
||||
checked={clearToken}
|
||||
disabled={readOnly}
|
||||
type="checkbox"
|
||||
onChange={(event) => setClearToken(event.target.checked)}
|
||||
/>
|
||||
Clear saved token
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
{(!onboarding || step === 1) && (
|
||||
<section className={onboarding ? "space-y-4" : "space-y-4 border-t pt-5"}>
|
||||
<h3 className="font-semibold">Repositories</h3>
|
||||
<Label className="sr-only" htmlFor="roi-repository-search">
|
||||
Search repositories
|
||||
</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
id="roi-repository-search"
|
||||
value={repositoryQuery}
|
||||
onChange={(event) => setRepositoryQuery(event.target.value)}
|
||||
placeholder="Search repositories"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={busy || !canLoadRepositories}
|
||||
onClick={() => void loadRepositories(1)}
|
||||
>
|
||||
Load repositories
|
||||
</Button>
|
||||
</div>
|
||||
{!canLoadRepositories && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{provider === "github" && !savedToken
|
||||
? "Save a GitHub token to browse repositories, or add a public repository by name."
|
||||
: "Save the source and connection settings before loading repositories."}
|
||||
</p>
|
||||
)}
|
||||
{repos.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{repos.map((repo) => (
|
||||
<Button
|
||||
key={repo}
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={readOnly}
|
||||
onClick={() => toggleRepository(repo)}
|
||||
aria-label={`Remove ${repo}`}
|
||||
>
|
||||
{repo} ×
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<Label htmlFor="roi-repository-name">Add a repository by name</Label>
|
||||
<div className="mt-2 flex gap-2">
|
||||
<Input
|
||||
id="roi-repository-name"
|
||||
aria-label="Repository name"
|
||||
placeholder={provider === "gitlab" ? "group/subgroup/project" : "owner/repository"}
|
||||
value={repositoryName}
|
||||
onChange={(e) => setRepositoryName(e.target.value)}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={!repositoryName.trim()}
|
||||
onClick={() => {
|
||||
if (!repos.includes(repositoryName.trim())) setRepos([...repos, repositoryName.trim()]);
|
||||
setRepositoryName("");
|
||||
}}
|
||||
>
|
||||
Add
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{availableRepos.length > 0 && (
|
||||
<div className="max-h-56 space-y-2 overflow-y-auto rounded-md border p-3">
|
||||
{availableRepos.map((repository) => (
|
||||
<label key={repository.name} className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
aria-label={`Select ${repository.name}`}
|
||||
checked={repos.includes(repository.name)}
|
||||
disabled={readOnly}
|
||||
type="checkbox"
|
||||
onChange={() => toggleRepository(repository.name)}
|
||||
/>
|
||||
<span>{repository.name}</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{repository.visibility}
|
||||
{repository.archived ? " · archived" : ""}
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{hasMoreRepos && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="link"
|
||||
className="w-fit px-0"
|
||||
disabled={busy}
|
||||
onClick={() => void loadRepositories(repositoryPage + 1)}
|
||||
>
|
||||
Load more repositories
|
||||
</Button>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
{(!onboarding || step === 2) && (
|
||||
<section className={onboarding ? "space-y-4" : "space-y-4 border-t pt-5"}>
|
||||
{!onboarding && <h3 className="font-semibold">Estimation and updates</h3>}
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="roi-estimator-model">Estimator model</Label>
|
||||
<SearchSelect
|
||||
inputId="roi-estimator-model"
|
||||
options={estimatorModelOptions(initialSettings)}
|
||||
value={model}
|
||||
onValueChange={(value) => setModel(value ?? "")}
|
||||
placeholder="Search estimator models"
|
||||
emptyText="No matching models configured on this gateway"
|
||||
disabled={readOnly}
|
||||
className="h-9"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
We recommend GPT-6 Luna for estimating PR effort. Choose a model configured on your gateway.
|
||||
</p>
|
||||
</div>
|
||||
<details>
|
||||
<summary className="cursor-pointer text-sm text-muted-foreground">Advanced estimator options</summary>
|
||||
<div className="mt-3 grid gap-2">
|
||||
<Label htmlFor="roi-estimator-prompt">Estimator prompt</Label>
|
||||
<Textarea
|
||||
id="roi-estimator-prompt"
|
||||
rows={5}
|
||||
disabled={readOnly}
|
||||
value={prompt}
|
||||
onChange={(event) => setPrompt(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</details>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="grid content-start gap-2">
|
||||
<Label htmlFor="roi-backfill-days">Backfill days</Label>
|
||||
<Input
|
||||
id="roi-backfill-days"
|
||||
min={1}
|
||||
max={3650}
|
||||
type="number"
|
||||
disabled={readOnly}
|
||||
value={backfillDays}
|
||||
onChange={(event) => setBackfillDays(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="roi-interval">Update interval (hours)</Label>
|
||||
<Input
|
||||
id="roi-interval"
|
||||
type="number"
|
||||
min={0}
|
||||
max={720}
|
||||
step="any"
|
||||
required
|
||||
value={intervalHours}
|
||||
onChange={(e) => setIntervalHours(e.target.value)}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Use 0 for manual updates. Automatic updates require at least 5 minutes and a running gateway.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<details>
|
||||
<summary className="cursor-pointer text-sm text-muted-foreground">Advanced settings</summary>
|
||||
<div className="mt-3 space-y-3">
|
||||
<Label htmlFor="roi-estimator-key">Estimator API key</Label>
|
||||
<Input
|
||||
id="roi-estimator-key"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
value={estimatorKey}
|
||||
onChange={(e) => {
|
||||
setEstimatorKey(e.target.value);
|
||||
setClearEstimatorKey(false);
|
||||
}}
|
||||
placeholder={initialSettings.has_estimator_key ? "Key saved" : "Optional gateway key"}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Defaults to the gateway admin key. Use a dedicated inference key to separate estimation costs from
|
||||
people's spend.
|
||||
</p>
|
||||
{initialSettings.has_estimator_key && (
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={clearEstimatorKey}
|
||||
onChange={(e) => setClearEstimatorKey(e.target.checked)}
|
||||
/>
|
||||
Use gateway admin key instead
|
||||
</label>
|
||||
)}
|
||||
<Button type="button" variant="link" onClick={() => setPrompt(initialSettings.default_prompt)}>
|
||||
Reset prompt
|
||||
</Button>
|
||||
{!onboarding && !readOnly && (
|
||||
<Button type="button" variant="outline" onClick={() => setResetOpen(true)}>
|
||||
Restart setup
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</details>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Estimates use descriptions, file counts, and commit messages, without source code. Hours represent
|
||||
estimated effort without AI, not measured hours saved.
|
||||
</p>
|
||||
</section>
|
||||
)}
|
||||
{!readOnly && (
|
||||
<div className="flex flex-wrap gap-2 border-t pt-5">
|
||||
{onboarding && step > 0 && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={busy || syncDisabled}
|
||||
onClick={() => setStep(step - 1)}
|
||||
>
|
||||
Back
|
||||
</Button>
|
||||
)}
|
||||
<Button disabled={formDisabled || (onboarding && !stepReady)} type="submit">
|
||||
{busy ? "Saving…" : submitLabel}
|
||||
</Button>
|
||||
{!onboarding && (
|
||||
<Button type="button" variant="outline" onClick={() => void testConnections()}>
|
||||
Test connections
|
||||
</Button>
|
||||
)}
|
||||
{!onboarding && (
|
||||
<Button disabled={runDisabled} type="button" variant="outline" onClick={() => void saveAndRun()}>
|
||||
Save and run analysis
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</fieldset>
|
||||
</form>
|
||||
<Dialog open={resetOpen} onOpenChange={setResetOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Restart setup?</DialogTitle>
|
||||
<DialogDescription>
|
||||
Clear reports and repository selections. Saved connections and cached estimates will be kept.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" disabled={busy} onClick={() => setResetOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button disabled={busy} onClick={() => void resetSetup()}>
|
||||
Restart setup
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,6 +0,0 @@
|
|||
import { createParser } from "nuqs";
|
||||
|
||||
export const parseAsDemoFlag = createParser({
|
||||
parse: (value) => (value === "1" ? true : null),
|
||||
serialize: () => "1",
|
||||
});
|
||||
|
|
@ -1,234 +0,0 @@
|
|||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
change,
|
||||
duration,
|
||||
money,
|
||||
visiblePeople,
|
||||
weeklyMerges,
|
||||
filterObservedPulls,
|
||||
reportPeople,
|
||||
recordedBranches,
|
||||
type ObservedPerson,
|
||||
type ObservedSnapshot,
|
||||
} from "./observedData";
|
||||
import { createObservedDemo } from "./observedDemo";
|
||||
|
||||
const period = (merged: number, spend: number | null) => ({
|
||||
merged_prs: merged,
|
||||
prs_per_week: merged / 4,
|
||||
median_merge_hours: null,
|
||||
direct_authored: merged,
|
||||
declared_agent_owned: 0,
|
||||
gateway_recorded_spend: spend ?? 0,
|
||||
recorded_spend_per_attributed_pr: spend !== null && merged > 0 ? spend / merged : null,
|
||||
spend_observation: spend === null ? ("no_records" as const) : ("records_present" as const),
|
||||
pr_urls: [],
|
||||
});
|
||||
const person = (name: string, merged: number, spend: number | null): ObservedPerson => ({
|
||||
name,
|
||||
email: `${name}@example.test`,
|
||||
logins: [`old-${name}`],
|
||||
periods: { current: period(merged, spend), previous: period(0, null), last_year: period(0, null) },
|
||||
});
|
||||
|
||||
describe("observed ROI metrics", () => {
|
||||
it("shows the same contributors when the browser has no Map.groupBy", () => {
|
||||
const sample = { ...createObservedDemo(7), people: [] };
|
||||
const expected = reportPeople(sample, false);
|
||||
const legacyMap = new Proxy(Map, {
|
||||
get: (target, key, receiver) => (key === "groupBy" ? undefined : Reflect.get(target, key, receiver)),
|
||||
});
|
||||
vi.stubGlobal("Map", legacyMap);
|
||||
try {
|
||||
expect(reportPeople(sample, false)).toEqual(expected);
|
||||
} finally {
|
||||
vi.unstubAllGlobals();
|
||||
}
|
||||
});
|
||||
|
||||
it("filters by attributed changes across providers, independently of spend or author names", () => {
|
||||
const sample = createObservedDemo(7);
|
||||
const external = {
|
||||
...sample.pulls.current[0],
|
||||
url: "https://gitlab.com/demo/api/-/merge_requests/999",
|
||||
author: "alex-demo",
|
||||
agent: false,
|
||||
connection_id: "demo-gitlab",
|
||||
};
|
||||
const report = { ...sample, pulls: { ...sample.pulls, current: [...sample.pulls.current, external] } };
|
||||
const noSpend = {
|
||||
...report,
|
||||
people: report.people.map((row) => ({
|
||||
...row,
|
||||
periods: {
|
||||
...row.periods,
|
||||
current: { ...row.periods.current, gateway_recorded_spend: 0, spend_observation: "no_records" as const },
|
||||
},
|
||||
})),
|
||||
};
|
||||
expect(filterObservedPulls(noSpend, "current", true)).toEqual(sample.pulls.current);
|
||||
expect(filterObservedPulls(noSpend, "current", false)).toEqual(report.pulls.current);
|
||||
expect(filterObservedPulls(noSpend, "current", true).some((pull) => pull.agent)).toBe(true);
|
||||
expect(reportPeople(noSpend, true).map((row) => row.email)).toEqual(sample.people.map((row) => row.email));
|
||||
});
|
||||
|
||||
it("keeps unmatched identities separate by host, with unknown spend and accurate periods", () => {
|
||||
const sample = createObservedDemo(7);
|
||||
const external = {
|
||||
...sample.pulls.current[0],
|
||||
author: "contributor",
|
||||
agent: false,
|
||||
connection_id: "gitlab-public",
|
||||
url: "https://gitlab.com/demo/api/-/merge_requests/999",
|
||||
merge_hours: 4,
|
||||
};
|
||||
const otherHost = {
|
||||
...external,
|
||||
connection_id: "gitlab-private",
|
||||
url: "https://git.example.test/demo/api/-/merge_requests/999",
|
||||
merge_hours: null,
|
||||
};
|
||||
const requested = {
|
||||
...external,
|
||||
url: "https://gitlab.com/demo/api/-/merge_requests/1000",
|
||||
author: "devin-ai",
|
||||
agent: true,
|
||||
requester: "contributor",
|
||||
merge_hours: 8,
|
||||
};
|
||||
const unassigned = { ...requested, url: "https://gitlab.com/demo/api/-/merge_requests/1001", requester: "" };
|
||||
const report = {
|
||||
...sample,
|
||||
pulls: {
|
||||
...sample.pulls,
|
||||
current: [...sample.pulls.current, external, otherHost, requested, unassigned],
|
||||
previous: [external],
|
||||
},
|
||||
};
|
||||
const outsiders = reportPeople(report, false).filter((row) => !row.matched);
|
||||
expect(outsiders).toHaveLength(2);
|
||||
expect(new Set(outsiders.map((row) => row.id)).size).toBe(2);
|
||||
const publicPerson = outsiders.find((row) => row.host === "gitlab.com")!;
|
||||
const expectedCurrent = {
|
||||
merged_prs: 2,
|
||||
prs_per_week: 2,
|
||||
median_merge_hours: 6,
|
||||
direct_authored: 1,
|
||||
declared_agent_owned: 1,
|
||||
spend_observation: "no_records",
|
||||
recorded_spend_per_attributed_pr: null,
|
||||
};
|
||||
expect(publicPerson.periods.current).toMatchObject(expectedCurrent);
|
||||
expect(publicPerson.periods.previous.merged_prs).toBe(1);
|
||||
expect(outsiders.find((row) => row.host === "git.example.test")!.periods.current.median_merge_hours).toBeNull();
|
||||
expect(filterObservedPulls(report, "current", false)).toContain(unassigned);
|
||||
});
|
||||
|
||||
it("filters branch spend using matched change ownership and the full repository and branch key", () => {
|
||||
const sample = createObservedDemo(7);
|
||||
const own = sample.pulls.current[0];
|
||||
const externalCost = { ...own.branch_cost, repo: "gitlab.com/outside/service", spend: 99 };
|
||||
const external = {
|
||||
...own,
|
||||
url: "https://gitlab.com/outside/service/-/merge_requests/999",
|
||||
branch_cost: externalCost,
|
||||
};
|
||||
const shared = { repo: own.branch_cost.repo, branch: "shared", spend: 12, requests: 4 };
|
||||
const ambiguous = {
|
||||
...own,
|
||||
source_branch: shared.branch,
|
||||
branch_cost: { ...shared, status: "ambiguous" as const },
|
||||
};
|
||||
const report = {
|
||||
...sample,
|
||||
pulls: { ...sample.pulls, current: [ambiguous, external] },
|
||||
unlinked_branches: [shared, { ...shared, branch: "unowned" }],
|
||||
};
|
||||
expect(recordedBranches(report, true)).toEqual([shared]);
|
||||
expect(recordedBranches(report, false).map((row) => row.branch)).toEqual([
|
||||
externalCost.branch,
|
||||
"shared",
|
||||
"unowned",
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not claim infinite growth when the baseline is missing", () => {
|
||||
expect(change(12, 0)).toBeNull();
|
||||
expect(change(0, 0)).toBeNull();
|
||||
expect(change(15, 10)).toBe(50);
|
||||
expect(change(0, 10)).toBe(-100);
|
||||
});
|
||||
|
||||
it("distinguishes missing cost, measured zero, and small nonzero spend", () => {
|
||||
expect(money(null)).toBe("Unavailable");
|
||||
expect(money(0)).toBe("$0.00");
|
||||
expect(money(0.001)).toBe("<$0.01");
|
||||
expect(money(1.235)).toBe("$1.24");
|
||||
expect(duration(null)).toBe("Unavailable");
|
||||
});
|
||||
|
||||
it("searches historical identities and sorts without changing the source", () => {
|
||||
const people = [person("Ari", 2, 8), person("Bea", 10, 0), person("Cam", 0, null)];
|
||||
expect(visiblePeople(people, " OLD-ARI ", "merged").map((row) => row.name)).toEqual(["Ari"]);
|
||||
expect(visiblePeople(people, "", "merged").map((row) => row.name)).toEqual(["Bea", "Ari", "Cam"]);
|
||||
expect(visiblePeople(people, "", "cost").map((row) => row.name)).toEqual(["Ari", "Bea", "Cam"]);
|
||||
expect(people.map((row) => row.name)).toEqual(["Ari", "Bea", "Cam"]);
|
||||
expect(visiblePeople(people, "missing", "name")).toEqual([]);
|
||||
});
|
||||
|
||||
it("keeps short elapsed merge times from rounding to zero hours", () => {
|
||||
expect(duration(null)).toBe("Unavailable");
|
||||
expect(duration(0)).toBe("0m");
|
||||
expect(duration(16 / 3600)).toBe("<1m");
|
||||
expect(duration(59 / 3600)).toBe("<1m");
|
||||
expect(duration(1 / 60)).toBe("1m");
|
||||
expect(duration(79 / 3600)).toBe("1.3m");
|
||||
expect(duration(140 / 3600)).toBe("2.3m");
|
||||
expect(duration(0.5)).toBe("30m");
|
||||
expect(duration(1)).toBe("1h");
|
||||
expect(duration(3.82)).toBe("3.8h");
|
||||
});
|
||||
|
||||
it.each([7, 28, 90])("includes every day of a %s-day range in its weekly chart", (days) => {
|
||||
const start = Date.parse("2026-01-01T00:00:00Z");
|
||||
const atDay = (day: number) => new Date(start + day * 86400000).toISOString();
|
||||
const pulls = Array.from({ length: days + 1 }, (_, day) => ({ merged_at: atDay(day) }));
|
||||
const snapshot = {
|
||||
periods: { current: { window: { start: "2026-01-01", end: atDay(days - 1).slice(0, 10) } } },
|
||||
pulls: { current: pulls },
|
||||
} as ObservedSnapshot;
|
||||
const weeks = weeklyMerges(snapshot, "current");
|
||||
expect(weeks).toHaveLength(Math.ceil(days / 7));
|
||||
expect(weeks.reduce((sum, count) => sum + count, 0)).toBe(days);
|
||||
expect(weeks.at(-1)).toBe(days % 7 || 7);
|
||||
});
|
||||
|
||||
it("aligns comparisons to their own UTC windows and counts each boundary once", () => {
|
||||
const currentStart = "2026-01-01";
|
||||
const previousStart = "2025-12-04";
|
||||
const pulls = [
|
||||
"2026-01-01T00:00:00Z",
|
||||
"2026-01-07T23:59:59Z",
|
||||
"2026-01-08T00:00:00Z",
|
||||
"2026-01-28T23:59:59Z",
|
||||
"2026-01-29T00:00:00Z",
|
||||
].map((merged_at, number) => ({
|
||||
number,
|
||||
merged_at,
|
||||
title: "Example",
|
||||
url: "https://github.com/example/repo/pull/1",
|
||||
author: "ari",
|
||||
agent: false,
|
||||
merge_hours: 1,
|
||||
}));
|
||||
const snapshot = {
|
||||
periods: {
|
||||
current: { window: { start: currentStart, end: "2026-01-28" } },
|
||||
previous: { window: { start: previousStart, end: "2025-12-31" } },
|
||||
},
|
||||
pulls: { current: pulls, previous: [{ ...pulls[0], merged_at: `${previousStart}T00:00:00Z` }] },
|
||||
} as ObservedSnapshot;
|
||||
expect(weeklyMerges(snapshot, "current")).toEqual([2, 1, 0, 1]);
|
||||
expect(weeklyMerges(snapshot, "previous")).toEqual([1, 0, 0, 0]);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,298 +0,0 @@
|
|||
import { z } from "zod";
|
||||
|
||||
const windowSchema = z.object({ start: z.string(), end: z.string() });
|
||||
const personPeriodFields = {
|
||||
merged_prs: z.number(),
|
||||
prs_per_week: z.number(),
|
||||
median_merge_hours: z.number().nullable(),
|
||||
direct_authored: z.number(),
|
||||
declared_agent_owned: z.number(),
|
||||
gateway_recorded_spend: z.number(),
|
||||
recorded_spend_per_attributed_pr: z.number().nullable(),
|
||||
spend_observation: z.enum(["records_present", "no_records"]),
|
||||
pr_urls: z.array(z.string()),
|
||||
};
|
||||
const personPeriodSchema = z.object(personPeriodFields);
|
||||
const periodFields = {
|
||||
window: windowSchema,
|
||||
merged_prs: z.number(),
|
||||
median_merge_hours: z.number().nullable(),
|
||||
human_authored: z.number(),
|
||||
agent_authored: z.number(),
|
||||
missing_author: z.number(),
|
||||
agents_without_requester: z.number(),
|
||||
matched_internal_prs: z.number(),
|
||||
new_bug_labeled_issues: z.number().nullable(),
|
||||
new_regression_labeled_issues: z.number().nullable(),
|
||||
explicitly_titled_revert_prs: z.number(),
|
||||
matched_users_recorded_spend: z.number(),
|
||||
spend_observation: z.enum(["records_present", "no_records"]),
|
||||
human_summary: z.object({ median_merge_hours: z.number().nullable() }),
|
||||
};
|
||||
const periodSchema = z.object(periodFields);
|
||||
const periods = <T extends z.ZodType>(schema: T) => z.object({ current: schema, previous: schema, last_year: schema });
|
||||
|
||||
const personFields = {
|
||||
name: z.string(),
|
||||
email: z.string(),
|
||||
logins: z.array(z.string()),
|
||||
periods: periods(personPeriodSchema),
|
||||
accounts: z.array(z.object({ connection_id: z.string(), login: z.string() })).optional(),
|
||||
};
|
||||
const branchCostFields = {
|
||||
repo: z.string(),
|
||||
branch: z.string(),
|
||||
spend: z.number().nullable(),
|
||||
requests: z.number(),
|
||||
status: z.enum(["matched", "unattributed", "ambiguous", "unavailable"]),
|
||||
};
|
||||
const branchSpendFields = { repo: z.string(), branch: z.string(), spend: z.number(), requests: z.number() };
|
||||
const pullFields = {
|
||||
connection_id: z.string().optional(),
|
||||
number: z.number(),
|
||||
title: z.string(),
|
||||
url: z
|
||||
.string()
|
||||
.url()
|
||||
.refine((url) => new URL(url).protocol === "https:"),
|
||||
author: z.string(),
|
||||
agent: z.boolean(),
|
||||
requester: z.string().optional(),
|
||||
merged_at: z.string(),
|
||||
merge_hours: z.number().nullable(),
|
||||
repo: z.string(),
|
||||
source_repo: z.string(),
|
||||
source_branch: z.string(),
|
||||
branch_cost: z.object(branchCostFields),
|
||||
};
|
||||
const snapshotFields = {
|
||||
source_provider: z.enum(["github", "gitlab", "mixed"]),
|
||||
repos: z.array(z.string()),
|
||||
unmatched_logins: z.array(z.string()),
|
||||
unlinked_branches: z.array(z.object(branchSpendFields)),
|
||||
captured_at: z.string(),
|
||||
periods: periods(periodSchema),
|
||||
people: z.array(z.object(personFields)),
|
||||
pulls: periods(z.array(z.object(pullFields))),
|
||||
};
|
||||
export const observedSnapshotSchema = z.object(snapshotFields);
|
||||
const settingsFields = {
|
||||
id: z.string().optional(),
|
||||
source_provider: z.enum(["github", "gitlab"]),
|
||||
api_url: z.string(),
|
||||
repos: z.array(z.string()),
|
||||
has_token: z.boolean(),
|
||||
connection_type: z.enum(["token", "app"]),
|
||||
update_interval_minutes: z.number(),
|
||||
ready: z.boolean(),
|
||||
};
|
||||
export const observedConnectionSchema = z.object(settingsFields);
|
||||
export const observedSettingsSchema = z.object({
|
||||
...settingsFields,
|
||||
connections: z.array(observedConnectionSchema).optional(),
|
||||
});
|
||||
export type ObservedConnection = z.infer<typeof observedConnectionSchema>;
|
||||
const statusFields = {
|
||||
running: z.boolean(),
|
||||
phase: z.string(),
|
||||
stage: z.string(),
|
||||
done: z.number(),
|
||||
total: z.number(),
|
||||
error: z.string().nullable(),
|
||||
finished_at: z.string().nullable().optional(),
|
||||
};
|
||||
export const observedStatusSchema = z.object(statusFields);
|
||||
export const observedReportResponseSchema = z.object({ report: observedSnapshotSchema.nullable() });
|
||||
export type ObservedSettings = z.infer<typeof observedSettingsSchema>;
|
||||
export type ObservedStatus = z.infer<typeof observedStatusSchema>;
|
||||
|
||||
export type ObservedSnapshot = z.infer<typeof observedSnapshotSchema>;
|
||||
export type ObservedPerson = ObservedSnapshot["people"][number];
|
||||
export type ObservedPull = ObservedSnapshot["pulls"]["current"][number];
|
||||
export type Period = keyof ObservedSnapshot["periods"];
|
||||
export type Comparison = Exclude<Period, "current">;
|
||||
export type PeopleSort = "merged" | "spend" | "cost" | "name";
|
||||
export type ReportPerson = ObservedPerson & { id: string; matched: boolean; host: string };
|
||||
|
||||
export const number = (value: number | null) =>
|
||||
value === null ? "Unavailable" : value.toLocaleString("en-US", { maximumFractionDigits: 1 });
|
||||
export function money(value: number | null) {
|
||||
if (value === null) return "Unavailable";
|
||||
if (value > 0 && value < 0.01) return "<$0.01";
|
||||
return value.toLocaleString("en-US", { style: "currency", currency: "USD", maximumFractionDigits: 2 });
|
||||
}
|
||||
export function duration(value: number | null) {
|
||||
if (value === null) return "Unavailable";
|
||||
if (value === 0) return "0m";
|
||||
if (value < 1 / 60) return "<1m";
|
||||
if (value < 1) return `${number(value * 60)}m`;
|
||||
return `${number(value)}h`;
|
||||
}
|
||||
export const change = (current: number, baseline: number) =>
|
||||
baseline === 0 ? null : ((current - baseline) / baseline) * 100;
|
||||
|
||||
export const accountLogins = (value: string) => [
|
||||
...new Set(
|
||||
value
|
||||
.split(/[\s,]+/)
|
||||
.map((login) => login.replace(/^@/, "").toLowerCase())
|
||||
.filter(Boolean),
|
||||
),
|
||||
];
|
||||
export const repositoryNames = (value: string) => [
|
||||
...new Set(
|
||||
value
|
||||
.split(/[\s,]+/)
|
||||
.filter(Boolean)
|
||||
.map((repo) => {
|
||||
const path = repo.replace(/^https:\/\/[^/]+\//, "");
|
||||
return path.replace(/\/$/, "").replace(/\.git$/, "");
|
||||
}),
|
||||
),
|
||||
];
|
||||
|
||||
export function dateRange(window: { start: string; end: string }) {
|
||||
const date = (value: string) =>
|
||||
new Date(`${value}T00:00:00Z`).toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
timeZone: "UTC",
|
||||
});
|
||||
return `${date(window.start)} – ${date(window.end)}, ${window.end.slice(0, 4)}`;
|
||||
}
|
||||
|
||||
export function visiblePeople<T extends ObservedPerson>(people: T[], query: string, sort: PeopleSort) {
|
||||
const value = (person: ObservedPerson) => {
|
||||
const current = person.periods.current;
|
||||
if (sort === "spend") return current.spend_observation === "no_records" ? -1 : current.gateway_recorded_spend;
|
||||
if (sort === "cost") return current.recorded_spend_per_attributed_pr ?? -1;
|
||||
return current.merged_prs;
|
||||
};
|
||||
return people
|
||||
.filter((person) =>
|
||||
[person.name, person.email, ...person.logins].join(" ").toLowerCase().includes(query.trim().toLowerCase()),
|
||||
)
|
||||
.toSorted((a, b) => (sort === "name" ? a.name.localeCompare(b.name) : value(b) - value(a)));
|
||||
}
|
||||
|
||||
export function weeklyMerges(snapshot: ObservedSnapshot, period: Period) {
|
||||
const start = Date.parse(`${snapshot.periods[period].window.start}T00:00:00Z`);
|
||||
const end = Date.parse(`${snapshot.periods[period].window.end}T00:00:00Z`) + 86_400_000;
|
||||
const days = (end - start) / 86_400_000;
|
||||
return Array.from(
|
||||
{ length: Math.ceil(days / 7) },
|
||||
(_, week) =>
|
||||
snapshot.pulls[period].filter((pull) => {
|
||||
const day = (Date.parse(pull.merged_at) - start) / 86_400_000;
|
||||
return day >= week * 7 && day < Math.min((week + 1) * 7, days);
|
||||
}).length,
|
||||
);
|
||||
}
|
||||
|
||||
export function syncMessage(status: ObservedStatus, report: ObservedSnapshot | null) {
|
||||
if (status.running) return status.total ? `${status.stage} · ${status.done} / ${status.total}` : status.stage;
|
||||
return report ? `Updated ${new Date(report.captured_at).toLocaleString()}` : "";
|
||||
}
|
||||
|
||||
export function recordedBranches(snapshot: ObservedSnapshot, matchedOnly = false) {
|
||||
const matched = snapshot.pulls.current.flatMap((pull) => {
|
||||
const cost = pull.branch_cost;
|
||||
if (cost.status !== "matched" || cost.spend === null) return [];
|
||||
return [{ repo: cost.repo, branch: cost.branch, spend: cost.spend, requests: cost.requests }];
|
||||
});
|
||||
const rows = [
|
||||
...new Map([...matched, ...snapshot.unlinked_branches].map((row) => [`${row.repo}\n${row.branch}`, row])).values(),
|
||||
];
|
||||
const keys = new Set(
|
||||
filterObservedPulls(snapshot, "current", true).map(
|
||||
(pull) => `${pull.branch_cost.repo}\n${pull.branch_cost.branch}`,
|
||||
),
|
||||
);
|
||||
return rows
|
||||
.filter((row) => !matchedOnly || keys.has(`${row.repo}\n${row.branch}`))
|
||||
.toSorted((a, b) => b.spend - a.spend);
|
||||
}
|
||||
|
||||
export function filterObservedPulls(snapshot: ObservedSnapshot, period: Period, matchedOnly: boolean) {
|
||||
if (!matchedOnly) return snapshot.pulls[period];
|
||||
const urls = new Set(snapshot.people.flatMap((person) => person.periods[period].pr_urls));
|
||||
return snapshot.pulls[period].filter((pull) => urls.has(pull.url));
|
||||
}
|
||||
|
||||
function unmatchedPeriod(
|
||||
pulls: ObservedPull[],
|
||||
window: { start: string; end: string },
|
||||
): ObservedPerson["periods"]["current"] {
|
||||
const hours = pulls
|
||||
.flatMap((pull) => (pull.merge_hours === null ? [] : [pull.merge_hours]))
|
||||
.toSorted((a, b) => a - b);
|
||||
const middle = Math.floor(hours.length / 2);
|
||||
const median = hours.length % 2 ? hours[middle] : (hours[middle - 1] + hours[middle]) / 2;
|
||||
const days = (Date.parse(window.end) - Date.parse(window.start)) / 86_400_000 + 1;
|
||||
return {
|
||||
merged_prs: pulls.length,
|
||||
prs_per_week: (pulls.length * 7) / days,
|
||||
median_merge_hours: hours.length ? median : null,
|
||||
direct_authored: pulls.filter((pull) => !pull.agent).length,
|
||||
declared_agent_owned: pulls.filter((pull) => pull.agent).length,
|
||||
gateway_recorded_spend: 0,
|
||||
recorded_spend_per_attributed_pr: null,
|
||||
spend_observation: "no_records",
|
||||
pr_urls: pulls.map((pull) => pull.url),
|
||||
};
|
||||
}
|
||||
|
||||
const ownerLogin = (pull: ObservedPull) => (pull.agent ? pull.requester ?? "" : pull.author);
|
||||
const accountKey = (pull: ObservedPull) =>
|
||||
`${pull.connection_id ?? new URL(pull.url).origin}\n${ownerLogin(pull).toLowerCase()}`;
|
||||
|
||||
export function reportPeople(snapshot: ObservedSnapshot, matchedOnly: boolean): ReportPerson[] {
|
||||
const matched = snapshot.people.map((person) => ({ ...person, id: person.email, matched: true, host: "" }));
|
||||
if (matchedOnly) return matched;
|
||||
const unmatchedGroups = (period: Period) => {
|
||||
const urls = new Set(snapshot.people.flatMap((person) => person.periods[period].pr_urls));
|
||||
const pulls = snapshot.pulls[period].filter((pull) => !urls.has(pull.url) && ownerLogin(pull));
|
||||
const sorted = pulls
|
||||
.map((pull) => ({ key: accountKey(pull), pull }))
|
||||
.toSorted((a, b) => a.key.localeCompare(b.key));
|
||||
const starts = sorted.flatMap((entry, index) =>
|
||||
index === 0 || entry.key !== sorted[index - 1].key ? [index] : [],
|
||||
);
|
||||
return new Map(
|
||||
starts.map((start, index) => [
|
||||
sorted[start].key,
|
||||
sorted.slice(start, starts[index + 1]).map((entry) => entry.pull),
|
||||
]),
|
||||
);
|
||||
};
|
||||
const groups = {
|
||||
current: unmatchedGroups("current"),
|
||||
previous: unmatchedGroups("previous"),
|
||||
last_year: unmatchedGroups("last_year"),
|
||||
};
|
||||
const accounts = new Map([...groups.current, ...groups.previous, ...groups.last_year]);
|
||||
const unmatched = [...accounts].map(([id, pulls]) => {
|
||||
const pull = pulls[0];
|
||||
const login = ownerLogin(pull);
|
||||
const period = (key: Period) => unmatchedPeriod(groups[key].get(id) ?? [], snapshot.periods[key].window);
|
||||
return {
|
||||
id,
|
||||
matched: false,
|
||||
host: new URL(pull.url).host,
|
||||
name: login,
|
||||
email: "",
|
||||
logins: [login],
|
||||
accounts: [{ connection_id: pull.connection_id ?? "", login }],
|
||||
periods: { current: period("current"), previous: period("previous"), last_year: period("last_year") },
|
||||
};
|
||||
});
|
||||
return [...matched, ...unmatched];
|
||||
}
|
||||
|
||||
export function changeTerms(provider: ObservedSnapshot["source_provider"]) {
|
||||
if (provider === "mixed")
|
||||
return { singular: "change", plural: "changes", requests: "Merged changes", lower: "merged changes" };
|
||||
return provider === "gitlab"
|
||||
? { singular: "MR", plural: "MRs", requests: "Merge requests", lower: "merge requests" }
|
||||
: { singular: "PR", plural: "PRs", requests: "Pull requests", lower: "pull requests" };
|
||||
}
|
||||
|
|
@ -1,42 +0,0 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { observedSnapshotSchema, weeklyMerges, type Period } from "./observedData";
|
||||
import { createObservedDemo } from "./observedDemo";
|
||||
|
||||
describe("observed sample report", () => {
|
||||
it("clamps the year-ago comparison on leap day while keeping the selected length", () => {
|
||||
const report = createObservedDemo(28, new Date("2024-03-01T12:00:00Z"));
|
||||
expect(report.periods.current.window.end).toBe("2024-02-29");
|
||||
expect(report.periods.last_year.window).toEqual({ start: "2023-02-01", end: "2023-02-28" });
|
||||
});
|
||||
|
||||
it.each([7, 28, 90])("keeps totals, attribution, costs, and comparison windows consistent for %i days", (days) => {
|
||||
const report = createObservedDemo(days, new Date("2026-10-03T14:00:00Z"));
|
||||
expect(observedSnapshotSchema.safeParse(report).success).toBe(true);
|
||||
for (const period of ["current", "previous", "last_year"] satisfies Period[]) {
|
||||
const metrics = report.periods[period];
|
||||
const pulls = report.pulls[period];
|
||||
const people = report.people.map((person) => person.periods[period]);
|
||||
const start = Date.parse(metrics.window.start);
|
||||
const end = Date.parse(metrics.window.end) + 86_400_000;
|
||||
expect((end - start) / 86_400_000).toBe(days);
|
||||
expect(metrics.merged_prs).toBe(pulls.length);
|
||||
expect(new Set(pulls.map((pull) => pull.url)).size).toBe(pulls.length);
|
||||
expect(weeklyMerges(report, period).reduce((sum, value) => sum + value, 0)).toBe(pulls.length);
|
||||
expect(pulls.every((pull) => Date.parse(pull.merged_at) >= start && Date.parse(pull.merged_at) < end)).toBe(true);
|
||||
expect(metrics.matched_users_recorded_spend).toBe(
|
||||
people.reduce((sum, person) => sum + person.gateway_recorded_spend, 0),
|
||||
);
|
||||
expect(people.reduce((sum, person) => sum + person.merged_prs, 0)).toBe(metrics.matched_internal_prs);
|
||||
for (const person of people) {
|
||||
const attributed = pulls.filter((pull) => person.pr_urls.includes(pull.url));
|
||||
expect(attributed).toHaveLength(person.merged_prs);
|
||||
expect(person.direct_authored).toBe(attributed.filter((pull) => !pull.agent).length);
|
||||
expect(person.declared_agent_owned).toBe(attributed.filter((pull) => pull.agent).length);
|
||||
expect(person.recorded_spend_per_attributed_pr).toBe(person.gateway_recorded_spend / person.merged_prs);
|
||||
}
|
||||
}
|
||||
expect(Date.parse(report.periods.previous.window.end) + 86_400_000).toBe(
|
||||
Date.parse(report.periods.current.window.start),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,120 +0,0 @@
|
|||
import type { ObservedPerson, ObservedPull, ObservedSnapshot, Period } from "./observedData";
|
||||
|
||||
const DAY = 86_400_000;
|
||||
const engineers = [
|
||||
{ name: "Alex Rivera", login: "alex-demo", weekly: [8, 6, 4] },
|
||||
{ name: "Sam Chen", login: "sam-demo", weekly: [6, 5, 4] },
|
||||
{ name: "Jordan Lee", login: "jordan-demo", weekly: [4, 4, 3] },
|
||||
];
|
||||
const titles = [
|
||||
"Add repository search",
|
||||
"Fix retry handling",
|
||||
"Speed up activity queries",
|
||||
"Add usage export",
|
||||
"Improve connection setup",
|
||||
"Fix pagination",
|
||||
];
|
||||
const isoDate = (timestamp: number) => new Date(timestamp).toISOString().slice(0, 10);
|
||||
|
||||
function median(pulls: ObservedPull[]) {
|
||||
const hours = pulls.map((pull) => pull.merge_hours ?? 0).toSorted((a, b) => a - b);
|
||||
const middle = Math.floor(hours.length / 2);
|
||||
if (!hours.length) return null;
|
||||
return hours.length % 2 ? hours[middle] : (hours[middle - 1] + hours[middle]) / 2;
|
||||
}
|
||||
|
||||
function samplePeriod(days: number, end: number, comparison: number) {
|
||||
const start = end - (days - 1) * DAY;
|
||||
const people = engineers.map((engineer, index) => {
|
||||
const count = Math.round((engineer.weekly[comparison] * days) / 7);
|
||||
const pulls: ObservedPull[] = Array.from({ length: count }, (_, position) => {
|
||||
const number = 10000 * (comparison + 1) + index * 1000 + position;
|
||||
const repo = index === 1 ? "demo/api" : "demo/web";
|
||||
const branch = `feature/sample-${number}`;
|
||||
const agent = position % 5 === 0;
|
||||
return {
|
||||
connection_id: index === 1 ? "demo-gitlab" : "demo-github",
|
||||
number,
|
||||
title: titles[position % titles.length],
|
||||
url: `https://example.com/${repo}/changes/${number}`,
|
||||
author: agent ? "demo-agent" : engineer.login,
|
||||
agent,
|
||||
merged_at: new Date(start + Math.floor((position * days) / count) * DAY + 12 * 3_600_000).toISOString(),
|
||||
merge_hours: position === 0 ? 16 / 3600 : 4 + ((position * 7) % 24) + comparison * 6,
|
||||
repo,
|
||||
source_repo: repo,
|
||||
source_branch: branch,
|
||||
branch_cost: { repo, branch, spend: 2 + (position % 4), requests: 20 + position, status: "matched" },
|
||||
};
|
||||
});
|
||||
const spend = pulls.reduce((total, pull) => total + (pull.branch_cost.spend ?? 0), 0);
|
||||
const metrics: ObservedPerson["periods"]["current"] = {
|
||||
merged_prs: pulls.length,
|
||||
prs_per_week: (pulls.length * 7) / days,
|
||||
median_merge_hours: median(pulls),
|
||||
direct_authored: pulls.filter((pull) => !pull.agent).length,
|
||||
declared_agent_owned: pulls.filter((pull) => pull.agent).length,
|
||||
gateway_recorded_spend: spend,
|
||||
recorded_spend_per_attributed_pr: pulls.length ? spend / pulls.length : null,
|
||||
spend_observation: "records_present",
|
||||
pr_urls: pulls.map((pull) => pull.url),
|
||||
};
|
||||
return { metrics, pulls };
|
||||
});
|
||||
const pulls = people.flatMap((person) => person.pulls).toSorted((a, b) => b.merged_at.localeCompare(a.merged_at));
|
||||
const metrics: ObservedSnapshot["periods"]["current"] = {
|
||||
window: { start: isoDate(start), end: isoDate(end) },
|
||||
merged_prs: pulls.length,
|
||||
median_merge_hours: median(pulls),
|
||||
human_authored: pulls.filter((pull) => !pull.agent).length,
|
||||
agent_authored: pulls.filter((pull) => pull.agent).length,
|
||||
missing_author: 0,
|
||||
agents_without_requester: 0,
|
||||
matched_internal_prs: pulls.length,
|
||||
new_bug_labeled_issues: Math.round(((comparison + 1) * days) / 7),
|
||||
new_regression_labeled_issues: comparison,
|
||||
explicitly_titled_revert_prs: 0,
|
||||
matched_users_recorded_spend: people.reduce((total, person) => total + person.metrics.gateway_recorded_spend, 0),
|
||||
spend_observation: "records_present",
|
||||
human_summary: { median_merge_hours: median(pulls.filter((pull) => !pull.agent)) },
|
||||
};
|
||||
return { metrics, pulls, people };
|
||||
}
|
||||
|
||||
export function createObservedDemo(days: number, now = new Date()): ObservedSnapshot {
|
||||
const end = Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()) - DAY;
|
||||
const yearAgo = new Date(end);
|
||||
const lastYear = yearAgo.getUTCFullYear() - 1;
|
||||
const month = yearAgo.getUTCMonth();
|
||||
const lastDay = new Date(Date.UTC(lastYear, month + 1, 0)).getUTCDate();
|
||||
const lastYearEnd = Date.UTC(lastYear, month, Math.min(yearAgo.getUTCDate(), lastDay));
|
||||
const periods = {
|
||||
current: samplePeriod(days, end, 0),
|
||||
previous: samplePeriod(days, end - days * DAY, 1),
|
||||
last_year: samplePeriod(days, lastYearEnd, 2),
|
||||
};
|
||||
const personPeriod = (period: Period, index: number) => periods[period].people[index].metrics;
|
||||
return {
|
||||
source_provider: "mixed",
|
||||
repos: ["demo/web", "demo/api"],
|
||||
unmatched_logins: [],
|
||||
unlinked_branches: [],
|
||||
captured_at: now.toISOString(),
|
||||
periods: {
|
||||
current: periods.current.metrics,
|
||||
previous: periods.previous.metrics,
|
||||
last_year: periods.last_year.metrics,
|
||||
},
|
||||
people: engineers.map((engineer, index) => ({
|
||||
name: engineer.name,
|
||||
email: `${engineer.login}@example.com`,
|
||||
logins: [engineer.login],
|
||||
periods: {
|
||||
current: personPeriod("current", index),
|
||||
previous: personPeriod("previous", index),
|
||||
last_year: personPeriod("last_year", index),
|
||||
},
|
||||
})),
|
||||
pulls: { current: periods.current.pulls, previous: periods.previous.pulls, last_year: periods.last_year.pulls },
|
||||
};
|
||||
}
|
||||
|
|
@ -1,203 +0,0 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
coverageLabel,
|
||||
effortNote,
|
||||
estimateLabel,
|
||||
estimatorModelOptions,
|
||||
filterPulls,
|
||||
formatMoney,
|
||||
formatNumber,
|
||||
formatSyncedAt,
|
||||
highestCostPulls,
|
||||
isMatchedPerson,
|
||||
isMatchedPull,
|
||||
peopleCsv,
|
||||
} from "./roiCalculatorData";
|
||||
import type { ROIPerson, ROIPull } from "./roiCalculatorData";
|
||||
|
||||
const pull = (overrides: Partial<ROIPull>): ROIPull => ({
|
||||
source_repo: "github.com/org/repo",
|
||||
source_branch: "feature",
|
||||
repo: "org/repo",
|
||||
number: 42,
|
||||
title: "Improve request routing",
|
||||
url: "https://github.com/org/repo/pull/42",
|
||||
login: "alice",
|
||||
emails: ["alice@example.com"],
|
||||
profile_email: "alice@example.com",
|
||||
merged_at: "2026-09-12T00:00:00Z",
|
||||
head_sha: "abc",
|
||||
additions: 10,
|
||||
deletions: 2,
|
||||
changed_files: 1,
|
||||
commit_count: 1,
|
||||
incomplete_metadata: false,
|
||||
estimate: { status: "estimated", hours: 4.5, reasoning: "Metadata-based estimate.", cached: false },
|
||||
email: "alice@example.com",
|
||||
match_method: "profile email",
|
||||
matched: true,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const summary = {
|
||||
metrics: { matched_prs: 1, merged_prs: 2 },
|
||||
};
|
||||
|
||||
describe("ROI calculator display helpers", () => {
|
||||
it.each([0, 8.5])("retains internal accounts with %s recorded spend and no changes in the filtered CSV", (spend) => {
|
||||
const internal: ROIPerson = {
|
||||
id: "internal@example.test",
|
||||
email: "internal@example.test",
|
||||
logins: [],
|
||||
spend,
|
||||
hours: 0,
|
||||
prs: 0,
|
||||
estimated_prs: 0,
|
||||
pending_prs: 0,
|
||||
match_methods: [],
|
||||
eligible: false,
|
||||
cost_per_hour: null,
|
||||
};
|
||||
const external = { ...internal, id: "outside", email: "", spend: null, logins: ["outside"] };
|
||||
const people = [internal, external].filter(isMatchedPerson);
|
||||
expect(people).toEqual([internal]);
|
||||
const report = { start: "2026-09-01", end: "2026-09-30", effort_basis: "without_ai", people };
|
||||
expect(peopleCsv(report)).toContain(`"internal@example.test","","${spend}","0","0"`);
|
||||
expect(peopleCsv(report)).not.toContain("outside");
|
||||
});
|
||||
|
||||
it("keeps linked identities without spend or estimates and excludes unrelated tagged spend", () => {
|
||||
const manual = pull({ match_method: "manual", matched: false });
|
||||
const external = pull({
|
||||
email: "",
|
||||
match_method: "no gateway match",
|
||||
branch_cost: { repo: "org/repo", branch: "external", spend: 10, requests: 2, status: "matched" },
|
||||
});
|
||||
expect(isMatchedPull(manual)).toBe(true);
|
||||
expect(filterPulls([manual, external], "", true)).toEqual([manual]);
|
||||
expect(filterPulls([manual, external], "", false)).toEqual([manual, external]);
|
||||
expect(filterPulls([manual, external], "missing", true)).toEqual([]);
|
||||
const person: ROIPerson = {
|
||||
id: "linked",
|
||||
email: "linked@example.test",
|
||||
logins: ["linked"],
|
||||
spend: null,
|
||||
hours: 0,
|
||||
prs: 1,
|
||||
estimated_prs: 0,
|
||||
pending_prs: 1,
|
||||
match_methods: ["manual"],
|
||||
eligible: false,
|
||||
cost_per_hour: null,
|
||||
};
|
||||
expect(isMatchedPerson(person)).toBe(true);
|
||||
expect(isMatchedPerson({ ...person, match_methods: ["no gateway match"] })).toBe(false);
|
||||
expect(isMatchedPerson({ ...person, email: "" })).toBe(false);
|
||||
});
|
||||
|
||||
it("ranks only attributed PR costs, limits the overview to five and preserves report order", () => {
|
||||
const pulls = [2, 6, 1, 4, 3, 5].map((spend) =>
|
||||
pull({
|
||||
number: spend,
|
||||
branch_cost: { status: "matched", spend, requests: 1, repo: "github.com/org/repo", branch: "feature" },
|
||||
}),
|
||||
);
|
||||
pulls.push(
|
||||
pull({
|
||||
number: 99,
|
||||
branch_cost: { status: "ambiguous", spend: 99, requests: 1, repo: "github.com/org/repo", branch: "feature" },
|
||||
}),
|
||||
);
|
||||
pulls.push(pull({ number: 100 }));
|
||||
expect(highestCostPulls(pulls).map((item) => item.number)).toEqual([6, 5, 4, 3, 2]);
|
||||
expect(pulls.map((item) => item.number)).toEqual([2, 6, 1, 4, 3, 5, 99, 100]);
|
||||
});
|
||||
it("formats spend and estimated hours without losing null values", () => {
|
||||
expect(formatMoney(1234.5)).toBe("$1,234.50");
|
||||
expect(formatMoney(0.0001)).toBe("$0.0001");
|
||||
expect(formatMoney(0.000186)).toBe("$0.000186");
|
||||
expect(formatMoney(0.0000001)).toBe("<$0.000001");
|
||||
expect(formatMoney(0)).toBe("$0.00");
|
||||
expect(formatMoney(null)).toBe("—");
|
||||
expect(formatNumber(4.25)).toBe("4.3");
|
||||
expect(formatNumber(null)).toBe("—");
|
||||
});
|
||||
|
||||
it("formats report sync timestamps in UTC", () => {
|
||||
expect(formatSyncedAt("2026-09-30T12:00:00Z")).toBe("Sep 30, 2026, 12:00 PM UTC");
|
||||
expect(formatSyncedAt("invalid")).toBe("invalid");
|
||||
});
|
||||
|
||||
it("keeps caveat copy tied to the estimate basis and reports match coverage", () => {
|
||||
expect(effortNote("without_ai")).toContain("not actual hours worked or hours saved");
|
||||
expect(effortNote(null)).toContain("Earlier estimates");
|
||||
expect(coverageLabel(summary)).toBe("1 of 2 matched");
|
||||
});
|
||||
|
||||
it("labels estimates and filters PRs by title, repository, number, or login", () => {
|
||||
const matchingPull = pull({});
|
||||
expect(estimateLabel(matchingPull.estimate)).toBe("4.5 hrs");
|
||||
const incompleteEstimate = { status: "needs_review" as const, hours: null, reasoning: "", cached: false };
|
||||
expect(estimateLabel(incompleteEstimate)).toBe("Needs review");
|
||||
expect(filterPulls([matchingPull], "ROUTING")).toEqual([matchingPull]);
|
||||
expect(filterPulls([matchingPull], "nobody")).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
it("exports precise spend, cohort eligibility and safely quoted CSV values", () => {
|
||||
const exportSummary = {
|
||||
start: "2026-09-01",
|
||||
end: "2026-09-30",
|
||||
effort_basis: "without_ai",
|
||||
people: [
|
||||
{
|
||||
id: "export-person",
|
||||
estimated_prs: 1,
|
||||
match_methods: ["manual"],
|
||||
email: '=HYPERLINK("bad")',
|
||||
logins: ["alice", "bob"],
|
||||
spend: 0.0001,
|
||||
hours: 4,
|
||||
prs: 1,
|
||||
pending_prs: 0,
|
||||
eligible: true,
|
||||
cost_per_hour: 0.000025,
|
||||
},
|
||||
],
|
||||
};
|
||||
const csv = peopleCsv(exportSummary);
|
||||
expect(csv.split("\r\n")).toHaveLength(2);
|
||||
expect(csv).toContain('"\'=HYPERLINK(""bad"")","alice;bob","0.0001","4","1","0","true","0.000025"');
|
||||
expect(csv).toContain('"2026-09-01","2026-09-30","without_ai"');
|
||||
});
|
||||
|
||||
it("recommends the real Luna model and preserves its gateway name for requests", () => {
|
||||
expect(
|
||||
estimatorModelOptions({
|
||||
available_models: ["general", "fast-estimator"],
|
||||
estimator_models: [
|
||||
{ model_name: "general", provider_models: ["anthropic/claude-haiku"] },
|
||||
{ model_name: "fast-estimator", provider_models: ["openai/gpt-6-luna"] },
|
||||
],
|
||||
}),
|
||||
).toEqual([
|
||||
{
|
||||
value: "fast-estimator",
|
||||
label: "GPT-6 Luna",
|
||||
sublabel: "Recommended · Gateway name: fast-estimator",
|
||||
recommended: true,
|
||||
},
|
||||
{ value: "general", label: "anthropic/claude-haiku", sublabel: "Gateway name: general", recommended: false },
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not invent available models or recommend an alias pointing to a different model", () => {
|
||||
expect(
|
||||
estimatorModelOptions({
|
||||
available_models: ["gpt-6-luna"],
|
||||
estimator_models: [{ model_name: "gpt-6-luna", provider_models: ["custom-model"] }],
|
||||
}),
|
||||
).toEqual([{ value: "gpt-6-luna", label: "custom-model", sublabel: "Gateway name: gpt-6-luna", recommended: false }]);
|
||||
expect(estimatorModelOptions({ available_models: [], estimator_models: [] })).toEqual([]);
|
||||
});
|
||||
|
|
@ -1,151 +0,0 @@
|
|||
import type { components } from "@/lib/http/schema";
|
||||
|
||||
export type ROISummary = components["schemas"]["ROISummaryResponse"];
|
||||
export type ROIPull = components["schemas"]["ROIPullResponse"];
|
||||
export type ROIPerson = components["schemas"]["ROIPersonResponse"];
|
||||
export type ROIEstimate = components["schemas"]["ROIEstimateResponse"];
|
||||
export type ROISyncStatus = components["schemas"]["ROISyncStatus"];
|
||||
export type ROISettings = components["schemas"]["ROISettingsResponse"];
|
||||
export type ROISettingsUpdate = components["schemas"]["ROISettingsUpdate"];
|
||||
export type ROIRepository = components["schemas"]["ROIRepository"];
|
||||
export type ROIRepositoriesResponse = components["schemas"]["ROIRepositoriesResponse"];
|
||||
export type ROIReportResponse = components["schemas"]["ROIReportResponse"];
|
||||
export type ROIIdentityMapUpdate = components["schemas"]["ROIIdentityMapUpdate"];
|
||||
export type ROIIdentityMapResponse = components["schemas"]["ROIIdentityMapResponse"];
|
||||
|
||||
const SYNCED_AT_FORMAT_OPTIONS: Intl.DateTimeFormatOptions = {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
timeZone: "UTC",
|
||||
timeZoneName: "short",
|
||||
};
|
||||
|
||||
export const formatMoney = (value: number | null | undefined): string => {
|
||||
if (value == null) return "—";
|
||||
if (value > 0 && value < 0.000001) return "<$0.000001";
|
||||
const options: Intl.NumberFormatOptions = {
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: Math.abs(value) > 0 && Math.abs(value) < 0.01 ? 6 : 2,
|
||||
};
|
||||
return new Intl.NumberFormat("en-US", options).format(value);
|
||||
};
|
||||
|
||||
export const formatNumber = (value: number | null | undefined): string =>
|
||||
value == null ? "—" : new Intl.NumberFormat("en-US", { maximumFractionDigits: 1 }).format(value);
|
||||
|
||||
export const formatSyncedAt = (value: string): string => {
|
||||
const timestamp = Date.parse(value);
|
||||
if (!Number.isFinite(timestamp)) return value;
|
||||
return new Intl.DateTimeFormat("en-US", SYNCED_AT_FORMAT_OPTIONS).format(timestamp);
|
||||
};
|
||||
|
||||
export const effortNote = (basis: string | null | undefined): string =>
|
||||
basis === "without_ai"
|
||||
? "Estimated engineering hours without AI assistance, not actual hours worked or hours saved."
|
||||
: "Earlier estimates did not specify AI assistance. Sync to estimate engineering hours without AI.";
|
||||
|
||||
export const coverageLabel = (summary: {
|
||||
metrics: Pick<ROISummary["metrics"], "matched_prs" | "merged_prs">;
|
||||
source_provider?: string;
|
||||
}): string => `${summary.metrics.matched_prs} of ${summary.metrics.merged_prs} matched`;
|
||||
|
||||
export const estimateLabel = (estimate: ROIEstimate): string => {
|
||||
if (estimate.status === "estimated") return `${formatNumber(estimate.hours)} hrs`;
|
||||
if (estimate.status === "error") return "Estimate failed";
|
||||
return "Needs review";
|
||||
};
|
||||
|
||||
const matchedMethods = new Set(["manual", "commit email", "profile email"]);
|
||||
export const isMatchedPerson = (person: ROIPerson) =>
|
||||
Boolean(person.email) && (person.spend !== null || person.match_methods.some((method) => matchedMethods.has(method)));
|
||||
export const isMatchedPull = (pull: ROIPull) => Boolean(pull.email) && matchedMethods.has(pull.match_method);
|
||||
|
||||
export const filterPulls = (pulls: ROIPull[], query: string, matchedOnly = false): ROIPull[] => {
|
||||
const normalized = query.trim().toLocaleLowerCase();
|
||||
const visible = matchedOnly ? pulls.filter(isMatchedPull) : pulls;
|
||||
if (!normalized) return visible;
|
||||
return visible.filter((pull) =>
|
||||
`${pull.title} ${pull.repo} ${pull.number} ${pull.login} ${pull.source_branch ?? ""}`
|
||||
.toLocaleLowerCase()
|
||||
.includes(normalized),
|
||||
);
|
||||
};
|
||||
|
||||
export const highestCostPulls = (pulls: ROIPull[]): ROIPull[] =>
|
||||
pulls
|
||||
.filter((pull) => pull.branch_cost?.status === "matched")
|
||||
.sort((left, right) => (right.branch_cost?.spend ?? 0) - (left.branch_cost?.spend ?? 0))
|
||||
.slice(0, 5);
|
||||
|
||||
export const peopleCsv = (summary: Pick<ROISummary, "people" | "start" | "end" | "effort_basis">): string => {
|
||||
const escape = (value: unknown): string => {
|
||||
const text = value == null ? "" : String(value);
|
||||
const safe = /^[=+@\-\t\r]/.test(text) ? `'${text}` : text;
|
||||
return `"${safe.replaceAll('"', '""')}"`;
|
||||
};
|
||||
const rows = summary.people.map((person) => [
|
||||
person.email,
|
||||
person.logins.join(";"),
|
||||
person.spend,
|
||||
person.hours,
|
||||
person.prs,
|
||||
person.pending_prs,
|
||||
person.eligible,
|
||||
person.cost_per_hour,
|
||||
summary.start,
|
||||
summary.end,
|
||||
summary.effort_basis ?? "unspecified",
|
||||
]);
|
||||
return [
|
||||
[
|
||||
"email",
|
||||
"source_logins",
|
||||
"gateway_spend_usd",
|
||||
"estimated_hours",
|
||||
"merged_prs",
|
||||
"pending_estimates",
|
||||
"in_matched_cohort",
|
||||
"cost_per_estimated_hour",
|
||||
"start_utc",
|
||||
"end_utc",
|
||||
"effort_basis",
|
||||
],
|
||||
...rows,
|
||||
]
|
||||
.map((row) => row.map(escape).join(","))
|
||||
.join("\r\n");
|
||||
};
|
||||
|
||||
export const branchCostLabel = (pull: ROIPull): string => {
|
||||
if (!pull.branch_cost || pull.branch_cost.status === "unavailable") return "Sync to calculate";
|
||||
if (pull.branch_cost.status === "ambiguous") return "Ambiguous branch";
|
||||
if (pull.branch_cost.status === "unattributed") return "No tagged requests";
|
||||
return formatMoney(pull.branch_cost.spend);
|
||||
};
|
||||
|
||||
export const estimatorModelOptions = (settings: Pick<ROISettings, "available_models" | "estimator_models">) => {
|
||||
const details = new Map(settings.estimator_models?.map((model) => [model.model_name, model]));
|
||||
const isLuna = (name: string) => /(?:^|\/)gpt-6-luna(?:-\d{4}-\d{2}-\d{2})?$/i.test(name);
|
||||
return settings.available_models
|
||||
.map((name) => {
|
||||
const models = details.get(name)?.provider_models ?? [];
|
||||
const recommended = models.length > 0 && models.every(isLuna);
|
||||
const label = [...new Set(models.map((model) => (isLuna(model) ? "GPT-6 Luna" : model)))].join(", ") || name;
|
||||
return {
|
||||
value: name,
|
||||
label,
|
||||
sublabel: [recommended ? "Recommended" : "", label !== name ? `Gateway name: ${name}` : ""]
|
||||
.filter(Boolean)
|
||||
.join(" · "),
|
||||
recommended,
|
||||
};
|
||||
})
|
||||
.sort(
|
||||
(left, right) => Number(right.recommended) - Number(left.recommended) || left.label.localeCompare(right.label),
|
||||
);
|
||||
};
|
||||
|
|
@ -1,60 +0,0 @@
|
|||
import { useCallback, useEffect, useState } from "react";
|
||||
import { apiClient } from "@/components/networking";
|
||||
import { extractProxyErrorMessage } from "@/lib/http/client";
|
||||
import {
|
||||
observedSettingsSchema,
|
||||
observedReportResponseSchema,
|
||||
observedStatusSchema,
|
||||
type ObservedSettings,
|
||||
type ObservedSnapshot,
|
||||
type ObservedStatus,
|
||||
} from "./observedData";
|
||||
|
||||
export type ObservedViewData = { settings: ObservedSettings; report: ObservedSnapshot | null; status: ObservedStatus };
|
||||
|
||||
export function useObservedReport(accessToken: string) {
|
||||
const [data, setData] = useState<ObservedViewData | null>(null);
|
||||
const [error, setError] = useState("");
|
||||
const [revision, setRevision] = useState(0);
|
||||
const refresh = useCallback(() => setRevision((value) => value + 1), []);
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
let timer: ReturnType<typeof setTimeout>;
|
||||
const options = { accessToken, signal: controller.signal };
|
||||
async function poll(previous?: ObservedViewData) {
|
||||
try {
|
||||
const status = observedStatusSchema.parse(
|
||||
await apiClient.get<unknown>("/roi-calculator/observed/sync", options),
|
||||
);
|
||||
const finished = previous?.status.running && !status.running;
|
||||
const changed = !previous || previous.status.finished_at !== status.finished_at || finished;
|
||||
const updated = changed
|
||||
? await Promise.all([
|
||||
apiClient
|
||||
.get<unknown>("/roi-calculator/observed/settings", options)
|
||||
.then((value) => observedSettingsSchema.parse(value)),
|
||||
apiClient
|
||||
.get<unknown>("/roi-calculator/observed/report", options)
|
||||
.then((value) => observedReportResponseSchema.parse(value)),
|
||||
])
|
||||
: null;
|
||||
if (controller.signal.aborted) return;
|
||||
const existing = previous ? { ...previous, status } : null;
|
||||
const next = updated ? { settings: updated[0], report: updated[1].report, status } : existing;
|
||||
setData(next);
|
||||
setError("");
|
||||
timer = setTimeout(() => void poll(next ?? undefined), status.running ? 2000 : 30000);
|
||||
} catch (reason) {
|
||||
if (controller.signal.aborted) return;
|
||||
setError(extractProxyErrorMessage(reason));
|
||||
timer = setTimeout(() => void poll(previous), 10000);
|
||||
}
|
||||
}
|
||||
void poll();
|
||||
return () => {
|
||||
controller.abort();
|
||||
clearTimeout(timer);
|
||||
};
|
||||
}, [accessToken, revision]);
|
||||
return { data, error, refresh };
|
||||
}
|
||||
|
|
@ -1,10 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
|
||||
import ObservedROIView from "./_components/ObservedROIView";
|
||||
|
||||
export default function ROICalculatorPage() {
|
||||
const { accessToken, isViewOnly } = useAuthorized();
|
||||
if (!accessToken) return null;
|
||||
return <ObservedROIView key={accessToken} accessToken={accessToken} isViewOnly={isViewOnly} />;
|
||||
}
|
||||
|
|
@ -25,7 +25,6 @@ import {
|
|||
Activity,
|
||||
Aperture,
|
||||
BarChart3,
|
||||
Calculator,
|
||||
Bell,
|
||||
Blocks,
|
||||
Bot,
|
||||
|
|
@ -224,17 +223,6 @@ const menuGroups: MenuGroup[] = [
|
|||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "roi-calculator",
|
||||
page: "roi-calculator",
|
||||
icon: <Calculator {...ICON} />,
|
||||
roles: all_admin_roles,
|
||||
label: (
|
||||
<span className="flex items-center gap-2">
|
||||
ROI Calculator <BetaBadge />
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "cost-optimization",
|
||||
page: "cost-optimization",
|
||||
|
|
|
|||
|
|
@ -21,7 +21,6 @@ export const pageDescriptions: Record<string, string> = {
|
|||
new_usage: "View usage analytics and metrics",
|
||||
"cost-optimization": "Track and configure cost-saving features: prompt compression, caching, and auto routing",
|
||||
"model-insights": "Model Leaderboard: compare usage, spend, tokens, and task mix across this gateway",
|
||||
"roi-calculator": "Compare gateway spend with estimated engineering effort for merged pull requests",
|
||||
logs: "Access request and response logs",
|
||||
lens: "Review agent activity and investigate patterns with supporting evidence",
|
||||
"guardrails-monitor": "Monitor guardrail performance and view logs",
|
||||
|
|
|
|||
|
|
@ -1,16 +0,0 @@
|
|||
import { Info } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
export function DemoNotice({ onExit }: { onExit?: () => void }) {
|
||||
return (
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 rounded-lg border border-info/20 bg-info/10 px-4 py-3">
|
||||
<p role="status" className="flex items-center gap-2 text-sm font-medium text-info">
|
||||
<Info aria-hidden="true" className="size-4 shrink-0" />
|
||||
You’re viewing demo data
|
||||
</p>
|
||||
<Button variant="outline" size="sm" onClick={onExit}>
|
||||
Exit demo
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
1550
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
1550
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
File diff suppressed because it is too large
Load diff
Loading…
Add table
Reference in a new issue