diff --git a/db/migrations/20261017090000_auto_approve_policies_repository_fk_cascade.sql b/db/migrations/20261017090000_auto_approve_policies_repository_fk_cascade.sql new file mode 100644 index 000000000..e75db1fa9 --- /dev/null +++ b/db/migrations/20261017090000_auto_approve_policies_repository_fk_cascade.sql @@ -0,0 +1,13 @@ +-- migrate:up + +-- Deleting a connector cascades to its repositories; repo override policies blocked that. +-- A repo's override policy is meaningless without the repo, so delete it too. +ALTER TABLE auto_approve_policies DROP CONSTRAINT auto_approve_policies_repository_id_fkey; +ALTER TABLE auto_approve_policies ADD CONSTRAINT auto_approve_policies_repository_id_fkey + FOREIGN KEY (repository_id) REFERENCES repositories(id) ON DELETE CASCADE; + +-- migrate:down + +ALTER TABLE auto_approve_policies DROP CONSTRAINT auto_approve_policies_repository_id_fkey; +ALTER TABLE auto_approve_policies ADD CONSTRAINT auto_approve_policies_repository_id_fkey + FOREIGN KEY (repository_id) REFERENCES repositories(id); diff --git a/db/migrations/20261017100000_default_auto_approve_policy.sql b/db/migrations/20261017100000_default_auto_approve_policy.sql new file mode 100644 index 000000000..006c4655a --- /dev/null +++ b/db/migrations/20261017100000_default_auto_approve_policy.sql @@ -0,0 +1,26 @@ +-- migrate:up + +-- Auto-approve is on by default: every org gets an org-level policy with the column defaults. +INSERT INTO auto_approve_policies (org_id) +SELECT o.id FROM orgs o +WHERE NOT EXISTS ( + SELECT 1 FROM auto_approve_policies p WHERE p.org_id = o.id AND p.repository_id IS NULL +); + +CREATE FUNCTION create_default_auto_approve_policy() RETURNS trigger + LANGUAGE plpgsql + AS $$ +BEGIN + INSERT INTO auto_approve_policies (org_id) VALUES (NEW.id); + RETURN NEW; +END; +$$; + +CREATE TRIGGER orgs_default_auto_approve_policy + AFTER INSERT ON orgs + FOR EACH ROW EXECUTE FUNCTION create_default_auto_approve_policy(); + +-- migrate:down + +DROP TRIGGER IF EXISTS orgs_default_auto_approve_policy ON orgs; +DROP FUNCTION IF EXISTS create_default_auto_approve_policy(); diff --git a/db/schema.sql b/db/schema.sql index 30e65c765..c7c796367 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -113,6 +113,20 @@ END $$; +-- +-- Name: create_default_auto_approve_policy(); Type: FUNCTION; Schema: public; Owner: - +-- + +CREATE FUNCTION public.create_default_auto_approve_policy() RETURNS trigger + LANGUAGE plpgsql + AS $$ +BEGIN + INSERT INTO auto_approve_policies (org_id) VALUES (NEW.id); + RETURN NEW; +END; +$$; + + -- -- Name: license_seat_assignments_set_updated_at(); Type: FUNCTION; Schema: public; Owner: - -- @@ -6120,6 +6134,13 @@ CREATE TRIGGER trg_org_billing_state_audit AFTER UPDATE ON public.org_billing_st 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: orgs orgs_default_auto_approve_policy; Type: TRIGGER; Schema: public; Owner: - +-- + +CREATE TRIGGER orgs_default_auto_approve_policy AFTER INSERT ON public.orgs FOR EACH ROW EXECUTE FUNCTION public.create_default_auto_approve_policy(); + + -- -- Name: plan_catalog trg_plan_catalog_updated_at; Type: TRIGGER; Schema: public; Owner: - -- @@ -6216,7 +6237,7 @@ ALTER TABLE ONLY public.auto_approve_policies -- ALTER TABLE ONLY public.auto_approve_policies - ADD CONSTRAINT auto_approve_policies_repository_id_fkey FOREIGN KEY (repository_id) REFERENCES public.repositories(id); + ADD CONSTRAINT auto_approve_policies_repository_id_fkey FOREIGN KEY (repository_id) REFERENCES public.repositories(id) ON DELETE CASCADE; -- @@ -7258,4 +7279,6 @@ INSERT INTO public.schema_migrations (version) VALUES ('20260905090000'), ('20261012090000'), ('20261012100000'), - ('20261013090000'); + ('20261013090000'), + ('20261017090000'), + ('20261017100000'); diff --git a/go.mod b/go.mod index 3300291b7..cf626a118 100644 --- a/go.mod +++ b/go.mod @@ -31,7 +31,7 @@ require ( github.com/aws/aws-sdk-go-v2/service/s3 v1.102.2 github.com/bwmarrin/discordgo v0.29.0 github.com/d1vbyz3r0/typed v0.2.3 - github.com/getkin/kin-openapi v0.145.0 + github.com/getkin/kin-openapi v0.151.0 github.com/go-co-op/gocron/v2 v2.22.0 github.com/go-swiss/fonts v0.0.0-20230807175105-90067c2f5042 github.com/golang-jwt/jwt/v5 v5.3.1 @@ -69,7 +69,7 @@ require ( google.golang.org/api v0.272.0 ) -require github.com/cdipaolo/goml v0.0.0-20220715001353-00e0c845ae1c // indirect +require github.com/cdipaolo/goml v0.0.0-20220715001353-00e0c845ae1c require ( cel.dev/expr v0.25.2 // indirect @@ -212,7 +212,7 @@ require ( github.com/sagikazarmark/locafero v0.7.0 // indirect github.com/sagikazarmark/slog-shim v0.1.0 // indirect github.com/sahilm/fuzzy v0.1.3 // indirect - github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 // indirect + github.com/santhosh-tekuri/jsonschema/v6 v6.0.3 // indirect github.com/shopspring/decimal v1.4.0 // indirect github.com/sirupsen/logrus v1.9.4 // indirect github.com/sorairolake/lzip-go v0.3.8 // indirect diff --git a/go.sum b/go.sum index 7fc425f84..70e7eed55 100644 --- a/go.sum +++ b/go.sum @@ -75,10 +75,6 @@ github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapp github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.55.0/go.mod h1:Mf6O40IAyB9zR/1J8nGDDPirZQQPbYJni8Yisy7NTMc= github.com/HexmosTech/deidentify v1.0.0 h1:yC+CMoFdkyGNgSU4gEyB/AAJbJ3KLjqNuNe3Km/LCDA= github.com/HexmosTech/deidentify v1.0.0/go.mod h1:1+fAOPPy8g/7KoF+OOY673j+3BxAs8xTzjlkNKgMWjU= -github.com/HexmosTech/git-lrc/blastradius v0.0.0-20261009104557-0cc6cb4e1a6c h1:ARY/iCSKMhvEWX3nG5OJVKQEsKc97l/rAsHUICmbozQ= -github.com/HexmosTech/git-lrc/blastradius v0.0.0-20261009104557-0cc6cb4e1a6c/go.mod h1:uAX2wgGb22UPGzmXVs6b8rKRzZnEkfFlnhxiv/ewp9M= -github.com/HexmosTech/git-lrc/blastradius v0.0.0-20261009151730-cb9fce8104d0 h1:4RTFP63IZ6cJJ+ibkAQ9m9sQbCa4Kxu0lkuiKw2++bQ= -github.com/HexmosTech/git-lrc/blastradius v0.0.0-20261009151730-cb9fce8104d0/go.mod h1:uAX2wgGb22UPGzmXVs6b8rKRzZnEkfFlnhxiv/ewp9M= github.com/HexmosTech/git-lrc/blastradius v0.1.0 h1:fiykl9PI7plYe6cnKYT6uojVlzr8/EJb3sZ5Om+W4L8= github.com/HexmosTech/git-lrc/blastradius v0.1.0/go.mod h1:uAX2wgGb22UPGzmXVs6b8rKRzZnEkfFlnhxiv/ewp9M= github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc= @@ -227,8 +223,8 @@ github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHk github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho= github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo= -github.com/getkin/kin-openapi v0.145.0 h1:htBX+Q7SevVaCUqymFegUKzH2WCbewl9tsmyn2FMGWY= -github.com/getkin/kin-openapi v0.145.0/go.mod h1:3BH9M9XDe/y9M5DSvEocVYAYq1w0qrhJHjC/vZi0AaY= +github.com/getkin/kin-openapi v0.151.0 h1:p8NIstL53h620DnlO9R4pR4QRkEzJ3jbw9flb4kc1hg= +github.com/getkin/kin-openapi v0.151.0/go.mod h1:1+BHDzstro+P5CKtPy1X4PfofnFgmRe6uvMy9+r9fKY= github.com/gitleaks/go-gitdiff v0.9.1 h1:ni6z6/3i9ODT685OLCTf+s/ERlWUNWQF4x1pvoNICw0= github.com/gitleaks/go-gitdiff v0.9.1/go.mod h1:pKz0X4YzCKZs30BL+weqBIG7mx0jl4tF1uXV9ZyNvrA= github.com/go-co-op/gocron/v2 v2.22.0 h1:uEuH2F7k7VoESb1BYSaffuuV+T0kkpzsC0aXk7/z79I= @@ -510,8 +506,8 @@ github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6g github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ= github.com/sahilm/fuzzy v0.1.3 h1:juByESSS32nVD81vr6tHmKmA/8zde7gE+x5CLxrzXPU= github.com/sahilm/fuzzy v0.1.3/go.mod h1:au6//VbVSqu6DFrkL2CfjlJ5iURpNCPeE+1GwY3XsT8= -github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ= -github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU= +github.com/santhosh-tekuri/jsonschema/v6 v6.0.3 h1:1EYB5IzjZawrrnELUi78f9fPu57HuXjmddZPjrls/28= +github.com/santhosh-tekuri/jsonschema/v6 v6.0.3/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU= github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k= github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME= github.com/shrsv/AgentLaws v0.2.1 h1:00pN3dEhsFFtz+qrcqBwVy8ieLzaivHbpyyuAk3+Lsk= diff --git a/internal/api/pull_request_review_trigger.go b/internal/api/pull_request_review_trigger.go index a1015647d..07f80397b 100644 --- a/internal/api/pull_request_review_trigger.go +++ b/internal/api/pull_request_review_trigger.go @@ -98,15 +98,7 @@ func (s *Server) createReview(c echo.Context) error { return c.JSON(http.StatusInternalServerError, ErrorResponse{Error: err.Error()}) } s.enrichMetadata(ctx) - if req.FromPRLink { - // Marks the review so the worker runs the approval policy check once it completes. - if _, err := s.db.Exec( - `UPDATE reviews SET metadata = COALESCE(metadata, '{}'::jsonb) || '{"pr_link_policy": true}'::jsonb WHERE id = $1 AND org_id = $2`, - ctx.review.ID, orgID, - ); err != nil { - log.Printf("[WARN] createReview: failed to mark review %d for policy check: %v", ctx.review.ID, err) - } - } + s.markForPolicyCheck(ctx.review.ID, orgID) s.trackActivity(ctx) if err := s.launchBackgroundProcessing(ctx); err != nil { log.Printf("[ERROR] createReview: Failed to queue manual review: %v", err) @@ -152,3 +144,15 @@ func (s *Server) getPullRequestOrg(c echo.Context) error { } return JSONWithEnvelope(c, http.StatusOK, map[string]interface{}{"org_id": orgID}) } + +// markForPolicyCheck flags a manual PR review so the worker runs the approval policy check once it completes. +// Reviews not linked to a known PR are left alone. +func (s *Server) markForPolicyCheck(reviewID, orgID int64) { + if _, err := s.db.Exec( + `UPDATE reviews SET metadata = COALESCE(metadata, '{}'::jsonb) || '{"pr_link_policy": true}'::jsonb + WHERE id = $1 AND org_id = $2 AND pull_request_id IS NOT NULL`, + reviewID, orgID, + ); err != nil { + log.Printf("[WARN] failed to mark review %d for policy check: %v", reviewID, err) + } +} diff --git a/internal/api/review_service.go b/internal/api/review_service.go index fd6066e1a..2be4c56f5 100644 --- a/internal/api/review_service.go +++ b/internal/api/review_service.go @@ -138,6 +138,7 @@ func (s *Server) TriggerReviewV2(c echo.Context) error { // Phase 4: Enrich metadata (fetch MR details, update DB) s.enrichMetadata(ctx) + s.markForPolicyCheck(ctx.review.ID, ctx.orgID) // Phase 5: Track activity (log the trigger event) s.trackActivity(ctx) diff --git a/internal/docindex/docs/routes_guide/explore/merge-requests.md b/internal/docindex/docs/routes_guide/explore/merge-requests.md index 14604ff91..88fec3c7e 100644 --- a/internal/docindex/docs/routes_guide/explore/merge-requests.md +++ b/internal/docindex/docs/routes_guide/explore/merge-requests.md @@ -16,7 +16,8 @@ Any authenticated org member. - Search/filter/sort PRs by provider, repo, state, etc. - Trigger a review directly for a specific pull request - (`triggerReviewForPullRequest`). + (`triggerReviewForPullRequest`). It opens the new review's page right away, + and the review runs the auto-approve policy check when it finishes. ## Related pages diff --git a/internal/docindex/docs/routes_guide/reviews/new-review.md b/internal/docindex/docs/routes_guide/reviews/new-review.md index 6d363f44e..a438ff62a 100644 --- a/internal/docindex/docs/routes_guide/reviews/new-review.md +++ b/internal/docindex/docs/routes_guide/reviews/new-review.md @@ -5,9 +5,9 @@ ## Purpose -Form to manually trigger a code review (as opposed to it firing from a git -webhook or the `lrc` CLI hook). Lets the user pick a connected AI provider -and target repository/PR, then submits the review job. +Start a code review of one pull/merge request right away (as opposed to it +firing from a git webhook or the `lrc` CLI hook). Below the form, an "Other +ways to review" section lists the other ways reviews can start. ## Who can access it @@ -18,15 +18,25 @@ self-hosted license limits. ## Key actions -- Select an AI connector and repository/PR to review. -- Submit the review (`triggerReview` API call); redirects to the new - review's detail page on success. +- **Back** (top left) returns to the previous page. +- Type a repository name, PR title or author to see matching open PRs. Click + one, or use ↑/↓ and Enter, to start its review. +- Or paste a PR/MR URL and click **Start review**. Either way the new review's + page opens right away, and for a PR LiveReview already synced the org's + auto-approve policy runs when the review finishes. +- Hover a provider chip (GitHub, GitLab, Bitbucket, Gitea, Azure DevOps) to see + its URL format. +- **Other ways to review** cards: On the PR (assign the bot as reviewer, + @mention it, or use the PR description's Start a review link), Pre-commit CLI, + IDE / MCP, CI/CD & API (CI rulesets), Chatbot (opens Livi with a review prompt), + and Scheduled reviews. - If blocked by quota, see an upgrade prompt (`UpgradePromptModal`) instead. ## Related pages - [Reviews list](reviews-list.md) - [Review Detail](review-detail.md) +- [Explore → Merge Requests](../explore/merge-requests.md) - [Create Review via CLI](create-review-cli.md) - [Create Review via MCP](create-review-mcp.md) - [AI Providers](../ai/ai-providers.md) diff --git a/internal/docindex/docs/routes_guide/reviews/pr-review-link.md b/internal/docindex/docs/routes_guide/reviews/pr-review-link.md index cc84d3138..147c26c53 100644 --- a/internal/docindex/docs/routes_guide/reviews/pr-review-link.md +++ b/internal/docindex/docs/routes_guide/reviews/pr-review-link.md @@ -6,12 +6,19 @@ ## Purpose When a new pull/merge request is opened on GitHub, GitLab, Bitbucket, Gitea, or Azure DevOps in a repo with the -LiveReview webhook installed, LiveReview adds a section with two links to the end of its description: +LiveReview webhook installed, LiveReview adds a section to the end of its description: - **Start a review** → this page. It starts a LiveReview review of that PR and opens the review's detail page. - **Review history** → the [Reviews list](reviews-list.md) filtered to that PR (`/reviews?pull_request_id=`). +- A line under the auto-approval decisions (or under the two links before any + decision): "Customize risk-based approval policy for **this repository** or + **your organization**". The repository link opens the repo's settings page, + Auto Approve section (`/explore/repositories//settings#auto-approve`); + the organization link opens [Settings → Auto Approve Policies](../settings/auto-approve.md) + (`/settings#auto-approve`). Both pages are for org owners and admins, and + open in the user's current org. `:id` is LiveReview's internal pull request id, not the GitHub/GitLab PR number. The section is added once per PR, only when it's opened (not for PRs that @@ -49,8 +56,9 @@ aren't members of that org see "pull request wasn't found". - Azure DevOps: a comment thread, then the bot votes Approved or "Waiting for author". - The decisions table shows the last 10 policy-checked reviews. -- Reviews started any other way (reviewer assignment, @mentions, Explore) don't - run the policy check. +- Reviews started by hand for a known PR also run the policy check and get the + Auto Approve tab: pasting the PR URL on New Review, or Start review in the + Explore PR table. Reviewer assignment and @mention reviews don't. ## Related pages diff --git a/internal/jobqueue/repo_sync_worker.go b/internal/jobqueue/repo_sync_worker.go index f44d698c4..09f5fb4c5 100644 --- a/internal/jobqueue/repo_sync_worker.go +++ b/internal/jobqueue/repo_sync_worker.go @@ -278,7 +278,7 @@ const ( legacySectionEnd = "" ) -// postLinksComment adds the "Start a review / Review history" links to the end of a new PR/MR's description. +// postLinksComment adds the LiveReview links (review, history, repo/org policy) to the end of a new PR/MR's description. // The links_comment_posted_at claim stops duplicate webhook deliveries from adding it twice. func (w *PRStateSyncWorker) postLinksComment(ctx context.Context, repositoryID, pullRequestID int64, number int) error { host, err := w.webhookStore.GetWebhookPublicEndpoint(ctx) @@ -299,7 +299,7 @@ func (w *PRStateSyncWorker) postLinksComment(ctx context.Context, repositoryID, info, err := loadRepositoryConnectorInfo(w.db, repositoryID) if err == nil { - err = writePRSection(ctx, info, number, buildPRLinksSection(host, pullRequestID, "")) + err = writePRSection(ctx, info, number, buildPRLinksSection(host, pullRequestID, repositoryID, "")) } if err != nil { // Release the claim so River's retry can add it. @@ -312,7 +312,7 @@ func (w *PRStateSyncWorker) postLinksComment(ctx context.Context, repositoryID, } // buildPRLinksSection renders the LiveReview section; history is the review history table, or "". -func buildPRLinksSection(host string, pullRequestID int64, history string) string { +func buildPRLinksSection(host string, pullRequestID, repositoryID int64, history string) string { host = strings.TrimSuffix(host, "/") return fmt.Sprintf(`%s @@ -322,8 +322,10 @@ func buildPRLinksSection(host string, pullRequestID int64, history string) strin - ▶️ **[Start a review](%s/#/pr/%d/review)**: reviews all changes in this PR against its target branch - 📜 **[Review history](%s/#/reviews?pull_request_id=%d)**: all past reviews of this PR -%s%s -`, prSectionStart, host, pullRequestID, host, pullRequestID, history, prSectionEnd) +%s⚙️ Customize risk-based approval policy for [this repository](%s/#/explore/repositories/%d/settings#auto-approve) or [your organization](%s/#/settings#auto-approve). + +%s +`, prSectionStart, host, pullRequestID, host, pullRequestID, history, host, repositoryID, host, prSectionEnd) } // refreshPRSection rewrites the LiveReview section with one history row per policy-checked review of this PR. @@ -369,7 +371,7 @@ func refreshPRSection(ctx context.Context, db *sql.DB, info *repositoryConnector return err } b.WriteString("\n") - return writePRSection(ctx, info, number, buildPRLinksSection(host, pullRequestID, b.String())) + return writePRSection(ctx, info, number, buildPRLinksSection(host, pullRequestID, info.RepositoryID, b.String())) } // decisionReason is the readable "why" for one decision row: each failing stage's reason, or that all passed. diff --git a/internal/policyengine/policy_test.go b/internal/policyengine/policy_test.go index 55cb838cd..022daa1d9 100644 --- a/internal/policyengine/policy_test.go +++ b/internal/policyengine/policy_test.go @@ -38,6 +38,7 @@ func seedOrg(t *testing.T, db *sql.DB, name string) int64 { // already inserted a row for this org by the time the test ends; its FK // has no cascade, so it must be cleared first or the org delete leaks. _, _ = db.Exec(`DELETE FROM dashboard_cache WHERE org_id = $1`, orgID) + _, _ = db.Exec(`DELETE FROM auto_approve_policies WHERE org_id = $1`, orgID) _, _ = db.Exec(`DELETE FROM orgs WHERE id = $1`, orgID) }) return orgID @@ -45,6 +46,15 @@ func seedOrg(t *testing.T, db *sql.DB, name string) int64 { func insertPolicy(t *testing.T, db *sql.DB, orgID int64, repoID *int64, mode string, minLevel int) int64 { var id int64 + if repoID == nil { + // Every new org already gets a default org-level row from a trigger; reuse it. + if err := db.QueryRow(` + UPDATE auto_approve_policies SET mode = $2, min_author_level = $3 + WHERE org_id = $1 AND repository_id IS NULL RETURNING id`, orgID, mode, minLevel).Scan(&id); err != nil { + t.Fatalf("update default policy: %v", err) + } + return id + } if err := db.QueryRow(` INSERT INTO auto_approve_policies (org_id, repository_id, mode, min_author_level) VALUES ($1, $2, $3, $4) RETURNING id`, orgID, repoID, mode, minLevel).Scan(&id); err != nil { @@ -62,6 +72,10 @@ func insertPolicy(t *testing.T, db *sql.DB, orgID int64, repoID *int64, mode str func TestResolvePolicyNoneConfigured(t *testing.T) { db := testDB(t) orgID := seedOrg(t, db, "policy-resolve-none") + // Drop the default row the org-insert trigger created. + if _, err := db.Exec(`DELETE FROM auto_approve_policies WHERE org_id = $1`, orgID); err != nil { + t.Fatalf("delete default policy: %v", err) + } _, found, err := ResolvePolicy(context.Background(), db, orgID, 0) if err != nil { diff --git a/ui/src/components/Connector/AzureDevOpsConnector.tsx b/ui/src/components/Connector/AzureDevOpsConnector.tsx index 611441adf..5bbc3c71e 100644 --- a/ui/src/components/Connector/AzureDevOpsConnector.tsx +++ b/ui/src/components/Connector/AzureDevOpsConnector.tsx @@ -9,7 +9,7 @@ const AzureDevOpsConnector: React.FC = () => { return (
-
diff --git a/ui/src/components/Connector/BitbucketConnector.tsx b/ui/src/components/Connector/BitbucketConnector.tsx index c35f33606..197bb78d3 100644 --- a/ui/src/components/Connector/BitbucketConnector.tsx +++ b/ui/src/components/Connector/BitbucketConnector.tsx @@ -33,7 +33,7 @@ const BitbucketConnector: React.FC = () => { return (
-
diff --git a/ui/src/components/Connector/GitHubConnector.tsx b/ui/src/components/Connector/GitHubConnector.tsx index 607d23409..129a314a5 100644 --- a/ui/src/components/Connector/GitHubConnector.tsx +++ b/ui/src/components/Connector/GitHubConnector.tsx @@ -40,7 +40,7 @@ const GitHubConnector: React.FC = () => { return (
-
diff --git a/ui/src/components/Connector/GitLabComConnector.tsx b/ui/src/components/Connector/GitLabComConnector.tsx index 60528f0a0..07865c57b 100644 --- a/ui/src/components/Connector/GitLabComConnector.tsx +++ b/ui/src/components/Connector/GitLabComConnector.tsx @@ -42,7 +42,7 @@ const GitLabComConnector: React.FC = () => { return (
-
diff --git a/ui/src/components/Connector/GitLabSelfHostedConnector.tsx b/ui/src/components/Connector/GitLabSelfHostedConnector.tsx index 1a51101db..f679e0a1c 100644 --- a/ui/src/components/Connector/GitLabSelfHostedConnector.tsx +++ b/ui/src/components/Connector/GitLabSelfHostedConnector.tsx @@ -42,7 +42,7 @@ const GitLabSelfHostedConnector: React.FC = () => { return (
-
diff --git a/ui/src/components/Connector/GiteaConnector.tsx b/ui/src/components/Connector/GiteaConnector.tsx index 8f29a2152..242534795 100644 --- a/ui/src/components/Connector/GiteaConnector.tsx +++ b/ui/src/components/Connector/GiteaConnector.tsx @@ -9,7 +9,7 @@ const GiteaConnector: React.FC = () => { return (
-
diff --git a/ui/src/components/UIPrimitives.tsx b/ui/src/components/UIPrimitives.tsx index 7560b1c40..5cbf6a5b7 100644 --- a/ui/src/components/UIPrimitives.tsx +++ b/ui/src/components/UIPrimitives.tsx @@ -867,6 +867,12 @@ export const Icons = { ), + Back: () => ( + + + + ), + // Tree view icons ChevronRight: () => ( diff --git a/ui/src/pages/Checkout/TeamCheckout.tsx b/ui/src/pages/Checkout/TeamCheckout.tsx index f687109e3..c7c2de449 100644 --- a/ui/src/pages/Checkout/TeamCheckout.tsx +++ b/ui/src/pages/Checkout/TeamCheckout.tsx @@ -1,4 +1,5 @@ import React, { useState, useEffect } from 'react'; +import { Icons } from '../../components/UIPrimitives'; import { useNavigate, useSearchParams } from 'react-router-dom'; import { useOrgContext } from '../../hooks/useOrgContext'; import apiClient from '../../api/apiClient'; @@ -831,9 +832,7 @@ const TeamCheckout: React.FC = () => { onClick={() => navigate('/subscribe')} className="inline-flex items-center text-slate-400 hover:text-white mb-4 transition-colors" > - - - + Back to pricing

diff --git a/ui/src/pages/CiRulesets/CiRulesetIntegration.tsx b/ui/src/pages/CiRulesets/CiRulesetIntegration.tsx index 0a112b125..c65958048 100644 --- a/ui/src/pages/CiRulesets/CiRulesetIntegration.tsx +++ b/ui/src/pages/CiRulesets/CiRulesetIntegration.tsx @@ -424,6 +424,7 @@ const CiRulesetIntegration: React.FC = () => {
- ); - } return ( ); if (error) { diff --git a/ui/src/pages/GitProviders/ConnectorDetails.tsx b/ui/src/pages/GitProviders/ConnectorDetails.tsx index 34b4218cf..4065f2d5f 100644 --- a/ui/src/pages/GitProviders/ConnectorDetails.tsx +++ b/ui/src/pages/GitProviders/ConnectorDetails.tsx @@ -667,10 +667,10 @@ const ConnectorDetails: React.FC = () => {
@@ -691,10 +691,10 @@ const ConnectorDetails: React.FC = () => {
diff --git a/ui/src/pages/Licenses/LicenseAssignment.tsx b/ui/src/pages/Licenses/LicenseAssignment.tsx index 2798c3631..ad1170fdf 100644 --- a/ui/src/pages/Licenses/LicenseAssignment.tsx +++ b/ui/src/pages/Licenses/LicenseAssignment.tsx @@ -1,4 +1,5 @@ import React, { useEffect, useState } from 'react'; +import { Icons } from '../../components/UIPrimitives'; import { useNavigate, useParams } from 'react-router-dom'; import moment from 'moment-timezone'; import { useOrgContext } from '../../hooks/useOrgContext'; @@ -315,9 +316,7 @@ const LicenseAssignment: React.FC = () => { onClick={() => navigate('/subscribe/manage')} className="inline-flex items-center text-slate-400 hover:text-white mb-4 transition-colors" > - - - + Back to Subscriptions
diff --git a/ui/src/pages/Reviews/CreateReviewCLI.tsx b/ui/src/pages/Reviews/CreateReviewCLI.tsx index 20a3498ae..36283d18b 100644 --- a/ui/src/pages/Reviews/CreateReviewCLI.tsx +++ b/ui/src/pages/Reviews/CreateReviewCLI.tsx @@ -1,6 +1,6 @@ import React, { useState } from 'react'; import { useNavigate } from 'react-router-dom'; -import { Card, PageHeader, Spinner } from '../../components/UIPrimitives'; +import { Button, Card, Icons, PageHeader, Spinner } from '../../components/UIPrimitives'; import { useDashboardQuery } from '../../api/dashboard'; import { OnboardingSteps } from '../../components/Dashboard/OnboardingSteps'; import { ErrorBoundary } from '../../components/ErrorBoundary'; @@ -37,6 +37,9 @@ const CreateReviewCLI: React.FC = () => { return (
+ { + const navigate = useNavigate(); return (
+ { return Math.max(1, Math.ceil(diffMs / (24 * 60 * 60 * 1000))); }; +// URL formats shown as provider chips. +const URL_EXAMPLES = [ + { provider: 'github', label: 'GitHub', url: 'https://github.com/owner/repo/pull/123' }, + { provider: 'gitlab', label: 'GitLab', url: 'https://gitlab.com/group/project/-/merge_requests/123' }, + { provider: 'bitbucket', label: 'Bitbucket', url: 'https://bitbucket.org/workspace/repo/pull-requests/123' }, + { provider: 'gitea', label: 'Gitea', url: 'https://gitea.example.com/owner/repo/pulls/123' }, + { provider: 'azuredevops', label: 'Azure DevOps', url: 'https://dev.azure.com/org/project/_git/repo/pullrequest/123' }, +]; + +const isUrlLike = (value: string) => /^https?:\/\//i.test(value.trim()); + const NewReview: React.FC = () => { const navigate = useNavigate(); const { isFreePlan, isSuperAdmin } = useOrgContext(); @@ -78,7 +92,11 @@ const NewReview: React.FC = () => { const [loading, setLoading] = useState(false); const [loadingConnectors, setLoadingConnectors] = useState(true); const [error, setError] = useState(null); - const [success, setSuccess] = useState(null); + const [suggestions, setSuggestions] = useState([]); + const [suggestOpen, setSuggestOpen] = useState(false); + const [activeIndex, setActiveIndex] = useState(0); + const [showOnPRSteps, setShowOnPRSteps] = useState(false); + const suggestRequestRef = useRef(0); const [showUpgradeModal, setShowUpgradeModal] = useState(false); const [upgradeReason, setUpgradeReason] = useState<'DAILY_LIMIT' | 'NOT_ORG_CREATOR'>('DAILY_LIMIT'); const [limitInfo, setLimitInfo] = useState<{ used: number; limit: number }>({ used: 3, limit: 3 }); @@ -131,38 +149,41 @@ const NewReview: React.FC = () => { ); }; - // Handle form submission - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault(); - if (isReadOnly) { - setShowPlanUpgradeDialog(true); - return; - } - setError(null); - setSuccess(null); - - // Validate URL - if (!url) { - setError('Please enter a URL'); - return; - } + // Provider of the connector matching the typed URL, for the input icon. + const detectedProvider = (() => { + const baseUrl = extractBaseUrl(url); + if (!baseUrl) return ''; + return connectors.find((c) => c.provider_url && c.provider_url.includes(baseUrl))?.provider || ''; + })(); - try { - // Validate URL format - new URL(url); - } catch (err) { - setError('Please enter a valid URL'); + // Open PRs matching the typed text (repo, title or author). + useEffect(() => { + if (isUrlLike(url) || !url.trim()) { + setSuggestions([]); return; } - - // Check if URL is from a supported provider - if (!isUrlSupported(url)) { - setError( - 'This URL is not from a connected Git provider. Please connect the appropriate Git provider first.' - ); + const requestId = ++suggestRequestRef.current; + const timer = setTimeout(() => { + getPullRequests({ state: 'open', search: url.trim(), perPage: 8 }) + .then((res) => { + if (requestId !== suggestRequestRef.current) return; + setSuggestions(res.pull_requests || []); + setActiveIndex(0); + }) + .catch(() => { + if (requestId === suggestRequestRef.current) setSuggestions([]); + }); + }, 300); + return () => clearTimeout(timer); + }, [url]); + + // Shared plan/quota gating and error handling for both ways of starting a review. + const startReview = async (run: () => Promise<{ reviewId: string }>) => { + if (isReadOnly) { + setShowPlanUpgradeDialog(true); return; } - + setError(null); if (quotaStatus?.envelope?.trial_readonly) { setError('Trial is read-only for this organization. Upgrade your plan to resume review creation.'); return; @@ -173,19 +194,9 @@ const NewReview: React.FC = () => { } setLoading(true); - try { - const request: TriggerReviewRequest = { url }; - const response = await triggerReview(request); - - setSuccess(`Review successfully triggered! Review ID: ${response.reviewId}`); - setUrl(''); - - // Navigate directly to the new review's detail page - setTimeout(() => { - navigate(`/reviews/${response.reviewId}`); - }, 1500); - + const response = await run(); + navigate(`/reviews/${response.reviewId}`); } catch (err: any) { console.error('Error triggering review:', err); @@ -226,26 +237,78 @@ const NewReview: React.FC = () => { } }; + const startForPullRequest = (pr: PullRequestWithRepo) => { + setSuggestOpen(false); + startReview(() => triggerReviewForPullRequest(pr.id)); + }; + + // Handle form submission: a pasted URL, or the highlighted suggestion. + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setError(null); + + if (!url.trim()) { + setError('Paste a pull request URL, or type a repository or PR title.'); + return; + } + if (!isUrlLike(url)) { + const pick = suggestions[activeIndex]; + if (pick) startForPullRequest(pick); + else setError('No open pull requests match. Paste the PR URL instead.'); + return; + } + + try { + new URL(url); + } catch (err) { + setError('Please enter a valid URL'); + return; + } + + // Check if URL is from a supported provider + if (!isUrlSupported(url)) { + setError( + 'This URL is not from a connected Git provider. Please connect the appropriate Git provider first.' + ); + return; + } + + startReview(() => triggerReview({ url })); + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (!suggestOpen || suggestions.length === 0) return; + if (e.key === 'ArrowDown') { + e.preventDefault(); + setActiveIndex((i) => (i + 1) % suggestions.length); + } else if (e.key === 'ArrowUp') { + e.preventDefault(); + setActiveIndex((i) => (i - 1 + suggestions.length) % suggestions.length); + } else if (e.key === 'Escape') { + setSuggestOpen(false); + } + }; + + const submitBlocked = + loading || + !url.trim() || + Boolean(quotaStatus?.envelope?.blocked) || + Boolean(quotaStatus?.envelope?.trial_readonly) || + (quotaStatus?.envelope?.usage_percent ?? 0) >= 100 || + quotaStatus?.can_trigger_reviews === false; + return ( -
+
+ navigate(-1)} - icon={ - - } - > - Back - - } + description="Review any pull or merge request." /> - + {loadingConnectors ? (
@@ -267,11 +330,6 @@ const NewReview: React.FC = () => { /> ) : (
- {/* Safety Banner */} - - -

Enter Merge/Pull Request URL

- {/* LOC 90% Warning Banner */} {/* LOC 100% Exhausted Banner */} {!quotaStatus?.envelope?.blocked && !quotaStatus?.envelope?.trial_readonly && (quotaStatus?.envelope?.usage_percent ?? 0) >= 100 && ( @@ -343,83 +401,141 @@ const NewReview: React.FC = () => { )} {error && ( - } - > + }> {error} )} - {success && ( - } - > -
-
{success}
-
Redirecting to reviews list...
-
-
- )} - -
- setUrl(e.target.value)} - disabled={loading} - icon={ - - } - helperText="Enter the URL of the merge/pull request to start a review" - /> -
- - {/* URL Examples */} -
-

Supported URL Examples:

-
-
• GitLab: https://gitlab.com/group/project/-/merge_requests/123
-
• GitHub: https://github.com/owner/repo/pull/123
-
• Bitbucket: https://bitbucket.org/workspace/repo/pull-requests/123
-
• Azure DevOps: https://dev.azure.com/org/project/_git/repo/pullrequest/123
+ +
+
+ 0} + aria-controls="pr-suggestions" + aria-activedescendant={suggestOpen && suggestions[activeIndex] ? `pr-suggestion-${suggestions[activeIndex].id}` : undefined} + placeholder="Paste a PR URL, or type a repo or PR title" + value={url} + onChange={(e) => { setUrl(e.target.value); setSuggestOpen(e.target.value.trim() !== ''); }} + onFocus={() => setSuggestOpen(url.trim() !== '')} + onBlur={() => setTimeout(() => setSuggestOpen(false), 150)} + onKeyDown={handleKeyDown} + disabled={loading} + autoFocus + icon={detectedProvider ? : } + /> + {suggestOpen && suggestions.length > 0 && ( +
    +
  • + Matching open pull requests +
  • + {suggestions.map((pr, i) => ( +
  • { e.preventDefault(); startForPullRequest(pr); }} + onMouseEnter={() => setActiveIndex(i)} + className={`flex items-start gap-3 px-3 py-2 cursor-pointer ${i === activeIndex ? 'bg-slate-700' : ''}`} + > + + + + {pr.repository_full_name.split('/').pop()} + #{pr.number} + {' '}{pr.title} + + + {pr.author_name || pr.author_username || 'unknown'} · {formatRelativeTime(pr.provider_updated_at || pr.last_synced_at)} + + +
  • + ))} +
  • + +
  • +
+ )}
- +
-
- - +
+ Supported: + {URL_EXAMPLES.map((ex) => ( + + + + {ex.label} + + + ))}
)} +

Other ways to review

+
+
+
+ + On the PR +
+

Start a review from the pull request itself.

+ + {showOnPRSteps && ( +
    +
  1. Assign the LiveReview bot as a reviewer.
  2. +
  3. Or @mention the bot in a PR comment.
  4. +
  5. Or click Start a review in the PR description.
  6. +
+ )} +
+ {[ + { icon: , title: 'Pre-commit CLI', text: 'Review staged changes before you commit with git-lrc.', link: 'Install the CLI', to: '/reviews/create-cli' }, + { icon: , title: 'IDE / MCP', text: 'Ask your AI coding agent to review changes through LiveReview\'s MCP server.', link: 'See setup details', to: '/reviews/create-mcp' }, + { icon: , title: 'CI/CD & API', text: 'Gate merges in CI, or start reviews from your own scripts with an API key.', link: 'Set up CI rulesets', to: '/ci-rulesets' }, + { icon: , title: 'Chatbot', text: 'Ask Livi to review a pull request in plain language.', link: 'Open the chat', to: '/chat?prefill=' + encodeURIComponent('Trigger a review for ') }, + { icon: , title: 'Scheduled', text: 'Review whole repositories on a regular schedule.', link: 'Set up a schedule', to: '/reviews/scheduled' }, + ].map((way) => ( +
+
+ {way.icon} + {way.title} +
+

{way.text}

+ +
+ ))} +
+ {/* Upgrade Modal */} { as={Link} to="/reviews" variant="outline" + icon={} > Back to Reviews @@ -519,17 +520,17 @@ const ReviewDetail: React.FC = () => { return (
{/* Header */} +
-

{review.repository.split('/').pop() || review.repository} diff --git a/ui/src/pages/Reviews/ScheduledReviewRuns.tsx b/ui/src/pages/Reviews/ScheduledReviewRuns.tsx index cdbb9a8c3..bf723dc5e 100644 --- a/ui/src/pages/Reviews/ScheduledReviewRuns.tsx +++ b/ui/src/pages/Reviews/ScheduledReviewRuns.tsx @@ -328,8 +328,8 @@ const ScheduledReviewRuns: React.FC = () => { return (
-
diff --git a/ui/src/pages/Reviews/ScheduledReviews.tsx b/ui/src/pages/Reviews/ScheduledReviews.tsx index 6ac6f54c1..587b385ee 100644 --- a/ui/src/pages/Reviews/ScheduledReviews.tsx +++ b/ui/src/pages/Reviews/ScheduledReviews.tsx @@ -564,6 +564,9 @@ const ScheduledReviews: React.FC = () => { return (
+