mirror of
https://github.com/alibaba/open-code-review.git
synced 2026-09-14 19:59:52 +08:00
7f8f254d84
`ocr session compare` had no equivalent in the web viewer (#1104). Adds GET /r/{repo}/compare?before=&after= rendering the same four buckets the CLI prints, plus a compare link on each session-list row pointing at the next-older session. Findings render with the existing/suggested code panels session.html already uses, so a finding whose fix is a patch shows the patch and not only the prose around it. The reviewed-path partition (manifest Completed + Reused) moves to an exported session.ReviewedPaths that the CLI now delegates to, so the two cannot drift. The viewer's own Summary.FilesReviewed is Coverage.Selected - the intended set - which would report files an interrupted run never reached as clean. Route registration moves out of StartServer into newMux, so the literal "compare" segment beating the {sessionID} wildcard is covered by a test that dispatches through the real ServeMux instead of calling the handler directly. The repo guard there now rejects "\" alongside "/": ServeMux unescapes each path segment, and "\" is a separator on Windows. encodeRepoPath maps both path separators to "-", so two distinct working directories ("/home/a/b" and "/home/a-b") can share one on-disk repo directory, and a compare request could otherwise pair sessions from different repos. handleCompare (internal/viewer/handler.go:185) rejects that case by comparing the two sessions' Summary.CWD, the same check the CLI does in cmd/opencodereview/session_cmd.go's runSessionCompare, tested at internal/viewer/compare_test.go:272. LoadSession (internal/viewer/store.go) now re-validates encodedRepo and sessionID itself through a new safeSegment helper and joins the value safeSegment returns rather than its own copy of the argument, instead of only trusting that every caller had already checked them with unsafeSegment. Both current callers (handleSession's route and handleCompare's before/after loop) already rejected traversal before reaching LoadSession, but a validate-then-use-the-original-argument shape across a function boundary is not something CodeQL's path-injection query can credit as a sanitizer, and it left LoadSession unsafe to call from anywhere that skipped that pre-check. ListSessions's sort by Timestamp alone was not a total order: two sessions with an equal (or both-zero) timestamp could swap position between calls, and sessions.html's "compare" link pairs row i with row i+1 as chronologically adjacent. The sort now breaks ties on SessionID so that pairing is deterministic. One divergence from the CLI is stated in the viewer docs: an empty bucket still renders as "New (0)" instead of being skipped. Entry points are plain links, never a form: the viewer's CSP sets form-action 'none', which blocks GET submissions with no server-side error. compare.html declares lang="en". The three older viewer templates still say lang="zh-CN" while serving English; fixing those is a separate change. Inherited limitation: session.Compare keys a finding on its current path, so a file renamed between the two runs reads as one resolved plus one new. The page shows what Compare returns and does not correct it. Signed-off-by: ChethanUK <chethanuk@outlook.com>
573 lines
18 KiB
Go
573 lines
18 KiB
Go
// SPDX-License-Identifier: Apache-2.0
|
|
// Copyright 2026 alibaba/open-code-review Contributors
|
|
|
|
package main
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"strings"
|
|
"text/tabwriter"
|
|
"time"
|
|
|
|
"github.com/alibaba/open-code-review/internal/model"
|
|
"github.com/alibaba/open-code-review/internal/session"
|
|
"github.com/spf13/cobra"
|
|
)
|
|
|
|
var sessionCmd = &cobra.Command{
|
|
Use: "session",
|
|
Aliases: []string{"sessions"},
|
|
Short: "List and inspect saved review sessions",
|
|
Args: cobra.NoArgs,
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
return cmd.Help()
|
|
},
|
|
}
|
|
|
|
var sessionListRepoDir string
|
|
var sessionListJSON bool
|
|
var sessionListLimit int
|
|
|
|
var sessionListCmd = &cobra.Command{
|
|
Use: "list [flags]",
|
|
Aliases: []string{"ls"},
|
|
Short: "List recent review sessions for the current repo",
|
|
Long: "List review sessions previously persisted to ~/.opencodereview/sessions/.\nThe session id printed here can be passed to 'ocr review --resume <id>'.",
|
|
Args: cobra.NoArgs,
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
return runSessionList()
|
|
},
|
|
}
|
|
|
|
var sessionShowRepoDir string
|
|
var sessionShowJSON bool
|
|
|
|
var sessionShowCmd = &cobra.Command{
|
|
Use: "show [flags] <session-id>",
|
|
Short: "Show one session's metadata and per-file items",
|
|
Args: exactArgs(1),
|
|
ValidArgsFunction: completeSessionIDs,
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
return runSessionShow(args[0])
|
|
},
|
|
}
|
|
|
|
var sessionCommentsRepoDir string
|
|
var sessionCommentsJSON bool
|
|
var sessionCommentsSeverity string
|
|
var sessionCommentsCategory string
|
|
|
|
var sessionCommentsCmd = &cobra.Command{
|
|
Use: "comments [flags] <session-id>",
|
|
Short: "Show the review comments recorded in one session",
|
|
Long: "Print every review comment persisted in a session, formatted like 'ocr review' terminal output.\nUse --json for machine-readable output and --severity/--category to filter findings.",
|
|
Args: exactArgs(1),
|
|
ValidArgsFunction: completeSessionIDs,
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
return runSessionComments(args[0])
|
|
},
|
|
}
|
|
|
|
var sessionCompareRepoDir string
|
|
var sessionCompareJSON bool
|
|
|
|
var sessionCompareCmd = &cobra.Command{
|
|
Use: "compare [flags] <before-session-id> <after-session-id>",
|
|
Aliases: []string{"diff"},
|
|
Short: "Compare the findings of two sessions",
|
|
Long: "Group the findings of two review sessions into new, persisting, resolved and not-reviewed.\nFindings are matched on path, category and the offending snippet, so a finding that only moved down the file still counts as persisting.\nUse --json for machine-readable output.",
|
|
Args: exactArgs(2),
|
|
// Both positionals are session ids, so the first one already typed must not
|
|
// stop completion of the second - hence the args reset rather than plain
|
|
// completeSessionIDs, which stops after one argument.
|
|
ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
|
|
if len(args) > 1 {
|
|
return nil, cobra.ShellCompDirectiveNoFileComp
|
|
}
|
|
return completeSessionIDs(cmd, nil, toComplete)
|
|
},
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
return runSessionCompare(args[0], args[1])
|
|
},
|
|
}
|
|
|
|
// completeSessionIDs offers the persisted session ids for the current repo
|
|
// (or --repo, when already typed) as shell completions, newest first, with a
|
|
// short summary as the completion description. It completes one positional;
|
|
// a command with two session ids (compare) resets args per positional.
|
|
func completeSessionIDs(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
|
|
if len(args) != 0 {
|
|
return nil, cobra.ShellCompDirectiveNoFileComp
|
|
}
|
|
repo, _ := cmd.Flags().GetString("repo")
|
|
resolvedRepo, err := resolveWorkingDirForSession(repo)
|
|
if err != nil {
|
|
return nil, cobra.ShellCompDirectiveNoFileComp
|
|
}
|
|
summaries, err := session.ListSessions(resolvedRepo)
|
|
if err != nil {
|
|
return nil, cobra.ShellCompDirectiveNoFileComp
|
|
}
|
|
completions := make([]string, 0, len(summaries))
|
|
for _, s := range summaries {
|
|
if !strings.HasPrefix(s.SessionID, toComplete) {
|
|
continue
|
|
}
|
|
desc := fmt.Sprintf("%s · %d comments · %s", describeStart(s), s.TotalComments, describeStatus(s))
|
|
completions = append(completions, s.SessionID+"\t"+desc)
|
|
}
|
|
return completions, cobra.ShellCompDirectiveNoFileComp
|
|
}
|
|
|
|
func init() {
|
|
sessionListCmd.Flags().StringVar(&sessionListRepoDir, "repo", "", "root directory of the git repository (default: current dir)")
|
|
sessionListCmd.Flags().BoolVar(&sessionListJSON, "json", false, "emit JSON instead of a table")
|
|
sessionListCmd.Flags().IntVar(&sessionListLimit, "limit", 20, "cap the number of listed sessions (0 = unlimited)")
|
|
|
|
sessionShowCmd.Flags().StringVar(&sessionShowRepoDir, "repo", "", "root directory of the git repository (default: current dir)")
|
|
sessionShowCmd.Flags().BoolVar(&sessionShowJSON, "json", false, "emit JSON instead of a table")
|
|
|
|
sessionCommentsCmd.Flags().StringVar(&sessionCommentsRepoDir, "repo", "", "root directory of the git repository (default: current dir)")
|
|
sessionCommentsCmd.Flags().BoolVar(&sessionCommentsJSON, "json", false, "emit the comments as a JSON array")
|
|
sessionCommentsCmd.Flags().StringVar(&sessionCommentsSeverity, "severity", "", "comma-separated severities to include (critical, high, medium, low)")
|
|
sessionCommentsCmd.Flags().StringVar(&sessionCommentsCategory, "category", "", "comma-separated categories to include (e.g. bug, security)")
|
|
sessionCommentsCmd.RegisterFlagCompletionFunc("severity", completeEnum("critical", "high", "medium", "low"))
|
|
sessionCommentsCmd.RegisterFlagCompletionFunc("category", completeEnum("bug", "security", "performance", "maintainability", "test", "style", "documentation", "other"))
|
|
|
|
sessionCompareCmd.Flags().StringVar(&sessionCompareRepoDir, "repo", "", "root directory of the git repository (default: current dir)")
|
|
sessionCompareCmd.Flags().BoolVar(&sessionCompareJSON, "json", false, "emit the comparison as JSON")
|
|
|
|
sessionCmd.AddCommand(sessionListCmd)
|
|
sessionCmd.AddCommand(sessionShowCmd)
|
|
sessionCmd.AddCommand(sessionCommentsCmd)
|
|
sessionCmd.AddCommand(sessionCompareCmd)
|
|
}
|
|
|
|
func runSessionList() error {
|
|
resolvedRepo, err := resolveWorkingDirForSession(sessionListRepoDir)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
summaries, err := session.ListSessions(resolvedRepo)
|
|
if err != nil {
|
|
return fmt.Errorf("list sessions: %w", err)
|
|
}
|
|
if sessionListLimit > 0 && len(summaries) > sessionListLimit {
|
|
summaries = summaries[:sessionListLimit]
|
|
}
|
|
|
|
if sessionListJSON {
|
|
enc := json.NewEncoder(os.Stdout)
|
|
enc.SetIndent("", " ")
|
|
return enc.Encode(summaries)
|
|
}
|
|
|
|
if len(summaries) == 0 {
|
|
fmt.Printf("No sessions found for %s\n", resolvedRepo)
|
|
return nil
|
|
}
|
|
printSessionTable(os.Stdout, summaries)
|
|
return nil
|
|
}
|
|
|
|
func runSessionShow(sessionID string) error {
|
|
resolvedRepo, err := resolveWorkingDirForSession(sessionShowRepoDir)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
summary, items, err := session.LoadDetail(resolvedRepo, sessionID)
|
|
if err != nil {
|
|
return fmt.Errorf("load session %q: %w", sessionID, err)
|
|
}
|
|
|
|
if sessionShowJSON {
|
|
payload := struct {
|
|
Summary *session.Summary `json:"summary"`
|
|
Items []session.ItemDetail `json:"items"`
|
|
}{Summary: summary, Items: items}
|
|
enc := json.NewEncoder(os.Stdout)
|
|
enc.SetIndent("", " ")
|
|
return enc.Encode(payload)
|
|
}
|
|
|
|
printSessionDetail(os.Stdout, summary, items)
|
|
return nil
|
|
}
|
|
|
|
func runSessionComments(sessionID string) error {
|
|
resolvedRepo, err := resolveWorkingDirForSession(sessionCommentsRepoDir)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
comments, err := session.LoadComments(resolvedRepo, sessionID)
|
|
if err != nil {
|
|
return fmt.Errorf("load session %q: %w", sessionID, err)
|
|
}
|
|
filtered := filterComments(comments, sessionCommentsSeverity, sessionCommentsCategory)
|
|
|
|
if sessionCommentsJSON {
|
|
if filtered == nil {
|
|
filtered = []model.LlmComment{}
|
|
}
|
|
enc := json.NewEncoder(os.Stdout)
|
|
enc.SetIndent("", " ")
|
|
return enc.Encode(filtered)
|
|
}
|
|
|
|
if len(filtered) == 0 {
|
|
if len(comments) == 0 {
|
|
fmt.Printf("No comments recorded in session %s.\n", sessionID)
|
|
} else {
|
|
fmt.Printf("No comments match the given filters (%d recorded in session %s).\n", len(comments), sessionID)
|
|
}
|
|
return nil
|
|
}
|
|
for _, c := range filtered {
|
|
renderComment(c, os.Stdout)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func runSessionCompare(beforeID, afterID string) error {
|
|
resolvedRepo, err := resolveWorkingDirForSession(sessionCompareRepoDir)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
beforeSummary, err := session.LoadSummary(resolvedRepo, beforeID)
|
|
if err != nil {
|
|
return fmt.Errorf("load session %q: %w", beforeID, err)
|
|
}
|
|
afterSummary, err := session.LoadSummary(resolvedRepo, afterID)
|
|
if err != nil {
|
|
return fmt.Errorf("load session %q: %w", afterID, err)
|
|
}
|
|
// Comparing findings across repositories is meaningless, so it is an error
|
|
// rather than a warning. A different review mode or range still compares
|
|
// usefully (a full scan against a diff run, say), so that only warns.
|
|
if beforeSummary.RepoDir != afterSummary.RepoDir {
|
|
return fmt.Errorf("sessions belong to different repositories: %s was recorded in %s, %s in %s",
|
|
beforeID, beforeSummary.RepoDir, afterID, afterSummary.RepoDir)
|
|
}
|
|
if beforeSummary.ReviewMode != afterSummary.ReviewMode {
|
|
// stderr, never stdout: --json output is piped into other tools.
|
|
fmt.Fprintf(os.Stderr, "[ocr] WARNING review modes differ (%s vs %s); the two runs may not have looked at the same files\n",
|
|
displayMode(beforeSummary.ReviewMode), displayMode(afterSummary.ReviewMode))
|
|
}
|
|
|
|
beforeComments, err := session.LoadComments(resolvedRepo, beforeID)
|
|
if err != nil {
|
|
return fmt.Errorf("load session %q: %w", beforeID, err)
|
|
}
|
|
afterComments, err := session.LoadComments(resolvedRepo, afterID)
|
|
if err != nil {
|
|
return fmt.Errorf("load session %q: %w", afterID, err)
|
|
}
|
|
result := session.Compare(beforeComments, afterComments, reviewedPaths(afterSummary))
|
|
|
|
if sessionCompareJSON {
|
|
payload := struct {
|
|
Before sessionCompareSide `json:"before"`
|
|
After sessionCompareSide `json:"after"`
|
|
session.CompareResult
|
|
}{
|
|
Before: describeCompareSide(beforeID, beforeSummary),
|
|
After: describeCompareSide(afterID, afterSummary),
|
|
CompareResult: result,
|
|
}
|
|
enc := json.NewEncoder(os.Stdout)
|
|
enc.SetIndent("", " ")
|
|
return enc.Encode(payload)
|
|
}
|
|
|
|
printSessionCompare(beforeID, afterID, beforeSummary, afterSummary, result)
|
|
return nil
|
|
}
|
|
|
|
// sessionCompareSide identifies one side of the comparison in JSON output, so a
|
|
// consumer can tell which run produced which bucket.
|
|
type sessionCompareSide struct {
|
|
SessionID string `json:"session_id"`
|
|
ReviewMode string `json:"review_mode,omitempty"`
|
|
Range string `json:"range,omitempty"`
|
|
}
|
|
|
|
func describeCompareSide(sessionID string, s *session.Summary) sessionCompareSide {
|
|
side := sessionCompareSide{SessionID: sessionID, ReviewMode: s.ReviewMode}
|
|
if r := describeRange(*s); r != "-" {
|
|
side.Range = r
|
|
}
|
|
return side
|
|
}
|
|
|
|
// reviewedPaths returns the paths the run actually reviewed. The partition
|
|
// choice and its rationale live with session.ReviewedPaths, which the web
|
|
// viewer's compare page calls too - the two must not drift.
|
|
func reviewedPaths(s *session.Summary) map[string]bool {
|
|
return session.ReviewedPaths(s.RunManifest)
|
|
}
|
|
|
|
// printSessionCompare writes to stdout, matching the other `ocr session`
|
|
// subcommands.
|
|
func printSessionCompare(beforeID, afterID string, before, after *session.Summary, result session.CompareResult) {
|
|
fmt.Printf("Comparing %s -> %s\n", beforeID, afterID)
|
|
fmt.Printf(" before: %s %s\n", displayMode(before.ReviewMode), describeRange(*before))
|
|
fmt.Printf(" after: %s %s\n", displayMode(after.ReviewMode), describeRange(*after))
|
|
fmt.Printf("%d new, %d persisting, %d resolved\n", len(result.New), len(result.Persisting), len(result.Resolved))
|
|
if n := len(result.NotReviewed); n > 0 {
|
|
fmt.Printf("%d finding(s) in files the after session did not review (not counted as resolved)\n", n)
|
|
}
|
|
|
|
for _, section := range []struct {
|
|
title string
|
|
findings []model.LlmComment
|
|
}{
|
|
{"New", result.New},
|
|
{"Persisting", result.Persisting},
|
|
{"Resolved", result.Resolved},
|
|
{"Not reviewed", result.NotReviewed},
|
|
} {
|
|
if len(section.findings) == 0 {
|
|
continue
|
|
}
|
|
fmt.Printf("\n=== %s (%d) ===\n", section.title, len(section.findings))
|
|
for _, c := range section.findings {
|
|
renderComment(c, os.Stdout)
|
|
}
|
|
}
|
|
}
|
|
|
|
// filterComments keeps comments whose severity and category are in the given
|
|
// comma-separated, case-insensitive filter lists. Empty filters keep everything.
|
|
func filterComments(comments []model.LlmComment, severities, categories string) []model.LlmComment {
|
|
sevSet := parseFilterSet(severities)
|
|
catSet := parseFilterSet(categories)
|
|
if sevSet == nil && catSet == nil {
|
|
return comments
|
|
}
|
|
var out []model.LlmComment
|
|
for _, c := range comments {
|
|
if sevSet != nil && !sevSet[strings.ToLower(c.Severity)] {
|
|
continue
|
|
}
|
|
if catSet != nil && !catSet[strings.ToLower(c.Category)] {
|
|
continue
|
|
}
|
|
out = append(out, c)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func parseFilterSet(s string) map[string]bool {
|
|
set := map[string]bool{}
|
|
for _, part := range strings.Split(s, ",") {
|
|
part = strings.ToLower(strings.TrimSpace(part))
|
|
if part != "" {
|
|
set[part] = true
|
|
}
|
|
}
|
|
if len(set) == 0 {
|
|
return nil
|
|
}
|
|
return set
|
|
}
|
|
|
|
// resolveWorkingDirForSession accepts an explicit --repo flag value and falls
|
|
// back to the current working directory. Unlike resolveRepoDir it does not
|
|
// require the target to be a git repository, so users can inspect sessions
|
|
// even after archiving a checkout.
|
|
func resolveWorkingDirForSession(input string) (string, error) {
|
|
dir, _, err := resolveWorkingDir(input, false)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return dir, nil
|
|
}
|
|
|
|
func printSessionTable(w io.Writer, summaries []session.Summary) {
|
|
tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0)
|
|
fmt.Fprintln(tw, "SESSION ID\tMODE\tRANGE\tFILES\tCOMMENTS\tSTATUS\tSTARTED")
|
|
for _, s := range summaries {
|
|
fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%d\t%s\t%s\n",
|
|
s.SessionID,
|
|
displayMode(s.ReviewMode),
|
|
describeRange(s),
|
|
describeFiles(s),
|
|
s.TotalComments,
|
|
describeStatus(s),
|
|
describeStart(s),
|
|
)
|
|
}
|
|
tw.Flush()
|
|
}
|
|
|
|
func printSessionDetail(w io.Writer, s *session.Summary, items []session.ItemDetail) {
|
|
fmt.Fprintf(w, "Session: %s\n", s.SessionID)
|
|
fmt.Fprintf(w, " File: %s\n", s.FilePath)
|
|
fmt.Fprintf(w, " Repo: %s\n", s.RepoDir)
|
|
if s.GitBranch != "" {
|
|
fmt.Fprintf(w, " Branch: %s\n", s.GitBranch)
|
|
}
|
|
if s.Model != "" {
|
|
fmt.Fprintf(w, " Model: %s\n", s.Model)
|
|
}
|
|
fmt.Fprintf(w, " Mode: %s\n", displayMode(s.ReviewMode))
|
|
if r := describeRange(*s); r != "" && r != "-" {
|
|
fmt.Fprintf(w, " Range: %s\n", r)
|
|
}
|
|
if s.ResumedFrom != "" {
|
|
fmt.Fprintf(w, " Resumed: from session %s\n", s.ResumedFrom)
|
|
}
|
|
if l := s.ResumeLineage; l != nil {
|
|
fmt.Fprintf(w, " Parent: run %s\n", l.ParentRunID)
|
|
if l.IsTransition() {
|
|
fmt.Fprintf(w, " Transition: %s → %s\n",
|
|
describeTarget(l.SourceProvider, l.SourceModel),
|
|
describeTarget(l.TargetProvider, l.TargetModel))
|
|
}
|
|
}
|
|
fmt.Fprintf(w, " Started: %s\n", describeStart(*s))
|
|
if !s.EndTime.IsZero() {
|
|
fmt.Fprintf(w, " Ended: %s\n", s.EndTime.Local().Format("2006-01-02 15:04:05"))
|
|
}
|
|
if s.Duration > 0 {
|
|
fmt.Fprintf(w, " Duration: %s\n", s.Duration.Round(time.Second))
|
|
}
|
|
fmt.Fprintf(w, " Status: %s\n", describeStatus(*s))
|
|
if s.RunManifest != nil {
|
|
fmt.Fprintf(w, " Coverage: %d selected = %d completed + %d reused + %d failed + %d waived\n",
|
|
s.SelectedFiles, s.CompletedFiles, s.ReusedFiles, s.FailedFiles, s.WaivedFiles)
|
|
} else {
|
|
fmt.Fprintf(w, " Files: %d completed, %d reused, %d failed (legacy checkpoints)\n",
|
|
s.CompletedFiles, s.ReusedFiles, s.FailedFiles)
|
|
}
|
|
fmt.Fprintf(w, " Comments: %d\n", s.TotalComments)
|
|
if s.LLMFailures > 0 {
|
|
fmt.Fprintf(w, " LLM err: %d\n", s.LLMFailures)
|
|
}
|
|
|
|
if len(items) == 0 {
|
|
return
|
|
}
|
|
fmt.Fprintln(w)
|
|
fmt.Fprintln(w, "Files:")
|
|
tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0)
|
|
fmt.Fprintln(tw, " TYPE\tFILE\tCOMMENTS\tNOTE")
|
|
for _, it := range items {
|
|
note := ""
|
|
switch it.Type {
|
|
case "reused":
|
|
note = "from " + shortSessionID(it.SourceSessionID)
|
|
case "failed":
|
|
note = truncate(it.Error, 60)
|
|
}
|
|
fmt.Fprintf(tw, " %s\t%s\t%d\t%s\n", it.Type, it.FilePath, it.Comments, note)
|
|
}
|
|
tw.Flush()
|
|
}
|
|
|
|
// describeTarget renders a provider/model pair for the transition line. Either
|
|
// side may be empty (a non-provider endpoint records no provider name).
|
|
func describeTarget(provider, model string) string {
|
|
switch {
|
|
case provider == "" && model == "":
|
|
return "-"
|
|
case provider == "":
|
|
return model
|
|
case model == "":
|
|
return provider
|
|
}
|
|
return provider + "/" + model
|
|
}
|
|
|
|
func displayMode(m string) string {
|
|
if m == "" {
|
|
return "-"
|
|
}
|
|
return m
|
|
}
|
|
|
|
func describeRange(s session.Summary) string {
|
|
switch s.ReviewMode {
|
|
case session.ReviewModeRange:
|
|
if s.DiffFrom != "" || s.DiffTo != "" {
|
|
return fmt.Sprintf("%s..%s", s.DiffFrom, s.DiffTo)
|
|
}
|
|
case session.ReviewModeCommit:
|
|
if s.DiffCommit != "" {
|
|
return s.DiffCommit
|
|
}
|
|
}
|
|
return "-"
|
|
}
|
|
|
|
func describeFiles(s session.Summary) string {
|
|
if s.RunManifest != nil {
|
|
parts := []string{fmt.Sprintf("%d", s.SelectedFiles)}
|
|
if s.ReusedFiles > 0 {
|
|
parts = append(parts, fmt.Sprintf("reused %d", s.ReusedFiles))
|
|
}
|
|
if s.FailedFiles > 0 {
|
|
parts = append(parts, fmt.Sprintf("failed %d", s.FailedFiles))
|
|
}
|
|
if s.WaivedFiles > 0 {
|
|
parts = append(parts, fmt.Sprintf("waived %d", s.WaivedFiles))
|
|
}
|
|
if len(parts) == 1 {
|
|
return parts[0]
|
|
}
|
|
return parts[0] + " (" + strings.Join(parts[1:], ", ") + ")"
|
|
}
|
|
total := s.CompletedFiles + s.ReusedFiles
|
|
if s.ReusedFiles > 0 {
|
|
return fmt.Sprintf("%d (reused %d)", total, s.ReusedFiles)
|
|
}
|
|
return fmt.Sprintf("%d", total)
|
|
}
|
|
|
|
func describeStatus(s session.Summary) string {
|
|
if s.Aborted {
|
|
return "aborted"
|
|
}
|
|
if s.RunManifest != nil {
|
|
switch s.RunManifest.TerminalState {
|
|
case session.StateComplete, session.StatePartial, session.StateFailed, session.StateSkipped:
|
|
return string(s.RunManifest.TerminalState)
|
|
default:
|
|
return "unknown"
|
|
}
|
|
}
|
|
if s.FailedFiles > 0 {
|
|
return fmt.Sprintf("legacy (%d fail)", s.FailedFiles)
|
|
}
|
|
return "legacy"
|
|
}
|
|
|
|
func describeStart(s session.Summary) string {
|
|
if s.StartTime.IsZero() {
|
|
return "-"
|
|
}
|
|
return s.StartTime.Local().Format("2006-01-02 15:04:05")
|
|
}
|
|
|
|
func shortSessionID(id string) string {
|
|
if len(id) > 8 {
|
|
return id[:8]
|
|
}
|
|
return id
|
|
}
|
|
|
|
func truncate(s string, n int) string {
|
|
s = strings.ReplaceAll(strings.ReplaceAll(s, "\n", " "), "\t", " ")
|
|
runes := []rune(s)
|
|
if len(runes) <= n {
|
|
return s
|
|
}
|
|
if n <= 1 {
|
|
return "…"
|
|
}
|
|
return string(runes[:n-1]) + "…"
|
|
}
|