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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions db/migrations/20261029120000_add_user_time_zone.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
-- migrate:up
ALTER TABLE users ADD COLUMN IF NOT EXISTS time_zone TEXT;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Severity: warning

You should add a NOT NULL constraint if the time_zone field is always required, or alternatively, provide a default value for it.

Suggestions:

  1. Consider adding NOT NULL constraint and a default value (e.g., 'UTC') if a time zone is always expected for users.
  2. If NULL is acceptable, ensure application logic handles NULL time zones gracefully (e.g., falling back to UTC).


-- migrate:down
ALTER TABLE users DROP COLUMN IF EXISTS time_zone;
4 changes: 2 additions & 2 deletions docs/pr-review-links-plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

## Goal

When a new PR/MR is opened, LiveReview adds a section with two links to the end of its description:
When a new PR/MR is opened, LiveReview adds a controls section with two links to the top of its description:

1. **Start a review** — opens LiveReview, triggers a review for that PR, and redirects to the
review details page.
Expand All @@ -21,7 +21,7 @@ built on CI/CD Gates jq rulesets) remain a later feature.
| Providers | GitHub, GitLab, Bitbucket, Gitea, Azure DevOps |
| Which repos | Every repo with the LiveReview webhook installed |
| Which PRs | Only newly opened PRs (GitHub action `opened`, GitLab action `open`). Existing open PRs, reopen, push, and edit events are ignored. |
| Where links go | A section at the end of the PR description, between the markdown-comment markers `[//]: # (livereview:links)` and `[//]: # (livereview:end)` (HTML comments show as text on Bitbucket; the old `<!-- … -->` markers are still recognised). Added at PR open; rewritten in place after each link-started review with an "Auto-approval decisions" table (one row per review, latest first: Review, Decision, Reason). The stage-by-stage policy breakdown is a separate PR comment. Replaced the earlier bot comment. |
| Where links go | A controls section at the top of the PR description, between `[//]: # (livereview:controls)` and `[//]: # (livereview:controls-end)`, followed by a horizontal line (HTML comments show as text on Bitbucket). Added at PR open. A separate results section at the bottom, between `livereview:results` and `livereview:results-end`, is rewritten in place after each link-started review with a "LiveReview auto-approval decisions" table. The old single section (`livereview:links` / `livereview:end`, or the legacy `<!-- … -->` markers) is removed and replaced on the next review. The table (one row per review, latest first: Review, Time (starter's time zone, UTC fallback), Decision, Reason). The stage-by-stage policy breakdown is a separate PR comment. Replaced the earlier bot comment. |
| Duplicate webhooks | Guarded by a "links added" flag (`links_comment_posted_at`) on the `pull_requests` row |
| Auth | Both links require a LiveReview login. Logged-out users log in and are sent back to the link (existing `redirectAfterLogin` flow). |
| Org | The user is switched to the PR's org automatically. Non-members get "not found". |
Expand Down
1 change: 1 addition & 0 deletions internal/api/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -1318,6 +1318,7 @@ func (s *Server) setupRoutes() {
protected.GET("/users/profile", s.profileHandlers.GetProfile)
protected.PUT("/users/profile", s.profileHandlers.UpdateProfile)
protected.PUT("/users/default-org", s.orgHandlers.SetDefaultOrganization)
protected.PUT("/users/time-zone", s.profileHandlers.UpdateTimeZone)
protected.PUT("/users/password", s.profileHandlers.ChangePassword)

// Development mode test endpoints (only enabled when DEV_MODE=true)
Expand Down
33 changes: 33 additions & 0 deletions internal/api/users/profile_handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"errors"
"net/http"
"strconv"
"time"

"github.com/labstack/echo/v4"
"github.com/livereview/internal/api/auth"
Expand Down Expand Up @@ -219,3 +220,35 @@ func (ph *ProfileHandlers) ChangePassword(c echo.Context) error {
"message": "Password changed successfully. Please log in again with your new password.",
})
}

// UpdateTimeZone saves the browser's IANA time zone so PR tables show the user's local time.
// It is a no-op when the zone is unchanged, so repeated calls write nothing.
func (ph *ProfileHandlers) UpdateTimeZone(c echo.Context) error {
if err := requireSession(c); err != nil {
return err
}
user := auth.GetUser(c)
if user == nil {
return echo.NewHTTPError(http.StatusUnauthorized, "User not found in context")
}

var req struct {
TimeZone string `json:"time_zone"`
}
if err := c.Bind(&req); err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "Invalid request body")
}
if len(req.TimeZone) > 64 || req.TimeZone == "" || req.TimeZone == "Local" {
return echo.NewHTTPError(http.StatusBadRequest, "time_zone must be a valid IANA zone, e.g. Asia/Kolkata")
}
if _, err := time.LoadLocation(req.TimeZone); err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "time_zone must be a valid IANA zone, e.g. Asia/Kolkata")
}

