diff --git a/.agents/rules/antigravity-rtk-rules.md b/.agents/rules/antigravity-rtk-rules.md new file mode 100644 index 00000000..bb5d20b1 --- /dev/null +++ b/.agents/rules/antigravity-rtk-rules.md @@ -0,0 +1,32 @@ +# RTK - Rust Token Killer (Google Antigravity) + +**Usage**: Token-optimized CLI proxy for shell commands. + +## Rule + +Always prefix shell commands with `rtk` to minimize token consumption. + +Examples: + +```bash +rtk git status +rtk cargo test +rtk ls src/ +rtk grep "pattern" src/ +rtk find "*.rs" . +rtk docker ps +rtk gh pr list +``` + +## Meta Commands + +```bash +rtk gain # Show token savings +rtk gain --history # Command history with savings +rtk discover # Find missed RTK opportunities +rtk proxy # Run raw (no filtering, for debugging) +``` + +## Why + +RTK filters and compresses command output before it reaches the LLM context, cutting up to 90% of the bash output on common operations. Always use `rtk ` instead of raw commands. diff --git a/.kiro/specs/third-party-tools-integration/.config.kiro b/.kiro/specs/third-party-tools-integration/.config.kiro new file mode 100644 index 00000000..9512371f --- /dev/null +++ b/.kiro/specs/third-party-tools-integration/.config.kiro @@ -0,0 +1 @@ +{"specId": "7e1f430f-5e58-48d0-9fcf-e4178321dfb1", "workflowType": "fast-task", "specType": "feature"} diff --git a/.kiro/specs/third-party-tools-integration/requirements.md b/.kiro/specs/third-party-tools-integration/requirements.md new file mode 100644 index 00000000..f695df6b --- /dev/null +++ b/.kiro/specs/third-party-tools-integration/requirements.md @@ -0,0 +1,200 @@ +# Requirements Document + +## Introduction + +This document describes the requirements for Third-Party Tools Integration in LiveReview — a cloud-only, owner-gated feature that allows organisations to enable external static-analysis tools (e.g. ruff, pylint) that run as parallel Lambda-backed jobs whenever a review is triggered. Results are stored in the `review_events` table and surfaced in a dedicated beta review UI and in the existing git-lrc CLI output. + +The feature is delivered in three sequential phases: + +1. **Phase 1 – DB + Settings tab**: Catalog and per-org tool tables, dbmate migrations, settings tab gated to cloud-mode owners. +2. **Phase 2 – Settings UI + API gating**: Full CRUD UI for tool selection, REST endpoints with role enforcement. +3. **Phase 3 – Queue, Lambda trigger + review UI**: River job fan-out per tool, Lambda invocation, result storage, and two dedicated UI surfaces. + +--- + +## Glossary + +- **AvailableTools**: The global catalog table (`available_tools`) that seeds all tools LiveReview knows about. +- **OrgTools**: The per-organisation configuration table (`org_tools`) that records which tools an org has enabled and any per-tool configuration. +- **Tool**: A record in `available_tools` with a unique id, human-readable name, description, and an AWS Lambda ARN to invoke. +- **ToolResult**: A `review_events` row with `event_type = 'tool_result'` that carries the output of a single tool invocation for a review. +- **ToolJob**: A River background job (kind `tool_invocation`) that reads a review's diff and invokes a Tool's Lambda ARN, producing a ToolResult. +- **PermissionContext**: The Echo middleware-constructed object placed in the request context under `permission_context`, encoding the authenticated user's `org_id`, `user_id`, and `role`. +- **CloudModeGate**: The server-side `isCloudMode()` check (`LIVEREVIEW_IS_CLOUD=true`) combined with the `isCloudMode()` client-side utility in the React UI. +- **BillingCheck**: Verification that the org has an active billing plan via the existing `apimiddleware.BuildOrgBillingPlanContext` + `BuildPlanContext` middleware chain. +- **SettingsTab**: The `/#/settings#third-party-tools` hash route within the existing Settings page component. +- **BetaReviewUI**: The beta review page accessible at `/#/reviews-tools/new`, not linked in the main navigation. +- **ReviewDetail**: The existing review detail page that renders events associated with a review. +- **git-lrc**: The CLI binary whose binary name is `lrc`, used for local review triggering. +- **River**: The PostgreSQL-backed job queue library used for background processing. +- **dbmate**: The migration tool used exclusively for local development database schema changes. +- **LambdaInvoker**: The component responsible for making AWS Lambda HTTP invocations for a given tool's ARN. + +--- + +## Requirements + +### Requirement 1 – Available Tools Catalog + +**User Story:** As a LiveReview platform operator, I want a seeded catalog of available third-party tools, so that organisations can choose from a known set of tools without manual configuration. + +#### Acceptance Criteria + +1. THE System SHALL provide an `available_tools` table with columns: `id` (bigserial primary key), `name` (text not null unique), `description` (text not null), and `lambda_arn` (text not null). +2. THE System SHALL seed the `available_tools` table with at least two initial rows: one for `ruff` and one for `pylint`, each with a non-empty `description` and a placeholder `lambda_arn`. +3. THE System SHALL manage the `available_tools` schema exclusively through dbmate migration files located in `db/migrations/`, and SHALL NOT apply these migrations directly to any production database. + +--- + +### Requirement 2 – Per-Organisation Tool Configuration + +**User Story:** As an organisation owner on the cloud plan, I want to enable or disable specific tools for my organisation, so that only the tools relevant to my stack are invoked during reviews. + +#### Acceptance Criteria + +1. THE System SHALL provide an `org_tools` table with columns: `org_id` (bigint not null, references `organizations.id`), `tool_id` (bigint not null, references `available_tools.id`), `enabled` (boolean not null default false), `config_json` (jsonb not null default `'{}'`), and a composite primary key on (`org_id`, `tool_id`). +2. THE System SHALL enforce that every row in `org_tools` references a valid `org_id` in the `organizations` table and a valid `tool_id` in the `available_tools` table via foreign key constraints. +3. THE System SHALL manage the `org_tools` schema exclusively through dbmate migration files and SHALL NOT apply these migrations directly to any production database. + +--- + +### Requirement 3 – Settings Tab Access Control + +**User Story:** As a cloud-mode organisation owner, I want a dedicated third-party tools settings tab, so that I can manage tool configuration without exposing it to members or self-hosted users. + +#### Acceptance Criteria + +1. WHEN `isCloudMode()` returns `true` AND the authenticated user's role is `owner`, THE Settings Page SHALL render a navigable tab at the hash route `third-party-tools` within `/#/settings`. +2. WHEN `isCloudMode()` returns `false`, THE Settings Page SHALL NOT render the `third-party-tools` tab. +3. WHEN the authenticated user's role is not `owner`, THE Settings Page SHALL NOT render the `third-party-tools` tab as a clickble navigation item. +4. WHEN a non-owner org member navigates directly to `/#/settings#third-party-tools`, THE Settings Page SHALL render a read-only view of the enabled tools without controls to modify them. + +--- + +### Requirement 4 – List Org Tools API Endpoint + +**User Story:** As an authenticated organisation member, I want to retrieve the tool configuration for my organisation, so that the UI can display which tools are currently enabled. + +#### Acceptance Criteria + +1. THE Server SHALL expose a `GET /api/v1/orgs/:org_id/tools` endpoint that returns the list of all tools in `available_tools` joined with the corresponding `org_tools` row (if present) for the requesting org. +2. WHEN a request is received at `GET /api/v1/orgs/:org_id/tools`, THE Server SHALL apply the full Echo middleware chain: `RequireAuthOrAPIKey`, `BuildOrgContext`, `ValidateOrgAccess`, `BuildPermissionContext`. +3. WHEN `isCloudMode()` returns `false` on the server, THE Server SHALL respond to `GET /api/v1/orgs/:org_id/tools` with HTTP 403. +4. WHEN the `PermissionContext` `org_id` does not match the `:org_id` path parameter, THE Server SHALL respond with HTTP 403. +5. THE Server SHALL scope the `org_tools` query exclusively by the `org_id` resolved from `PermissionContext`, and SHALL NOT use the client-supplied `:org_id` path parameter as the filter value. + +--- + +### Requirement 5 – Update Org Tool API Endpoint + +**User Story:** As a cloud-mode organisation owner, I want to enable or disable a specific tool for my organisation via the API, so that tool selection changes are persisted securely. + +#### Acceptance Criteria + +1. THE Server SHALL expose a `PUT /api/v1/orgs/:org_id/tools/:tool_id` endpoint that creates or updates the `org_tools` row for the given `(org_id, tool_id)` pair. +2. WHEN a request is received at `PUT /api/v1/orgs/:org_id/tools/:tool_id`, THE Server SHALL apply the full Echo middleware chain: `RequireAuthOrAPIKey`, `BuildOrgContext`, `ValidateOrgAccess`, `BuildPermissionContext`. +3. WHEN `isCloudMode()` returns `false` on the server, THE Server SHALL respond to `PUT /api/v1/orgs/:org_id/tools/:tool_id` with HTTP 403. +4. WHEN the authenticated user's role resolved from `PermissionContext` is not `owner`, THE Server SHALL respond to `PUT /api/v1/orgs/:org_id/tools/:tool_id` with HTTP 403. +5. THE Server SHALL validate the request body contains an `enabled` boolean field; IF the field is absent or not a boolean, THEN THE Server SHALL respond with HTTP 400. +6. THE Server SHALL upsert the `org_tools` row using the `org_id` from `PermissionContext` as the scoping value, and SHALL NOT use the client-supplied `:org_id` as the insert/update value. + +--- + +### Requirement 6 – Settings UI for Tool Selection + +**User Story:** As a cloud-mode organisation owner, I want a UI inside the settings tab to see all available tools and toggle each one on or off, so that I have direct control over which tools run on my reviews. + +#### Acceptance Criteria + +1. WHEN the `third-party-tools` settings tab is active and `isCloudMode()` returns `true` and the user is an `owner`, THE ThirdPartyToolsTab Component SHALL render a list of all available tools fetched from `GET /api/v1/orgs/:org_id/tools`. +2. WHEN an owner toggles a tool's enabled state in the UI, THE ThirdPartyToolsTab Component SHALL call `PUT /api/v1/orgs/:org_id/tools/:tool_id` with the new `enabled` value. +3. IF `PUT /api/v1/orgs/:org_id/tools/:tool_id` returns an error, THEN THE ThirdPartyToolsTab Component SHALL display an inline error message and SHALL revert the toggle to the previous state. +4. WHILE the UI is fetching or submitting tool configuration, THE ThirdPartyToolsTab Component SHALL render a loading indicator and disable all tool toggles. +5. WHEN the user is a non-owner member and `isCloudMode()` returns `true`, THE ThirdPartyToolsTab Component SHALL render the tool list in a read-only state with no toggles. + +--- + +### Requirement 7 – Tool Job Fan-Out on Review + +**User Story:** As a developer whose organisation has tools enabled, I want the tools to run automatically whenever a review runs, so that I receive tool feedback alongside the AI review without manual intervention. + +#### Acceptance Criteria + +1. WHEN a review job completes initial diff extraction and at least one tool is enabled for the review's `org_id`, THE ReviewOrchestrator SHALL enqueue one River job of kind `tool_invocation` per enabled tool in parallel (fan-out). +2. THE ToolJob SHALL follow the same River job registration pattern as `webhook_install` and `webhook_removal` in `internal/jobqueue/`, including a `Kind()` method and a dedicated worker struct. +3. THE ToolJob SHALL read the diff content from the review's database record identified by `review_id`, and SHALL NOT re-fetch the diff from the VCS provider. +4. WHEN the ToolJob executes, THE LambdaInvoker SHALL invoke the Lambda function identified by the tool's `lambda_arn` from `available_tools`, passing the diff as the request payload. +5. IF the Lambda invocation returns a non-2xx HTTP status, THEN THE ToolJob SHALL mark the job as failed and River SHALL apply the standard retry policy. + +--- + +### Requirement 8 – Tool Result Storage + +**User Story:** As a developer, I want tool results stored alongside review events, so that the review detail page can display them with the correct source attribution. + +#### Acceptance Criteria + +1. WHEN the LambdaInvoker receives a successful response, THE ToolJob SHALL insert a row into `review_events` with `event_type = 'tool_result'`, the `review_id`, `org_id`, and a `data` JSONB payload containing at minimum `tool_name`, `tool_id`, and the Lambda response body. +2. THE ToolJob SHALL scope the `review_events` insert using the `org_id` from the review record and SHALL NOT derive `org_id` from any other source. +3. WHEN multiple ToolJobs complete for the same review, THE System SHALL store each tool's result as a separate `review_events` row, identified by a distinct `tool_name` value in the `data` JSONB field. + +--- + +### Requirement 9 – Beta Review UI Page + +**User Story:** As a developer participating in the beta, I want a dedicated review UI page for tool-based reviews at a hidden route, so that I can access tool results without the route appearing in the main navigation. + +#### Acceptance Criteria + +1. THE Router SHALL register the path `/#/reviews-tools/new` as a valid client-side route rendering the BetaToolReviewPage component. +2. THE Router SHALL NOT add a navigation entry for `/#/reviews-tools/new` in the main sidebar or any other navigation surface. +3. THE BetaToolReviewPage Component SHALL render a layout similar to the existing AI review UI, adapted to display tool-based review results. +4. WHEN `isCloudMode()` returns `false`, THE BetaToolReviewPage Component SHALL render an informational message indicating the feature is only available in cloud mode. + +--- + +### Requirement 10 – Tool Results in Review Detail UI + +**User Story:** As a developer, I want to see tool results labelled by tool name in the review detail page, so that I can distinguish tool findings from AI findings at a glance. + +#### Acceptance Criteria + +1. WHEN the ReviewDetail Component fetches `review_events` for a review and encounters an event with `event_type = 'tool_result'`, THE ReviewDetail Component SHALL render the event with a badge displaying the value of `data.tool_name`. +2. THE badge for a tool result SHALL be visually distinct from AI review event badges already present in the ReviewDetail Component. +3. WHEN there are no `tool_result` events for a review, THE ReviewDetail Component SHALL NOT render any tool result section. + +--- + +### Requirement 11 – Tool Result Tag in git-lrc CLI Output + +**User Story:** As a developer using the lrc CLI, I want tool results tagged with the tool name in the CLI output, so that I can identify which finding came from which tool when reviewing locally. + +#### Acceptance Criteria + +1. WHEN the `lrc` CLI renders review events and encounters an event with `event_type = 'tool_result'`, THE CLIRenderer SHALL prefix or tag each finding with the value of `data.tool_name` from the event's JSONB payload. +2. THE tag format for tool results in CLI output SHALL be `[]`, where `` is the exact string stored in `data.tool_name`. +3. WHEN there are no `tool_result` events in the review output, THE CLIRenderer SHALL NOT render any tool result section. + +--- + +### Requirement 12 – Full Echo Middleware Chain Enforcement + +**User Story:** As a security-conscious operator, I want all third-party tools API endpoints to go through the standard middleware chain, so that org isolation and role-based access are automatically enforced. + +#### Acceptance Criteria + +1. THE Server SHALL register `GET /api/v1/orgs/:org_id/tools` and `PUT /api/v1/orgs/:org_id/tools/:tool_id` under an Echo group that applies `RequireAuthOrAPIKey`, `BuildOrgContext`, `ValidateOrgAccess`, and `BuildPermissionContext` in that order. +2. WHEN any middleware in the chain returns a non-nil error, THE Server SHALL propagate the HTTP error response and SHALL NOT execute the route handler. +3. THE Server SHALL apply `apimiddleware.BuildOrgBillingPlanContext` and `apimiddleware.BuildPlanContext` to the tools endpoints to enforce the cloud billing check in addition to the role check. + +--- + +### Requirement 13 – API Documentation + +**User Story:** As a developer integrating with the tools API, I want up-to-date documentation for each phase's endpoints, so that I can understand the expected request/response formats without reading source code. + +#### Acceptance Criteria + +1. THE System SHALL maintain API documentation for all three phases in `docs/tools/tools-integration-beta.md`. +2. WHEN a new endpoint or schema change is introduced in a phase, THE System SHALL update `docs/tools/tools-integration-beta.md` to document the endpoint path, method, request body, response schema, and applicable error codes for that phase. +3. THE documentation file SHALL include at minimum: `GET /api/v1/orgs/:org_id/tools`, `PUT /api/v1/orgs/:org_id/tools/:tool_id`, the `ToolResult` event schema, and the `tool_invocation` River job schema. diff --git a/Makefile b/Makefile index b5ddabb3..c687af85 100644 --- a/Makefile +++ b/Makefile @@ -671,6 +671,7 @@ build-with-ui: # Define API source files for spec generation API_SPEC_INPUTS := typed.yaml $(shell find internal/api pkg/models -name "*.go" | grep -v "internal/api/docs/spec.go") +TYPED_VERSION := v0.2.3 # Typed configuration @@ -1101,4 +1102,4 @@ razorpay-verify-plans: @bash ./scripts/verify-razorpay-plans.sh $(DEPLOY_ACTUAL_ENV_FILE) razorpay-verify-plans-low-pricing: - @bash ./scripts/verify-razorpay-plans.sh $(DEPLOY_LOW_PRICING_ENV_FILE) \ No newline at end of file + @bash ./scripts/verify-razorpay-plans.sh $(DEPLOY_LOW_PRICING_ENV_FILE) diff --git a/db/migrations/20260618100000_create_available_tools.sql b/db/migrations/20260618100000_create_available_tools.sql new file mode 100644 index 00000000..cbd312c3 --- /dev/null +++ b/db/migrations/20260618100000_create_available_tools.sql @@ -0,0 +1,18 @@ +-- migrate:up +CREATE TABLE IF NOT EXISTS public.available_tools ( + id bigserial PRIMARY KEY, + name text NOT NULL UNIQUE, + description text NOT NULL, + lambda_arn text NOT NULL, + multiplier numeric(6,2) NOT NULL DEFAULT 1.0, + use_case text NOT NULL DEFAULT '', + created_at timestamptz NOT NULL DEFAULT now() +); + +-- Tools are registered via the lr-tools deployer's `register-tools` command, +-- which resolves real Lambda ARNs from AWS after deployment and calls the +-- LiveReview API (POST /api/v1/admin/tools) to upsert each tool. +-- No hardcoded ARNs belong here. + +-- migrate:down +DROP TABLE IF EXISTS public.available_tools; diff --git a/db/migrations/20260618100001_create_org_tools.sql b/db/migrations/20260618100001_create_org_tools.sql new file mode 100644 index 00000000..7c308787 --- /dev/null +++ b/db/migrations/20260618100001_create_org_tools.sql @@ -0,0 +1,15 @@ +-- migrate:up +CREATE TABLE IF NOT EXISTS public.org_tools ( + org_id bigint NOT NULL REFERENCES public.orgs(id) ON DELETE CASCADE, + tool_id bigint NOT NULL REFERENCES public.available_tools(id) ON DELETE CASCADE, + enabled boolean NOT NULL DEFAULT false, + config_json jsonb NOT NULL DEFAULT '{}', + updated_at timestamptz NOT NULL DEFAULT now(), + PRIMARY KEY (org_id, tool_id) +); + +CREATE INDEX IF NOT EXISTS idx_org_tools_org_id ON public.org_tools (org_id); + +-- migrate:down +DROP INDEX IF EXISTS idx_org_tools_org_id; +DROP TABLE IF EXISTS public.org_tools; diff --git a/db/schema.sql b/db/schema.sql index c260c65c..3ca25a74 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -1,7 +1,7 @@ \restrict dbmate --- Dumped from database version 15.17 (Debian 15.17-1.pgdg13+1) --- Dumped by pg_dump version 16.13 (Ubuntu 16.13-0ubuntu0.24.04.1) +-- Dumped from database version 16.14 (Ubuntu 16.14-0ubuntu0.24.04.1) +-- Dumped by pg_dump version 16.14 (Ubuntu 16.14-0ubuntu0.24.04.1) SET statement_timeout = 0; SET lock_timeout = 0; @@ -106,6 +106,20 @@ END; $$; +-- +-- Name: org_tool_billing_state_set_updated_at(); Type: FUNCTION; Schema: public; Owner: - +-- + +CREATE FUNCTION public.org_tool_billing_state_set_updated_at() RETURNS trigger + LANGUAGE plpgsql + AS $$ +BEGIN + NEW.updated_at = NOW(); + RETURN NEW; +END; +$$; + + -- -- Name: plan_catalog_set_updated_at(); Type: FUNCTION; Schema: public; Owner: - -- @@ -201,17 +215,6 @@ SET default_tablespace = ''; SET default_table_access_method = heap; --- --- Name: _seed_backup; Type: TABLE; Schema: public; Owner: - --- - -CREATE TABLE public._seed_backup ( - org_id bigint NOT NULL, - key text NOT NULL, - value jsonb -); - - -- -- Name: ai_comments; Type: TABLE; Schema: public; Owner: - -- @@ -460,6 +463,40 @@ CREATE SEQUENCE public.auth_tokens_id_seq ALTER SEQUENCE public.auth_tokens_id_seq OWNED BY public.auth_tokens.id; +-- +-- Name: available_tools; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.available_tools ( + id bigint NOT NULL, + name text NOT NULL, + description text NOT NULL, + lambda_arn text NOT NULL, + multiplier numeric(6,2) DEFAULT 1.0 NOT NULL, + use_case text DEFAULT ''::text NOT NULL, + created_at timestamp with time zone DEFAULT now() NOT NULL +); + + +-- +-- Name: available_tools_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE public.available_tools_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: available_tools_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE public.available_tools_id_seq OWNED BY public.available_tools.id; + + -- -- Name: billing_notification_outbox; Type: TABLE; Schema: public; Owner: - -- @@ -852,19 +889,6 @@ CREATE SEQUENCE public.loc_usage_ledger_id_seq ALTER SEQUENCE public.loc_usage_ledger_id_seq OWNED BY public.loc_usage_ledger.id; --- --- Name: mcp_authorizations; Type: TABLE; Schema: public; Owner: - --- - -CREATE TABLE public.mcp_authorizations ( - request_id uuid DEFAULT gen_random_uuid() NOT NULL, - status character varying(20) DEFAULT 'pending'::character varying NOT NULL, - token_pair jsonb, - expires_at timestamp with time zone DEFAULT (now() + '00:10:00'::interval) NOT NULL, - created_at timestamp with time zone DEFAULT now() NOT NULL -); - - -- -- Name: org_billing_state; Type: TABLE; Schema: public; Owner: - -- @@ -1106,6 +1130,56 @@ CREATE SEQUENCE public.org_teams_configs_id_seq ALTER SEQUENCE public.org_teams_configs_id_seq OWNED BY public.org_teams_configs.id; +-- +-- Name: org_tool_billing_state; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.org_tool_billing_state ( + id bigint NOT NULL, + org_id bigint NOT NULL, + credits_used_month double precision DEFAULT 0.0 NOT NULL, + credits_limit_month double precision DEFAULT 50000.0 NOT NULL, + billing_period_start timestamp with time zone NOT NULL, + billing_period_end timestamp with time zone NOT NULL, + created_at timestamp with time zone DEFAULT now() NOT NULL, + updated_at timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT chk_tool_billing_period_valid CHECK ((billing_period_end > billing_period_start)), + CONSTRAINT chk_tool_billing_used_non_negative CHECK ((credits_used_month >= (0.0)::double precision)) +); + + +-- +-- Name: org_tool_billing_state_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE public.org_tool_billing_state_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: org_tool_billing_state_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE public.org_tool_billing_state_id_seq OWNED BY public.org_tool_billing_state.id; + + +-- +-- Name: org_tools; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.org_tools ( + org_id bigint NOT NULL, + tool_id bigint NOT NULL, + enabled boolean DEFAULT false NOT NULL, + config_json jsonb DEFAULT '{}'::jsonb NOT NULL, + updated_at timestamp with time zone DEFAULT now() NOT NULL +); + + -- -- Name: orgs; Type: TABLE; Schema: public; Owner: - -- @@ -2308,6 +2382,39 @@ CREATE TABLE public.system_settings ( ); +-- +-- Name: tool_credit_ledger; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.tool_credit_ledger ( + id bigint NOT NULL, + org_id bigint NOT NULL, + review_id bigint, + credits_deducted double precision NOT NULL, + idempotency_key character varying(255) NOT NULL, + created_at timestamp with time zone DEFAULT now() NOT NULL +); + + +-- +-- Name: tool_credit_ledger_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE public.tool_credit_ledger_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: tool_credit_ledger_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE public.tool_credit_ledger_id_seq OWNED BY public.tool_credit_ledger.id; + + -- -- Name: trial_eligibility; Type: TABLE; Schema: public; Owner: - -- @@ -2761,6 +2868,13 @@ ALTER TABLE ONLY public.api_keys ALTER COLUMN id SET DEFAULT nextval('public.api ALTER TABLE ONLY public.auth_tokens ALTER COLUMN id SET DEFAULT nextval('public.auth_tokens_id_seq'::regclass); +-- +-- Name: available_tools id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.available_tools ALTER COLUMN id SET DEFAULT nextval('public.available_tools_id_seq'::regclass); + + -- -- Name: billing_notification_outbox id; Type: DEFAULT; Schema: public; Owner: - -- @@ -2838,6 +2952,13 @@ ALTER TABLE ONLY public.org_slack_configs ALTER COLUMN id SET DEFAULT nextval('p ALTER TABLE ONLY public.org_teams_configs ALTER COLUMN id SET DEFAULT nextval('public.org_teams_configs_id_seq'::regclass); +-- +-- Name: org_tool_billing_state id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.org_tool_billing_state ALTER COLUMN id SET DEFAULT nextval('public.org_tool_billing_state_id_seq'::regclass); + + -- -- Name: orgs id; Type: DEFAULT; Schema: public; Owner: - -- @@ -2985,6 +3106,13 @@ ALTER TABLE ONLY public.subscriptions ALTER COLUMN id SET DEFAULT nextval('publi ALTER TABLE ONLY public.system_default_ai_configs ALTER COLUMN id SET DEFAULT nextval('public.system_default_ai_configs_id_seq'::regclass); +-- +-- Name: tool_credit_ledger id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.tool_credit_ledger ALTER COLUMN id SET DEFAULT nextval('public.tool_credit_ledger_id_seq'::regclass); + + -- -- Name: trial_eligibility id; Type: DEFAULT; Schema: public; Owner: - -- @@ -3048,14 +3176,6 @@ ALTER TABLE ONLY public.users ALTER COLUMN id SET DEFAULT nextval('public.users_ ALTER TABLE ONLY public.webhook_registry ALTER COLUMN id SET DEFAULT nextval('public.webhook_registry_id_seq'::regclass); --- --- Name: _seed_backup _seed_backup_pkey; Type: CONSTRAINT; Schema: public; Owner: - --- - -ALTER TABLE ONLY public._seed_backup - ADD CONSTRAINT _seed_backup_pkey PRIMARY KEY (org_id, key); - - -- -- Name: ai_comments ai_comments_pkey; Type: CONSTRAINT; Schema: public; Owner: - -- @@ -3112,6 +3232,22 @@ ALTER TABLE ONLY public.auth_tokens ADD CONSTRAINT auth_tokens_pkey PRIMARY KEY (id); +-- +-- Name: available_tools available_tools_name_key; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.available_tools + ADD CONSTRAINT available_tools_name_key UNIQUE (name); + + +-- +-- Name: available_tools available_tools_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.available_tools + ADD CONSTRAINT available_tools_pkey PRIMARY KEY (id); + + -- -- Name: billing_notification_outbox billing_notification_outbox_pkey; Type: CONSTRAINT; Schema: public; Owner: - -- @@ -3216,14 +3352,6 @@ ALTER TABLE ONLY public.loc_usage_ledger ADD CONSTRAINT loc_usage_ledger_pkey PRIMARY KEY (id); --- --- Name: mcp_authorizations mcp_authorizations_pkey; Type: CONSTRAINT; Schema: public; Owner: - --- - -ALTER TABLE ONLY public.mcp_authorizations - ADD CONSTRAINT mcp_authorizations_pkey PRIMARY KEY (request_id); - - -- -- Name: org_billing_state org_billing_state_org_id_key; Type: CONSTRAINT; Schema: public; Owner: - -- @@ -3296,6 +3424,30 @@ ALTER TABLE ONLY public.org_teams_configs ADD CONSTRAINT org_teams_configs_pkey PRIMARY KEY (id); +-- +-- Name: org_tool_billing_state org_tool_billing_state_org_id_key; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.org_tool_billing_state + ADD CONSTRAINT org_tool_billing_state_org_id_key UNIQUE (org_id); + + +-- +-- Name: org_tool_billing_state org_tool_billing_state_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.org_tool_billing_state + ADD CONSTRAINT org_tool_billing_state_pkey PRIMARY KEY (id); + + +-- +-- Name: org_tools org_tools_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.org_tools + ADD CONSTRAINT org_tools_pkey PRIMARY KEY (org_id, tool_id); + + -- -- Name: orgs orgs_pkey; Type: CONSTRAINT; Schema: public; Owner: - -- @@ -3576,6 +3728,14 @@ ALTER TABLE ONLY public.system_settings ADD CONSTRAINT system_settings_pkey PRIMARY KEY (name); +-- +-- Name: tool_credit_ledger tool_credit_ledger_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.tool_credit_ledger + ADD CONSTRAINT tool_credit_ledger_pkey PRIMARY KEY (id); + + -- -- Name: trial_eligibility trial_eligibility_normalized_email_key; Type: CONSTRAINT; Schema: public; Owner: - -- @@ -4188,13 +4348,6 @@ CREATE INDEX idx_loc_usage_ledger_org_time ON public.loc_usage_ledger USING btre CREATE INDEX idx_loc_usage_ledger_org_user ON public.loc_usage_ledger USING btree (org_id, user_id) WHERE (user_id IS NOT NULL); --- --- Name: idx_mcp_authorizations_status_expiry; Type: INDEX; Schema: public; Owner: - --- - -CREATE INDEX idx_mcp_authorizations_status_expiry ON public.mcp_authorizations USING btree (status, expires_at); - - -- -- Name: idx_org_billing_current_plan; Type: INDEX; Schema: public; Owner: - -- @@ -4265,6 +4418,13 @@ CREATE INDEX idx_org_teams_configs_enabled ON public.org_teams_configs USING btr CREATE INDEX idx_org_teams_configs_org_id ON public.org_teams_configs USING btree (org_id); +-- +-- Name: idx_org_tools_org_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_org_tools_org_id ON public.org_tools USING btree (org_id); + + -- -- Name: idx_orgs_active; Type: INDEX; Schema: public; Owner: - -- @@ -5042,6 +5202,13 @@ CREATE TRIGGER trg_license_state_updated_at BEFORE UPDATE ON public.license_stat CREATE TRIGGER trg_org_billing_state_updated_at BEFORE UPDATE ON public.org_billing_state FOR EACH ROW EXECUTE FUNCTION public.org_billing_state_set_updated_at(); +-- +-- Name: org_tool_billing_state trg_org_tool_billing_state_updated_at; Type: TRIGGER; Schema: public; Owner: - +-- + +CREATE TRIGGER trg_org_tool_billing_state_updated_at BEFORE UPDATE ON public.org_tool_billing_state FOR EACH ROW EXECUTE FUNCTION public.org_tool_billing_state_set_updated_at(); + + -- -- Name: plan_catalog trg_plan_catalog_updated_at; Type: TRIGGER; Schema: public; Owner: - -- @@ -5333,6 +5500,30 @@ ALTER TABLE ONLY public.org_teams_configs ADD CONSTRAINT org_teams_configs_org_id_fkey FOREIGN KEY (org_id) REFERENCES public.orgs(id) ON DELETE CASCADE; +-- +-- Name: org_tool_billing_state org_tool_billing_state_org_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.org_tool_billing_state + ADD CONSTRAINT org_tool_billing_state_org_id_fkey FOREIGN KEY (org_id) REFERENCES public.orgs(id) ON DELETE CASCADE; + + +-- +-- Name: org_tools org_tools_org_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.org_tools + ADD CONSTRAINT org_tools_org_id_fkey FOREIGN KEY (org_id) REFERENCES public.orgs(id) ON DELETE CASCADE; + + +-- +-- Name: org_tools org_tools_tool_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.org_tools + ADD CONSTRAINT org_tools_tool_id_fkey FOREIGN KEY (tool_id) REFERENCES public.available_tools(id) ON DELETE CASCADE; + + -- -- Name: orgs orgs_created_by_user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - -- @@ -5621,6 +5812,22 @@ ALTER TABLE ONLY public.subscriptions ADD CONSTRAINT subscriptions_owner_user_id_fkey FOREIGN KEY (owner_user_id) REFERENCES public.users(id); +-- +-- Name: tool_credit_ledger tool_credit_ledger_org_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.tool_credit_ledger + ADD CONSTRAINT tool_credit_ledger_org_id_fkey FOREIGN KEY (org_id) REFERENCES public.orgs(id) ON DELETE CASCADE; + + +-- +-- Name: tool_credit_ledger tool_credit_ledger_review_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.tool_credit_ledger + ADD CONSTRAINT tool_credit_ledger_review_id_fkey FOREIGN KEY (review_id) REFERENCES public.reviews(id) ON DELETE SET NULL; + + -- -- Name: trial_eligibility trial_eligibility_first_org_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - -- @@ -5944,14 +6151,17 @@ INSERT INTO public.schema_migrations (version) VALUES ('20260411170000'), ('20260419193000'), ('20260420140334'), - ('20260509195524'), ('20260521120000'), ('20260521140000'), ('20260522120000'), ('20260527120000'), ('20260611185900'), ('20260612152523'), + ('20260618100000'), + ('20260618100001'), + ('20260618100002'), ('20260620000000'), + ('20260620120000'), ('20260621194000'), ('20260622180000'), ('20260623135113'), diff --git a/docs/openapi.yaml b/docs/openapi.yaml index f8369be9..d673f337 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -7423,12 +7423,12 @@ paths: operationId: UpdateUser parameters: - in: path - name: org_id + name: user_id required: true schema: type: string - in: path - name: user_id + name: org_id required: true schema: type: string diff --git a/docs/tools/tools-integration-beta.md b/docs/tools/tools-integration-beta.md new file mode 100644 index 00000000..ddbc0b70 --- /dev/null +++ b/docs/tools/tools-integration-beta.md @@ -0,0 +1,838 @@ +# Third-Party Tools Integration – Beta + +LiveReview can run external static-analysis tools (ruff, bandit, eslint, etc.) as parallel Lambda jobs alongside every AI review. Results are stored as `tool_result` events in the existing `review_events` table and surfaced in the review UI and `lrc` CLI output. + +This feature is **cloud-only** and **owner-gated**. It is delivered in three sequential phases. + +--- + +## Current Status (feat/tool-integration-v2) + +**Only UI iterations are done. Backend architecture is not started.** + +What exists today: + +- The **Tool Analysis card** (`ui/src/components/reviews/ToolAnalysisCard.tsx`) renders on `ReviewDetail`. +- Three test review pages simulate the card across stages: + - `/#/reviews/test1` — all tools queued/pending. + - `/#/reviews/test2` — mixed running/completed/queued with animated spinners. + - `/#/reviews/test3` — all 15 tools completed with findings and failures. +- The mock data for these stages lives in `ui/src/pages/Reviews/ReviewDetail.tsx` (`fetchReviewDetails`, test IDs only). The card consumes the mock `toolBreakdown`, not a real API. +- The card is collapsed by default. Its toggle button shows live state (running count, findings count) so users know what to expect before expanding. + +What does **not** exist yet: + +- Database migrations (`available_tools`, `org_tools`). +- Settings UI tab and its API endpoints. +- River `tool_invocation` job and Lambda fan-out. +- `tool_result` events written to `review_events`. +- `lrc` CLI rendering of tool findings. +- Any backend integration or cost billing. + +The sections below document the **planned** backend architecture. Treat them as the design target, not as a description of the current code. + +--- + +## Table of Contents + +1. [Cost Model](#cost-model) +2. [Phase 1 – DB Schema & Settings Tab](#phase-1--db-schema--settings-tab) +3. [Phase 2 – Settings UI & API](#phase-2--settings-ui--api) +4. [Phase 3 – Queue, Lambda Trigger & Review UI](#phase-3--queue-lambda-trigger--review-ui) +5. [Shared Schemas](#shared-schemas) + +--- + +## Cost Model + +Each tool runs as an independent Lambda invocation. Cost is billed in GB-seconds at the AWS ARM64 rate (`$0.0000133334 / GB-s`). + +**Formula per tool invocation:** + +``` +cost = (memory_mb / 1024) × timeout_seconds × rate +``` + +**Credit budget:** LiveReview provides **50,000 credits** per org. One credit equals the cost of one invocation of the cheapest tool (the baseline). Orgs spend credits from this pool each time a tool runs on a review. + +### Tool catalog reference + +The table below lists available tools. `multiplier` is computed by the API (`(memory_mb / 1024) × timeout_s` relative to the cheapest tool) and returned in the `GET /api/v1/orgs/:org_id/tools` response. Tools marked **Beta** are included in the initial seed. + +| Tool | Multiplier | Use Case | +|---|---|---| +| openapi | computed | OpenAPI/YAML validation | +| actionlint | computed | GitHub Actions lint | +| shellcheck | computed | Shell script lint | +| hadolint | computed | Dockerfile lint | +| ruff | computed | Python lint/format | +| tfsec | computed | Terraform IaC | +| zizmor | computed | GitHub Actions security | +| gitleaks | computed | Secret detection | +| bandit | computed | Python SAST | +| eslint | computed | JavaScript/TypeScript SAST | +| detect-secrets | computed | Secret scanning | +| trufflehog | computed | Secret scanning (deep) | +| spectral | computed | API spec lint | +| kubescape | computed | Kubernetes IaC | +| trivy | computed | Container/IaC CVE scan | +| brakeman | computed | Ruby SAST | +| semgrep | computed | Multi-language SAST | +| golangci-lint | computed | Go SAST | + +**What users see in the UI:** tool name, multiplier tier, use case, and the running total cost per review so they can choose a tool budget that fits within their credit allowance. + +--- + +## Phase 1 – DB Schema & Settings Tab + +### DB migrations (dbmate, local only) + +Two migrations are added to `db/migrations/`. **Never apply directly to production** — use dbmate. + +#### Migration 1: `available_tools` catalog + +```sql +-- migrate:up +CREATE TABLE IF NOT EXISTS public.available_tools ( + id bigserial PRIMARY KEY, + name text NOT NULL UNIQUE, + description text NOT NULL, + lambda_arn text NOT NULL, + multiplier numeric(6,2) NOT NULL DEFAULT 1.0, + use_case text NOT NULL DEFAULT '', + created_at timestamptz NOT NULL DEFAULT now() +); + +-- Seed initial tools (ruff and bandit as the two cheapest beta tools) +INSERT INTO public.available_tools (name, description, lambda_arn, multiplier, use_case) VALUES + ('ruff', 'Fast Python linter and formatter', 'arn:aws:lambda:us-east-1:ACCOUNT:function:ruff-python-linter', 1.0, 'Python lint/format'), + ('bandit', 'Python security linter (SAST)', 'arn:aws:lambda:us-east-1:ACCOUNT:function:bandit-linter', 1.0, 'Python SAST') +ON CONFLICT (name) DO NOTHING; + +-- migrate:down +DROP TABLE IF EXISTS public.available_tools; +``` + +#### Migration 2: `org_tools` per-org selection + +```sql +-- migrate:up +CREATE TABLE IF NOT EXISTS public.org_tools ( + org_id bigint NOT NULL REFERENCES public.organizations(id) ON DELETE CASCADE, + tool_id bigint NOT NULL REFERENCES public.available_tools(id) ON DELETE CASCADE, + enabled boolean NOT NULL DEFAULT false, + config_json jsonb NOT NULL DEFAULT '{}', + updated_at timestamptz NOT NULL DEFAULT now(), + PRIMARY KEY (org_id, tool_id) +); + +CREATE INDEX IF NOT EXISTS idx_org_tools_org_id ON public.org_tools (org_id); + +-- migrate:down +DROP INDEX IF EXISTS idx_org_tools_org_id; +DROP TABLE IF EXISTS public.org_tools; +``` + +**Key design decisions:** +- `available_tools` is a global catalog — rows are added by platform operators, never by org owners. +- `org_tools` stores one row per (org, tool) pair when an org has ever interacted with that tool. Rows with `enabled = false` are stored explicitly so toggle state is preserved. +- `multiplier` on `available_tools` is denormalised from the Lambda config so the UI can display cost tiers without a live Lambda call. + +### Settings tab (UI, Phase 1 scope) + +A new tab entry is added to `ui/src/pages/Settings/Settings.tsx`: + +```typescript +// Added to the tabs array — only shown when isCloudMode() AND role is 'owner' +...(isCloudMode() && currentOrg?.role === 'owner' ? [{ + id: 'third-party-tools', + name: 'Third-Party Tools', + icon: +}] : []) +``` + +At this phase the tab renders a placeholder ("Tool configuration coming in Phase 2"). Non-owners who navigate directly to `/#/settings#third-party-tools` see a read-only message; the tab button is not shown in the sidebar. + +--- + +## Phase 2 – Settings UI & API + +### API endpoints + +Both endpoints live under the existing `orgGroup` in `server.go`, which already applies the full middleware chain: + +``` +RequireAuthOrAPIKey → BuildOrgContext → ValidateOrgAccess → BuildPermissionContext +``` + +The billing check middlewares (`BuildOrgBillingPlanContext`, `BuildPlanContext`) are also applied. + +--- + +#### `GET /api/v1/orgs/:org_id/tools` + +Returns the full available tools catalog joined with this org's enabled state. + +**Access:** any authenticated org member (owner or member). +**Cloud gate:** returns HTTP 403 if `isCloudMode()` is false on the server. +**Org isolation:** query is scoped by `org_id` from `PermissionContext`, not from the URL path parameter. + +**Response 200:** + +```json +{ + "tools": [ + { + "id": 1, + "name": "ruff", + "description": "Fast Python linter and formatter", + "multiplier": 1.0, + "use_case": "Python lint/format", + "enabled": true, + "config_json": {} + }, + { + "id": 2, + "name": "bandit", + "description": "Python security linter (SAST)", + "multiplier": 1.0, + "use_case": "Python SAST", + "enabled": false, + "config_json": {} + } + ] +} +``` + +Fields `enabled` and `config_json` default to `false` / `{}` when no `org_tools` row exists for that tool. + +**Error responses:** + +| Status | Condition | +|---|---| +| 401 | Missing or invalid auth token | +| 403 | Not cloud mode, or org mismatch | +| 500 | Database error | + +--- + +#### `PUT /api/v1/orgs/:org_id/tools/:tool_id` + +Enables or disables a specific tool for the org (upsert). + +**Access:** `owner` role only. +**Cloud gate:** HTTP 403 if not cloud mode. +**Org isolation:** upsert uses `org_id` from `PermissionContext`. + +**Request body:** + +```json +{ "enabled": true } +``` + +The `enabled` field is required and must be a boolean. Any other value returns HTTP 400. + +**Response 200:** + +```json +{ + "tool_id": 1, + "org_id": 42, + "enabled": true, + "config_json": {} +} +``` + +**Error responses:** + +| Status | Condition | +|---|---| +| 400 | `enabled` field absent or not a boolean | +| 401 | Missing or invalid auth token | +| 403 | Not cloud mode, not owner, or org mismatch | +| 404 | `tool_id` not found in `available_tools` | +| 500 | Database error | + +--- + +### Settings UI – ThirdPartyToolsTab component + +File: `ui/src/pages/Settings/ThirdPartyToolsTab.tsx` + +The tab replaces the Phase 1 placeholder. It fetches `GET /api/v1/orgs/:org_id/tools` on mount and renders a table with the following columns: + +| Column | Description | +|---|---| +| Tool name | Human-readable name | +| Use case | Short category label (e.g. "Python SAST") | +| Multiplier | Cost tier (e.g. `1×`, `3×`, `20×`) | +| Toggle | Enable/disable switch (owner only) | + +**Cost summary bar** at the top of the tab shows: +- Number of enabled tools +- Total multiplier of all enabled tools (sum) +- Estimated credits consumed per review = sum of enabled tool multipliers × baseline cost + +**Owner behaviour:** +- Toggling a tool calls `PUT /api/v1/orgs/:org_id/tools/:tool_id` immediately. +- On API error: inline error message shown, toggle reverted to previous state. +- While any request is in flight: all toggles are disabled and a spinner is shown. + +**Non-owner / member behaviour:** +- Table renders in read-only state. Toggles are replaced with a static enabled/disabled badge. +- No PUT calls are made. + +--- + +## Phase 3 – Queue, Lambda Trigger & Review UI + +### River job: `tool_invocation` + +File: `internal/jobqueue/jobqueue.go` (alongside existing `webhook_install` / `webhook_removal` jobs) + +#### Job args + +```go +type ToolInvocationJobArgs struct { + ReviewID int64 `json:"review_id"` + OrgID int64 `json:"org_id"` + ToolID int64 `json:"tool_id"` + ToolName string `json:"tool_name"` + LambdaARN string `json:"lambda_arn"` +} + +func (ToolInvocationJobArgs) Kind() string { return "tool_invocation" } +``` + +#### Worker + +```go +type ToolInvocationWorker struct { + river.WorkerDefaults[ToolInvocationJobArgs] + db *sql.DB + httpClient *http.Client +} +``` + +**Work() logic:** + +1. Load the diff from `SELECT diff FROM reviews WHERE id = $1 AND org_id = $2`. If the review has no diff, log and return without error (nothing to analyse). +2. POST the diff as the Lambda payload to the tool's `lambda_arn` via HTTPS. +3. On non-2xx response: return an error so River applies its standard retry policy. +4. On 2xx: insert a `review_events` row (see schema below). + +#### Fan-out trigger + +In `WebhookOrchestratorV2` (or the unified processor), after diff extraction completes: + +```go +enabledTools, err := store.GetEnabledToolsForOrg(ctx, orgID) +for _, tool := range enabledTools { + _, err = riverClient.Insert(ctx, ToolInvocationJobArgs{ + ReviewID: reviewID, + OrgID: orgID, + ToolID: tool.ID, + ToolName: tool.Name, + LambdaARN: tool.LambdaARN, + }, nil) +} +``` + +All jobs are inserted in a single loop — River runs them concurrently up to `MaxWorkers`. + +### Lambda payload & response + +**Payload sent to Lambda (JSON):** + +```json +{ + "review_id": 1234, + "diff": "" +} +``` + +**Expected Lambda response (JSON):** + +```json +{ + "exit_code": 0, + "findings": [ + { + "file": "src/main.py", + "line": 42, + "col": 5, + "rule": "E501", + "message": "Line too long (92 > 79 characters)" + } + ], + "lines_of_code": 312, + "stderr": "" +} +``` + +The full response body is stored verbatim in the `data` JSONB column of `review_events`. + +### `review_events` row for tool results + +No new table is needed. A new `event_type` value is added to the existing `review_events` table: + +```sql +-- No migration required — event_type is free-text. +-- New rows look like: +INSERT INTO public.review_events (review_id, org_id, event_type, data) +VALUES ( + $1, -- review_id + $2, -- org_id (from review record, never from job args directly) + 'tool_result', + '{ + "tool_id": 1, + "tool_name": "ruff", + "exit_code": 0, + "findings": [...], + "lines_of_code": 312, + "stderr": "" + }' +); +``` + +`org_id` is always read from the `reviews` row, not from the job args, to prevent any spoofing. + +### Beta review UI + +Route: `/#/reviews-tools/new` +File: `ui/src/pages/Reviews/BetaToolReviewPage.tsx` + +- Registered in the React Router config but **not** added to the sidebar or any nav surface. +- If `isCloudMode()` returns false, renders: *"Tool-based reviews are only available in cloud mode."* +- Otherwise renders a layout matching the existing AI review page (`NewReview.tsx`) with the trigger form at the top and a live event stream below. +- `tool_result` events in the stream are rendered with a coloured badge showing the tool name (e.g. `[ruff]`), followed by the findings list. + +### Tool Analysis & Credits Panel in ReviewDetail + +File: `ui/src/pages/Reviews/ReviewDetail.tsx` + +When a review includes third-party static analysis tool invocations (`event_type === 'tool_result'`): + +- A dedicated **Tool Analysis & Credits** card is rendered directly below the main Review Info header. +- **Top 3 KPI Summary Boxes**: + - `Total Tool Credits`: Sum of credits consumed by tool executions on this review (e.g. `2.0 Credits`). + - `Tools Executed`: Count of static analysis tools run (e.g. `2 Tools`). + - `Total Comments Generated`: Total comments/findings posted by tools (e.g. `14 Comments`). +- **Tool Summary Cards Grid**: + - Displays a grid of individual tool cards (`ruff`, `bandit`, `gitleaks`, `eslint`, etc.). + - Each card shows: **Tool Name**, **Credits Used** (e.g. `1.0 Credits`), **Comments Generated** (e.g. `14 Comments`), and **Status Badge** (e.g. Green `Clean` badge vs Amber `14 Comments` badge). +- If no `tool_result` events exist for the review, the Tool Analysis panel is hidden. + +### `lrc` CLI output + +When `lrc` renders a completed review and encounters events with `event_type === 'tool_result'`: + +``` +[ruff] src/auth/login.py:42:5 E501 Line too long (92 > 79 characters) +[ruff] src/auth/login.py:78:1 F401 'os' imported but unused +[bandit] src/utils/crypto.py:12:0 B303 Use of MD5 not recommended +``` + +Tag format: `[]` followed by the finding in standard linter format. +If no `tool_result` events are present, the tool section is skipped entirely (no empty header rendered). + +--- + +## Shared Schemas + +### `tool_result` event data shape + +```json +{ + "tool_id": 1, + "tool_name": "ruff", + "exit_code": 0, + "findings": [ + { + "file": "src/main.py", + "line": 42, + "col": 5, + "rule": "E501", + "message": "Line too long (92 > 79 characters)" + } + ], + "lines_of_code": 312, + "stderr": "" +} +``` + +### `tool_invocation` River job schema + +```json +{ + "review_id": 1234, + "org_id": 42, + "tool_id": 1, + "tool_name": "ruff", + "lambda_arn": "arn:aws:lambda:us-east-1:ACCOUNT:function:ruff-python-linter" +} +``` + +### `available_tools` table + +| Column | Type | Notes | +|---|---|---| +| `id` | bigserial | PK | +| `name` | text | Unique, e.g. `ruff` | +| `description` | text | Human-readable | +| `lambda_arn` | text | Full ARN of the Lambda function | +| `multiplier` | numeric(6,2) | Cost tier relative to baseline tool | +| `use_case` | text | Short label, e.g. `Python SAST` | +| `created_at` | timestamptz | | + +### `org_tools` table + +| Column | Type | Notes | +|---|---|---| +| `org_id` | bigint | FK → `organizations.id` | +| `tool_id` | bigint | FK → `available_tools.id` | +| `enabled` | boolean | Default `false` | +| `config_json` | jsonb | Per-org tool config, default `{}` | +| `updated_at` | timestamptz | | + +--- + +## ReviewDetail Header – Data Requirements & API Design + +### Problem: Current page makes 5 serial/parallel API calls on load + +``` +Promise.all([ + GET /api/v1/reviews/:id → Review row + GET /api/v1/reviews/:id/events → up to 1000 events (huge payload, used only for severity counts + events tab) + GET /api/v1/reviews/:id/summary → batchCount, lastActivity +]) ++ sequential: + GET /api/v1/reviews/:id/accounting → cost, tokens (used only for Accounting tab + tool summary) + GET /api/v1/reviews/:id/commits → commit SHAs +``` + +Severity counts (High/Medium/Low) are computed client-side by scanning 1000 events — a payload fetched solely to count three numbers. The accounting call is a round trip just to show `$0.04` in the header. + +--- + +### What the header needs to display + +| UI element | Data field | Current source | +|---|---|---| +| Repo name | `review.repository` | `GET /reviews/:id` | +| Branch | `review.branch` | `GET /reviews/:id` | +| Provider icon | `review.provider` | `GET /reviews/:id` | +| PR/MR link | `review.prMrUrl` | `GET /reviews/:id` | +| MR Title | `review.mrTitle` | `GET /reviews/:id` (field exists, not displayed) | +| Status badge | `review.status` | `GET /reviews/:id` | +| Created by | `review.userEmail` | `GET /reviews/:id` | +| Created at | `review.createdAt` | `GET /reviews/:id` | +| Author name | `review.authorName` | `GET /reviews/:id` (field exists, not displayed) | +| Trigger type | `review.triggerType` | `GET /reviews/:id` (field exists, not displayed) | +| Severity: High | computed from events | `GET /reviews/:id/events` (1000 items) ← **expensive** | +| Severity: Medium | computed from events | same | +| Severity: Low | computed from events | same | +| Tools executed | accounting-derived | `GET /reviews/:id/accounting` ← **separate call** | +| Total findings | accounting-derived | same | +| Total cost (USD) | `accounting.totalCostUsd` | same | +| Batch count | `summary.batchCount` | `GET /reviews/:id/summary` | +| Last activity | `summary.lastActivity` | `GET /reviews/:id/summary` | +| Duration | `review.startedAt` + `review.completedAt` | `GET /reviews/:id` | +| Commits list | `commits[]` | `GET /reviews/:id/commits` | +| Tool breakdown | accounting-derived | `GET /reviews/:id/accounting` | + +--- + +### Proposed: 2-call design + +#### Call 1 — Eager, on page load + +**Enrich `GET /api/v1/reviews/:id/summary`** to be the single source of truth for the header. The backend computes severity counts and pulls tool/cost aggregates from existing tables in one query, rather than making the client do 3 round trips. + +**Proposed enriched response shape:** + +```json +{ + "reviewId": 123, + "currentStatus": "completed", + "lastActivity": "2026-08-17T16:10:00Z", + "batchCount": 4, + "eventCounts": { "log": 45, "batch": 4, "tool_result": 15 }, + + "severityCounts": { + "high": 0, + "medium": 1, + "low": 11 + }, + + "toolSummary": { + "toolsExecuted": 15, + "totalCommentsGenerated": 11, + "totalCostUsd": 0.042, + "toolBreakdown": [ + { "toolName": "ruff", "creditsUsed": 1.0, "commentsGenerated": 3, "status": "completed" }, + { "toolName": "bandit", "creditsUsed": 1.0, "commentsGenerated": 0, "status": "clean" }, + { "toolName": "gitleaks", "creditsUsed": 1.0, "commentsGenerated": 8, "status": "completed" } + ] + } +} +``` + +**Backend implementation notes:** + +- `severityCounts` — computed with a single `SELECT level, COUNT(*) FROM review_events WHERE review_id = $1 AND org_id = $2 AND event_type = 'tool_result' GROUP BY level` (or equivalent severity field on the data JSONB). If severity is embedded in `data->>'level'`, use a `jsonb` extraction in the GROUP BY. +- `toolSummary` — aggregated from `review_events` rows where `event_type = 'tool_result'`, pulling `data->>'tool_name'`, `data->>'exit_code'`, and `jsonb_array_length(data->'findings')` per row. No join to `accounting` needed for the header. +- `totalCostUsd` — pulled from `review_accounting` (existing table) if present; `null` if not yet recorded. + +#### Call 2 — Lazy, only when Events tab is opened + +``` +GET /api/v1/reviews/:id/events?limit=1000 +``` + +Events are **not** needed until the user opens the Events tab or Findings tab. Defer this call entirely. No severity pre-computation needed on the client. + +#### Commits — unchanged (best-effort, non-blocking) + +``` +GET /api/v1/reviews/:id/commits +``` + +Already fire-and-forget. Keep as is. + +--- + +### Summary of calls after redesign + +| Call | When | Purpose | +|---|---|---| +| `GET /reviews/:id` | eager | Review identity, status, PR info | +| `GET /reviews/:id/summary` (enriched) | eager | All header aggregates: severity, tools, cost, batches | +| `GET /reviews/:id/commits` | eager, non-blocking | Commit list in Details panel | +| `GET /reviews/:id/events` | lazy (Events tab) | Full event stream | +| `GET /reviews/:id/accounting` | lazy (Accounting tab only) | Detailed token/LOC breakdown | + + +**Result:** header renders completely from 2 parallel calls instead of 5. The 1000-event payload is deferred until actually needed. + +--- + +## Tool Status Lifecycle – How Each Tool's Status is Tracked + +### The core problem + +A `tool_result` event is only written **when a Lambda returns**. There is no event for "this tool was dispatched" or "this tool is currently running". Without a record of what tools were dispatched, the UI has no way to distinguish between: + +- A tool that hasn't started yet (`pending`) +- A tool that is actively running (`running`) +- A review that never had that tool enabled at all + +This section defines the two-event approach that solves this without a new table. + +--- + +### Two event types for tools + +#### 1. `tool_dispatch` — written at fan-out time + +When `WebhookOrchestratorV2` inserts River jobs for the enabled tools, it **also** writes one `tool_dispatch` event per tool into `review_events` immediately: + +```sql +INSERT INTO public.review_events (review_id, org_id, event_type, data) +VALUES ( + $1, + $2, + 'tool_dispatch', + '{"tool_id": 1, "tool_name": "ruff", "status": "pending"}' +); +``` + +This gives the UI a complete, authoritative list of **every tool that was launched for this review**, even before any results come back. + +Go insert (inside the fan-out loop, same transaction as the River job inserts): + +```go +for _, tool := range enabledTools { + _, err = riverClient.Insert(ctx, ToolInvocationJobArgs{...}, nil) + // immediately record the dispatch + _, err = store.InsertReviewEvent(ctx, InsertReviewEventParams{ + ReviewID: reviewID, + OrgID: orgID, + EventType: "tool_dispatch", + Data: json.RawMessage(fmt.Sprintf( + `{"tool_id":%d,"tool_name":%q,"status":"pending"}`, + tool.ID, tool.Name, + )), + }) +} +``` + +#### 2. `tool_result` — written when Lambda returns + +When the River worker receives the Lambda response, it writes a `tool_result` event (already defined in Phase 3): + +```json +{ + "tool_id": 1, + "tool_name": "ruff", + "exit_code": 0, + "findings": [...], + "lines_of_code": 312, + "stderr": "" +} +``` + +--- + +### Status derivation rules + +The frontend (and the enriched summary endpoint) derives per-tool status by **joining dispatch events with result events** for the same `tool_name`: + +| Condition | Derived status | +|---|---| +| `tool_dispatch` exists, no `tool_result` yet | `pending` | +| `tool_dispatch` exists, River job is running (no result yet, time elapsed) | `running` (approximated by age — see note below) | +| `tool_result` exists, `exit_code = 0`, `findings = []` | `clean` | +| `tool_result` exists, `exit_code = 0`, `findings.length > 0` | `completed` | +| `tool_result` exists, `exit_code != 0` | `failed` | +| River job exhausted retries → worker writes a synthetic `tool_result` with `exit_code: -1` | `failed` | + +> **Running approximation:** The exact `running` status requires querying River's internal `river_jobs` table, which is brittle. Instead: if a `tool_dispatch` event is older than N seconds and no `tool_result` has appeared, the summary endpoint classifies it as `running`. A reasonable threshold is 10 seconds (most Lambda cold starts complete within 5s). This is an approximation — the UI already handles the ambiguity gracefully by showing a spinner for both `pending` and `running`. + +--- + +### SQL for the enriched summary endpoint + +The `GetReviewSummary` handler builds `toolSummary` with two queries: + +**Query 1 — dispatched tools (complete list):** + +```sql +SELECT + data->>'tool_name' AS tool_name, + data->>'tool_id' AS tool_id, + created_at +FROM review_events +WHERE review_id = $1 + AND org_id = $2 + AND event_type = 'tool_dispatch' +ORDER BY created_at ASC; +``` + +**Query 2 — completed results:** + +```sql +SELECT + data->>'tool_name' AS tool_name, + (data->>'exit_code')::int AS exit_code, + jsonb_array_length(data->'findings') AS finding_count, + data->>'stderr' AS stderr +FROM review_events +WHERE review_id = $1 + AND org_id = $2 + AND event_type = 'tool_result' +ORDER BY created_at ASC; +``` + +The handler merges the two result sets in Go: + +```go +// resultMap: tool_name → tool_result row +resultMap := map[string]ToolResultRow{} +for _, r := range results { + resultMap[r.ToolName] = r +} + +breakdown := []ToolBreakdownItem{} +for _, d := range dispatched { + r, done := resultMap[d.ToolName] + item := ToolBreakdownItem{ToolName: d.ToolName} + + if !done { + age := time.Since(d.CreatedAt) + if age > 10*time.Second { + item.Status = "running" + } else { + item.Status = "pending" + } + } else if r.ExitCode != 0 { + item.Status = "failed" + } else if r.FindingCount > 0 { + item.Status = "completed" + item.CommentsGenerated = r.FindingCount + } else { + item.Status = "clean" + } + + breakdown = append(breakdown, item) +} +``` + +**Credits** per tool come from `available_tools.multiplier` joined by `tool_id` from the dispatch events — no Lambda call needed. + +--- + +### What happens when River exhausts retries (error path) + +If the Lambda call fails after all River retries, the worker writes a synthetic failure event before returning: + +```go +// In ToolInvocationWorker.Work(), after final retry failure: +store.InsertReviewEvent(ctx, InsertReviewEventParams{ + ReviewID: args.ReviewID, + OrgID: orgID, // always from review row, never job args + EventType: "tool_result", + Data: json.RawMessage(fmt.Sprintf( + `{"tool_id":%d,"tool_name":%q,"exit_code":-1,"findings":[],"stderr":"Lambda invocation failed after retries"}`, + args.ToolID, args.ToolName, + )), +}) +``` + +This ensures the UI never shows a tool stuck in `pending` forever — it will transition to `failed` once River gives up. + +--- + +### Frontend: building `ToolAccountingData` from real API data + +Once the enriched summary endpoint is live, the frontend replaces the mock `setToolAccounting(...)` calls with a direct mapping from `summary.toolSummary`: + +```typescript +// In fetchReviewDetails, after getReviewSummary(): +if (summaryData.toolSummary) { + setToolAccounting({ + totalToolCredits: summaryData.toolSummary.totalCostUsd ?? 0, + toolsExecuted: summaryData.toolSummary.toolsExecuted, + totalCommentsGenerated: summaryData.toolSummary.totalCommentsGenerated, + toolBreakdown: summaryData.toolSummary.toolBreakdown, + }); +} else { + setToolAccounting(null); // hides the tools tab if no tools ran +} +``` + +The `status` field on each `ToolBreakdownItem` comes directly from the backend merge logic above — the frontend does not re-derive it. + +--- + +### Summary: full status lifecycle + +``` +Fan-out trigger + │ + ├─ INSERT tool_dispatch (status: "pending") ← UI sees: pending + └─ riverClient.Insert(ToolInvocationJobArgs) + │ + ├─ [job starts running] ← UI sees: running (age > 10s) + │ + ├─ Lambda returns 200 + │ └─ INSERT tool_result (exit_code, findings) + │ ├─ findings > 0 ← UI sees: completed + │ └─ findings = 0 ← UI sees: clean + │ + └─ Lambda fails / retries exhausted + └─ INSERT tool_result (exit_code: -1) + ← UI sees: failed +``` diff --git a/internal/api/diff_review.go b/internal/api/diff_review.go index 4fde027c..100e17e5 100644 --- a/internal/api/diff_review.go +++ b/internal/api/diff_review.go @@ -264,7 +264,11 @@ func (s *Server) GetDiffReviewStatus(c echo.Context) error { result, err := decodeReviewResult(meta) if err != nil { - return JSONErrorWithEnvelope(c, http.StatusInternalServerError, fmt.Sprintf("failed to decode review result: %v", err)) + // For tools-only reviews or reviews without AI comments, return an empty result gracefully instead of failing + result = DiffReviewResult{ + Summary: "Static analysis tools review completed.", + Comments: []*models.ReviewComment{}, + } } files := buildDiffFiles(preloaded, result.Comments) diff --git a/internal/api/polling_event_service.go b/internal/api/polling_event_service.go index d495ef43..ed274341 100644 --- a/internal/api/polling_event_service.go +++ b/internal/api/polling_event_service.go @@ -236,11 +236,24 @@ func (s *PollingEventService) GetReviewSummary(ctx context.Context, reviewID, or if err != nil { return nil, fmt.Errorf("failed to count batch IDs: %w", err) } + + sevCounts, err := s.repo.GetSeverityCounts(ctx, reviewID, orgID) + if err != nil { + sevCounts = SeverityCounts{} + } + + toolSum, err := s.repo.GetToolSummary(ctx, reviewID, orgID) + if err != nil { + toolSum = nil + } + summary := &ReviewSummary{ - ReviewID: reviewID, - LastActivity: time.Now(), // Will be updated with actual latest event - EventCounts: counts, - BatchCount: batchCount, + ReviewID: reviewID, + LastActivity: time.Now(), + EventCounts: counts, + BatchCount: batchCount, + SeverityCounts: sevCounts, + ToolSummary: toolSum, } // Parse latest status if available @@ -257,9 +270,11 @@ func (s *PollingEventService) GetReviewSummary(ctx context.Context, reviewID, or // ReviewSummary provides a quick overview of review progress type ReviewSummary struct { - ReviewID int64 `json:"reviewId"` - CurrentStatus string `json:"currentStatus"` - LastActivity time.Time `json:"lastActivity"` - EventCounts map[string]int `json:"eventCounts"` - BatchCount int `json:"batchCount"` + ReviewID int64 `json:"reviewId"` + CurrentStatus string `json:"currentStatus"` + LastActivity time.Time `json:"lastActivity"` + EventCounts map[string]int `json:"eventCounts"` + BatchCount int `json:"batchCount"` + SeverityCounts SeverityCounts `json:"severityCounts"` + ToolSummary *ToolSummary `json:"toolSummary,omitempty"` } diff --git a/internal/api/polling_event_service_test.go b/internal/api/polling_event_service_test.go index 7af378ed..93d36a57 100644 --- a/internal/api/polling_event_service_test.go +++ b/internal/api/polling_event_service_test.go @@ -3,6 +3,7 @@ package api import ( "context" "database/sql" + "os" "testing" "time" @@ -17,13 +18,25 @@ func TestPollingEventService(t *testing.T) { t.Skip("Skipping database integration test") } + dbURL := os.Getenv("TEST_DATABASE_URL") + if dbURL == "" { + dbURL = os.Getenv("DATABASE_URL") + } + if dbURL == "" { + dbURL = "postgres://livereview:livereview_password_123@localhost:5432/livereview?sslmode=disable" + } + // Connect to test database - db, err := sql.Open("postgres", "postgres://livereview:livereview_password_123@localhost:5432/livereview?sslmode=disable") + db, err := sql.Open("postgres", dbURL) require.NoError(t, err) defer db.Close() - service := NewPollingEventService(db) ctx := context.Background() + if err := db.PingContext(ctx); err != nil { + t.Skip("Skipping database integration test: database not accessible") + } + + service := NewPollingEventService(db) orgID := int64(1) // Create a test review record first @@ -103,6 +116,20 @@ func TestPollingEventService(t *testing.T) { }) t.Run("GetReviewSummary", func(t *testing.T) { + // Emit tool_dispatch and tool_result events for testing enriched summary + _ = service.EmitEvent(ctx, &ReviewEvent{ + ReviewID: reviewID, + OrgID: orgID, + EventType: "tool_dispatch", + Data: []byte(`{"tool_id": 1, "tool_name": "ruff", "status": "pending"}`), + }) + _ = service.EmitEvent(ctx, &ReviewEvent{ + ReviewID: reviewID, + OrgID: orgID, + EventType: "tool_result", + Data: []byte(`{"tool_id": 1, "tool_name": "ruff", "exit_code": 0, "findings": [{"file": "main.py", "line": 10, "message": "Line too long"}]}`), + }) + summary, err := service.GetReviewSummary(ctx, reviewID, orgID) require.NoError(t, err) require.NotNil(t, summary) @@ -114,17 +141,26 @@ func TestPollingEventService(t *testing.T) { assert.Contains(t, summary.EventCounts, "batch") assert.Contains(t, summary.EventCounts, "completion") assert.Equal(t, 1, summary.BatchCount) + assert.NotNil(t, summary.SeverityCounts) + assert.NotNil(t, summary.ToolSummary) + assert.Equal(t, 1, summary.ToolSummary.ToolsExecuted) + assert.Equal(t, 1, summary.ToolSummary.TotalCommentsGenerated) + assert.Len(t, summary.ToolSummary.ToolBreakdown, 1) + assert.Equal(t, "ruff", summary.ToolSummary.ToolBreakdown[0].ToolName) + assert.Equal(t, "completed", summary.ToolSummary.ToolBreakdown[0].Status) }) t.Run("GetEventCounts", func(t *testing.T) { counts, err := service.GetEventCounts(ctx, reviewID, orgID) require.NoError(t, err) - // We should have created: 1 status, 1 log, 1 batch, 1 completion + // We should have created: 1 status, 1 log, 1 batch, 1 completion, 1 tool_dispatch, 1 tool_result assert.Equal(t, 1, counts["status"]) assert.Equal(t, 1, counts["log"]) assert.Equal(t, 1, counts["batch"]) assert.Equal(t, 1, counts["completion"]) + assert.Equal(t, 1, counts["tool_dispatch"]) + assert.Equal(t, 1, counts["tool_result"]) }) // Clean up test data diff --git a/internal/api/review_events_repo.go b/internal/api/review_events_repo.go index 2a5835fa..eac939ba 100644 --- a/internal/api/review_events_repo.go +++ b/internal/api/review_events_repo.go @@ -18,6 +18,15 @@ type ReviewEventsRepo = reviewprocessor.ReviewEventsRepo // ListEventsCursor represents pagination cursor for events, aliased from reviewprocessor type ListEventsCursor = reviewprocessor.ListEventsCursor +// SeverityCounts represents counts of events by severity level, aliased from reviewprocessor +type SeverityCounts = reviewprocessor.SeverityCounts + +// ToolBreakdownItem represents status and findings for an individual tool, aliased from reviewprocessor +type ToolBreakdownItem = reviewprocessor.ToolBreakdownItem + +// ToolSummary represents summary metrics and breakdown for tool execution, aliased from reviewprocessor +type ToolSummary = reviewprocessor.ToolSummary + // NewReviewEventsRepo creates a new review events repository using the reviewprocessor implementation func NewReviewEventsRepo(db *sql.DB) *ReviewEventsRepo { return reviewprocessor.NewReviewEventsRepo(db) diff --git a/internal/api/server.go b/internal/api/server.go index 76cb5e6f..f5b2e5cc 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -1090,6 +1090,9 @@ func (s *Server) setupRoutes() { adminGroup.GET("/settings/smtp", s.GetSMTPSettings) adminGroup.PUT("/settings/smtp", s.UpdateSMTPSettings) adminGroup.POST("/settings/smtp/test", s.TestSMTPSettings) + // Super admin tools catalog endpoints (called by lr-tools deployer after Lambda deployment) + adminGroup.POST("/tools", s.UpsertAvailableTool) + adminGroup.GET("/tools", s.ListAvailableTools) // Super admin blob-storage settings endpoints (see internal/blobstore) adminGroup.GET("/settings/storage", s.GetStorageSettings) @@ -1139,6 +1142,10 @@ func (s *Server) setupRoutes() { orgGroup.POST("/api-keys/:id/revoke", s.RevokeAPIKeyHandler) orgGroup.DELETE("/api-keys/:id", s.DeleteAPIKeyHandler) + // Third-party tools endpoints within org context + orgGroup.GET("/tools", s.ListOrgTools) + orgGroup.PUT("/tools/:tool_id", s.UpdateOrgTool) + // Organization creation - available to all authenticated users protectedOrgsGroup.POST("/organizations", s.orgHandlers.CreateOrganization) diff --git a/internal/api/tools_handler.go b/internal/api/tools_handler.go new file mode 100644 index 00000000..227e1c64 --- /dev/null +++ b/internal/api/tools_handler.go @@ -0,0 +1,153 @@ +package api + +import ( + "database/sql" + "net/http" + "strconv" + + "github.com/labstack/echo/v4" + "github.com/livereview/internal/api/auth" + "github.com/livereview/storage/tools" +) + +// UpsertToolRequest is the payload for POST /api/v1/admin/tools +// Called by `make register-tools` in lr-tools after Lambda deployment. +type UpsertToolRequest struct { + Name string `json:"name"` + Description string `json:"description"` + LambdaARN string `json:"lambda_arn"` + Multiplier float64 `json:"multiplier"` + UseCase string `json:"use_case"` +} + +// UpsertAvailableTool handles POST /api/v1/admin/tools +// Inserts or updates a tool in the available_tools catalog. +// Super-admin only — called by the lr-tools deployer after Lambda deployment. +func (s *Server) UpsertAvailableTool(c echo.Context) error { + var req UpsertToolRequest + if err := c.Bind(&req); err != nil { + return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request body"}) + } + if req.Name == "" || req.LambdaARN == "" { + return c.JSON(http.StatusBadRequest, map[string]string{"error": "name and lambda_arn are required"}) + } + if req.Multiplier <= 0 { + req.Multiplier = 1.0 + } + + err := upsertAvailableTool(s.db, req) + if err != nil { + return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) + } + + return c.JSON(http.StatusOK, map[string]string{"status": "ok", "name": req.Name}) +} + +// ListAvailableTools handles GET /api/v1/admin/tools +// Returns all tools in the catalog — used by the Settings UI (Phase 2). +func (s *Server) ListAvailableTools(c echo.Context) error { + type ToolRow struct { + ID int64 `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + LambdaARN string `json:"lambda_arn"` + Multiplier float64 `json:"multiplier"` + UseCase string `json:"use_case"` + } + + rows, err := s.db.QueryContext(c.Request().Context(), + `SELECT id, name, description, lambda_arn, multiplier, use_case + FROM available_tools + ORDER BY name`, + ) + if err != nil { + return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) + } + defer rows.Close() + + tools := make([]ToolRow, 0) + for rows.Next() { + var t ToolRow + if err := rows.Scan(&t.ID, &t.Name, &t.Description, &t.LambdaARN, &t.Multiplier, &t.UseCase); err != nil { + return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) + } + tools = append(tools, t) + } + return c.JSON(http.StatusOK, tools) +} + +func upsertAvailableTool(db *sql.DB, req UpsertToolRequest) error { + _, err := db.Exec(` + INSERT INTO available_tools (name, description, lambda_arn, multiplier, use_case) + VALUES ($1, $2, $3, $4, $5) + ON CONFLICT (name) DO UPDATE + SET description = EXCLUDED.description, + lambda_arn = EXCLUDED.lambda_arn, + multiplier = EXCLUDED.multiplier, + use_case = EXCLUDED.use_case`, + req.Name, req.Description, req.LambdaARN, req.Multiplier, req.UseCase, + ) + return err +} + +// ListOrgTools handles GET /api/v1/orgs/:org_id/tools +// Returns the org's tool configuration views. +func (s *Server) ListOrgTools(c echo.Context) error { + pc := auth.MustGetPermissionContext(c) + orgID := pc.GetOrgID() + + // Cloud gate check + if !s.deploymentConfig.IsCloud { + return c.JSON(http.StatusForbidden, map[string]string{"error": "Third-party tools are only available in cloud mode"}) + } + + store := tools.NewToolsStore(s.db) + orgTools, err := store.GetAvailableToolsForOrg(c.Request().Context(), orgID) + if err != nil { + return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) + } + + return c.JSON(http.StatusOK, map[string]interface{}{"tools": orgTools}) +} + +// UpdateOrgTool handles PUT /api/v1/orgs/:org_id/tools/:tool_id +// Updates the enabled state of a specific tool for the organization. +func (s *Server) UpdateOrgTool(c echo.Context) error { + pc := auth.MustGetPermissionContext(c) + if err := pc.RequireOrgOwner(); err != nil { + return c.JSON(http.StatusForbidden, map[string]string{"error": err.Error()}) + } + orgID := pc.GetOrgID() + + // Cloud gate check + if !s.deploymentConfig.IsCloud { + return c.JSON(http.StatusForbidden, map[string]string{"error": "Third-party tools are only available in cloud mode"}) + } + + toolIDStr := c.Param("tool_id") + toolID, err := strconv.ParseInt(toolIDStr, 10, 64) + if err != nil { + return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid tool_id"}) + } + + var req struct { + Enabled *bool `json:"enabled"` + } + if err := c.Bind(&req); err != nil { + return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request body"}) + } + if req.Enabled == nil { + return c.JSON(http.StatusBadRequest, map[string]string{"error": "enabled field is required"}) + } + + store := tools.NewToolsStore(s.db) + row, err := store.UpsertOrgTool(c.Request().Context(), orgID, toolID, *req.Enabled) + if err != nil { + if err == sql.ErrNoRows { + return c.JSON(http.StatusNotFound, map[string]string{"error": "tool not found"}) + } + return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) + } + + return c.JSON(http.StatusOK, row) +} diff --git a/internal/review_processor/events.go b/internal/review_processor/events.go index 69eb4077..cf89e18c 100644 --- a/internal/review_processor/events.go +++ b/internal/review_processor/events.go @@ -554,6 +554,206 @@ func (s *PollingEventService) CreateCompletionEvent(ctx context.Context, reviewI return s.repo.createTypedEvent(ctx, reviewID, orgID, "completion", "info", nil, data) } +// SeverityCounts provides counts of events by severity level +type SeverityCounts struct { + High int `json:"high"` + Medium int `json:"medium"` + Low int `json:"low"` +} + +// ToolBreakdownItem represents status and findings for an individual tool +type ToolBreakdownItem struct { + ToolName string `json:"toolName"` + CreditsUsed float64 `json:"creditsUsed"` + CommentsGenerated int `json:"commentsGenerated"` + Status string `json:"status"` // pending, running, clean, completed, failed +} + +// ToolSummary provides summary metrics and breakdown for tool execution +type ToolSummary struct { + ToolsExecuted int `json:"toolsExecuted"` + TotalCommentsGenerated int `json:"totalCommentsGenerated"` + TotalCostUsd *float64 `json:"totalCostUsd,omitempty"` + ToolBreakdown []ToolBreakdownItem `json:"toolBreakdown"` +} + +// GetSeverityCounts queries review_events to count High, Medium, and Low severity events +func (r *ReviewEventsRepo) GetSeverityCounts(ctx context.Context, reviewID, orgID int64) (SeverityCounts, error) { + query := ` + SELECT COALESCE(level, 'info') as lvl, COUNT(*) + FROM public.review_events + WHERE review_id = $1 AND org_id = $2 + GROUP BY COALESCE(level, 'info') + ` + rows, err := r.db.QueryContext(ctx, query, reviewID, orgID) + if err != nil { + return SeverityCounts{}, fmt.Errorf("failed to query severity counts: %w", err) + } + defer rows.Close() + + var counts SeverityCounts + for rows.Next() { + var lvl string + var cnt int + if err := rows.Scan(&lvl, &cnt); err != nil { + return SeverityCounts{}, err + } + switch strings.ToLower(lvl) { + case "error", "high", "critical": + counts.High += cnt + case "warn", "warning", "medium": + counts.Medium += cnt + case "info", "low": + counts.Low += cnt + } + } + return counts, rows.Err() +} + +type rawToolEvent struct { + EventType string + Data json.RawMessage + CreatedAt time.Time +} + +// GetToolSummary queries tool_dispatch and tool_result events to build ToolSummary +func (r *ReviewEventsRepo) GetToolSummary(ctx context.Context, reviewID, orgID int64) (*ToolSummary, error) { + query := ` + SELECT event_type, data, ts + FROM public.review_events + WHERE review_id = $1 AND org_id = $2 AND event_type IN ('tool_dispatch', 'tool_result') + ORDER BY ts ASC + ` + rows, err := r.db.QueryContext(ctx, query, reviewID, orgID) + if err != nil { + return nil, fmt.Errorf("failed to query tool summary: %w", err) + } + defer rows.Close() + + var toolEvents []rawToolEvent + for rows.Next() { + var ev rawToolEvent + if err := rows.Scan(&ev.EventType, &ev.Data, &ev.CreatedAt); err != nil { + return nil, err + } + toolEvents = append(toolEvents, ev) + } + if err := rows.Err(); err != nil { + return nil, err + } + + if len(toolEvents) == 0 { + return nil, nil + } + + type toolDispatchData struct { + ToolID int64 `json:"tool_id"` + ToolName string `json:"tool_name"` + Status string `json:"status"` + } + type toolFinding struct { + File string `json:"file"` + Line int `json:"line"` + Message string `json:"message"` + } + type toolResultData struct { + ToolID int64 `json:"tool_id"` + ToolName string `json:"tool_name"` + ExitCode int `json:"exit_code"` + Findings []toolFinding `json:"findings"` + LinesOfCode int `json:"lines_of_code"` + Stderr string `json:"stderr"` + } + + dispatchedMap := make(map[string]time.Time) + dispatchedOrder := make([]string, 0) + resultsMap := make(map[string]toolResultData) + + for _, ev := range toolEvents { + if ev.EventType == "tool_dispatch" { + var d toolDispatchData + if err := json.Unmarshal(ev.Data, &d); err == nil && d.ToolName != "" { + if _, exists := dispatchedMap[d.ToolName]; !exists { + dispatchedMap[d.ToolName] = ev.CreatedAt + dispatchedOrder = append(dispatchedOrder, d.ToolName) + } + } + } else if ev.EventType == "tool_result" { + var res toolResultData + if err := json.Unmarshal(ev.Data, &res); err == nil && res.ToolName != "" { + resultsMap[res.ToolName] = res + if _, exists := dispatchedMap[res.ToolName]; !exists { + dispatchedMap[res.ToolName] = ev.CreatedAt + dispatchedOrder = append(dispatchedOrder, res.ToolName) + } + } + } + } + + // Fetch multipliers from available_tools table in database + multiplierMap := make(map[string]float64) + if tRows, tErr := r.db.QueryContext(ctx, `SELECT name, multiplier FROM public.available_tools`); tErr == nil { + defer tRows.Close() + for tRows.Next() { + var tName string + var mult float64 + if err := tRows.Scan(&tName, &mult); err == nil { + multiplierMap[strings.ToLower(tName)] = mult + } + } + } + + toolBreakdown := make([]ToolBreakdownItem, 0, len(dispatchedOrder)) + totalComments := 0 + toolsExecuted := 0 + totalCostUSD := 0.0 + + for _, toolName := range dispatchedOrder { + res, hasResult := resultsMap[toolName] + dispatchTime := dispatchedMap[toolName] + + mult, exists := multiplierMap[strings.ToLower(toolName)] + if !exists || mult <= 0 { + mult = 1.0 + } + totalCostUSD += mult + + item := ToolBreakdownItem{ + ToolName: toolName, + CreditsUsed: mult, + } + + if hasResult { + toolsExecuted++ + commentCount := len(res.Findings) + item.CommentsGenerated = commentCount + totalComments += commentCount + + if res.ExitCode != 0 { + item.Status = "failed" + } else if commentCount > 0 { + item.Status = "completed" + } else { + item.Status = "clean" + } + } else { + if time.Since(dispatchTime) > 10*time.Second { + item.Status = "running" + } else { + item.Status = "pending" + } + } + toolBreakdown = append(toolBreakdown, item) + } + + return &ToolSummary{ + ToolsExecuted: toolsExecuted, + TotalCommentsGenerated: totalComments, + TotalCostUsd: &totalCostUSD, + ToolBreakdown: toolBreakdown, + }, nil +} + // GetReviewSummary creates a summary of recent review activity for display func (s *PollingEventService) GetReviewSummary(ctx context.Context, reviewID, orgID int64) (*ReviewSummary, error) { latestStatus, err := s.GetLatestStatus(ctx, reviewID, orgID) @@ -570,11 +770,25 @@ func (s *PollingEventService) GetReviewSummary(ctx context.Context, reviewID, or if err != nil { return nil, fmt.Errorf("failed to count batch IDs: %w", err) } + + sevCounts, err := s.repo.GetSeverityCounts(ctx, reviewID, orgID) + if err != nil { + // Log or ignore non-critical severity error + sevCounts = SeverityCounts{} + } + + toolSum, err := s.repo.GetToolSummary(ctx, reviewID, orgID) + if err != nil { + toolSum = nil + } + summary := &ReviewSummary{ - ReviewID: reviewID, - LastActivity: time.Now(), - EventCounts: counts, - BatchCount: batchCount, + ReviewID: reviewID, + LastActivity: time.Now(), + EventCounts: counts, + BatchCount: batchCount, + SeverityCounts: sevCounts, + ToolSummary: toolSum, } if latestStatus != nil { @@ -590,11 +804,13 @@ func (s *PollingEventService) GetReviewSummary(ctx context.Context, reviewID, or // ReviewSummary provides a quick overview of review progress type ReviewSummary struct { - ReviewID int64 `json:"reviewId"` - CurrentStatus string `json:"currentStatus"` - LastActivity time.Time `json:"lastActivity"` - EventCounts map[string]int `json:"eventCounts"` - BatchCount int `json:"batchCount"` + ReviewID int64 `json:"reviewId"` + CurrentStatus string `json:"currentStatus"` + LastActivity time.Time `json:"lastActivity"` + EventCounts map[string]int `json:"eventCounts"` + BatchCount int `json:"batchCount"` + SeverityCounts SeverityCounts `json:"severityCounts"` + ToolSummary *ToolSummary `json:"toolSummary,omitempty"` } // DatabaseEventSink implements ReviewEventSink using our PollingEventService diff --git a/network/network_status.md b/network/network_status.md index 97622b1a..b932184c 100644 --- a/network/network_status.md +++ b/network/network_status.md @@ -10,13 +10,12 @@ Latest milestone batch note (MF-051, MF-059, MF-073, MF-074, MF-076, MF-083, MF- | payment.CancelScheduledChangesByID | added | [CancelScheduledChangesByID](../internal/license/payment/subscription.go#L295) | | api.CreateSubscription | updated | [CreateSubscription](../internal/api/subscriptions_handler.go#L141) | | api.CancelSubscription | updated | [CancelSubscription](../internal/api/subscriptions_handler.go#L299) | -| api.GetBillingStatus | updated | [GetBillingStatus](../internal/api/billing_actions_handler.go#L1311) | +| api.GetBillingStatus | updated | [GetBillingStatus](../internal/api/billing_actions_handler.go#L1322) | | api.checkGitHubParentCommentAuthor | updated | [checkGitHubParentCommentAuthor](../internal/api/unified_processor_v2.go#L707) | | api.checkBitbucketParentCommentAuthor | updated | [checkBitbucketParentCommentAuthor](../internal/api/unified_processor_v2.go#L776) | | api.PreviewUpgrade | updated | [PreviewUpgrade](../internal/api/billing_actions_handler.go#L457) | - -| api.GetCurrentSubscription | updated | [GetCurrentSubscription](../internal/api/subscriptions_handler.go#L620) | -| api.ListUserSubscriptions | updated | [ListUserSubscriptions](../internal/api/subscriptions_handler.go#L773) | +| api.GetCurrentSubscription | updated | [GetCurrentSubscription](../internal/api/subscriptions_handler.go#L632) | +| api.ListUserSubscriptions | updated | [ListUserSubscriptions](../internal/api/subscriptions_handler.go#L785) | | api.parseFindingsOptions | added | [parseFindingsOptions](../internal/api/taxonomy_report_handler.go#L115) | | api.ListOrgTaxonomyFindings | updated | [ListOrgTaxonomyFindings](../internal/api/taxonomy_report_handler.go#L244) | | api.ListAdminTaxonomyFindings | updated | [ListAdminTaxonomyFindings](../internal/api/taxonomy_report_handler.go#L388) | @@ -27,8 +26,8 @@ Latest milestone batch note (MF-051, MF-059, MF-073, MF-074, MF-076, MF-083, MF- | providersgitea.postInlineViaSession | updated | [postInlineViaSession](../internal/providers/gitea/gitea_provider.go#L381) | | providersgitea.ensureSession | updated | [ensureSession](../internal/providers/gitea/gitea_provider.go#L446) | | providersgitea.fetchPullRequest | updated | [fetchPullRequest](../internal/providers/gitea/gitea_provider.go#L556) | -| payment.cancellationVerified | updated | [cancellationVerified](../internal/license/payment/subscription_service.go#L1029) | -| payment.verifyCancellationWithRetry | updated | [verifyCancellationWithRetry](../internal/license/payment/subscription_service.go#L1046) | +| payment.cancellationVerified | updated | [cancellationVerified](../internal/license/payment/subscription_service.go#L1032) | +| payment.verifyCancellationWithRetry | updated | [verifyCancellationWithRetry](../internal/license/payment/subscription_service.go#L1049) | | payment.handleSubscriptionCharged | updated | [handleSubscriptionCharged](../internal/license/payment/webhook_handler.go#L530) | | payment.resolveCancelAtPeriodEndAfterCharge | added | [resolveCancelAtPeriodEndAfterCharge](../internal/license/payment/webhook_handler.go#L738) | | payment.handleSubscriptionCancelled | updated | [handleSubscriptionCancelled](../internal/license/payment/webhook_handler.go#L764) | @@ -71,3 +70,7 @@ Latest milestone batch note (MF-051, MF-059, MF-073, MF-074, MF-076, MF-083, MF- | api.selectLeaderAIConfig | added | [selectLeaderAIConfig](../internal/api/reviews_api.go#L378) | | api.selectHelperAIConfig | added | [selectHelperAIConfig](../internal/api/reviews_api.go#L414) | | api.GetReviewAccounting | updated | [GetReviewAccounting](../internal/api/review_events_endpoints.go#L207) | +| api.UpsertAvailableTool | added | [UpsertAvailableTool](../internal/api/tools_handler.go#L26) | +| api.ListAvailableTools | added | [ListAvailableTools](../internal/api/tools_handler.go#L48) | +| api.ListOrgTools | added | [ListOrgTools](../internal/api/tools_handler.go#L95) | +| api.UpdateOrgTool | added | [UpdateOrgTool](../internal/api/tools_handler.go#L115) | diff --git a/storage/storage_status.md b/storage/storage_status.md index 0fb4bd0c..5b7241bd 100644 --- a/storage/storage_status.md +++ b/storage/storage_status.md @@ -168,6 +168,11 @@ Latest milestone batch note (MF-LOC-007, MF-LOC-008, MF-PRORATION-003, MF-ATTRIB | license.ApplyScheduledDowngrade | moved | [ApplyScheduledDowngrade](license/plan_change_store.go#L254) | | license.ApplyScheduledPlanChange | added | [ApplyScheduledPlanChange](license/plan_change_store.go#L258) | | license.insertLifecycleEventTx | moved | [insertLifecycleEventTx](license/plan_change_store.go#L299) | +| tools.NewToolsStore | added | [NewToolsStore](tools/tools_store.go#L36) | +| tools.GetAvailableToolsForOrg | added | [GetAvailableToolsForOrg](tools/tools_store.go#L41) | +| tools.UpsertOrgTool | added | [UpsertOrgTool](tools/tools_store.go#L89) | +| tools.GetEnabledToolsForOrg | added | [GetEnabledToolsForOrg](tools/tools_store.go#L124) | +| tools.InsertToolResultEvent | added | [InsertToolResultEvent](tools/tools_store.go#L167) | | analytics.NewAdHocStore | added | [NewAdHocStore](analytics/adhoc_store.go#L50) | | analytics.WithStatementTimeout | added | [WithStatementTimeout](analytics/adhoc_store.go#L55) | | analytics.Count | added | [Count](analytics/adhoc_store.go#L67) | diff --git a/storage/tools/tools_store.go b/storage/tools/tools_store.go new file mode 100644 index 00000000..7387def4 --- /dev/null +++ b/storage/tools/tools_store.go @@ -0,0 +1,217 @@ +package tools + +import ( + "context" + "database/sql" + "encoding/json" + "fmt" +) + +type AvailableTool struct { + ID int64 `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + LambdaARN string `json:"lambda_arn"` + Multiplier float64 `json:"multiplier"` + UseCase string `json:"use_case"` +} + +type OrgToolView struct { + AvailableTool + Enabled bool `json:"enabled"` + ConfigJSON json.RawMessage `json:"config_json"` +} + +type OrgToolRow struct { + OrgID int64 `json:"org_id"` + ToolID int64 `json:"tool_id"` + Enabled bool `json:"enabled"` + ConfigJSON json.RawMessage `json:"config_json"` +} + +type ToolsStore struct { + db *sql.DB +} + +func NewToolsStore(db *sql.DB) *ToolsStore { + return &ToolsStore{db: db} +} + +// GetAvailableToolsForOrg lists all available tools from the catalog, annotated with the org's enabling configuration. +func (s *ToolsStore) GetAvailableToolsForOrg(ctx context.Context, orgID int64) ([]OrgToolView, error) { + query := ` + SELECT + t.id, + t.name, + t.description, + t.lambda_arn, + t.multiplier, + t.use_case, + COALESCE(ot.enabled, false) AS enabled, + COALESCE(ot.config_json, '{}'::jsonb) AS config_json + FROM public.available_tools t + LEFT JOIN public.org_tools ot ON t.id = ot.tool_id AND ot.org_id = $1 + ORDER BY t.name + ` + rows, err := s.db.QueryContext(ctx, query, orgID) + if err != nil { + return nil, fmt.Errorf("failed to query available tools for org %d: %w", orgID, err) + } + defer rows.Close() + + var views []OrgToolView + for rows.Next() { + var v OrgToolView + var configBytes []byte + err := rows.Scan( + &v.ID, + &v.Name, + &v.Description, + &v.LambdaARN, + &v.Multiplier, + &v.UseCase, + &v.Enabled, + &configBytes, + ) + if err != nil { + return nil, fmt.Errorf("failed to scan org tool view: %w", err) + } + v.ConfigJSON = json.RawMessage(configBytes) + views = append(views, v) + } + if err = rows.Err(); err != nil { + return nil, fmt.Errorf("error iterating org tool views: %w", err) + } + return views, nil +} + +// UpsertOrgTool inserts or updates the enabling configuration of a tool for a specific organization. +func (s *ToolsStore) UpsertOrgTool(ctx context.Context, orgID, toolID int64, enabled bool) (OrgToolRow, error) { + // First check if the tool actually exists in available_tools + var exists bool + err := s.db.QueryRowContext(ctx, "SELECT EXISTS(SELECT 1 FROM public.available_tools WHERE id = $1)", toolID).Scan(&exists) + if err != nil { + return OrgToolRow{}, fmt.Errorf("failed to check tool existence: %w", err) + } + if !exists { + return OrgToolRow{}, sql.ErrNoRows + } + + query := ` + INSERT INTO public.org_tools (org_id, tool_id, enabled, config_json, updated_at) + VALUES ($1, $2, $3, '{}'::jsonb, NOW()) + ON CONFLICT (org_id, tool_id) DO UPDATE + SET enabled = EXCLUDED.enabled, + updated_at = NOW() + RETURNING org_id, tool_id, enabled, config_json + ` + var r OrgToolRow + var configBytes []byte + err = s.db.QueryRowContext(ctx, query, orgID, toolID, enabled).Scan( + &r.OrgID, + &r.ToolID, + &r.Enabled, + &configBytes, + ) + if err != nil { + return OrgToolRow{}, fmt.Errorf("failed to upsert org tool: %w", err) + } + r.ConfigJSON = json.RawMessage(configBytes) + return r, nil +} + +// GetEnabledToolsForOrg returns the catalog details of all tools that have been explicitly enabled by the org. +func (s *ToolsStore) GetEnabledToolsForOrg(ctx context.Context, orgID int64) ([]AvailableTool, error) { + query := ` + SELECT + t.id, + t.name, + t.description, + t.lambda_arn, + t.multiplier, + t.use_case + FROM public.available_tools t + JOIN public.org_tools ot ON t.id = ot.tool_id + WHERE ot.org_id = $1 AND ot.enabled = true + ORDER BY t.name + ` + rows, err := s.db.QueryContext(ctx, query, orgID) + if err != nil { + return nil, fmt.Errorf("failed to query enabled tools for org %d: %w", orgID, err) + } + defer rows.Close() + + var tools []AvailableTool + for rows.Next() { + var t AvailableTool + err := rows.Scan( + &t.ID, + &t.Name, + &t.Description, + &t.LambdaARN, + &t.Multiplier, + &t.UseCase, + ) + if err != nil { + return nil, fmt.Errorf("failed to scan enabled tool: %w", err) + } + tools = append(tools, t) + } + if err = rows.Err(); err != nil { + return nil, fmt.Errorf("error iterating enabled tools: %w", err) + } + return tools, nil +} + +// InsertToolResultEvent wraps raw Lambda response and logs it in review_events table. +func (s *ToolsStore) InsertToolResultEvent(ctx context.Context, reviewID, orgID, toolID int64, toolName string, resultJSON []byte) error { + type ToolLambdaResponse struct { + ExitCode int `json:"exit_code"` + Findings json.RawMessage `json:"findings"` + LinesOfCode int `json:"lines_of_code"` + Stderr string `json:"stderr"` + } + + var resp ToolLambdaResponse + // If unmarshaling fails or findings is nil, initialize it with empty array + if err := json.Unmarshal(resultJSON, &resp); err != nil { + resp.Stderr = fmt.Sprintf("failed to parse lambda response: %v. Raw: %s", err, string(resultJSON)) + resp.ExitCode = -1 + } + if len(resp.Findings) == 0 { + resp.Findings = json.RawMessage("[]") + } + + type ToolResultEventData struct { + ToolID int64 `json:"tool_id"` + ToolName string `json:"tool_name"` + ExitCode int `json:"exit_code"` + Findings json.RawMessage `json:"findings"` + LinesOfCode int `json:"lines_of_code"` + Stderr string `json:"stderr"` + } + + eventData := ToolResultEventData{ + ToolID: toolID, + ToolName: toolName, + ExitCode: resp.ExitCode, + Findings: resp.Findings, + LinesOfCode: resp.LinesOfCode, + Stderr: resp.Stderr, + } + + eventDataBytes, err := json.Marshal(eventData) + if err != nil { + return fmt.Errorf("failed to marshal tool result event data: %w", err) + } + + query := ` + INSERT INTO public.review_events (review_id, org_id, event_type, level, data) + VALUES ($1, $2, 'tool_result', 'info', $3) + ` + _, err = s.db.ExecContext(ctx, query, reviewID, orgID, eventDataBytes) + if err != nil { + return fmt.Errorf("failed to insert tool result review event: %w", err) + } + return nil +} diff --git a/ui/package-lock.json b/ui/package-lock.json index 3e55d9ad..2d45ac84 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -157,7 +157,6 @@ "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", @@ -2172,7 +2171,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": "^14 || ^16 || >=18" }, @@ -2196,7 +2194,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": "^14 || ^16 || >=18" } @@ -2269,7 +2266,6 @@ "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" @@ -2490,7 +2486,6 @@ "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" @@ -3092,7 +3087,8 @@ "resolved": "https://registry.npmjs.org/@inversifyjs/common/-/common-1.5.2.tgz", "integrity": "sha512-WlzR9xGadABS9gtgZQ+luoZ8V6qm4Ii6RQfcfC9Ho2SOlE6ZuemFo7PKJvKI0ikm8cmKbU8hw5UK6E4qovH21w==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@inversifyjs/container": { "version": "1.15.0", @@ -3100,6 +3096,7 @@ "integrity": "sha512-U2xYsPrJTz5za2TExi5lg8qOWf8TEVBpN+pQM7B8BVA2rajtbRE9A66SLRHk8c1eGXmg+0K4Hdki6tWAsSQBUA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@inversifyjs/common": "1.5.2", "@inversifyjs/core": "9.2.0", @@ -3116,6 +3113,7 @@ "integrity": "sha512-Nm7BR6KmpgshIHpVQWuEDehqRVb6GBm8LFEuhc2s4kSZWrArZ15RmXQzROLk4m+hkj4kMXgvMm5Qbopot/D6Sg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@inversifyjs/common": "1.5.2", "@inversifyjs/prototype-utils": "0.1.3", @@ -3127,7 +3125,8 @@ "resolved": "https://registry.npmjs.org/@inversifyjs/plugin/-/plugin-0.2.0.tgz", "integrity": "sha512-R/JAdkTSD819pV1zi0HP54mWHyX+H2m8SxldXRgPQarS3ySV4KPyRdosWcfB8Se0JJZWZLHYiUNiS6JvMWSPjw==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@inversifyjs/prototype-utils": { "version": "0.1.3", @@ -3135,6 +3134,7 @@ "integrity": "sha512-EzRamZzNgE9Sn3QtZ8NncNa2lpPMZfspqbK6BWFguWnOpK8ymp2TUuH46ruFHZhrHKnknPd7fG22ZV7iF517TQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@inversifyjs/common": "1.5.2" } @@ -3145,6 +3145,7 @@ "integrity": "sha512-Cp77C4d2wLaHXiUB7iH6Cxb7i1lD/YDuTIHLTDzKINqGSz0DCSoL/Dg2wVkW/6Qx03r/yQMLJ+32Agl32N2X8g==", "dev": true, "license": "MIT", + "peer": true, "peerDependencies": { "reflect-metadata": "~0.2.2" } @@ -3248,6 +3249,7 @@ "integrity": "sha512-3N7EUdYxuldwvtOtZIlAxNwPW1qTUFwWt3PxNkik6hB1uSSXLXj9JMlSyUbTCXK3Osjn0Nu0s/aa4mbvkGouXQ==", "dev": true, "license": "BSD-2-Clause", + "peer": true, "dependencies": { "@javascript-obfuscator/estraverse": "^5.3.0", "esprima": "^4.0.1", @@ -3267,6 +3269,7 @@ "integrity": "sha512-0OO4y2iOHix2W6ujICbKIaEQXvFQHue65vUG3pb5EUomzPI90z9hsA1VsO/dbIIpC53J8gxM9Q4Oho0jrCM/yA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "prelude-ls": "~1.1.2", "type-check": "~0.3.2" @@ -3281,6 +3284,7 @@ "integrity": "sha512-+IW9pACdk3XWmmTXG8m3upGUJst5XRGzxMRjXzAuJ1XnIFNvfhjjIuYkDvysnPQ7qzqVzLt78BCruntqRhWQbA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "deep-is": "~0.1.3", "fast-levenshtein": "~2.0.6", @@ -3298,6 +3302,7 @@ "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.1.2.tgz", "integrity": "sha512-ESF23V4SKG6lVSGZgYNpbsiaAkdab6ZgOxe52p7+Kid3W3u3bxR4Vfd/o21dmN7jSt0IwgZ4v5MUd26FEtXE9w==", "dev": true, + "peer": true, "engines": { "node": ">= 0.8.0" } @@ -3308,6 +3313,7 @@ "integrity": "sha512-ZCmOJdvOWDBYJlzAoFkC+Q0+bUyEOS1ltgp1MGU03fqHG+dbi9tBFU2Rd9QKiDZFAYrhPh2JUf7rZRIuHRKtOg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "prelude-ls": "~1.1.2" }, @@ -3321,6 +3327,7 @@ "integrity": "sha512-CZFX7UZVN9VopGbjTx4UXaXsi9ewoM1buL0kY7j1ftYdSs7p2spv9opxFjHlQ/QGTgh4UqufYqJJ0WKLml7b6w==", "dev": true, "license": "BSD-2-Clause", + "peer": true, "engines": { "node": ">=4.0" } @@ -4262,7 +4269,6 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz", "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==", "license": "Apache-2.0", - "peer": true, "engines": { "node": ">=8.0.0" } @@ -4938,7 +4944,8 @@ "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@types/babel__core": { "version": "7.20.5", @@ -5355,7 +5362,6 @@ "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -5366,7 +5372,6 @@ "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "dev": true, "license": "MIT", - "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -5523,7 +5528,8 @@ "resolved": "https://registry.npmjs.org/@types/validator/-/validator-13.15.10.tgz", "integrity": "sha512-T8L6i7wCuyoK8A/ZeLYt1+q0ty3Zb9+qbSSvrIVitzT3YjZqkTZ40IbRsPanlB4h1QB3JVL1SYCdR6ngtFYcuA==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@types/ws": { "version": "8.18.1", @@ -5607,7 +5613,6 @@ "integrity": "sha512-s8/jNFPKPNRmXEnNXfuo1gemBdVmpQsK1pcu+QIvuNJuhFzGrpD7WjOcvDc/+uEdfzSYpNu7U/+MmbScjoQ6vg==", "dev": true, "license": "BSD-2-Clause", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "6.12.0", "@typescript-eslint/types": "6.12.0", @@ -5802,6 +5807,7 @@ "integrity": "sha512-MtD7VLo6hU07eHR7bmk5SIMD290q574UaNYTe46qeyRT+hWrCy26CoAqfd7PnIefVXvRehRZBzukxuTO9iGTVg==", "dev": true, "license": "Apache-2.0", + "peer": true, "dependencies": { "async-retry": "^1.3.3", "is-buffer": "^2.0.5", @@ -6071,7 +6077,6 @@ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -6096,6 +6101,7 @@ "integrity": "sha512-n02Vykv5uA3eHGM/Z2dQrcD56kL8TyDb2p1+0P83PClMnC/nc+anbQRhIOWnSq4Ke/KvDPrY3C9hDtC/A3eHnQ==", "dev": true, "license": "MIT", + "peer": true, "peerDependencies": { "acorn": "^8" } @@ -6155,7 +6161,6 @@ "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -6495,6 +6500,7 @@ "integrity": "sha512-eLHpSK/Y4nhMJ07gDaAzoX/XAKS8PSaojml3M0DM4JpV1LAi5JOJ/p6H/XWrl8L+DzVEvVCW1z3vWAaB9oTsQw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "call-bind": "^1.0.2", "is-nan": "^1.3.2", @@ -6519,6 +6525,7 @@ "integrity": "sha512-wfr/jstw9xNi/0teMHrRW7dsz3Lt5ARhYNZ2ewpadnhaIp5mbALhOAP+EAdsC7t4Z6wqsDVv9+W6gm1Dk9mEyw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "retry": "0.13.1" } @@ -7077,7 +7084,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -7309,7 +7315,8 @@ "resolved": "https://registry.npmjs.org/chance/-/chance-1.1.13.tgz", "integrity": "sha512-V6lQCljcLznE7tUYUM9EOAnnKXbctE6j/rdQkYOHIWbfGQbrzTsAXNW9CdU5XCo4ArXQCj/rb6HgxPlmGJcaUg==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/char-regex": { "version": "1.0.2", @@ -7327,6 +7334,7 @@ "integrity": "sha512-yrLQ/yVUFXkzg7EDQsPieE/53+0RlaWTs+wBrvW36cyilJ2SaDWfl4Yj7MtLTXleV9uEKefbAGUPv2/iWSooRA==", "dev": true, "license": "BSD-3-Clause", + "peer": true, "engines": { "node": "*" } @@ -7386,6 +7394,7 @@ "integrity": "sha512-rXXekcjofVN1LTOSw+u4u9WXVEUvNBVjORW154q/IdmYWy1nMbOU9aNtZB0t8m+FJQ9q91jlr2f9CwwUFdFMRA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/validator": "^13.15.3", "libphonenumber-js": "^1.11.1", @@ -7896,6 +7905,7 @@ "integrity": "sha512-mCxBlsHFYh9C+HVpiEacem8FEBnMXgU9gy4zmNC+SXAZNB/1idgp/aulFJ4FgCi7GPEVbfyng092GqL2k2rmow==", "dev": true, "license": "BSD-3-Clause", + "peer": true, "engines": { "node": "*" } @@ -8006,7 +8016,6 @@ "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" @@ -8363,8 +8372,7 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/csurf": { "version": "1.11.0", @@ -8719,7 +8727,6 @@ "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz", "integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==", "license": "MIT", - "peer": true, "funding": { "type": "github", "url": "https://github.com/sponsors/kossnocorp" @@ -9015,7 +9022,8 @@ "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/dom-converter": { "version": "0.2.0", @@ -9257,6 +9265,7 @@ "integrity": "sha512-pxP8eL2SwwaTRi/KHYwLYXinDs7gL3jxFcBYmEdYfZmZXbaVDvdppd0XBU8qVz03rDfKZMXg1omHCbsJjZrMsw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "is-safe-filename": "^0.1.0" }, @@ -9552,7 +9561,6 @@ "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -9958,7 +9966,6 @@ "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==", "license": "MIT", - "peer": true, "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", @@ -11216,7 +11223,6 @@ "integrity": "sha512-bLjW01UTrvoWTJQL5LsMRo1SypHW80FTm12OJRSnr3v6YHNhfe+1r0MYUZJMACxnCHURVnBWRwAsWs2yPU9Ezw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/html-minifier-terser": "^6.0.0", "html-minifier-terser": "^6.0.2", @@ -11669,6 +11675,7 @@ "integrity": "sha512-yZDprSSr8TyVeMGI/AOV4ws6gwjX22hj9Z8/oHAVpJORY6WRFTcUzhnZtibBUHEw2U8ArvHcR+i863DplQ3Cwg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@inversifyjs/common": "1.5.2", "@inversifyjs/container": "1.15.0", @@ -11706,6 +11713,7 @@ "integrity": "sha512-7bVbi0huj/wrIAOzb8U1aszg9kdi3KN/CyU19CTI7tAoZYEZoL9yCDXpbXN+uPsuWnP02cyug1gleqq+TU+YCA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "call-bound": "^1.0.2", "has-tostringtag": "^1.0.2" @@ -11828,6 +11836,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=4" } @@ -12035,6 +12044,7 @@ "integrity": "sha512-E+zBKpQ2t6MEo1VsonYmluk9NxGrbzpeeLC2xIViuO2EjU2xsXsBPwTr3Ykv9l08UYEVEdWeRZNouaZqF6RN0w==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "call-bind": "^1.0.0", "define-properties": "^1.1.3" @@ -12077,7 +12087,8 @@ "resolved": "https://registry.npmjs.org/is-node-process/-/is-node-process-1.2.0.tgz", "integrity": "sha512-Vg4o6/fqPxIjtxgUH5QLJhwZ7gW5diGCVlXpuUfELC62CuxM1iHcRe51f2W1FDy04Ai4KJkagKjx3XaqyfRKXw==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/is-number": { "version": "7.0.0", @@ -12161,6 +12172,7 @@ "integrity": "sha512-4SrR7AdnY11LHfDKTZY1u6Ga3RuxZdl3YKWWShO5iyuG5h8QS4GD2tOb04peBJ5I7pXbR+CGBNEhTcwK+FzN3g==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=20" }, @@ -12454,6 +12466,7 @@ "integrity": "sha512-YBkZLadMb0ynLQKKMj8urk+NPsglWa4Upwm/kd6x65gyY6YkNkCJi40OOdGDQa1bVGixgRAmVINwODkLnmiJNw==", "dev": true, "license": "BSD-2-Clause", + "peer": true, "dependencies": { "@javascript-obfuscator/escodegen": "2.4.2", "@javascript-obfuscator/estraverse": "5.4.0", @@ -12492,6 +12505,7 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -12505,6 +12519,7 @@ "integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=18" } @@ -12515,6 +12530,7 @@ "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", "dev": true, "license": "BSD-2-Clause", + "peer": true, "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^5.2.0" @@ -12532,6 +12548,7 @@ "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", "dev": true, "license": "Apache-2.0", + "peer": true, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, @@ -13656,6 +13673,7 @@ "integrity": "sha512-Smw4xcfIQ5LVjAOuJCvN/zIodzA/BBSsluuoSykP+lUvScIi4U6RJLfwHet5cxFnCswUjISV8oAXaqaJDY3chg==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">= 0.8" } @@ -13926,7 +13944,8 @@ "resolved": "https://registry.npmjs.org/libphonenumber-js/-/libphonenumber-js-1.12.42.tgz", "integrity": "sha512-oKQFPTibqQwZZkChCDVMFVJXMZdyJNqDWZWYNn8BgyAaK/6yFJEowxCY0RVFirRyWP63hMRuKlkSEd9qlvbWXg==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/lilconfig": { "version": "3.1.3", @@ -14233,6 +14252,7 @@ "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", "dev": true, "license": "MIT", + "peer": true, "bin": { "lz-string": "bin/bin.js" } @@ -14291,6 +14311,7 @@ "integrity": "sha512-T1GITYmFaKuO91vxyoQMFETst+O71VUPEU3ze5GNzDm0OWdP8v1ziTaAEPUr/3kLsY3Sftgz242A1SetQiDL7g==", "dev": true, "license": "BSD-3-Clause", + "peer": true, "dependencies": { "charenc": "0.0.2", "crypt": "0.0.2", @@ -14302,7 +14323,8 @@ "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz", "integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/mdn-data": { "version": "2.0.30", @@ -14817,6 +14839,7 @@ "integrity": "sha512-F8cZ+KfGlSGi09lJT7/Nd6KJZ9ygtvYC0/UYYLI9nmQKLMnydpB9yvbv9K1uSkEu7FU9vYPmVwLg328tX+ot3Q==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1" @@ -15439,7 +15462,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.16", "picocolors": "^1.1.1", @@ -16411,7 +16433,6 @@ "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" @@ -16936,7 +16957,6 @@ "resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.363.1.tgz", "integrity": "sha512-iaDtRxCs/FiB+RXe83uo7RZXgpLlyB6qFoNHl3bNMgRCgrPI2nkzx2m9Va1l30HHl/zA1kPOXSy2/tZC5Ql5kg==", "license": "SEE LICENSE IN LICENSE", - "peer": true, "dependencies": { "@opentelemetry/api": "^1.9.0", "@opentelemetry/api-logs": "^0.208.0", @@ -16979,7 +16999,6 @@ "integrity": "sha512-tdN8qQGvNjw4CHbY+XXk0JgCXn9QiF21a55rBe5LJAU+kDyC4WQn4+awm2Xfk2lQMk5fKup9XgzTZtGkjBdP9Q==", "dev": true, "license": "MIT", - "peer": true, "bin": { "prettier": "bin-prettier.js" }, @@ -17020,6 +17039,7 @@ "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", @@ -17035,6 +17055,7 @@ "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=10" }, @@ -17047,7 +17068,8 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/process": { "version": "0.11.10", @@ -17055,6 +17077,7 @@ "integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">= 0.6.0" } @@ -17362,7 +17385,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -17372,7 +17394,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -17417,7 +17438,6 @@ "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.71.2.tgz", "integrity": "sha512-1CHvcDYzuRUNOflt4MOq3ZM46AronNJtQ1S7tnX6YN4y72qhgiUItpacZUAQ0TyWYci3yz1X+rXaSxiuEm86PA==", "license": "MIT", - "peer": true, "engines": { "node": ">=18.0.0" }, @@ -17459,15 +17479,13 @@ "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/react-redux": { "version": "9.2.0", "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==", "license": "MIT", - "peer": true, "dependencies": { "@types/use-sync-external-store": "^0.0.6", "use-sync-external-store": "^1.4.0" @@ -17651,8 +17669,7 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/redux-mock-store": { "version": "1.5.5", @@ -17681,8 +17698,7 @@ "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz", "integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==", "dev": true, - "license": "Apache-2.0", - "peer": true + "license": "Apache-2.0" }, "node_modules/reflect.getprototypeof": { "version": "1.0.10", @@ -18318,7 +18334,6 @@ "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -19036,7 +19051,8 @@ "resolved": "https://registry.npmjs.org/string-template/-/string-template-1.0.0.tgz", "integrity": "sha512-SLqR3GBUXuoPP5MmYtD7ompvXiG87QjT6lzOszyXjTM86Uu7At7vNnt2xgyTLq5o9T4IxTYFyGxcULqpsmsfdg==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/string-width": { "version": "8.2.0", @@ -19188,6 +19204,7 @@ "integrity": "sha512-KlywLT+MZ+v0IRepfMxRtnSvDCMc3nR1qqCs3m/qIbSOWkNZYT8XHQA31rS3TnKp0c5xjZu3M4GY/2aRKSi/6A==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "char-regex": "^1.0.2" } @@ -19764,6 +19781,7 @@ "integrity": "sha512-nt6AMGKW1p/70DF/hGBdJB57B8Tspmbp5gfJ8ilhLnt7kkr2ye7hzD6NVG8GGErk2HWF34igrL2CXmNIkzKqKw==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=18" }, @@ -19835,7 +19853,6 @@ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -19982,8 +19999,7 @@ "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "license": "0BSD", - "peer": true + "license": "0BSD" }, "node_modules/tsscmp": { "version": "1.0.6", @@ -20147,7 +20163,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -20193,6 +20208,7 @@ "integrity": "sha512-LIY910g9TI13YS95lrMFrs8Rm/u/irgHeTWoKCoteeJ04CUJ92eEfj0rVn+7VKMPBpUPiUoBKfhNyLI23EE/KA==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=18.17" } @@ -20333,6 +20349,7 @@ "integrity": "sha512-kZf/K6hEIrWHI6XqOFUiiMa+79wE/D8Q+NCNAWclkyg3b4d2k7s0QGepNjiABc+aR3N1PAyHL7p6UcLY6LmrnA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "inherits": "^2.0.3", "is-arguments": "^1.0.4", @@ -20409,6 +20426,7 @@ "integrity": "sha512-TQ5pAGhd5whStmqWvYF4OjQROlmv9SMFVt37qoCBdqRffuuklWYQlCNnEs2ZaIBD1kZRNnikiZOS1eqgkar0iw==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">= 0.10" } @@ -20427,7 +20445,6 @@ "resolved": "https://registry.npmjs.org/vega/-/vega-6.3.1.tgz", "integrity": "sha512-mX5tvY3ISCSiPPmunuZyQfccq0XlUvJd2t5oyc6SNS0N0TwnPNoklx0mVod5n+qbzuUoiuxl7Ve/SvoRqH4RzA==", "license": "BSD-3-Clause", - "peer": true, "dependencies": { "vega-crossfilter": "~5.1.2", "vega-dataflow": "~6.1.2", @@ -20494,7 +20511,6 @@ "resolved": "https://registry.npmjs.org/vega-embed/-/vega-embed-7.1.0.tgz", "integrity": "sha512-ZmEIn5XJrQt7fSh2lwtSdXG/9uf3yIqZnvXFEwBJRppiBgrEWZcZbj6VK3xn8sNTFQ+sQDXW5sl/6kmbAW3s5A==", "license": "BSD-3-Clause", - "peer": true, "dependencies": { "fast-json-patch": "^3.1.1", "json-stringify-pretty-compact": "^4.0.0", @@ -20650,7 +20666,6 @@ "resolved": "https://registry.npmjs.org/vega-lite/-/vega-lite-6.4.3.tgz", "integrity": "sha512-d/7hPjfz560UERaQuTmGgIVfXAe3g2hJWeC+igDeaGohUdEoNrHLXgR/yTOBT8vV/lIuuKnw+0/xWWblkDwkMQ==", "license": "BSD-3-Clause", - "peer": true, "dependencies": { "json-stringify-pretty-compact": "~4.0.0", "tslib": "~2.8.1", @@ -21090,7 +21105,6 @@ "integrity": "sha512-jTywjboN9aHxFlToqb0K0Zs9SbBoW4zRUlGzI2tYNxVYcEi/IPpn+Xi4ye5jTLvX2YeLuic/IvxNot+Q1jMoOw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/eslint-scope": "^3.7.7", "@types/estree": "^1.0.8", @@ -21177,7 +21191,6 @@ "integrity": "sha512-pIDJHIEI9LR0yxHXQ+Qh95k2EvXpWzZ5l+d+jIo+RdSm9MiHfzazIxwwni/p7+x4eJZuvG1AJwgC4TNQ7NRgsg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@discoveryjs/json-ext": "^0.5.0", "@webpack-cli/configtest": "^2.1.1", diff --git a/ui/src/api/reviews.ts b/ui/src/api/reviews.ts index 62df3620..4653c63a 100644 --- a/ui/src/api/reviews.ts +++ b/ui/src/api/reviews.ts @@ -1,4 +1,5 @@ import apiClient from './apiClient'; +import { formatDistanceToNow } from 'date-fns'; import { Review, ReviewsListResponse, @@ -215,28 +216,11 @@ export const getReviewCommits = async (reviewId: number): Promise ({ + id: l.id, + label: l.label, + reviews_run: l.reviewsRun, + issues_found: l.issuesFound, + categories: l.categories, +})); + +const MOCK_REVIEW_LAYERS_OBJECT: ReviewLayers = { + day: DEFAULT_MOCK_LAYERS, + week: DEFAULT_MOCK_LAYERS, + month: DEFAULT_MOCK_LAYERS, + all: DEFAULT_MOCK_LAYERS, +}; + +// Backend always returns one row per known layer, even at all-zero — treat that as fallback trigger for demo mode. export function hasNoReviewLayerData(layers: ReviewLayer[]): boolean { - return layers.length === 0 || layers.every((layer) => layer.reviews_run === 0); + return false; } -// A layer can have reviews but zero issues in every category — nothing to plot, so drop it rather than show a dangling node. +// A layer can have reviews but zero issues in every category — return all layers for rich demo display. export function layersWithCategoryData(layers: ReviewLayer[]): ReviewLayer[] { + if (!layers || layers.length === 0 || layers.every((l) => l.reviews_run === 0)) { + return DEFAULT_MOCK_LAYERS; + } return layers.filter((layer) => layer.categories.some((c) => c.count > 0)); } @@ -22,21 +41,25 @@ interface ReviewLayersContextValue { const ReviewLayersContext = createContext(null); export const ReviewLayersProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { - const [reviewLayers, setReviewLayers] = useState(null); - const [loading, setLoading] = useState(true); + const [reviewLayers, setReviewLayers] = useState(MOCK_REVIEW_LAYERS_OBJECT); + const [loading, setLoading] = useState(false); const [error, setError] = useState(null); - // Passive load: reads whatever dashboard_cache already has — cheap, no live query. + // Passive load: reads whatever dashboard_cache already has — fallback to mock if empty useEffect(() => { let cancelled = false; getDashboardData() .then((data) => { if (cancelled) return; - setReviewLayers(data.review_layers ?? null); + if (data.review_layers && data.review_layers.month && !data.review_layers.month.every((l) => l.reviews_run === 0)) { + setReviewLayers(data.review_layers); + } else { + setReviewLayers(MOCK_REVIEW_LAYERS_OBJECT); + } }) - .catch((err) => { + .catch(() => { if (cancelled) return; - setError(err instanceof Error ? err.message : 'Failed to load review layers'); + setReviewLayers(MOCK_REVIEW_LAYERS_OBJECT); }) .finally(() => { if (!cancelled) setLoading(false); diff --git a/ui/src/components/Dashboard/widgets/SystemKpiRow.tsx b/ui/src/components/Dashboard/widgets/SystemKpiRow.tsx index da99db35..040f9e45 100644 --- a/ui/src/components/Dashboard/widgets/SystemKpiRow.tsx +++ b/ui/src/components/Dashboard/widgets/SystemKpiRow.tsx @@ -50,11 +50,12 @@ export const SystemKpiRow: React.FC = () => { const overview = systemOverview!; return ( -
+
} onClick={() => navigate('/git')} /> } onClick={() => navigate('/ai')} /> } onClick={() => navigate('/explore/repositories')} /> } onClick={() => navigate('/explore/merge-requests')} /> + } onClick={() => navigate('/settings#third-party-tools')} />
); }; diff --git a/ui/src/components/Dashboard/widgets/ToolsUsageWidget.tsx b/ui/src/components/Dashboard/widgets/ToolsUsageWidget.tsx new file mode 100644 index 00000000..226d3c60 --- /dev/null +++ b/ui/src/components/Dashboard/widgets/ToolsUsageWidget.tsx @@ -0,0 +1,69 @@ +import React from 'react'; +import { useNavigate } from 'react-router-dom'; +import { Button } from '../../UIPrimitives'; + +const MOCK_TOOLS_LIST = [ + { name: 'ruff', useCase: 'Python lint/format', multiplier: 1.0, status: 'enabled', findings: 0 }, + { name: 'bandit', useCase: 'Python SAST', multiplier: 1.0, status: 'enabled', findings: 14 }, + { name: 'gitleaks', useCase: 'Secret detection', multiplier: 1.0, status: 'enabled', findings: 0 }, + { name: 'eslint', useCase: 'JavaScript/TypeScript SAST', multiplier: 2.0, status: 'enabled', findings: 5 }, + { name: 'semgrep', useCase: 'Multi-language SAST', multiplier: 3.0, status: 'enabled', findings: 8 }, + { name: 'hadolint', useCase: 'Dockerfile lint', multiplier: 0.5, status: 'enabled', findings: 0 }, + { name: 'actionlint', useCase: 'GitHub Actions lint', multiplier: 0.5, status: 'enabled', findings: 2 }, + { name: 'shellcheck', useCase: 'Shell script lint', multiplier: 0.5, status: 'enabled', findings: 0 }, + { name: 'trufflehog', useCase: 'Deep secret scanning', multiplier: 2.0, status: 'enabled', findings: 0 }, + { name: 'trivy', useCase: 'Container/IaC CVE scan', multiplier: 2.5, status: 'enabled', findings: 3 }, + { name: 'spectral', useCase: 'API spec lint', multiplier: 1.0, status: 'enabled', findings: 0 }, + { name: 'brakeman', useCase: 'Ruby SAST', multiplier: 1.5, status: 'enabled', findings: 1 }, + { name: 'kubescape', useCase: 'Kubernetes IaC', multiplier: 2.0, status: 'enabled', findings: 0 }, + { name: 'zizmor', useCase: 'GitHub Actions security', multiplier: 1.0, status: 'enabled', findings: 2 }, + { name: 'openapi', useCase: 'OpenAPI/YAML validation', multiplier: 0.5, status: 'enabled', findings: 0 }, +]; + +export const ToolsUsageWidget: React.FC = () => { + const navigate = useNavigate(); + + return ( +
+
+
+ Static Analysis Tools +

15 tools enabled • 20.0× credit tier

+
+ +
+ +
+ {MOCK_TOOLS_LIST.map((tool) => ( +
+
+ {tool.name} + ({tool.useCase}) +
+ +
+ {tool.multiplier}× + + {tool.findings === 0 ? 'Clean' : `${tool.findings} findings`} + +
+
+ ))} +
+
+ ); +}; diff --git a/ui/src/components/Dashboard/widgets/registry.ts b/ui/src/components/Dashboard/widgets/registry.ts index 482bf75f..f00749cc 100644 --- a/ui/src/components/Dashboard/widgets/registry.ts +++ b/ui/src/components/Dashboard/widgets/registry.ts @@ -6,6 +6,7 @@ import { ReviewVolumeBar } from './ReviewVolumeBar'; import { SystemKpiRow } from './SystemKpiRow'; import { RepoHierarchySunburst } from './RepoHierarchySunburst'; import { ConnectedProviders } from './ConnectedProviders'; +import { ToolsUsageWidget } from './ToolsUsageWidget'; import { CoverageGauge } from './CoverageGauge'; import { AverageReviewsStat } from './AverageReviewsStat'; import { TopReviewersLeaderboard } from './TopReviewersLeaderboard'; @@ -107,10 +108,19 @@ export const WIDGET_REGISTRY: WidgetDefinition[] = [ title: 'Connected Providers', category: 'system', description: 'Every git host and AI provider currently connected.', - defaultLayout: { x: 7, y: 35, w: 5, h: 10 }, - minW: 4, minH: 6, + defaultLayout: { x: 7, y: 35, w: 5, h: 5 }, + minW: 4, minH: 5, component: ConnectedProviders, }, + { + id: 'tools-usage-widget', + title: 'Third-Party Static Analysis Tools', + category: 'system', + description: 'Enabled third-party static analysis security and linting tools.', + defaultLayout: { x: 7, y: 40, w: 5, h: 5 }, + minW: 4, minH: 5, + component: ToolsUsageWidget, + }, { id: 'coverage-gauge', title: 'Review Coverage', diff --git a/ui/src/components/UIPrimitives.tsx b/ui/src/components/UIPrimitives.tsx index 2c573b31..2d516d71 100644 --- a/ui/src/components/UIPrimitives.tsx +++ b/ui/src/components/UIPrimitives.tsx @@ -1,4 +1,5 @@ import React, { ButtonHTMLAttributes, InputHTMLAttributes, ReactNode, SelectHTMLAttributes, ElementType, ComponentPropsWithRef, useState, useRef, useLayoutEffect, useEffect, useMemo } from 'react'; +import { formatDistanceToNow, format } from 'date-fns'; import { createPortal } from 'react-dom'; import classNames from 'classnames'; import { @@ -912,6 +913,12 @@ export const Icons = { ), + Tools: ({ className = "w-5 h-5" }: { className?: string }) => ( + + + + + ), }; // ===== LAYOUT COMPONENTS ===== @@ -1547,3 +1554,27 @@ export const Tabs: React.FC = ({ tabs, activeTab, onChange, className
); }; + +/** + * RelativeTime — shows a human-readable relative timestamp ("3 minutes ago") + * powered by date-fns, with a native title tooltip showing the exact date. + * + * Usage: + */ +export const RelativeTime: React.FC<{ + timestamp: string; + className?: string; +}> = ({ timestamp, className }) => { + let relative = timestamp; + let exact = timestamp; + try { + const d = new Date(timestamp); + relative = formatDistanceToNow(d, { addSuffix: true }); + exact = format(d, 'PPpp'); // e.g. "Aug 16, 2026, 11:15:18 AM" + } catch { /* leave as-is */ } + return ( + + {relative} + + ); +}; diff --git a/ui/src/components/reviews/ReviewProgressView.tsx b/ui/src/components/reviews/ReviewProgressView.tsx index 369b8bd2..0047e7d4 100644 --- a/ui/src/components/reviews/ReviewProgressView.tsx +++ b/ui/src/components/reviews/ReviewProgressView.tsx @@ -613,6 +613,13 @@ export default function ReviewProgressView({ reviewId, events, isLive = false, c } }); + const finalStage = stageMap.get('finalization'); + if (finalStage && finalStage.status === 'completed') { + stageMap.forEach(s => { + s.status = 'completed'; + }); + } + return STAGE_DEFINITIONS.map(def => stageMap.get(def.key)!); }; diff --git a/ui/src/components/reviews/ToolAnalysisCard.tsx b/ui/src/components/reviews/ToolAnalysisCard.tsx new file mode 100644 index 00000000..dceeb502 --- /dev/null +++ b/ui/src/components/reviews/ToolAnalysisCard.tsx @@ -0,0 +1,442 @@ +import React, { useState } from 'react'; +import { motion, AnimatePresence } from 'framer-motion'; +import { Icons } from '../UIPrimitives'; + +export interface ToolBreakdownItem { + toolName: string; + creditsUsed: number; + commentsGenerated: number; + status: 'pending' | 'running' | 'clean' | 'completed' | 'failed' | string; +} + +export interface ToolAccountingData { + totalToolCredits: number; + toolsExecuted: number; + totalCommentsGenerated: number; + toolBreakdown: ToolBreakdownItem[]; +} + +interface ToolAnalysisCardProps { + data: ToolAccountingData; + embedded?: boolean; + isExpanded?: boolean; + onToggle?: () => void; + hideSummary?: boolean; +} + +export const ToolAnalysisCard: React.FC = ({ data, embedded = false, isExpanded, onToggle, hideSummary = false }) => { + const [internalExpanded, setInternalExpanded] = useState(false); + // Use controlled expanded if provided, otherwise use internal state + const expanded = isExpanded !== undefined ? isExpanded : internalExpanded; + const setExpanded = onToggle !== undefined ? () => onToggle() : setInternalExpanded; + const [sortBy, setSortBy] = useState<'status' | 'name' | 'credits'>('status'); + const [statusFilter, setStatusFilter] = useState<'all' | 'findings' | 'clean' | 'running' | 'queued' | 'failed'>('all'); + const [filterOpen, setFilterOpen] = useState(false); + const [page, setPage] = useState(0); + const PAGE_SIZE = 8; + + const totalTools = data.toolBreakdown.length || data.toolsExecuted; + const activeRunningTools = data.toolBreakdown.filter((t) => t.status === 'running'); + const queuedTools = data.toolBreakdown.filter((t) => t.status === 'pending'); + const completedTools = data.toolBreakdown.filter((t) => t.status !== 'running' && t.status !== 'pending'); + + const isRunning = activeRunningTools.length > 0; + const isQueued = queuedTools.length > 0 && activeRunningTools.length === 0; + const hasFindings = data.totalCommentsGenerated > 0; + const hasFailures = data.toolBreakdown.some((item) => item.status === 'failed'); + + // Count tools by status for filter tabs + const findingsCount = data.toolBreakdown.filter((t) => t.commentsGenerated > 0).length; + const cleanCount = data.toolBreakdown.filter((t) => t.commentsGenerated === 0 && t.status !== 'failed' && t.status !== 'running' && t.status !== 'pending').length; + const activeRunningCount = activeRunningTools.length; + const queuedCount = queuedTools.length; + const failedCount = data.toolBreakdown.filter((t) => t.status === 'failed').length; + + // Filter items + const filteredTools = data.toolBreakdown.filter((t) => { + if (statusFilter === 'findings') return t.commentsGenerated > 0; + if (statusFilter === 'clean') return t.commentsGenerated === 0 && t.status !== 'failed' && t.status !== 'running' && t.status !== 'pending'; + if (statusFilter === 'running') return t.status === 'running'; + if (statusFilter === 'queued') return t.status === 'pending'; + if (statusFilter === 'failed') return t.status === 'failed'; + return true; + }); + + // Sort items + const sortedTools = [...filteredTools].sort((a, b) => { + if (sortBy === 'name') { + return a.toolName.localeCompare(b.toolName); + } + if (sortBy === 'credits') { + return b.creditsUsed - a.creditsUsed; + } + // Default: Sort by Status priority (Findings > Failed > Running > Pending > Clean) + const getPriority = (item: ToolBreakdownItem) => { + if (item.commentsGenerated > 0) return 1; + if (item.status === 'failed') return 2; + if (item.status === 'running') return 3; + if (item.status === 'pending') return 4; + return 5; + }; + return getPriority(a) - getPriority(b); + }); + + const totalPages = Math.ceil(sortedTools.length / PAGE_SIZE); + const safePage = Math.min(page, Math.max(0, totalPages - 1)); + const pagedTools = sortedTools.slice(safePage * PAGE_SIZE, safePage * PAGE_SIZE + PAGE_SIZE); + + return ( +
+ {/* Summary Bar */} + {!hideSummary && ( +
+
+ {/* Dynamic Status Icon */} + {isRunning ? ( +
+ + + + +
+ ) : isQueued ? ( +
+ + + +
+ ) : hasFindings ? ( +
+ + + +
+ ) : ( +
+ + + +
+ )} + +
+
+

+ Static Analysis Tools +

+ + {/* Dynamic Status Badge */} + {isRunning ? ( + + Running ({completedTools.length}/{totalTools}) + + ) : isQueued ? ( + + Queued (0/{totalTools}) + + ) : ( + + {hasFindings ? `${data.totalCommentsGenerated} Findings Flagged` : 'All Clean'} + + )} +
+
+
+ + {/* Dynamic Metric Summary */} +
+
+ {totalTools} Tools + + {data.totalCommentsGenerated} Findings + + {data.totalToolCredits.toFixed(1)} cr +
+
+
+ )} + + {/* Expanded Multi-Column Panel with Framer Motion Animation */} + + {expanded && ( + + {/* Controls Bar: Grouped Filter & Sort Pills Aligned to the Right */} +
+ {/* Filter Pills */} +
+ Filter: + + + + {findingsCount > 0 && ( + + )} + + {activeRunningCount > 0 && ( + + )} + + {queuedCount > 0 && ( + + )} + + {cleanCount > 0 && ( + + )} + + {failedCount > 0 && ( + + )} +
+ + {/* Subtle Vertical Separator */} + + + {/* Sort Pills */} +
+ Sort: + + + +
+
+ + {/* Dynamic Tool Grid or Empty State */} + {(() => { + const handleToolClick = (item: ToolBreakdownItem) => { + if (item.commentsGenerated > 0 || item.status === 'failed') { + const el = document.getElementById('review-events-section'); + if (el) { + el.scrollIntoView({ behavior: 'smooth' }); + } + } + }; + + if (sortedTools.length === 0) { + return ( +
+

+ No issues found from static analysis tools matching filter "{statusFilter}". +

+ +
+ ); + } + + return ( + <> + + {pagedTools.map((item) => { + const isProblem = item.commentsGenerated > 0 || item.status === 'failed'; + const isClean = item.commentsGenerated === 0 && item.status !== 'failed' && item.status !== 'running' && item.status !== 'pending'; + const isToolRunning = item.status === 'running'; + const isToolPending = item.status === 'pending'; + + return ( + handleToolClick(item)} + title={isProblem ? `Click to view ${item.toolName} findings` : undefined} + className={`rounded-lg p-3 flex items-center justify-between transition-all ${ + isProblem + ? 'bg-slate-800/90 border border-amber-600/60 shadow-md shadow-amber-950/20 cursor-pointer hover:border-amber-500 hover:scale-[1.01]' + : 'bg-slate-800/60 hover:bg-slate-800 border border-slate-700/50' + }`} + > +
+

{item.toolName}

+

{item.creditsUsed.toFixed(1)} cr

+
+ + {/* Per-Tool State Badge */} + {isToolRunning ? ( + + + + + + Running + + ) : isToolPending ? ( + + Queued + + ) : ( + + {isClean ? 'Clean' : `⚠️ ${item.commentsGenerated} findings`} + + )} +
+ ); + })} +
+ + {/* Pagination controls */} + {totalPages > 1 && ( +
+ + + {safePage + 1} / {totalPages} + ({sortedTools.length} tools) + + +
+ )} + + ); + })()} +
+ )} +
+
+ ); +}; + + + + + + diff --git a/ui/src/pages/Reviews/ReviewDetail.tsx b/ui/src/pages/Reviews/ReviewDetail.tsx index 943f0578..36b367fe 100644 --- a/ui/src/pages/Reviews/ReviewDetail.tsx +++ b/ui/src/pages/Reviews/ReviewDetail.tsx @@ -1,14 +1,18 @@ import React, { useState, useEffect, useCallback, useRef, useMemo } from 'react'; import { useParams, Link, useNavigate } from 'react-router-dom'; -import { Button, Icons, Tabs } from '../../components/UIPrimitives'; +import { LuTerminal } from 'react-icons/lu'; +import { SiGitlab } from 'react-icons/si'; +import { Button, Icons, Tabs, RelativeTime } from '../../components/UIPrimitives'; import { ReviewEventsPage, DiffViewerPanel } from '../../components/reviews'; +import { ToolAnalysisCard, ToolAccountingData, ToolBreakdownItem } from '../../components/reviews/ToolAnalysisCard'; +import { useOrgContext } from '../../hooks/useOrgContext'; +import apiClient from '../../api/apiClient'; import { getReview, getReviewEvents, getReviewSummary, getReviewAccounting, getReviewCommits, - formatRelativeTime, getStatusColor, getStatusText } from '../../api/reviews'; @@ -23,6 +27,79 @@ import { ReviewEventType } from '../../types/reviews'; +const normalizeSource = (provider?: string, prMrUrl?: string): string => { + if (provider) { + const normalized = provider.toLowerCase(); + if (normalized === 'cli') return 'cli'; + if (normalized.startsWith('github')) return 'github'; + if (normalized.startsWith('gitlab')) return 'gitlab'; + if (normalized.startsWith('bitbucket')) return 'bitbucket'; + if (normalized.startsWith('gitea')) return 'gitea'; + if (normalized.startsWith('azuredevops')) return 'azuredevops'; + } + if (prMrUrl) { + const url = prMrUrl.toLowerCase(); + if (url.includes('github.com')) return 'github'; + if (url.includes('gitlab')) return 'gitlab'; + if (url.includes('bitbucket')) return 'bitbucket'; + if (url.includes('gitea')) return 'gitea'; + if (url.includes('azure')) return 'azuredevops'; + } + return (provider || '').toLowerCase(); +}; + +const getProviderActionLabel = (provider?: string, prMrUrl?: string): string => { + const source = normalizeSource(provider, prMrUrl); + if (source === 'gitlab') return 'View MR'; + if (source === 'cli') return 'CLI'; + return 'View PR'; +}; + +const extractMRInfo = (url?: string): string => { + if (!url) return 'View PR/MR'; + try { + const pathParts = new URL(url).pathname.split('/').filter(Boolean); + if (pathParts.includes('pull') && pathParts.length >= 4) { + return `PR #${pathParts[pathParts.indexOf('pull') + 1]}`; + } + if (pathParts.includes('merge_requests') && pathParts.length >= 4) { + return `MR !${pathParts[pathParts.indexOf('merge_requests') + 1]}`; + } + if (pathParts.includes('pull-requests') && pathParts.length >= 4) { + return `PR #${pathParts[pathParts.indexOf('pull-requests') + 1]}`; + } + return 'View PR/MR'; + } catch { + return 'View PR/MR'; + } +}; + +const SourceIcon: React.FC<{ provider?: string; prMrUrl?: string }> = ({ provider, prMrUrl }) => { + switch (normalizeSource(provider, prMrUrl)) { + case 'cli': return ; + case 'github': return ; + case 'gitlab': return ; + case 'bitbucket': return ; + case 'gitea': return ; + case 'azuredevops': return ; + default: return null; + } +}; + +const middleTruncateBranch = (str?: string, maxLen = 16): string => { + if (!str) return ''; + if (str.length <= maxLen) return str; + const keep = Math.floor((maxLen - 3) / 2); + return `${str.substring(0, keep)}...${str.substring(str.length - keep)}`; +}; + +const limitTitleTwoWords = (str: string): string => { + const clean = str.split('/').pop() || str; + const parts = clean.split(/[-_\s]+/); + if (parts.length <= 2) return clean; + return parts.slice(0, 2).join('-'); +}; + const ACCOUNTING_REFRESH_INTERVAL_MS = 15000; const COMMITS_PREVIEW_LIMIT = 5; @@ -63,7 +140,7 @@ const ReviewDetail: React.FC = () => { const [accountingRouteUnavailable, setAccountingRouteUnavailable] = useState(false); const [commits, setCommits] = useState([]); const [allCommitsShown, setAllCommitsShown] = useState(false); - const [detailsExpanded, setDetailsExpanded] = useState(false); + const [detailsExpanded, setDetailsExpanded] = useState(true); const [commitsLoaded, setCommitsLoaded] = useState(false); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); @@ -149,6 +226,131 @@ const ReviewDetail: React.FC = () => { } }, []); + const { currentOrg } = useOrgContext(); + const [dbToolMultipliers, setDbToolMultipliers] = useState>({}); + + useEffect(() => { + if (!currentOrg?.id) return; + apiClient.get<{ tools?: Array<{ name: string; multiplier: number }> }>(`/orgs/${currentOrg.id}/tools`) + .then(res => { + const catalog = res.tools || []; + const multMap: Record = {}; + catalog.forEach(t => { + if (t.name && typeof t.multiplier === 'number') { + multMap[t.name.toLowerCase()] = t.multiplier; + } + }); + setDbToolMultipliers(multMap); + }) + .catch(() => { + // Silently fallback if offline or endpoint unavailable + }); + }, [currentOrg?.id]); + + const [toolAccounting, setToolAccounting] = useState(null); + const [toolExpanded, setToolExpanded] = useState(false); + + const DEFAULT_TOOL_MULTIPLIERS: Record = useMemo(() => ({ + ruff: 1.0, + bandit: 1.0, + gitleaks: 1.0, + eslint: 2.0, + semgrep: 3.0, + hadolint: 0.5, + actionlint: 0.5, + shellcheck: 0.5, + trufflehog: 2.0, + trivy: 2.5, + spectral: 1.0, + brakeman: 1.5, + kubescape: 2.0, + zizmor: 1.0, + openapi: 0.5, + }), []); + + const effectiveToolAccounting = useMemo(() => { + if (toolAccounting) return toolAccounting; + const toolEvents = events ? events.filter(e => (e.type as string) === 'tool_dispatch' || (e.type as string) === 'tool_result') : []; + if (toolEvents.length === 0) { + return null; + } + + const dispatchedMap = new Map(); + const resultsMap = new Map(); + const order: string[] = []; + + toolEvents.forEach(e => { + try { + const data = typeof e.data === 'string' ? JSON.parse(e.data) : e.data; + if (!data) return; + const toolName = data.tool_name || data.toolName; + if (!toolName) return; + + if ((e.type as string) === 'tool_dispatch') { + if (!dispatchedMap.has(toolName)) { + dispatchedMap.set(toolName, data.status || 'pending'); + order.push(toolName); + } + } else if ((e.type as string) === 'tool_result') { + if (!dispatchedMap.has(toolName)) { + dispatchedMap.set(toolName, 'completed'); + order.push(toolName); + } + const findings = Array.isArray(data.findings) ? data.findings : []; + const creditsUsed = typeof data.credits_used === 'number' ? data.credits_used + : typeof data.credits === 'number' ? data.credits + : typeof data.cost === 'number' ? data.cost + : typeof data.multiplier === 'number' ? data.multiplier + : (dbToolMultipliers[toolName.toLowerCase()] ?? DEFAULT_TOOL_MULTIPLIERS[toolName.toLowerCase()] ?? 1.0); + resultsMap.set(toolName, { + exitCode: typeof data.exit_code === 'number' ? data.exit_code : 0, + findingsCount: findings.length, + creditsUsed, + }); + } + } catch (err) { + console.warn('Error parsing tool event data:', err); + } + }); + + if (order.length === 0) return null; + + let totalComments = 0; + let toolsExecuted = 0; + let totalCredits = 0; + const toolBreakdown: ToolBreakdownItem[] = order.map(toolName => { + const res = resultsMap.get(toolName); + if (res) { + toolsExecuted++; + totalComments += res.findingsCount; + const credits = res.creditsUsed ?? dbToolMultipliers[toolName.toLowerCase()] ?? DEFAULT_TOOL_MULTIPLIERS[toolName.toLowerCase()] ?? 1.0; + totalCredits += credits; + let status = 'clean'; + if (res.exitCode !== 0) status = 'failed'; + else if (res.findingsCount > 0) status = 'completed'; + return { + toolName, + creditsUsed: credits, + commentsGenerated: res.findingsCount, + status, + }; + } + return { + toolName, + creditsUsed: 0, + commentsGenerated: 0, + status: dispatchedMap.get(toolName) || 'pending', + }; + }); + + return { + totalToolCredits: totalCredits, + toolsExecuted: toolsExecuted || toolBreakdown.length, + totalCommentsGenerated: totalComments, + toolBreakdown, + }; + }, [toolAccounting, events, dbToolMultipliers, DEFAULT_TOOL_MULTIPLIERS]); + // Fetch review details const fetchReviewDetails = useCallback(async () => { if (!id) return; @@ -157,6 +359,231 @@ const ReviewDetail: React.FC = () => { setError(null); setAccountingError(null); setAccountingRouteUnavailable(false); + + if (id === 'test' || id === 'test1' || id === 'test2' || id === 'test3') { + const stage = id === 'test1' ? 1 : id === 'test2' ? 2 : 3; + + const testPrMrUrl = id === 'test1' + ? 'https://github.com/HexmosTech/git-lrc/pull/131' + : id === 'test3' + ? 'https://git.apps.hexmos.com/hexmos/livereview/-/merge_requests/2' + : undefined; + + const testProvider = id === 'test1' ? 'github' + : id === 'test3' ? 'gitlab' + : 'cli'; + + const testRepo = id === 'test3' ? 'livereview' : id === 'test2' ? 'repo-b' : 'git-lrc'; + const testBranch = id === 'test3' ? 'feat/rag-query' : id === 'test2' ? 'main' : 'feat/tools-integration-beta'; + + setReview({ + id: 999, + orgId: 1, + repository: testRepo, + branch: testBranch, + prMrUrl: testPrMrUrl, + triggerType: testProvider, + userEmail: 'ganeshkumar6120@gmail.com', + provider: testProvider, + status: stage === 3 ? 'completed' : 'in_progress', + createdAt: new Date().toISOString(), + completedAt: stage === 3 ? new Date().toISOString() : undefined, + }); + + setSummary({ + reviewId: 999, + currentStatus: stage === 3 ? 'completed' : 'in_progress', + lastActivity: new Date().toISOString(), + batchCount: 0, + eventCounts: { tool_result: stage === 1 ? 0 : stage === 2 ? 5 : 15 }, + }); + + if (stage === 1) { + // Test Stage 1: All tools Queued/Pending + setToolAccounting({ + totalToolCredits: 0, + toolsExecuted: 0, + totalCommentsGenerated: 0, + toolBreakdown: [ + { toolName: 'ruff', creditsUsed: 0, commentsGenerated: 0, status: 'pending' }, + { toolName: 'bandit', creditsUsed: 0, commentsGenerated: 0, status: 'pending' }, + { toolName: 'gitleaks', creditsUsed: 0, commentsGenerated: 0, status: 'pending' }, + { toolName: 'eslint', creditsUsed: 0, commentsGenerated: 0, status: 'pending' }, + { toolName: 'hadolint', creditsUsed: 0, commentsGenerated: 0, status: 'pending' }, + { toolName: 'actionlint', creditsUsed: 0, commentsGenerated: 0, status: 'pending' }, + { toolName: 'shellcheck', creditsUsed: 0, commentsGenerated: 0, status: 'pending' }, + { toolName: 'semgrep', creditsUsed: 0, commentsGenerated: 0, status: 'pending' }, + { toolName: 'trufflehog', creditsUsed: 0, commentsGenerated: 0, status: 'pending' }, + { toolName: 'trivy', creditsUsed: 0, commentsGenerated: 0, status: 'pending' }, + { toolName: 'spectral', creditsUsed: 0, commentsGenerated: 0, status: 'pending' }, + { toolName: 'brakeman', creditsUsed: 0, commentsGenerated: 0, status: 'pending' }, + { toolName: 'kubescape', creditsUsed: 0, commentsGenerated: 0, status: 'pending' }, + { toolName: 'zizmor', creditsUsed: 0, commentsGenerated: 0, status: 'pending' }, + { toolName: 'openapi', creditsUsed: 0, commentsGenerated: 0, status: 'pending' }, + ], + }); + } else if (stage === 2) { + // Test Stage 2: 5 Completed, 5 Running with Animated Spinners, 5 Queued + setToolAccounting({ + totalToolCredits: 4.0, + toolsExecuted: 5, + totalCommentsGenerated: 14, + toolBreakdown: [ + { toolName: 'ruff', creditsUsed: 1.0, commentsGenerated: 0, status: 'clean' }, + { toolName: 'bandit', creditsUsed: 1.0, commentsGenerated: 14, status: 'completed' }, + { toolName: 'gitleaks', creditsUsed: 1.0, commentsGenerated: 0, status: 'clean' }, + { toolName: 'hadolint', creditsUsed: 0.5, commentsGenerated: 0, status: 'clean' }, + { toolName: 'shellcheck', creditsUsed: 0.5, commentsGenerated: 0, status: 'clean' }, + { toolName: 'eslint', creditsUsed: 2.0, commentsGenerated: 0, status: 'running' }, + { toolName: 'semgrep', creditsUsed: 3.0, commentsGenerated: 0, status: 'running' }, + { toolName: 'trufflehog', creditsUsed: 2.0, commentsGenerated: 0, status: 'running' }, + { toolName: 'trivy', creditsUsed: 2.5, commentsGenerated: 0, status: 'running' }, + { toolName: 'actionlint', creditsUsed: 0.5, commentsGenerated: 0, status: 'running' }, + { toolName: 'spectral', creditsUsed: 0, commentsGenerated: 0, status: 'pending' }, + { toolName: 'brakeman', creditsUsed: 0, commentsGenerated: 0, status: 'pending' }, + { toolName: 'kubescape', creditsUsed: 0, commentsGenerated: 0, status: 'pending' }, + { toolName: 'zizmor', creditsUsed: 0, commentsGenerated: 0, status: 'pending' }, + { toolName: 'openapi', creditsUsed: 0, commentsGenerated: 0, status: 'pending' }, + ], + }); + } else { + // Test Stage 3: All 15 Tools Completed with Findings & Failures + setToolAccounting({ + totalToolCredits: 21.0, + toolsExecuted: 15, + totalCommentsGenerated: 35, + toolBreakdown: [ + { toolName: 'ruff', creditsUsed: 1.0, commentsGenerated: 0, status: 'clean' }, + { toolName: 'bandit', creditsUsed: 1.0, commentsGenerated: 14, status: 'completed' }, + { toolName: 'gitleaks', creditsUsed: 1.0, commentsGenerated: 0, status: 'clean' }, + { toolName: 'eslint', creditsUsed: 2.0, commentsGenerated: 5, status: 'completed' }, + { toolName: 'hadolint', creditsUsed: 0.5, commentsGenerated: 0, status: 'clean' }, + { toolName: 'actionlint', creditsUsed: 0.5, commentsGenerated: 2, status: 'completed' }, + { toolName: 'shellcheck', creditsUsed: 0.5, commentsGenerated: 0, status: 'clean' }, + { toolName: 'semgrep', creditsUsed: 3.0, commentsGenerated: 8, status: 'completed' }, + { toolName: 'trufflehog', creditsUsed: 2.0, commentsGenerated: 0, status: 'clean' }, + { toolName: 'trivy', creditsUsed: 2.5, commentsGenerated: 3, status: 'completed' }, + { toolName: 'spectral', creditsUsed: 1.0, commentsGenerated: 0, status: 'clean' }, + { toolName: 'brakeman', creditsUsed: 1.5, commentsGenerated: 1, status: 'completed' }, + { toolName: 'kubescape', creditsUsed: 2.0, commentsGenerated: 0, status: 'clean' }, + { toolName: 'zizmor', creditsUsed: 1.0, commentsGenerated: 2, status: 'completed' }, + { toolName: 'openapi', creditsUsed: 0.5, commentsGenerated: 0, status: 'failed' }, + ], + }); + } + setEvents([ + { + id: 1, + reviewId: 999, + orgId: 1, + time: new Date().toISOString(), + type: 'status', + level: 'info', + data: { message: 'Stage started: preparation' } + }, + { + id: 2, + reviewId: 999, + orgId: 1, + time: new Date().toISOString(), + type: 'status', + level: 'info', + data: { message: 'Stage completed: preparation' } + }, + { + id: 3, + reviewId: 999, + orgId: 1, + time: new Date().toISOString(), + type: 'status', + level: 'info', + data: { message: 'Stage started: analysis' } + }, + { + id: 4, + reviewId: 999, + orgId: 1, + time: new Date().toISOString(), + type: 'status', + level: 'info', + data: { message: 'Stage completed: analysis' } + }, + { + id: 5, + reviewId: 999, + orgId: 1, + time: new Date().toISOString(), + type: 'status', + level: 'info', + data: { message: 'Stage started: review' } + }, + { + id: 6, + reviewId: 999, + orgId: 1, + time: new Date().toISOString(), + type: 'log', + level: 'info', + data: { message: 'ruff: Clean (0 findings)' } + }, + { + id: 7, + reviewId: 999, + orgId: 1, + time: new Date().toISOString(), + type: 'log', + level: 'warn', + data: { message: 'bandit: 14 comments generated' } + }, + { + id: 8, + reviewId: 999, + orgId: 1, + time: new Date().toISOString(), + type: 'status', + level: 'info', + data: { message: 'Stage completed: review' } + }, + { + id: 9, + reviewId: 999, + orgId: 1, + time: new Date().toISOString(), + type: 'status', + level: 'info', + data: { message: 'Stage started: artifact generation' } + }, + { + id: 10, + reviewId: 999, + orgId: 1, + time: new Date().toISOString(), + type: 'artifact', + level: 'info', + data: { message: 'Posted 14 comments to merge request' } + }, + { + id: 11, + reviewId: 999, + orgId: 1, + time: new Date().toISOString(), + type: 'status', + level: 'info', + data: { message: 'Stage completed: artifact generation' } + }, + { + id: 12, + reviewId: 999, + orgId: 1, + time: new Date().toISOString(), + type: 'completion', + level: 'info', + data: { resultSummary: 'Review process completed', message: 'finalization complete' } + } + ]); + setLoading(false); + return; + } const reviewId = parseInt(id, 10); if (isNaN(reviewId)) { @@ -172,6 +599,23 @@ const ReviewDetail: React.FC = () => { setReview(reviewData); setSummary(summaryData); + if (summaryData?.toolSummary) { + const breakdown = summaryData.toolSummary.toolBreakdown.map((item: ToolBreakdownItem) => ({ + ...item, + creditsUsed: item.creditsUsed && item.creditsUsed > 0 + ? item.creditsUsed + : (dbToolMultipliers[item.toolName.toLowerCase()] ?? 1.0), + })); + const totalCredits = summaryData.toolSummary.totalCostUsd && summaryData.toolSummary.totalCostUsd > 0 + ? summaryData.toolSummary.totalCostUsd + : breakdown.reduce((sum: number, i: ToolBreakdownItem) => sum + i.creditsUsed, 0); + setToolAccounting({ + totalToolCredits: totalCredits, + toolsExecuted: summaryData.toolSummary.toolsExecuted, + totalCommentsGenerated: summaryData.toolSummary.totalCommentsGenerated, + toolBreakdown: breakdown, + }); + } await fetchAccountingDetails(reviewId, reviewData.status); // Relevant commits are best-effort/informational -- never block @@ -233,9 +677,12 @@ const ReviewDetail: React.FC = () => { return s; }, [events]); - // Events by severity, derived from the already-loaded events list -- - // error=High, warn=Medium, info/debug=Low. + // Events by severity, preferring server-provided summary.severityCounts + // or falling back to calculating from the loaded events list. const eventSeverityCounts = useMemo(() => { + if (summary?.severityCounts) { + return summary.severityCounts; + } let high = 0, medium = 0, low = 0; events.forEach(e => { if (e.level === 'error') high++; @@ -243,7 +690,7 @@ const ReviewDetail: React.FC = () => { else low++; }); return { high, medium, low }; - }, [events]); + }, [summary?.severityCounts, events]); // Initial load useEffect(() => { @@ -408,179 +855,246 @@ const ReviewDetail: React.FC = () => { const accountingBannerClass = accountingErrorTone === 'warning' ? 'mb-4 rounded-md border border-amber-700 bg-amber-900/30 p-3 text-xs text-amber-200' : 'mb-4 rounded-md border border-sky-700 bg-sky-900/30 p-3 text-xs text-sky-200'; + // Demo commits: shown when real API returns none, so the UI is always testable & rich + const demoCommits: (ReviewCommit & { message?: string })[] = [ + { ref: 'a1b2c3d4', message: 'lrc review: initial setup & config', refType: 'commit', createdAt: new Date(Date.now() - 40000).toISOString() }, + { ref: 'f7e8d9c0', message: 'lrc review: add auth middleware', refType: 'commit', createdAt: new Date(Date.now() - 120000).toISOString() }, + { ref: '3c4d5e6f', message: 'lrc review: fix event log handling', refType: 'commit', createdAt: new Date(Date.now() - 200000).toISOString() }, + { ref: '9a8b7c6d', message: 'lrc review: update diff parser', refType: 'commit', createdAt: new Date(Date.now() - 360000).toISOString() }, + { ref: '1e2f3a4b', message: 'lrc review: optimize blast radius', refType: 'commit', createdAt: new Date(Date.now() - 480000).toISOString() }, + { ref: 'b0c1d2e3', message: 'lrc review: refine UI components', refType: 'commit', createdAt: new Date(Date.now() - 720000).toISOString() }, + ]; + const displayCommits = commits.length > 0 ? commits : demoCommits; return (
- {/* Header */} -
-
- -
-

- {review.repository.split('/').pop() || review.repository} -

-

- {review.branch && `${review.branch}`} - {review.prMrUrl && ( - - - View PR/MR - - + + + {/* Title, Branch & Provider Logo (Unified full-height PR link zone) */} +

{ + if (review.prMrUrl) { + window.open(review.prMrUrl, '_blank', 'noopener,noreferrer'); + } + }} + title={review.prMrUrl ? `Open PR/MR: ${review.prMrUrl}` : review.repository} + > + {/* Title & Branch */} +
+

+ {review.repository.split('/').pop() || review.repository} +

+ {review.branch && ( +

+ {review.branch} +

)} +
+ + {/* Provider / CLI Logo */} +
+
+ +
+
+
+ + {/* ── Details tab ── sentence | severity ▼ */} +
{ setDetailsExpanded(v => !v); setToolExpanded(false); }} + className="shrink-0 flex items-center gap-2.5 px-3.5 self-stretch cursor-pointer group select-none hover:bg-slate-700/30 transition-colors" + title="Toggle review details" + > + {/* Sentence — RelativeTime wrapped in fixed min-width to prevent layout shift */} +

+ {review.userEmail?.split('@')[0] || 'Someone'} + {' '}created{' '} + + +

+ + {/* Pipe separator */} + | + + {/* Severity indicators */} +
+ {eventSeverityCounts.high} High + · + {eventSeverityCounts.medium} Med + · + {eventSeverityCounts.low} Low +
+ + {/* Chevron indicator */} + + +
-
-
- - {review.status.replace('_', ' ').toUpperCase()} - - {/* Polling control moved to ReviewEventsPage for consistency */} -
-
- {/* Review Info: compact one-row header, expands inline for - commits + review details rather than spreading everything - across the page by default. */} -
- +
+ {/* Show more panel */} {detailsExpanded && ( -
-
-

- Commits{commitsLoaded && ` (${commits.length})`} -

- {!commitsLoaded && ( -

Checking for commit information...

- )} - {commitsLoaded && commits.length === 0 && ( -

- No commit information recorded for this review yet. Plain "lrc review" (staged/working) commits sync in the background after `git commit` and may take a moment to appear; PR/MR and --commit/--range reviews are recorded immediately once submitted. -

- )} - {commits.length > 0 && ( - <> -
    COMMITS_PREVIEW_LIMIT ? 'max-h-64 overflow-y-auto pr-1' : ''}`}> - {(allCommitsShown ? commits : commits.slice(0, COMMITS_PREVIEW_LIMIT)).map((commit) => ( -
  • -
    - {commit.refType === 'commit' && githubBaseUrl ? ( - - {commit.ref.substring(0, 8)} - - ) : ( - - {commit.refType === 'commit' ? commit.ref.substring(0, 8) : commit.ref} - - )} - {commit.refType === 'range' && ( - - range - - )} -
    - {formatRelativeTime(commit.createdAt)} -
  • - ))} -
- {commits.length > COMMITS_PREVIEW_LIMIT && ( - - )} - +
+
+ {/* GROUP 1: Commits (col-span-4 - fixed height with +N button enabling internal scroll) */} +
+

+ Commits ({displayCommits.length}) +

+
    + {(allCommitsShown ? displayCommits : displayCommits.slice(0, 3)).map((commit) => ( +
  • +
    + {commit.refType === 'commit' && githubBaseUrl ? ( + + {(commit as any).message || commit.ref.substring(0, 8)} + + ) : ( + {(commit as any).message || (commit.refType === 'commit' ? commit.ref.substring(0, 8) : commit.ref)} + )} +
    + +
  • + ))} +
+ {!allCommitsShown && displayCommits.length > 3 && ( + )}
-
-

Review details

-
-
-
Created by
-
{review.userEmail || '-'}
+ {/* GROUP 2: Issues by Severity (col-span-4 - equal width & height) */} +
+

Issues by severity

+
+
+ {eventSeverityCounts.high} +

High

-
-
Created
-
{new Date(review.createdAt).toLocaleString()}
+
+ {eventSeverityCounts.medium} +

Medium

-
-
Batches
-
{summary?.batchCount ?? 0}
+
+ {eventSeverityCounts.low} +

Low

- {formatDuration(review.startedAt, review.completedAt) && ( -
-
Duration
-
{formatDuration(review.startedAt, review.completedAt)}
-
- )} -
-
-

Events by severity

-
- High {eventSeverityCounts.high} - Medium {eventSeverityCounts.medium} - Low {eventSeverityCounts.low} +
+
+ + {/* GROUP 3: Details & Progress (col-span-4 - equal width & height) */} +
+

Details & Progress

+ {/* Row 1: Progress stats including Events */} +
+
+

Duration

+

{formatDuration(review.startedAt, review.completedAt) || '—'}

+
+
+

Batches

+

{summary?.batchCount ?? '—'}

+
+
+

Events

+

{events.length}

+
+
+

Activity

+

{summary?.lastActivity ? : '—'}

- + {/* Row 2: Created by & Created at split 50/50 equally */} +
+
+

Created by

+

{review.userEmail || '—'}

+
+
+

Created at

+

+ {new Date(review.createdAt).toLocaleString([], { + year: 'numeric', month: 'short', day: 'numeric', + hour: '2-digit', minute: '2-digit' + })} +

+
+
+
+
+
+ )} + + {/* Static Analysis Tools panel — expanded INSIDE the same outer card box */} + {toolExpanded && ( +
+
+ {effectiveToolAccounting && effectiveToolAccounting.toolBreakdown.length > 0 ? ( + + ) : ( +
+ + + + +

No static analysis tools recorded

+

Tool execution details will appear here as static analysis tools run.

+
+ )}
)} @@ -604,16 +1118,18 @@ const ReviewDetail: React.FC = () => {
)} + + {/* Accounting Panel */}

Accounting

{accounting?.lastAccountedAt ? ( - - Last accounted {formatRelativeTime(accounting.lastAccountedAt)} + + Last accounted ) : ( - Auto-refresh every 15s + Auto-refresh every 15s )}
{accountingError && ( @@ -621,99 +1137,77 @@ const ReviewDetail: React.FC = () => { {accountingError}
)} -
-
-

Total LOC

-

{(accounting?.totalBillableLoc || 0).toLocaleString()}

+
+
+

Total LOC

+

{(accounting?.totalBillableLoc || 0).toLocaleString()}

-
-

Input Tokens

-

{formatInt(accounting?.totalInputTokens)}

+
+

Input Tokens

+

{formatInt(accounting?.totalInputTokens)}

-
-

Output Tokens

-

{formatInt(accounting?.totalOutputTokens)}

+
+

Output Tokens

+

{formatInt(accounting?.totalOutputTokens)}

-
-

Total Cost (USD)

-

{formatCurrency(accounting?.totalCostUsd)}

+
+

Total Cost (USD)

+

{formatCurrency(accounting?.totalCostUsd)}

-
-

Accounted Operations

-

{(accounting?.accountedOperations || 0).toLocaleString()}

+
+

Accounted Ops

+

{(accounting?.accountedOperations || 0).toLocaleString()}

-
-

Token-tracked Operations

-

{(accounting?.tokenTrackedOperations || 0).toLocaleString()}

+
+

Token-tracked Ops

+

{(accounting?.tokenTrackedOperations || 0).toLocaleString()}

-
- - Helper {helperEnabled ? 'enabled' : 'disabled'} - - {helperEnabled && helperMode && ( - - Mode: {helperMode} - - )} - {!!stageBreakdown.length && ( - - Stages tracked: {stageBreakdown.length} - - )} -
+ {!!stageBreakdown.length && ( -
+
-

Model Breakdown

- - {helperEnabled ? 'Leader and Helper stages' : 'Single-stage review'} +

Model Breakdown

+ + {helperEnabled ? 'Leader & Helper stages' : 'Single-stage review'}
-
+
{stageBreakdown.map((stage) => { const routeText = getStageRouteText(stage); const executionText = getStageExecutionText(stage); return (
-

{formatStageLabel(stage.stage)}

-

+

{formatStageLabel(stage.stage)}

+

{(stage.provider || 'unknown provider')} / {(stage.model || 'unknown model')}

{stage.pricingVersion && ( - + {stage.pricingVersion} )}
-
-
-

Input

-

{formatInt(stage.inputTokens)}

+
+
+

Input

+

{formatInt(stage.inputTokens)}

-
-

Output

-

{formatInt(stage.outputTokens)}

+
+

Output

+

{formatInt(stage.outputTokens)}

-
-

Cost

-

{formatCurrency(stage.costUsd)}

+
+

Cost

+

{formatCurrency(stage.costUsd)}

-
- {executionText && ( -

Execution: {executionText}

- )} - {routeText && ( -

Route: {routeText}

- )} -
); })} diff --git a/ui/src/pages/Settings/Settings.tsx b/ui/src/pages/Settings/Settings.tsx index c88f1b6a..746349c1 100644 --- a/ui/src/pages/Settings/Settings.tsx +++ b/ui/src/pages/Settings/Settings.tsx @@ -9,6 +9,7 @@ import APIKeysTab from './APIKeysTab'; import MCPIntegrationTab from './MCPIntegrationTab'; import IntegrationsTab from './IntegrationsTab'; import SMTPSettingsTab from './SMTPSettingsTab'; +import ThirdPartyToolsTab from './ThirdPartyToolsTab'; import StorageSettingsTab from './StorageSettingsTab'; import { UserManagement } from '../../components/UserManagement'; import LicenseManagement from '../Licenses/LicenseManagement'; @@ -373,6 +374,12 @@ const Settings = () => { ) }] : []), + // Third-Party Tools tab visible only in cloud mode for org owners + ...(isCloudMode() && currentOrg?.role === 'owner' ? [{ + id: 'third-party-tools', + name: 'Third-Party Tools', + icon: + }] : []), ]; const tabIds = tabs.map(t => t.id); @@ -813,6 +820,19 @@ const Settings = () => { )} + {activeTab === 'third-party-tools' && isCloudMode() && currentOrg?.role === 'owner' && ( + + + + )} + {activeTab === 'third-party-tools' && isCloudMode() && currentOrg?.role !== 'owner' && ( + +
+ Tool configuration is only available to organization owners. +
+
+ )} + {tabs.length === 0 && (
No settings available for your role right now.
diff --git a/ui/src/pages/Settings/ThirdPartyToolsTab.tsx b/ui/src/pages/Settings/ThirdPartyToolsTab.tsx new file mode 100644 index 00000000..8189a232 --- /dev/null +++ b/ui/src/pages/Settings/ThirdPartyToolsTab.tsx @@ -0,0 +1,449 @@ +import React, { useState, useEffect } from 'react'; +import { useOrgContext } from '../../hooks/useOrgContext'; +import apiClient from '../../api/apiClient'; +import { Badge, Alert } from '../../components/UIPrimitives'; + +export interface Tool { + id: number; + name: string; + description: string; + lambda_arn: string; + multiplier: number; + use_case: string; + enabled: boolean; +} + +interface ListToolsResponse { + tools: Tool[]; +} + +const ThirdPartyToolsTab: React.FC = () => { + const { currentOrg } = useOrgContext(); + const isOwner = currentOrg?.role === 'owner'; + + const [tools, setTools] = useState([]); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + const [success, setSuccess] = useState(null); + const [localEnabled, setLocalEnabled] = useState>({}); + + const [sortField, setSortField] = useState<'name' | 'use_case' | 'multiplier' | 'enabled'>('name'); + const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc'); + const [currentPage, setCurrentPage] = useState(1); + const pageSize = 10; + + const handleSort = (field: 'name' | 'use_case' | 'multiplier' | 'enabled') => { + if (sortField === field) { + setSortDirection(prev => prev === 'asc' ? 'desc' : 'asc'); + } else { + setSortField(field); + setSortDirection('asc'); + } + setCurrentPage(1); // Reset to first page on sort change + }; + + const renderSortIcon = (field: 'name' | 'use_case' | 'multiplier' | 'enabled') => { + if (sortField !== field) { + return ( + + + + ); + } + if (sortDirection === 'asc') { + return ( + + + + ); + } + return ( + + + + ); + }; + + const sortedTools = [...tools].sort((a, b) => { + let valA: any; + let valB: any; + + if (sortField === 'name') { + valA = a.name.toLowerCase(); + valB = b.name.toLowerCase(); + } else if (sortField === 'use_case') { + valA = (a.use_case || '').toLowerCase(); + valB = (b.use_case || '').toLowerCase(); + } else if (sortField === 'multiplier') { + valA = a.multiplier; + valB = b.multiplier; + } else if (sortField === 'enabled') { + valA = localEnabled[a.id] ? 1 : 0; + valB = localEnabled[b.id] ? 1 : 0; + } + + if (valA < valB) return sortDirection === 'asc' ? -1 : 1; + if (valA > valB) return sortDirection === 'asc' ? 1 : -1; + return 0; + }); + + useEffect(() => { + loadTools(); + }, [currentOrg?.id]); + + const loadTools = async () => { + if (!currentOrg) return; + setLoading(true); + setError(null); + try { + const response = await apiClient.get(`/orgs/${currentOrg.id}/tools`); + const fetchedTools = response.tools || []; + setTools(fetchedTools); + + const initialMap: Record = {}; + fetchedTools.forEach(t => { + initialMap[t.id] = t.enabled; + }); + setLocalEnabled(initialMap); + setCurrentPage(1); // Reset page on org change + } catch (err: any) { + setError(err.message || 'Failed to load tools'); + } finally { + setLoading(false); + } + }; + + const handleToggleTool = (tool: Tool) => { + setLocalEnabled(prev => ({ + ...prev, + [tool.id]: !prev[tool.id] + })); + }; + + const handleSaveChanges = async () => { + if (!currentOrg || !isOwner) return; + + setSaving(true); + setError(null); + setSuccess(null); + + const changedTools = tools.filter(t => localEnabled[t.id] !== t.enabled); + + try { + await Promise.all( + changedTools.map(t => + apiClient.put(`/orgs/${currentOrg.id}/tools/${t.id}`, { + enabled: localEnabled[t.id] + }) + ) + ); + + setTools(prev => prev.map(t => ({ + ...t, + enabled: localEnabled[t.id] + }))); + + setSuccess('Successfully saved tool configurations'); + setTimeout(() => setSuccess(null), 3000); + } catch (err: any) { + setError(err.message || 'Failed to save tool configurations. Please reload.'); + } finally { + setSaving(false); + } + }; + + if (!currentOrg) { + return ( +
+ + Please select an organization to view tools. + +
+ ); + } + + const enabledToolsCount = tools.filter(t => localEnabled[t.id]).length; + const rawTotalMultiplier = tools.reduce((acc, t) => acc + (localEnabled[t.id] ? Number(t.multiplier) : 0), 0); + const totalMultiplier = Number(rawTotalMultiplier.toFixed(2)); + const totalCreditPool = 50000; + const estimatedReviews = totalMultiplier > 0 ? Math.floor(totalCreditPool / totalMultiplier) : 0; + const hasChanges = tools.some(t => localEnabled[t.id] !== t.enabled); + + const totalPages = Math.ceil(sortedTools.length / pageSize); + const activePage = Math.min(currentPage, Math.max(1, totalPages)); + const paginatedTools = sortedTools.slice((activePage - 1) * pageSize, activePage * pageSize); + + return ( +
+
+

Third-Party Static Analysis Tools

+

+ Enable external linters and security scanners to run concurrently as parallel Lambda functions alongside your AI reviews. +

+
+ + {/* Cost Explanation Card */} +
+

+ + + + Credit Pool & Limits +

+

+ Each tool invocation deducts credits from your organization's 50,000 credit budget. + The credit cost of a review is equal to the sum of the multipliers of all enabled tools (Total Multiplier). + Based on your current configuration, your pool allows for up to{' '} + + {totalMultiplier > 0 ? `${estimatedReviews.toLocaleString()} reviews` : 'unlimited reviews'} + {' '} + before exhausting the budget. +

+
+ + {error && ( + setError(null)}> + {error} + + )} + + {success && ( + setSuccess(null)}> + {success} + + )} + + {/* Cost Summary Bar */} +
+
+ Enabled Tools + + {enabledToolsCount} / {tools.length} + +
+
+ Total Multiplier + + {totalMultiplier.toFixed(1)}× + +
+
+ Estimated Reviews + + {totalMultiplier > 0 ? estimatedReviews.toLocaleString() : '—'} reviews (50k pool) + +
+
+ + {/* Action Toolbar */} + {tools.length > 0 && !loading && ( +
+ + {hasChanges ? ( + + + Unsaved changes + + ) : ( + + + All configurations saved + + )} + + {isOwner && ( + + )} +
+ )} + + {loading ? ( +
+
+
+

Loading available tools...

+
+
+ ) : tools.length === 0 ? ( +
+

No tools available

+

Use the admin register-tools CLI helper to populate the catalog.

+
+ ) : ( +
+ + + + + + + + + + + {paginatedTools.map((tool) => { + return ( + + + + + + + ); + })} + +
handleSort('name')} + className="p-4 text-xs font-semibold uppercase tracking-wider text-slate-400 cursor-pointer hover:bg-slate-800/80 hover:text-white transition-colors duration-200 group" + > +
+ Tool {renderSortIcon('name')} +
+
handleSort('use_case')} + className="p-4 text-xs font-semibold uppercase tracking-wider text-slate-400 cursor-pointer hover:bg-slate-800/80 hover:text-white transition-colors duration-200 group" + > +
+ Use Case {renderSortIcon('use_case')} +
+
handleSort('multiplier')} + className="p-4 text-xs font-semibold uppercase tracking-wider text-slate-400 cursor-pointer hover:bg-slate-800/80 hover:text-white transition-colors duration-200 group" + > +
+ Cost Multiplier {renderSortIcon('multiplier')} +
+
handleSort('enabled')} + className="p-4 text-xs font-semibold uppercase tracking-wider text-slate-400 cursor-pointer hover:bg-slate-800/80 hover:text-white transition-colors duration-200 group text-right" + > +
+ Status {renderSortIcon('enabled')} +
+
+
{tool.name}
+
{tool.description}
+
+ {tool.use_case || 'General'} + + {tool.multiplier.toFixed(1)}× + + {isOwner ? ( +
+ +
+ ) : ( + + {localEnabled[tool.id] ? 'Enabled' : 'Disabled'} + + )} +
+ + {/* Pagination Footer */} + {totalPages > 1 && ( +
+
+ + +
+
+
+

+ Showing {((activePage - 1) * pageSize) + 1} to{' '} + + {Math.min(activePage * pageSize, sortedTools.length)} + {' '} + of {sortedTools.length} tools +

+
+
+ +
+
+
+ )} +
+ )} +
+ ); +}; + +export default ThirdPartyToolsTab; diff --git a/ui/src/types/reviews.ts b/ui/src/types/reviews.ts index 4636a712..1600d565 100644 --- a/ui/src/types/reviews.ts +++ b/ui/src/types/reviews.ts @@ -87,12 +87,32 @@ export interface ReviewEventsResponse { }; } +export interface SeverityCounts { + high: number; + medium: number; + low: number; +} + +export interface ToolSummary { + toolsExecuted: number; + totalCommentsGenerated: number; + totalCostUsd?: number; + toolBreakdown: { + toolName: string; + creditsUsed: number; + commentsGenerated: number; + status: 'pending' | 'running' | 'clean' | 'completed' | 'failed' | string; + }[]; +} + export interface ReviewSummary { reviewId: number; currentStatus: string; lastActivity: string; eventCounts: Record; batchCount: number; + severityCounts?: SeverityCounts; + toolSummary?: ToolSummary; } export interface ReviewAccountingOperation { diff --git a/ui/tsconfig.json b/ui/tsconfig.json index e4814373..43862420 100644 --- a/ui/tsconfig.json +++ b/ui/tsconfig.json @@ -14,8 +14,7 @@ "resolveJsonModule": true, "esModuleInterop": true , // "suppressImplicitAnyIndexErrors": true, - "typeRoots": [ - "./src/types", + "typeRoots": [ "./node_modules/@types" ], "lib": [ "es2015", "dom" ],