From 1c2445635b742a94cc031ab711ec56da767f9481 Mon Sep 17 00:00:00 2001 From: Alexey Kartashov Date: Fri, 8 May 2026 20:10:56 +0200 Subject: [PATCH 01/29] feature(cli): Implement full generic results command This commit implements the argus run results command, replacing the stub with a full implmentation that tries to mimic frontent response as much as possible - Output is provided as multiple tables, each table uses ANSI escape sequences to display the result status. --- cli/cmd/root.go | 2 + cli/cmd/testrun.go | 28 +++- cli/internal/models/runs.go | 273 +++++++++++++++++++++++++++++----- cli/internal/output/output.go | 14 ++ cli/internal/output/text.go | 43 ++++++ 5 files changed, 321 insertions(+), 39 deletions(-) diff --git a/cli/cmd/root.go b/cli/cmd/root.go index 650c9f5c..3185632c 100644 --- a/cli/cmd/root.go +++ b/cli/cmd/root.go @@ -50,6 +50,7 @@ var ( cacheTTL string nonInteractive bool verbosity int + noColor bool ) func init() { @@ -63,6 +64,7 @@ func init() { rootCmd.PersistentFlags().StringVar(&cacheTTL, "cache-ttl", "", "override the default cache TTL (e.g. 10m, 1h); ignored when --no-cache is set") rootCmd.PersistentFlags().BoolVar(&nonInteractive, "non-interactive", false, "disable interactive prompts; return an error instead of triggering re-authentication") rootCmd.PersistentFlags().CountVarP(&verbosity, "verbose", "v", "increase log verbosity: -v/-vv mirrors info logs to stderr, -vvv mirrors debug logs to stdout") + rootCmd.PersistentFlags().BoolVar(&noColor, "no-color", false, "disable colored output; use bracket status indicators instead (e.g. (OK), (FAIL))") } var rootCmd = &cobra.Command{ diff --git a/cli/cmd/testrun.go b/cli/cmd/testrun.go index 6f62d19e..f62dfcf9 100644 --- a/cli/cmd/testrun.go +++ b/cli/cmd/testrun.go @@ -515,12 +515,29 @@ var resultsCmd = &cobra.Command{ testID, _ := cmd.Flags().GetString("test-id") runID, _ := cmd.Flags().GetString("run-id") + + // Auto-resolve test-id from the run when not provided. + if testID == "" { + log.Debug().Str("run_id", runID).Msg("resolving test-id from run") + fetcher := newRunFetcher() + resolved, err := services.ResolveTestID(ctx, client, c, fetcher, runID, "") + if err != nil { + log.Error().Err(err).Str("run_id", runID).Msg("failed to resolve test-id") + return fmt.Errorf("resolving test-id: %w", err) + } + testID = resolved + log.Debug().Str("run_id", runID).Str("test_id", testID).Msg("test-id resolved from run") + } + log.Debug().Str("test_id", testID).Str("run_id", runID).Msg("fetching run results") cacheKey := cache.ResultsKey(testID, runID) if cached, _, err := cache.Get[models.FetchResultsResponse](c, cacheKey); isCacheable(err) { log.Debug().Str("run_id", runID).Msg("results served from cache") + showURLs, _ := cmd.Flags().GetBool("show-urls") + cached.ShowURLs = showURLs + cached.NoColor = noColor return out.Write(cached) } @@ -558,11 +575,14 @@ var resultsCmd = &cobra.Command{ return err } - result := models.FetchResultsResponse{Tables: envelope.Tables} + result := models.FetchResultsResponse{ResultTables: envelope.Tables} + showURLs, _ := cmd.Flags().GetBool("show-urls") + result.ShowURLs = showURLs + result.NoColor = noColor if cacheErr := cache.Set(c, cacheKey, result, route, cache.TTLResults); cacheErr != nil { log.Warn().Err(cacheErr).Str("run_id", runID).Msg("failed to cache results") } - log.Info().Str("test_id", testID).Str("run_id", runID).Int("table_count", len(result.Tables)).Msg("results fetched successfully") + log.Info().Str("test_id", testID).Str("run_id", runID).Int("table_count", len(result.ResultTables)).Msg("results fetched successfully") return out.Write(result) }, } @@ -742,9 +762,9 @@ func init() { _ = activityCmd.MarkFlagRequired("run-id") // run results - resultsCmd.Flags().String("test-id", "", "Test UUID (required)") + resultsCmd.Flags().String("test-id", "", "Test UUID (optional, auto-resolved from run-id if omitted)") resultsCmd.Flags().String("run-id", "", "Run UUID (required)") - _ = resultsCmd.MarkFlagRequired("test-id") + resultsCmd.Flags().Bool("show-urls", false, "Display full URLs in cell values instead of 'link'") _ = resultsCmd.MarkFlagRequired("run-id") // run comments diff --git a/cli/internal/models/runs.go b/cli/internal/models/runs.go index 28213e31..5253a3b1 100644 --- a/cli/internal/models/runs.go +++ b/cli/internal/models/runs.go @@ -7,6 +7,8 @@ import ( "strconv" "strings" "time" + + "github.com/scylladb/argus/cli/internal/output" ) // --------------------------------------------------------------------------- @@ -491,12 +493,16 @@ func (a ActivityResponse) Rows() [][]string { type ResultCell struct { Value any `json:"value"` Status string `json:"status"` + Type string `json:"type,omitempty"` } // ResultColumnMeta describes one column in a ResultTable. type ResultColumnMeta struct { - Name string `json:"name"` - Status string `json:"status"` + Name string `json:"name"` + Unit string `json:"unit,omitempty"` + Type string `json:"type,omitempty"` + HigherIsBetter *bool `json:"higher_is_better,omitempty"` + Visible *bool `json:"visible,omitempty"` } // ResultTable is one performance/result table returned by the fetch_results @@ -505,26 +511,170 @@ type ResultTable struct { Description string `json:"description"` TableData map[string]map[string]ResultCell `json:"table_data"` Columns []ResultColumnMeta `json:"columns"` - Rows []string `json:"rows"` + RowNames []string `json:"rows"` TableStatus string `json:"table_status"` + ShowURLs bool `json:"-"` + NoColor bool `json:"-"` } // Headers implements output.Tabular for ResultTable. The first column is the // row name; subsequent columns come from the table's Columns metadata. +// When a column has a unit defined, it is appended in brackets (e.g. "latency [ms]"). func (rt ResultTable) Headers() []string { h := make([]string, 0, 1+len(rt.Columns)) h = append(h, "Row") for _, c := range rt.Columns { - h = append(h, c.Name) + if c.Unit != "" { + h = append(h, fmt.Sprintf("%s [%s]", c.Name, strings.ReplaceAll(c.Unit, " ", ""))) + } else { + h = append(h, c.Name) + } } return h } +// ANSI escape sequences for cell status highlighting. +const ( + ansiReset = "\033[0m" + ansiGreen = "\033[32m" + ansiRed = "\033[31m" + ansiYellow = "\033[33m" +) + +// colorByStatus wraps s with ANSI color codes matching the cell status, +// mirroring the frontend ResultCellStatusStyleMap. When noColor is true, +// a bracket status indicator is appended instead (e.g. "(OK)", "(FAIL)"). +func colorByStatus(s, status string, noColor bool) string { + if noColor { + switch status { + case "PASS": + return s + " (OK)" + case "ERROR": + return s + " (FAIL)" + case "WARNING": + return s + " (WARN)" + default: + return s + } + } + switch status { + case "PASS": + return ansiGreen + s + ansiReset + case "ERROR": + return ansiRed + s + ansiReset + case "WARNING": + return ansiYellow + s + ansiReset + default: + return s + } +} + +// formatCellValue formats a cell's value according to its type, mirroring the +// frontend Cell.svelte formatting logic. +// +// - FLOAT → 2 decimal places (e.g. "3.14") +// - INTEGER → locale-style thousands separators (e.g. "1,234,567") +// - DURATION → HH:MM:SS +// - URLs → "link" (since terminals can't click) +// - nil → "N/A" +// - default → fmt.Sprint +func formatCellValue(cell ResultCell, showURLs bool) string { + if cell.Value == nil { + return "N/A" + } + + // String values: detect URLs / images like the frontend does. + if s, ok := cell.Value.(string); ok { + if isURL(s) && !showURLs { + return "link" + } + return s + } + + // Numeric values: format based on the column type stored in the cell. + num, ok := toFloat64(cell.Value) + if !ok { + return fmt.Sprint(cell.Value) + } + + switch cell.Type { + case "FLOAT": + return strconv.FormatFloat(num, 'f', 2, 64) + case "INTEGER": + return formatInteger(int64(num)) + case "DURATION": + return formatDuration(num) + default: + return fmt.Sprint(cell.Value) + } +} + +// isURL checks whether s looks like an HTTP(S) URL. +func isURL(s string) bool { + return strings.HasPrefix(s, "http://") || strings.HasPrefix(s, "https://") +} + +// toFloat64 converts a JSON-decoded numeric value (float64 or json.Number) to float64. +func toFloat64(v any) (float64, bool) { + switch n := v.(type) { + case float64: + return n, true + case json.Number: + f, err := n.Float64() + return f, err == nil + case int: + return float64(n), true + case int64: + return float64(n), true + default: + return 0, false + } +} + +// formatInteger formats an integer with comma-separated thousands groups +// (e.g. 1234567 → "1,234,567"), matching Number.toLocaleString() in JS. +func formatInteger(n int64) string { + s := strconv.FormatInt(n, 10) + if n < 0 { + return "-" + insertCommas(s[1:]) + } + return insertCommas(s) +} + +func insertCommas(s string) string { + if len(s) <= 3 { + return s + } + remainder := len(s) % 3 + var b strings.Builder + if remainder > 0 { + b.WriteString(s[:remainder]) + b.WriteByte(',') + } + for i := remainder; i < len(s); i += 3 { + if i > remainder { + b.WriteByte(',') + } + b.WriteString(s[i : i+3]) + } + return b.String() +} + +// formatDuration converts seconds to HH:MM:SS, matching the frontend +// durationToStr helper. +func formatDuration(seconds float64) string { + total := int(seconds) + h := total / 3600 + m := (total % 3600) / 60 + s := total % 60 + return fmt.Sprintf("%02d:%02d:%02d", h, m, s) +} + // Rows implements output.Tabular for ResultTable. Row order follows -// rt.Rows; column order follows rt.Columns. -func (rt ResultTable) TableRows() [][]string { - out := make([][]string, 0, len(rt.Rows)) - for _, rowName := range rt.Rows { +// rt.RowNames; column order follows rt.Columns. +func (rt ResultTable) Rows() [][]string { + out := make([][]string, 0, len(rt.RowNames)) + for _, rowName := range rt.RowNames { row := make([]string, 0, 1+len(rt.Columns)) row = append(row, rowName) colData := rt.TableData[rowName] @@ -533,7 +683,7 @@ func (rt ResultTable) TableRows() [][]string { if !ok { row = append(row, "") } else { - row = append(row, fmt.Sprint(cell.Value)) + row = append(row, colorByStatus(formatCellValue(cell, rt.ShowURLs), cell.Status, rt.NoColor)) } } out = append(out, row) @@ -543,43 +693,96 @@ func (rt ResultTable) TableRows() [][]string { // FetchResultsEnvelope is the non-standard envelope returned by the // fetch_results endpoint. Unlike other endpoints the payload key is "tables" -// rather than "response". +// rather than "response". Each element is a single-key map from the table +// name to its data. type FetchResultsEnvelope struct { - Status string `json:"status"` - Tables []ResultTable `json:"tables"` + Status string `json:"status"` + Tables []map[string]ResultTable `json:"tables"` } -// FetchResultsResponse wraps []ResultTable to implement output.Tabular by -// rendering all tables sequentially. +// FetchResultsResponse wraps the result tables for output. In JSON mode a +// compact representation is produced via MarshalJSON; in text mode each table +// is rendered separately via the MultiTabular interface. type FetchResultsResponse struct { - Tables []ResultTable + ResultTables []map[string]ResultTable `json:"tables"` + ShowURLs bool `json:"-"` + NoColor bool `json:"-"` +} + +// jsonCell is the compact JSON representation of a single result cell. +type jsonCell struct { + Value any `json:"value"` + Status string `json:"status"` +} + +// jsonRow is the compact JSON representation of a single result row. +type jsonRow struct { + Name string `json:"name"` + Cells map[string]jsonCell `json:"cells"` } -// Headers implements output.Tabular. Uses the first table's headers or -// returns a single "Table" column when empty. -func (f FetchResultsResponse) Headers() []string { - if len(f.Tables) == 0 { - return []string{"Table"} +// jsonTable is the compact JSON representation of a single result table. +type jsonTable struct { + Name string `json:"name"` + Description string `json:"description,omitempty"` + Status string `json:"status"` + Rows []jsonRow `json:"rows"` +} + +// MarshalJSON produces a compact JSON array of tables with inlined row data, +// suitable for consumption by tools like jq. +func (f FetchResultsResponse) MarshalJSON() ([]byte, error) { + tables := make([]jsonTable, 0, len(f.ResultTables)) + for _, entry := range f.ResultTables { + for name, tbl := range entry { + jt := jsonTable{ + Name: name, + Description: tbl.Description, + Status: tbl.TableStatus, + Rows: make([]jsonRow, 0, len(tbl.RowNames)), + } + for _, rowName := range tbl.RowNames { + jr := jsonRow{ + Name: rowName, + Cells: make(map[string]jsonCell, len(tbl.Columns)), + } + colData := tbl.TableData[rowName] + for _, col := range tbl.Columns { + if cell, ok := colData[col.Name]; ok { + jr.Cells[col.Name] = jsonCell{ + Value: cell.Value, + Status: cell.Status, + } + } + } + jt.Rows = append(jt.Rows, jr) + } + tables = append(tables, jt) + } } - // Prefix with "Table" column to distinguish between tables. - h := []string{"Table"} - h = append(h, f.Tables[0].Headers()...) - return h + return json.Marshal(tables) } -// Rows implements output.Tabular. Each result table's rows are emitted with -// the table description prepended as the first column. -func (f FetchResultsResponse) Rows() [][]string { - var rows [][]string - for _, tbl := range f.Tables { - for _, r := range tbl.TableRows() { - row := make([]string, 0, 1+len(r)) - row = append(row, tbl.Description) - row = append(row, r...) - rows = append(rows, row) +// Tables implements output.MultiTabular. Each result table is returned as a +// NamedTable whose name is the table's description (falling back to the map +// key when no description is set). +func (f FetchResultsResponse) Tables() []output.NamedTable { + out := make([]output.NamedTable, 0, len(f.ResultTables)) + for _, entry := range f.ResultTables { + for name, tbl := range entry { + tbl.ShowURLs = f.ShowURLs + tbl.NoColor = f.NoColor + title := name + if tbl.Description != "" && tbl.Description != name { + title = fmt.Sprintf("%s\n%s", name, tbl.Description) + } + out = append(out, output.NamedTable{ + Name: title, + Tab: tbl, + }) } } - return rows + return out } // --------------------------------------------------------------------------- diff --git a/cli/internal/output/output.go b/cli/internal/output/output.go index ca49211c..c1b2ee9b 100644 --- a/cli/internal/output/output.go +++ b/cli/internal/output/output.go @@ -18,6 +18,20 @@ type Tabular interface { Rows() [][]string } +// NamedTable pairs a human-readable name with a [Tabular] value so that +// multi-table outputs can label each table independently. +type NamedTable struct { + Name string + Tab Tabular +} + +// MultiTabular is implemented by values that contain several independent +// tables. The text renderer prints each table separately with a header line; +// the JSON renderer ignores this and marshals the value directly. +type MultiTabular interface { + Tables() []NamedTable +} + // Outputter writes a value to the configured destination in the // implementation-specific format. // diff --git a/cli/internal/output/text.go b/cli/internal/output/text.go index eb1bad69..732c5c79 100644 --- a/cli/internal/output/text.go +++ b/cli/internal/output/text.go @@ -46,6 +46,11 @@ func newText(w io.Writer) Outputter { // Write renders v as a text table. v should implement [Tabular] for // meaningful column output; non-Tabular values fall back to a raw JSON row. func (t *textOutputter) Write(v any) error { + // Multi-table values get one table per entry with a header line. + if mt, ok := v.(MultiTabular); ok { + return t.writeMulti(mt) + } + tab, ok := v.(Tabular) if !ok { switch v := v.(type) { @@ -107,6 +112,44 @@ func (t *textOutputter) writeRawJSON(v any) error { return nil } +// writeMulti renders a [MultiTabular] as a sequence of labelled tables. +func (t *textOutputter) writeMulti(mt MultiTabular) error { + for i, nt := range mt.Tables() { + if i > 0 { + if _, err := fmt.Fprintln(t.w); err != nil { + return fmt.Errorf("%w: %w", ErrTextOutputRow, err) + } + } + if _, err := fmt.Fprintf(t.w, "\n## %s\n\n", nt.Name); err != nil { + return fmt.Errorf("%w: %w", ErrTextOutputRow, err) + } + + table := tablewriter.NewTable(t.w) + configureTableWidths(table) + + if headers := nt.Tab.Headers(); len(headers) > 0 { + table.Header(headers) + } + + for _, row := range nt.Tab.Rows() { + if err := table.Append(row); err != nil { + _ = table.Close() + return fmt.Errorf("%w: %w", ErrTextOutputRow, err) + } + } + + if err := table.Render(); err != nil { + _ = table.Close() + return fmt.Errorf("%w: %w", ErrTextOutputRender, err) + } + + if err := table.Close(); err != nil { + return err + } + } + return nil +} + // colMaxWidth is the maximum character width for any single table column. const colMaxWidth = 80 From 5986666e65bea1de7c20b781ebe36eb5422bfcd2 Mon Sep 17 00:00:00 2001 From: Alexey Kartashov Date: Fri, 8 May 2026 21:17:36 +0200 Subject: [PATCH 02/29] feature(cli): Add Issue Subcommand This commit adds a new subcommand and two new commands under it for the Go cli client to be able to both list and submit new issues. --- cli/cmd/run_issue.go | 165 ++++++++++++++++++++++++++++++++++ cli/internal/api/routes.go | 10 ++- cli/internal/models/issues.go | 61 +++++++++++++ 3 files changed, 233 insertions(+), 3 deletions(-) create mode 100644 cli/cmd/run_issue.go create mode 100644 cli/internal/models/issues.go diff --git a/cli/cmd/run_issue.go b/cli/cmd/run_issue.go new file mode 100644 index 00000000..a5555dcd --- /dev/null +++ b/cli/cmd/run_issue.go @@ -0,0 +1,165 @@ +package cmd + +import ( + "encoding/json" + + "github.com/scylladb/argus/cli/internal/api" + "github.com/scylladb/argus/cli/internal/logging" + "github.com/scylladb/argus/cli/internal/models" + "github.com/scylladb/argus/cli/internal/services" + "github.com/spf13/cobra" + + "fmt" +) + +// --------------------------------------------------------------------------- +// Subcommand: run issue +// --------------------------------------------------------------------------- + +var issueCmd = &cobra.Command{ + Use: "issue", + Short: "Commands for run issue operations", + Long: `Manage issues linked to test runs in Argus.`, +} + +// --------------------------------------------------------------------------- +// Subcommand: run issue add +// --------------------------------------------------------------------------- + +var issueAddCmd = &cobra.Command{ + Use: "add", + Short: "Submit an issue for a test run", + Long: `Link an issue (GitHub or Jira) to a test run. + +If --test-id is omitted it will be resolved automatically from the run.`, + RunE: func(cmd *cobra.Command, _ []string) error { + cmd.SilenceUsage = true + ctx := cmd.Context() + client := APIClientFrom(ctx) + out := OutputterFrom(ctx) + c := CacheFrom(ctx) + log := logging.For(LoggerFrom(ctx), "run-issue-add") + + runID, _ := cmd.Flags().GetString("run-id") + issueURL, _ := cmd.Flags().GetString("issue-url") + flagTestID, _ := cmd.Flags().GetString("test-id") + + log.Debug().Str("run_id", runID).Str("issue_url", issueURL).Str("test_id", flagTestID).Msg("submitting issue") + + fetcher := newRunFetcher() + testID, err := services.ResolveTestID(ctx, client, c, fetcher, runID, flagTestID) + if err != nil { + log.Error().Err(err).Str("run_id", runID).Msg("failed to resolve test ID") + return err + } + log.Debug().Str("run_id", runID).Str("test_id", testID).Msg("test ID resolved") + + route := fmt.Sprintf(api.TestRunIssueSubmit, testID, runID) + body := map[string]string{"issue_url": issueURL} + req, err := client.NewRequest(ctx, "POST", route, body) + if err != nil { + log.Error().Err(err).Str("run_id", runID).Str("route", route).Msg("failed to build request") + return err + } + + result, err := api.DoJSON[json.RawMessage](client, req) + if err != nil { + log.Error().Err(err).Str("run_id", runID).Str("issue_url", issueURL).Msg("failed to submit issue") + return err + } + + log.Info().Str("run_id", runID).Str("test_id", testID).Str("issue_url", issueURL).Msg("issue submitted successfully") + return out.Write(result) + }, +} + +// --------------------------------------------------------------------------- +// Subcommand: run issue list +// --------------------------------------------------------------------------- + +var issueListCmd = &cobra.Command{ + Use: "list", + Short: "List issues linked to a test run or other entity", + Long: `Fetch issues (GitHub and Jira) linked to an Argus entity. + +Exactly one filter flag must be provided: + --run-id, --release-id, --group-id, --test-id, --user-id, --view-id, --event-id`, + RunE: func(cmd *cobra.Command, _ []string) error { + cmd.SilenceUsage = true + ctx := cmd.Context() + client := APIClientFrom(ctx) + out := OutputterFrom(ctx) + log := logging.For(LoggerFrom(ctx), "issue-list") + + filters := []struct { + flag string + key string + }{ + {"run-id", "run_id"}, + {"release-id", "release_id"}, + {"group-id", "group_id"}, + {"test-id", "test_id"}, + {"user-id", "user_id"}, + {"view-id", "view_id"}, + {"event-id", "event_id"}, + } + + var filterKey, entityID string + for _, f := range filters { + if v, _ := cmd.Flags().GetString(f.flag); v != "" { + if filterKey != "" { + return fmt.Errorf("only one filter flag may be specified") + } + filterKey = f.key + entityID = v + } + } + if filterKey == "" { + return fmt.Errorf("one of --run-id, --release-id, --group-id, --test-id, --user-id, --view-id, or --event-id is required") + } + + log.Debug().Str("entity_id", entityID).Str("filter_key", filterKey).Msg("listing issues") + + route := fmt.Sprintf("%s?filterKey=%s&id=%s", api.IssuesGet, filterKey, entityID) + log.Debug().Str("route", route).Msg("fetching issues from API") + + req, err := client.NewRequest(ctx, "GET", route, nil) + if err != nil { + log.Error().Err(err).Str("route", route).Msg("failed to build request") + return err + } + + result, err := api.DoJSON[[]json.RawMessage](client, req) + if err != nil { + log.Error().Err(err).Str("entity_id", entityID).Msg("failed to fetch issues") + return err + } + + issues := models.ParseIssues(result) + log.Info().Str("entity_id", entityID).Int("count", len(issues)).Msg("issues fetched successfully") + return out.Write(models.NewTabularSlice(issues)) + }, +} + +// --------------------------------------------------------------------------- +// Registration +// --------------------------------------------------------------------------- + +func init() { + issueAddCmd.Flags().String("run-id", "", "Run UUID (required)") + issueAddCmd.Flags().String("issue-url", "", "Issue URL to link (required)") + issueAddCmd.Flags().String("test-id", "", "Test UUID (optional, resolved from the run if omitted)") + _ = issueAddCmd.MarkFlagRequired("run-id") + _ = issueAddCmd.MarkFlagRequired("issue-url") + + issueListCmd.Flags().String("run-id", "", "Filter by run UUID") + issueListCmd.Flags().String("release-id", "", "Filter by release UUID") + issueListCmd.Flags().String("group-id", "", "Filter by group UUID") + issueListCmd.Flags().String("test-id", "", "Filter by test UUID") + issueListCmd.Flags().String("user-id", "", "Filter by user UUID") + issueListCmd.Flags().String("view-id", "", "Filter by view UUID") + issueListCmd.Flags().String("event-id", "", "Filter by event UUID") + + issueCmd.AddCommand(issueAddCmd, issueListCmd) + rootCmd.AddCommand(issueCmd) +} diff --git a/cli/internal/api/routes.go b/cli/internal/api/routes.go index e6f04c17..fa887b78 100644 --- a/cli/internal/api/routes.go +++ b/cli/internal/api/routes.go @@ -25,6 +25,10 @@ const ( TestRunCommentUpdate = "/api/v1/test/%s/run/%s/comment/%s/update" // POST – update a comment (test_id, run_id, comment_id) TestRunCommentDelete = "/api/v1/test/%s/run/%s/comment/%s/delete" // POST – delete a comment (test_id, run_id, comment_id) + // Issue routes + TestRunIssueSubmit = "/api/v1/test/%s/run/%s/issues/submit" // POST – submit an issue (test_id, run_id) + IssuesGet = "/api/v1/issues/get" // GET – list issues (filterKey, id query params) + // Pytest result routes TestRunPytestResults = "/api/v1/run/%s/pytest/results" // GET – pytest results for a run (run_id) @@ -44,11 +48,11 @@ const ( SSHTunnel = "/api/v1/client/ssh/tunnel" // POST – register public key and receive proxy config // SSH tunnel routes – admin (requires Admin role) - AdminProxyTunnelConfig = "/admin/api/v1/proxy-tunnel/config" // GET – one active config (tunnel_id query param optional); POST – create - AdminProxyTunnelConfigs = "/admin/api/v1/proxy-tunnel/configs" // GET – all configs (active_only query param optional) + AdminProxyTunnelConfig = "/admin/api/v1/proxy-tunnel/config" // GET – one active config (tunnel_id query param optional); POST – create + AdminProxyTunnelConfigs = "/admin/api/v1/proxy-tunnel/configs" // GET – all configs (active_only query param optional) AdminProxyTunnelSetActive = "/admin/api/v1/proxy-tunnel/config/%s/active" // POST – enable/disable a config (tunnel_id) AdminProxyTunnelDelete = "/admin/api/v1/proxy-tunnel/config/%s" // DELETE – permanently remove a config (tunnel_id) - AdminSSHKeys = "/admin/api/v1/ssh/keys" // GET – list all registered keys with metadata + AdminSSHKeys = "/admin/api/v1/ssh/keys" // GET – list all registered keys with metadata AdminSSHKeyDelete = "/admin/api/v1/ssh/keys/%s" // DELETE – revoke a key (key_id) ) diff --git a/cli/internal/models/issues.go b/cli/internal/models/issues.go new file mode 100644 index 00000000..57705121 --- /dev/null +++ b/cli/internal/models/issues.go @@ -0,0 +1,61 @@ +package models + +import "encoding/json" + +// Issue is a unified display model for both GitHub and Jira issues returned by +// the /issues/get endpoint. The Key field is synthesized: owner/repo#number for +// GitHub issues, or the Jira issue key. +type Issue struct { + Key string `json:"key"` + Subtype string `json:"subtype"` + Title string `json:"title"` + State string `json:"state"` + URL string `json:"url"` +} + +// ParseIssues converts raw JSON issue objects (which may be GitHub or Jira +// flavored) into a unified slice of Issue for tabular display. +func ParseIssues(raw []json.RawMessage) []Issue { + issues := make([]Issue, 0, len(raw)) + for _, r := range raw { + var m map[string]json.RawMessage + if err := json.Unmarshal(r, &m); err != nil { + continue + } + + var issue Issue + issue.Subtype = unquote(m["subtype"]) + issue.State = unquote(m["state"]) + + switch issue.Subtype { + case "github": + owner := unquote(m["owner"]) + repo := unquote(m["repo"]) + var number json.Number + _ = json.Unmarshal(m["number"], &number) + issue.Key = owner + "/" + repo + "#" + number.String() + issue.Title = unquote(m["title"]) + issue.URL = unquote(m["url"]) + case "jira": + issue.Key = unquote(m["key"]) + issue.Title = unquote(m["summary"]) + issue.URL = unquote(m["permalink"]) + default: + issue.Title = unquote(m["title"]) + issue.URL = unquote(m["url"]) + } + + issues = append(issues, issue) + } + return issues +} + +// unquote removes surrounding quotes from a raw JSON string value. +func unquote(raw json.RawMessage) string { + var s string + if raw == nil { + return "" + } + _ = json.Unmarshal(raw, &s) + return s +} From cedd3ad1e7f856f81f0e7a2d612225815f8b0ec9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Du=C5=A1an=20Malu=C5=A1ev?= Date: Tue, 12 May 2026 12:30:01 +0200 Subject: [PATCH 03/29] fix(cli-release): extract inline workflow scripts and fix first-release tag resolution grep -v exits 1 when no lines match; with bash pipefail enabled this killed the Resolve previous CLI tag step on the first release. Fix by wrapping grep in a subshell with || true. Also extract all inline bash blocks into versioned scripts under cli/scripts/ for easier local testing. --- .github/workflows/cli-release.yml | 36 +++---------------------------- cli/scripts/extract-semver.sh | 12 +++++++++++ cli/scripts/resolve-prev-tag.sh | 21 ++++++++++++++++++ cli/scripts/verify-tag-on-main.sh | 21 ++++++++++++++++++ 4 files changed, 57 insertions(+), 33 deletions(-) create mode 100755 cli/scripts/extract-semver.sh create mode 100755 cli/scripts/resolve-prev-tag.sh create mode 100755 cli/scripts/verify-tag-on-main.sh diff --git a/.github/workflows/cli-release.yml b/.github/workflows/cli-release.yml index 93230fb8..56c95ee4 100644 --- a/.github/workflows/cli-release.yml +++ b/.github/workflows/cli-release.yml @@ -39,42 +39,13 @@ jobs: - name: Verify tag is on master/main shell: bash - run: | - # Abort if the tagged commit is not an ancestor of master (or main). - # This prevents a cli/v* tag pushed on a feature branch from - # accidentally publishing a release. - TAG_SHA=$(git rev-list -n1 "${GITHUB_REF_NAME}") - for branch in master main; do - if git show-ref --verify --quiet "refs/remotes/origin/${branch}"; then - if git merge-base --is-ancestor "${TAG_SHA}" "origin/${branch}"; then - echo "Tag ${GITHUB_REF_NAME} (${TAG_SHA}) is on ${branch}. Proceeding." - exit 0 - fi - fi - done - echo "ERROR: Tag ${GITHUB_REF_NAME} is not reachable from master/main. Aborting release." >&2 - exit 1 + run: cli/scripts/verify-tag-on-main.sh - name: Resolve previous CLI tag id: prev_tag shell: bash run: | - CURRENT_TAG="${GITHUB_REF_NAME}" # e.g. cli/v1.2.0 - - # List cli/v[0-9]* tags reachable from HEAD, sorted ascending by - # git's own version comparator (version:refname). This correctly - # orders pre-releases: cli/v1.0.0-rc1 < cli/v1.0.0, unlike sort -V - # which can produce the wrong result for pre-release suffixes. - # Exclude the current tag then take the last entry = immediate predecessor. - PREV=$(git tag --merged HEAD --list 'cli/v[0-9]*' --sort=version:refname \ - | grep -v "^${CURRENT_TAG}$" \ - | tail -1) - - if [[ -z "$PREV" ]]; then - echo "No previous CLI tag found – treating this as the first release." - PREV="FIRST" - fi - + PREV=$(cli/scripts/resolve-prev-tag.sh) echo "Previous tag: ${PREV}" echo "tag=${PREV}" >> "$GITHUB_OUTPUT" @@ -163,8 +134,7 @@ jobs: - name: Extract semver from tag id: semver run: | - TAG="${GITHUB_REF_NAME}" # cli/v1.2.0 - VERSION="${TAG#cli/}" # v1.2.0 + VERSION=$(cli/scripts/extract-semver.sh) echo "version=${VERSION}" >> "$GITHUB_OUTPUT" - name: Run GoReleaser diff --git a/cli/scripts/extract-semver.sh b/cli/scripts/extract-semver.sh new file mode 100755 index 00000000..f77fe8cf --- /dev/null +++ b/cli/scripts/extract-semver.sh @@ -0,0 +1,12 @@ +#!/usr/bin/env bash +# extract-semver.sh — strip the "cli/" prefix from a release tag. +# +# Usage: +# GITHUB_REF_NAME=cli/v1.2.0 ./extract-semver.sh +# +# Prints the bare semver (e.g. "v1.2.0") to stdout. + +set -euo pipefail + +TAG="${GITHUB_REF_NAME}" +echo "${TAG#cli/}" diff --git a/cli/scripts/resolve-prev-tag.sh b/cli/scripts/resolve-prev-tag.sh new file mode 100755 index 00000000..ec70a69b --- /dev/null +++ b/cli/scripts/resolve-prev-tag.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env bash +# resolve-prev-tag.sh — find the previous cli/v* tag relative to the current one. +# +# Usage: +# GITHUB_REF_NAME=cli/v1.2.0 ./resolve-prev-tag.sh +# +# Prints the previous tag name to stdout, or "FIRST" when no prior tag exists. + +set -euo pipefail + +CURRENT_TAG="${GITHUB_REF_NAME}" + +PREV=$(git tag --merged HEAD --list 'cli/v[0-9]*' --sort=version:refname \ + | (grep -v "^${CURRENT_TAG}$" || true) \ + | tail -1) + +if [[ -z "$PREV" ]]; then + PREV="FIRST" +fi + +echo "${PREV}" diff --git a/cli/scripts/verify-tag-on-main.sh b/cli/scripts/verify-tag-on-main.sh new file mode 100755 index 00000000..91811fcd --- /dev/null +++ b/cli/scripts/verify-tag-on-main.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env bash +# verify-tag-on-main.sh — abort if the current tag is not reachable from master/main. +# +# Usage: +# GITHUB_REF_NAME=cli/v1.2.0 ./verify-tag-on-main.sh +# +# Exits 0 when the tag is on master/main, 1 otherwise. + +set -euo pipefail + +TAG_SHA=$(git rev-list -n1 "${GITHUB_REF_NAME}") +for branch in master main; do + if git show-ref --verify --quiet "refs/remotes/origin/${branch}"; then + if git merge-base --is-ancestor "${TAG_SHA}" "origin/${branch}"; then + echo "Tag ${GITHUB_REF_NAME} (${TAG_SHA}) is on ${branch}. Proceeding." + exit 0 + fi + fi +done +echo "ERROR: Tag ${GITHUB_REF_NAME} is not reachable from master/main. Aborting release." >&2 +exit 1 From 9cb58378af4490e353f3928f3bb2bdd83c04b0f2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Du=C5=A1an=20Malu=C5=A1ev?= Date: Tue, 12 May 2026 16:15:48 +0200 Subject: [PATCH 04/29] fix(cli-release): skip goreleaser tag-commit validation GORELEASER_CURRENT_TAG is set to the stripped semver (v0.1.1) but the actual git tag is cli/v0.1.1, so goreleaser's validation always fails. Co-Authored-By: Claude Sonnet 4.6 (1M context) --- .github/workflows/cli-release.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/cli-release.yml b/.github/workflows/cli-release.yml index 56c95ee4..304f53f3 100644 --- a/.github/workflows/cli-release.yml +++ b/.github/workflows/cli-release.yml @@ -149,6 +149,7 @@ jobs: --config cli/.goreleaser.yml --release-notes "${{ runner.temp }}/cli-changelog.md" --clean + --skip=validate env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # Tell GoReleaser to treat the stripped semver as the current tag so From 398947264f56c229db5a930978893585096b08e3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Du=C5=A1an=20Malu=C5=A1ev?= Date: Tue, 12 May 2026 16:39:10 +0200 Subject: [PATCH 05/29] fix(cli-release): use cli/ prefixed tag for release instead of creating a bare semver tag Add monorepo.tag_prefix to .goreleaser.yml so GoReleaser strips "cli/" for version templating while targeting the existing git tag. Pass the full GITHUB_REF_NAME (e.g. cli/v1.2.0) as GORELEASER_CURRENT_TAG and remove the now-redundant extract-semver step. Co-Authored-By: Claude Sonnet 4.6 (1M context) --- .github/workflows/cli-release.yml | 16 +++++----------- cli/.goreleaser.yml | 3 +++ 2 files changed, 8 insertions(+), 11 deletions(-) diff --git a/.github/workflows/cli-release.yml b/.github/workflows/cli-release.yml index 304f53f3..a54c462f 100644 --- a/.github/workflows/cli-release.yml +++ b/.github/workflows/cli-release.yml @@ -129,14 +129,6 @@ jobs: name: cli-changelog path: ${{ runner.temp }} - # GoReleaser uses the tag as-is for the version. - # cli/v1.2.0 → strip the "cli/" prefix so archives are named v1.2.0. - - name: Extract semver from tag - id: semver - run: | - VERSION=$(cli/scripts/extract-semver.sh) - echo "version=${VERSION}" >> "$GITHUB_OUTPUT" - - name: Run GoReleaser uses: goreleaser/goreleaser-action@v6 with: @@ -152,6 +144,8 @@ jobs: --skip=validate env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - # Tell GoReleaser to treat the stripped semver as the current tag so - # it doesn't choke on the "cli/" prefix. - GORELEASER_CURRENT_TAG: ${{ steps.semver.outputs.version }} + # Use the full cli/vX.Y.Z tag so GoReleaser targets the existing + # git tag rather than creating a bare vX.Y.Z tag. + # The monorepo.tag_prefix in .goreleaser.yml strips "cli/" for + # version templating (archives, ldflags, etc.). + GORELEASER_CURRENT_TAG: ${{ github.ref_name }} diff --git a/cli/.goreleaser.yml b/cli/.goreleaser.yml index e06875b6..c92fdbc5 100644 --- a/cli/.goreleaser.yml +++ b/cli/.goreleaser.yml @@ -3,6 +3,9 @@ version: 2 project_name: argus +monorepo: + tag_prefix: "cli/" + # The changelog is generated by our custom script and passed via --release-notes. # Disable GoReleaser's built-in changelog so it does not overwrite our notes. changelog: From 3caff9fb2805125962e6821e17256874d9058a61 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Du=C5=A1an=20Malu=C5=A1ev?= Date: Tue, 12 May 2026 16:52:28 +0200 Subject: [PATCH 06/29] fix(cli-release): replace Pro-only monorepo config with VERSION env var Remove the monorepo.tag_prefix block (GoReleaser Pro feature) that caused an unmarshal error on the OSS binary. Instead, the workflow strips the cli/v prefix into a bare VERSION env var (e.g. 1.2.3) and keeps GORELEASER_CURRENT_TAG as the full cli/vX.Y.Z tag so the GitHub Release is still published under the prefixed tag. All .goreleaser.yml templates that previously used {{ .Version }} now use {{ .Env.VERSION }}. --- .github/workflows/cli-release.yml | 16 ++++++++++++---- cli/.goreleaser.yml | 9 +++------ 2 files changed, 15 insertions(+), 10 deletions(-) diff --git a/.github/workflows/cli-release.yml b/.github/workflows/cli-release.yml index a54c462f..c078bd55 100644 --- a/.github/workflows/cli-release.yml +++ b/.github/workflows/cli-release.yml @@ -129,6 +129,14 @@ jobs: name: cli-changelog path: ${{ runner.temp }} + - name: Resolve bare version + id: version + shell: bash + run: | + # Strip "cli/v" to get bare semver (e.g. cli/v1.2.3 → 1.2.3). + # Used as VERSION in GoReleaser templates for archive names and ldflags. + echo "value=${GITHUB_REF_NAME#cli/v}" >> "$GITHUB_OUTPUT" + - name: Run GoReleaser uses: goreleaser/goreleaser-action@v6 with: @@ -144,8 +152,8 @@ jobs: --skip=validate env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - # Use the full cli/vX.Y.Z tag so GoReleaser targets the existing - # git tag rather than creating a bare vX.Y.Z tag. - # The monorepo.tag_prefix in .goreleaser.yml strips "cli/" for - # version templating (archives, ldflags, etc.). + # Full cli/vX.Y.Z tag → GoReleaser publishes the GitHub Release under this tag. GORELEASER_CURRENT_TAG: ${{ github.ref_name }} + # Bare version (e.g. 1.2.3) → referenced as {{ .Env.VERSION }} in + # .goreleaser.yml for archive names, ldflags, and checksum filenames. + VERSION: ${{ steps.version.outputs.value }} diff --git a/cli/.goreleaser.yml b/cli/.goreleaser.yml index c92fdbc5..5df76c39 100644 --- a/cli/.goreleaser.yml +++ b/cli/.goreleaser.yml @@ -3,9 +3,6 @@ version: 2 project_name: argus -monorepo: - tag_prefix: "cli/" - # The changelog is generated by our custom script and passed via --release-notes. # Disable GoReleaser's built-in changelog so it does not overwrite our notes. changelog: @@ -24,7 +21,7 @@ builds: # Inject version information at link time ldflags: - -s -w - - -X main.version={{ .Version }} + - -X main.version={{ .Env.VERSION }} - -X main.commit={{ .Commit }} - -X main.date={{ .Date }} env: @@ -42,7 +39,7 @@ builds: archives: - id: argus-archive name_template: >- - argus_{{ .Version }}_ + argus_{{ .Env.VERSION }}_ {{- if eq .Os "darwin"}}macOS{{- else}}{{ .Os }}{{- end}}_ {{- .Arch }} formats: @@ -63,7 +60,7 @@ archives: strip_parent: true checksum: - name_template: "argus_{{ .Version }}_checksums.txt" + name_template: "argus_{{ .Env.VERSION }}_checksums.txt" algorithm: sha256 release: From 498ac96237ca3823ab8871ea70ae5aa780331217 Mon Sep 17 00:00:00 2001 From: Alexey Kartashov Date: Fri, 8 May 2026 18:50:00 +0200 Subject: [PATCH 07/29] chore(plugins/sct): Migration script for legacy events and tab reoder This commit adds a migration script for SCTTestRun.events field, extracting plaintext events and processing them into fully parsed SCTEvent table events, as well as putting any ERROR and CRITICAL events found into SCTUnprocessedEvent event queue. Additionally, this commit includes a script to drop events, allocated_resources and nemesis_data columns from the primary SCTTestRun table, to be executed after confirming the migration. Fixes ARGUS-55 --- frontend/TestRun/TestRun.svelte | 11 +- scripts/migration/migration_2026-05-08.py | 207 ++++++++++++++++++ .../migration_2026-05-08_drop_columns.py | 29 +++ 3 files changed, 243 insertions(+), 4 deletions(-) create mode 100644 scripts/migration/migration_2026-05-08.py create mode 100644 scripts/migration/migration_2026-05-08_drop_columns.py diff --git a/frontend/TestRun/TestRun.svelte b/frontend/TestRun/TestRun.svelte index be19dc86..2491ebb3 100644 --- a/frontend/TestRun/TestRun.svelte +++ b/frontend/TestRun/TestRun.svelte @@ -78,6 +78,7 @@ end_time: string, build_job_url: string, subtest_name: string, + events: any[], status: string, test_id: string, release_id: string, @@ -339,9 +340,14 @@ - + {#if testRun.events.length > 0} + + {/if} @@ -357,9 +363,6 @@ -