if _, err := ph.db.ExecContext(c.Request().Context(),
`UPDATE users SET time_zone = $1 WHERE id = $2 AND time_zone IS DISTINCT FROM $1`,
req.TimeZone, user.ID); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to save time zone")
}
return c.NoContent(http.StatusNoContent)
}
6 changes: 4 additions & 2 deletions internal/docindex/docs/routes_guide/reviews/pr-review-link.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,8 +53,10 @@ organization with that PR or that account available see "pull request wasn't fou
- When a review started from this link finishes, LiveReview runs the org's
[auto-approve policy](../settings/auto-approve.md) on it. The result is shown
in two places:
- **PR description:** under the two links, an "Auto-approval decisions"
table with one row per policy-checked review, latest first: Review (link),
- **PR description:** the controls (Start a review, Review history, policy
links) sit at the top, above a horizontal line. A "LiveReview auto-approval
decisions" table stays at the bottom, with one row per policy-checked review, latest first: Review (link),
Time (in the review starter's saved time zone, e.g. "29 Oct 2026, 19:35 IST"; UTC until they open the app once),
Decision (✅ Approved / ❌ Not approved / ⚠️ Force-approved), and Reason
(the failing stage and why, the repo mode override such as
"mode override: always_approve", the force-approve reason, or "All policy
Expand Down
5 changes: 5 additions & 0 deletions internal/docindex/docs/routes_guide/reviews/review-detail.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,11 @@ assessment: Approved, Not approved, or Pending. For a blocked review, owners/adm
`force_approve_min_level`) can **force approve** with a reason — recorded in the
audit log and posted to the PR/MR. Any member can **mark a production outcome**
(revert / incident / confirmed-safe) with a reason, which feeds the risk model.
Each stage row shows a pass/fail/skipped icon, its reason, and a **Details**
toggle; clicking anywhere on a stage row also opens or closes its details. At the end of the row, owners/admins get a **Configure** link on every
stage except the final decision; it opens that stage's sub-tab in Settings →
Auto Approve Policies. The eligibility row adds **Your level** before it, which
opens the viewer's own user settings (review level).
See [Settings → Auto Approve](../settings/auto-approve.md).

## Blast-radius scoring and ordering
Expand Down
127 changes: 87 additions & 40 deletions internal/jobqueue/repo_sync_worker.go
Original file line number Diff line number Diff line change
Expand Up @@ -338,11 +338,14 @@ func (w *PRStateSyncWorker) Work(ctx context.Context, job *river.Job[PRStateSync
// The LiveReview section in a PR description sits between these markers so it can be replaced in place.
// They are markdown comments, which every provider hides (Bitbucket shows HTML comments as text).
const (
prSectionStart = "[//]: # (livereview:links)"
prSectionEnd = "[//]: # (livereview:end)"
// Markers used before the switch, still matched so older sections get replaced.
controlsStart = "[//]: # (livereview:controls)"
controlsEnd = "[//]: # (livereview:controls-end)"
resultsStart = "[//]: # (livereview:results)"
resultsEnd = "[//]: # (livereview:results-end)"
// Old single section (controls and table together, at the bottom), removed when found.
prSectionStart = "[//]: # (livereview:links)"
prSectionEnd = "[//]: # (livereview:end)"
legacySectionStart = "<!-- livereview:links -->"
legacySectionEnd = "<!-- livereview:end -->"
)

// postLinksComment adds the LiveReview links (review, history, repo/org policy) to the end of a new PR/MR's description.
Expand All @@ -366,7 +369,9 @@ func (w *PRStateSyncWorker) postLinksComment(ctx context.Context, repositoryID,

info, err := loadActingConnectorInfo(w.db, repositoryID)
if err == nil {
err = writePRSection(ctx, info, number, buildPRLinksSection(host, pullRequestID, repositoryID, ""))
err = writePRSection(ctx, info, number, func(body string) string {
return upsertControlsSection(body, buildControlsSection(host, pullRequestID, repositoryID))
})
}
if err != nil {
// Release the claim so River's retry can add it.
Expand All @@ -378,30 +383,35 @@ func (w *PRStateSyncWorker) postLinksComment(ctx context.Context, repositoryID,
return nil
}

// buildPRLinksSection renders the LiveReview section; history is the review history table, or "".
func buildPRLinksSection(host string, pullRequestID, repositoryID int64, history string) string {
// buildControlsSection renders the control panel (start review, history, policy links) shown at the top of the PR.
func buildControlsSection(host string, pullRequestID, repositoryID int64) string {
host = strings.TrimSuffix(host, "/")
return fmt.Sprintf(`%s

---
### 🔍 LiveReview

- ▶️ **[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⚙️ Customize risk-based approval policy for [this repository](%s/#/explore/repositories/%d/settings#auto-approve) or [your organization](%s/#/settings#auto-approve).
⚙️ 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)
`, controlsStart, host, pullRequestID, host, pullRequestID, host, repositoryID, host, controlsEnd)
}

// buildResultsSection wraps the decisions table, which stays at the bottom of the PR.
func buildResultsSection(history string) string {
return fmt.Sprintf("%s\n\n---\n%s%s\n", resultsStart, history, resultsEnd)
}

// refreshPRSection rewrites the LiveReview section with one history row per policy-checked review of this PR.
// Rows are rebuilt from the DB each time, so retries never duplicate them.
func refreshPRSection(ctx context.Context, db *sql.DB, info *repositoryConnectorInfo, host string, pullRequestID int64, number int) error {
rows, err := db.QueryContext(ctx,
// ponytail: only the last 10 rows, so the table stays under description size limits (Azure DevOps: 4000 chars).
`SELECT id, policy, forced_reason FROM (
SELECT r.id, r.metadata->'policy_result' AS policy,
`SELECT id, created_at, policy, forced_reason, time_zone FROM (

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Severity: warning

Please add r.user_email to the select list for the time_zone subquery.

Suggestions:

  1. Add r.user_email to the SELECT clause of the outer query. The time_zone subquery needs r.user_email to link to the users table, but r.user_email is not available in the outer query's SELECT list. This will cause a SQL error.

SELECT r.id, r.created_at, r.metadata->'policy_result' AS policy,
(SELECT u.time_zone FROM users u WHERE lower(u.email) = lower(r.user_email) LIMIT 1) AS time_zone,
(SELECT o.reason FROM review_policy_overrides o
WHERE o.review_id = r.id AND o.org_id = r.org_id ORDER BY o.created_at DESC LIMIT 1) AS forced_reason
FROM reviews r
Expand All @@ -414,12 +424,13 @@ func refreshPRSection(ctx context.Context, db *sql.DB, info *repositoryConnector

host = strings.TrimSuffix(host, "/")
var b strings.Builder
b.WriteString("#### Auto-approval decisions\n\n| Review | Decision | Reason |\n|---|---|---|\n")
b.WriteString("#### LiveReview auto-approval decisions\n\n| Review | Time | Decision | Reason |\n|---|---|---|---|\n")
for rows.Next() {
var reviewID int64
var createdAt time.Time
var raw []byte
var forcedReason sql.NullString
if err := rows.Scan(&reviewID, &raw, &forcedReason); err != nil {
var forcedReason, timeZone sql.NullString
if err := rows.Scan(&reviewID, &createdAt, &raw, &forcedReason, &timeZone); err != nil {
return err
}
var res policyengine.FunnelResult
Expand All @@ -432,13 +443,27 @@ func refreshPRSection(ctx context.Context, db *sql.DB, info *repositoryConnector
} else if !res.Approved {
decision = "❌ Not approved"
}
fmt.Fprintf(&b, "| [#%d](%s/#/reviews/%d) | %s | %s |\n", reviewID, host, reviewID, decision, strings.ReplaceAll(reason, "|", "\\|"))
fmt.Fprintf(&b, "| [#%d](%s/#/reviews/%d) | %s | %s | %s |\n", reviewID, host, reviewID, formatReviewTime(createdAt, timeZone), decision, strings.ReplaceAll(reason, "|", "\\|"))
}
if err := rows.Err(); err != nil {
return err
}
b.WriteString("\n")
return writePRSection(ctx, info, number, buildPRLinksSection(host, pullRequestID, info.RepositoryID, b.String()))
controls := buildControlsSection(host, pullRequestID, info.RepositoryID)
results := buildResultsSection(b.String())
return writePRSection(ctx, info, number, func(body string) string {
return upsertResultsSection(upsertControlsSection(body, controls), results)
})
}

// formatReviewTime shows the time in the review starter's saved zone, or UTC if none is saved.
func formatReviewTime(t time.Time, timeZone sql.NullString) string {
if timeZone.Valid {
if loc, err := time.LoadLocation(timeZone.String); err == nil {
return t.In(loc).Format("2 Jan 2006, 15:04 MST")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Severity: info

Consider using time.RFC822 or time.RFC1123Z for a more standard date and time format.

Suggestions:

  1. Use a standard time format constant like time.RFC822 or time.RFC1123Z for consistency and easier parsing if this output is consumed by other systems.
  2. Ensure the chosen format is clear and unambiguous for users across different locales.

}
}
return t.UTC().Format("2 Jan 2006, 15:04 UTC")
}

// decisionReason is the readable "why" for one decision row: each failing stage's reason, or that all passed.
Expand Down Expand Up @@ -470,31 +495,53 @@ func decisionReason(res *policyengine.FunnelResult) string {
return strings.Join(parts, "; ")
}

// upsertPRSection swaps the old LiveReview section in body for section, or appends section if there is none.
// Sections from before the end marker existed were always last, so they are replaced up to the end of body.
func upsertPRSection(body, section string) string {
startMarker, endMarker := prSectionStart, prSectionEnd
start := strings.Index(body, startMarker)
if start < 0 {
startMarker, endMarker = legacySectionStart, legacySectionEnd
start = strings.Index(body, startMarker)
}
if start < 0 {
if strings.TrimSpace(body) == "" {
return section
}
return strings.TrimRight(body, "\n") + "\n\n" + section
// replaceBetween swaps the text from start through end markers for section.
// ok is false if either marker is missing, so a lost end marker never deletes the author's text.
func replaceBetween(body, start, end, section string) (string, bool) {
i := strings.Index(body, start)
if i < 0 {
return body, false
}
rest := ""
if end := strings.Index(body[start:], endMarker); end >= 0 {
rest = body[start+end+len(endMarker):]
section = strings.TrimRight(section, "\n")
j := strings.Index(body[i:], end)
if j < 0 {
return body, false
}
return body[:i] + strings.TrimRight(section, "\n") + body[i+j+len(end):], true
}

// removeOldSection drops the old single section; the legacy one was always last, so it goes to the end of body.
func removeOldSection(body string) string {
if next, ok := replaceBetween(body, prSectionStart, prSectionEnd, ""); ok {
return strings.TrimRight(next, "\n") + "\n"
}
if i := strings.Index(body, legacySectionStart); i >= 0 {
return strings.TrimRight(body[:i], "\n") + "\n"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Severity: warning

Ensure that body[:i] does not inadvertently remove content that appears before legacySectionStart.

Suggestions:

  1. The removeOldSection function for legacySectionStart (if i := strings.Index(body, legacySectionStart); i >= 0 { return strings.TrimRight(body[:i], "\n") + "\n" }) removes everything after the start of the legacy section. This assumes the legacy section was always at the very end of the PR description. If any content could appear after the legacy section but before the new sections, it would be deleted. Confirm this assumption is safe.

}
return body
}

// upsertControlsSection puts the control panel at the top of body, replacing it in place if already there.
func upsertControlsSection(body, section string) string {
body = removeOldSection(body)
if next, ok := replaceBetween(body, controlsStart, controlsEnd, section); ok {
return next
}
if strings.TrimSpace(body) == "" {
return section
}
return section + "\n" + strings.TrimLeft(body, "\n")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Severity: warning

Make sure that strings.TrimLeft(body, "\n") does not remove more newlines than intended.

Suggestions:

  1. If body starts with multiple newlines, strings.TrimLeft(body, "\n") will remove all of them. This might lead to less vertical spacing than desired between the new controls section and the original PR body. Consider strings.TrimLeft(body, "\n") or strings.TrimSpace(body) if the intent is to normalize leading newlines, but be aware of potential formatting changes.

}

// upsertResultsSection keeps the decisions table at the bottom of body, replacing it in place if already there.
func upsertResultsSection(body, section string) string {
if next, ok := replaceBetween(body, resultsStart, resultsEnd, section); ok {
return next
}
return body[:start] + section + rest
return strings.TrimRight(body, "\n") + "\n\n" + section
}

// writePRSection reads the PR/MR description and saves it with the LiveReview section updated.
func writePRSection(ctx context.Context, info *repositoryConnectorInfo, number int, section string) error {
// writePRSection reads the PR/MR description and saves it after update changes the LiveReview sections.
func writePRSection(ctx context.Context, info *repositoryConnectorInfo, number int, update func(body string) string) error {
var get func() (string, error)
var set func(string) error
switch info.Provider {
Expand Down Expand Up @@ -544,7 +591,7 @@ func writePRSection(ctx context.Context, info *repositoryConnectorInfo, number i
if err != nil {
return err
}
if next := upsertPRSection(body, section); next != body {
if next := update(body); next != body {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Severity: warning

This is a read-modify-write operation on the PR body. Concurrent updates could overwrite changes. Consider implementing optimistic locking or job serialization to handle this.

Suggestions:

  1. Implement optimistic locking using SCM API's ETag/versioning if available.
  2. Ensure job queue processing for a given PR is strictly serialized to prevent concurrent updates.

return set(next)
}
return nil
Expand Down
3 changes: 3 additions & 0 deletions ui/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import React, { Suspense, useEffect, useState } from 'react';
import { HashRouter as Router, Routes, Route, Navigate, useNavigate, useLocation, Link } from 'react-router-dom';
import { Navbar } from './components/Navbar/Navbar';
import { URLMismatchBanner } from './components/URLMismatchBanner';
import { useSyncTimeZone } from './hooks/useSyncTimeZone';
import { useAppDispatch, useAppSelector } from './store/configureStore';
import { logout, checkSetupStatus, fetchUser } from './store/Auth/reducer';
import { fetchLicenseStatus, openModal as openLicenseModal, closeModal as closeLicenseModal } from './store/License/slice';
Expand Down Expand Up @@ -198,6 +199,8 @@ const AppContent: React.FC = () => {
}
}, [dispatch, accessToken]);

useSyncTimeZone();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Severity: warning

A new hook is being called. Please ensure that there are no blocking operations or critical errors introduced by this hook.

Suggestions:

  1. Verify useSyncTimeZone is non-blocking and handles its own errors gracefully, as it's called at the top level.


// Kick off initial license status load (non-blocking UI)
useEffect(() => {
// Only attempt after authentication established to avoid 401 noise
Expand Down
7 changes: 7 additions & 0 deletions ui/src/api/profile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,3 +37,10 @@ export const updateProfile = async (req: UpdateProfileRequest): Promise<UserProf
export const changePassword = async (req: ChangePasswordRequest): Promise<{ message: string }> => {
return apiClient.put<{ message: string }>('/users/password', req);
};

/**
* Save the browser's time zone (IANA name) so PR tables show this user's local time.
*/
export const saveTimeZone = async (timeZone: string): Promise<void> => {
await apiClient.put('/users/time-zone', { time_zone: timeZone });
};
Loading
Loading