From 055a4527ea42bc7e0c9c87f7696bd6670e4e86e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Corn=C3=A9=20Dorrestijn?= Date: Thu, 12 Nov 2026 13:37:25 +0100 Subject: [PATCH 1/3] feat(LAN-0): add an all issues view of every project issue Every issue of the board's project, open and closed, in a list that sorts and filters locally, offline too, from a mirror of the project in the index (internal/mirror): read whole once in the background, then only what changed. The assignee and quick filters ask Jira for the keys they find. Cards carry resolution and resolved, as search terms, a sort and columns. The filter builder counts each value over what the rest of the search leaves. The terminal list draws only the rows near the screen, so a list of thousands stays quick. --- README.md | 2 +- docs/guide/02-board-and-panel.md | 21 ++ docs/reference/search.md | 10 +- e2e/tui/bulk_test.go | 4 +- internal/demo/json.go | 7 +- internal/i18n/nl/ui-board.json | 13 +- internal/i18n/nl/web-board-issue.json | 6 +- internal/index/index.go | 8 +- internal/index/project.go | 134 +++++++++ internal/index/project_test.go | 73 +++++ internal/jira/agile.go | 10 +- internal/jira/search.go | 111 ++++++-- internal/jira/search_test.go | 39 +++ internal/mirror/mirror.go | 164 +++++++++++ internal/mirror/mirror_test.go | 179 ++++++++++++ internal/ui/allissues.go | 183 ++++++++++++ internal/ui/allissues_test.go | 285 +++++++++++++++++++ internal/ui/filter_builder.go | 21 +- internal/ui/header.go | 4 +- internal/ui/jira_cache.go | 14 +- internal/ui/jira_cache_test.go | 4 +- internal/ui/jira_search.go | 9 +- internal/ui/jira_sort.go | 15 +- internal/ui/jiratab.go | 198 ++++++++++--- internal/ui/model.go | 4 +- internal/web/README.md | 2 +- internal/web/api_allissues.go | 125 ++++++++ internal/web/api_allissues_test.go | 82 ++++++ internal/web/api_core.go | 5 +- internal/web/api_sites.go | 2 + internal/web/jstest/cardquery.test.mjs | 28 ++ internal/web/jstest/cardsort.test.mjs | 6 + internal/web/server.go | 4 + internal/web/static/js/lib/cardlist.js | 5 +- internal/web/static/js/lib/cardquery.js | 23 +- internal/web/static/js/lib/cardsort.js | 3 + internal/web/static/js/lib/chrome.js | 4 +- internal/web/static/js/views/board.js | 51 +++- internal/web/static/js/views/board_filter.js | 2 +- internal/web/static/js/views/index.js | 1 + internal/web/static/js/views/issues.js | 5 + main.go | 8 +- webcmd.go | 13 + 43 files changed, 1785 insertions(+), 102 deletions(-) create mode 100644 internal/index/project.go create mode 100644 internal/index/project_test.go create mode 100644 internal/mirror/mirror.go create mode 100644 internal/mirror/mirror_test.go create mode 100644 internal/ui/allissues.go create mode 100644 internal/ui/allissues_test.go create mode 100644 internal/web/api_allissues.go create mode 100644 internal/web/api_allissues_test.go create mode 100644 internal/web/jstest/cardquery.test.mjs create mode 100644 internal/web/static/js/views/issues.js diff --git a/README.md b/README.md index 4a4fb9d4..ed11bc06 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ config: ![The board in the browser](docs/screenshots/web/board.png) - Swim lanes or a sortable list, with drag and drop between lanes and to rank -- Sprints, backlog and kanban boards; the board's quick filters plus your own +- Sprints, backlog and kanban boards, and all of a project's issues, closed ones too, sorted and filtered offline; the board's quick filters plus your own - Local search, JQL with completion, a command palette, jump to any issue by key - Issue panel with description (edited as markdown), comments, links, subtasks, an epic's children, attachments and private notes; ask an LLM command about it - Sprint planning and refinement; burndown, velocity, cycle time and retro charts; an epic roadmap; releases diff --git a/docs/guide/02-board-and-panel.md b/docs/guide/02-board-and-panel.md index 9a4338e5..b61fdc1c 100644 --- a/docs/guide/02-board-and-panel.md +++ b/docs/guide/02-board-and-panel.md @@ -59,6 +59,27 @@ The [search reference](../reference/search.md) has every field. > field, a comparison and a value from lists, and it writes the query for > you. Each term shows as a chip; a click removes it. +## Every issue of the project + +The sprint shows this sprint; **All issues**, the view after the backlog +(`[` `]`, or pick it from the views; in the browser a page of its own in +the top bar), lists the whole project, open and closed, as a list only. Search, the filter builder and sort work on all of them at once, +offline too: laneway keeps a copy of the project in its local index. The +first time a project is opened here it is read whole in the background, +newest first, the title row saying how far it got; after that only what +changed is read. + +- The title row counts the issues, the open ones and, with a search, the + ones shown: `9812 issues · 312 open · 87 shown`. +- `-is:done` leaves the closed ones out, `resolved<30d` keeps what was + closed this month and `resolution:duplicate` how it was closed. The list + sorts by resolved too, the newest first. +- The assignee filter, mine and the quick filters ask Jira which issues + they find, so they mean what they mean on the board. + +The filter builder counts each value over what the rest of your search +leaves, so every value it offers finds something. + ## Arrange it - Lanes or a list: `t` swaps them. The list sorts by rank, priority, diff --git a/docs/reference/search.md b/docs/reference/search.md index 1f8a65be..deb244e0 100644 --- a/docs/reference/search.md +++ b/docs/reference/search.md @@ -16,6 +16,8 @@ is:flagged also done, pr, unassigned, mine, overdue, notes (has: too) due<7d age>3d due within a week, in progress over 3 days (h, d, w) updated<1d changed within a day (updated>7d: quiet a week) created<7d made within a week +resolved<7d resolved within a week +resolution:dup how it was resolved (resolution: unresolved) reporter:bob who reported it component:api one of its components pr:open deploy:prod pull request state, deployed environment @@ -26,8 +28,9 @@ sprint:4 the sprint it is in now Fields: `status`, `assignee` (`who`), `type`, `prio` (`priority`), `epic` (`parent`), `label` (`labels`), `key`, `points` (`sp`), `due`, `age`, -`updated`, `created`, `reporter`, `component` (`components`), `pr`, -`deploy`, `sprint`, `is` (`has`), and `ui.custom_fields` quoted. +`updated`, `created`, `resolved`, `resolution`, `reporter`, `component` +(`components`), `pr`, `deploy`, `sprint`, `is` (`has`), and +`ui.custom_fields` quoted. - `:` matches part of a value, `=` the whole of it. - Spans take a whole number and `h`, `d` or `w`; `age` counts cards in @@ -35,7 +38,8 @@ Fields: `status`, `assignee` (`who`), `type`, `prio` (`priority`), `epic` - `is:notes` finds issues with private notes. The filter builder (`F` in both) writes these terms from three columns: -field, comparison and value. Each term shows as a chip; a click removes it. +field, comparison and value, each value with how many issues the rest of +the search leaves it. Each term shows as a chip; a click removes it. `ui.filters` names queries to recall from the palette. ## JQL diff --git a/e2e/tui/bulk_test.go b/e2e/tui/bulk_test.go index 1541462f..32cec0c9 100644 --- a/e2e/tui/bulk_test.go +++ b/e2e/tui/bulk_test.go @@ -13,7 +13,9 @@ func TestBulkUndo(t *testing.T) { s.wait("Edit 2 marked") s.keys("3") s.wait("Assign to me") - s.keys("Down", "Down", "Down", "Down", "Enter") // Sam Okafor + s.typ("Sam") // by name: the people the index has seen rank first + s.wait("▸ Sam Okafor") + s.keys("Enter") s.wait("enter again assigns 2 issues") s.keys("Enter") sams := func(n int) func(string) bool { diff --git a/internal/demo/json.go b/internal/demo/json.go index f80d411d..f4a868d0 100644 --- a/internal/demo/json.go +++ b/internal/demo/json.go @@ -256,8 +256,13 @@ func (s *Server) issueJSON(iss *issue, full bool) map[string]any { if labels == nil { labels = []string{} } + var resolution any + if !iss.resolved.IsZero() { + resolution = map[string]any{"id": "1", "name": "Done"} + } fields := map[string]any{ - "summary": iss.summary, "status": statusJSON(iss.status), "issuetype": typeJSON(iss.typ), + "resolution": resolution, + "summary": iss.summary, "status": statusJSON(iss.status), "issuetype": typeJSON(iss.typ), "priority": map[string]any{"id": strconv.Itoa(slices.Index(priorities, iss.priority) + 1), "name": iss.priority}, "assignee": userJSON(iss.assignee), "reporter": userJSON(&iss.reporter), "labels": labels, "created": stamp(iss.created), "updated": stamp(iss.updated), "resolutiondate": stamp(iss.resolved), "statuscategorychangedate": stamp(changed), "duedate": due, "description": docOr(iss.descDoc, iss.description), "parent": parent, diff --git a/internal/i18n/nl/ui-board.json b/internal/i18n/nl/ui-board.json index 54ec5b1c..ded8dc74 100644 --- a/internal/i18n/nl/ui-board.json +++ b/internal/i18n/nl/ui-board.json @@ -230,5 +230,16 @@ "✎ edit": "✎ bewerken", "✕ delete": "✕ verwijderen", "✕ delete? again": "✕ verwijderen? nogmaals", - "%s has closed · showing %s": "%s is gesloten · %s staat er nu" + "%s has closed · showing %s": "%s is gesloten · %s staat er nu", + " · %d shown": " · %d getoond", + "%d issues · %d open": "%d issues · %d open", + "All issues": "Alle issues", + "Reading every issue of %s…": "Alle issues van %s worden gelezen…", + "Resolution": "Resolutie", + "all issues: %s": "alle issues: %s", + "bad project key": "ongeldige projectsleutel", + "offline: left out %s": "offline: weggelaten %s", + "reading all issues: %d of %d": "alle issues lezen: %d van %d", + "the issue index is not available": "de issue-index is niet beschikbaar", + "this site keeps no issue index": "deze site houdt geen issue-index bij" } diff --git a/internal/i18n/nl/web-board-issue.json b/internal/i18n/nl/web-board-issue.json index c6f79138..a32bb3ee 100644 --- a/internal/i18n/nl/web-board-issue.json +++ b/internal/i18n/nl/web-board-issue.json @@ -553,7 +553,6 @@ "Upload files": "Bestanden uploaden", "Value": "Waarde", "View": "Weergave", - "View: sprint, backlog, whole board, your views (v, [ ])": "Weergave: sprint, backlog, heel bord, je weergaven (v, [ ])", "Visible to %s": "Zichtbaar voor %s", "Vote / take back the vote": "Stemmen / stem intrekken", "Vote taken back": "Stem ingetrokken", @@ -818,5 +817,8 @@ "mute": "dempen", "full screen": "volledig scherm", "Picture-in-picture (p)": "Beeld-in-beeld (p)", - "picture-in-picture": "beeld-in-beeld" + "picture-in-picture": "beeld-in-beeld", + "View: sprint, backlog, whole board, all issues, your views (v, [ ])": "Weergave: sprint, backlog, heel bord, alle issues, je eigen weergaven (v, [ ])", + "the whole project": "het hele project", + "All issues is a list only": "Alle issues is alleen een lijst" } diff --git a/internal/index/index.go b/internal/index/index.go index 67678baf..e1c2cf69 100644 --- a/internal/index/index.go +++ b/internal/index/index.go @@ -49,6 +49,12 @@ CREATE TABLE IF NOT EXISTS user_syncs ( project TEXT PRIMARY KEY, at INTEGER NOT NULL ); +CREATE TABLE IF NOT EXISTS project_syncs ( + project TEXT PRIMARY KEY, + at INTEGER NOT NULL, + keys_at INTEGER NOT NULL, + complete INTEGER NOT NULL +); ` version = 2 @@ -99,7 +105,7 @@ func (ix *Index) migrate() error { return err } if v != version { - for _, t := range []string{"issues", "users", "user_syncs"} { + for _, t := range []string{"issues", "users", "user_syncs", "project_syncs"} { if _, err := ix.db.Exec("DROP TABLE IF EXISTS " + t); err != nil { return err } diff --git a/internal/index/project.go b/internal/index/project.go new file mode 100644 index 00000000..8b5c2184 --- /dev/null +++ b/internal/index/project.go @@ -0,0 +1,134 @@ +package index + +import ( + "encoding/json" + "time" + + "github.com/cornedor/laneway/internal/jira" +) + +// A project mirrored whole: every issue of it in the index, kept up to date +// by what changed since (the all issues view, internal/ui/allissues.go). + +// ProjectSync is where a project's mirror stands. +type ProjectSync struct { + // At is when the last sync started: what changed after it is not in yet. + At time.Time + // KeysAt is when the issues gone from the project were last dropped. + KeysAt time.Time + // Complete is whether every issue of the project was read once; until + // then the index holds only the ones read so far. + Complete bool +} + +// ProjectSync is project's mirror state, zero when it was never mirrored. +func (ix *Index) ProjectSync(project string) ProjectSync { + var s ProjectSync + if ix == nil { + return s + } + var at, keysAt int64 + var complete bool + if err := ix.db.QueryRow("SELECT at, keys_at, complete FROM project_syncs WHERE project = ?", project).Scan(&at, &keysAt, &complete); err != nil { + return s + } + return ProjectSync{At: time.UnixMilli(at), KeysAt: time.UnixMilli(keysAt), Complete: complete} +} + +// SetProjectSync records where project's mirror stands. +func (ix *Index) SetProjectSync(project string, s ProjectSync) error { + if ix == nil { + return nil + } + ix.mu.Lock() + defer ix.mu.Unlock() + _, err := ix.db.Exec("INSERT OR REPLACE INTO project_syncs (project, at, keys_at, complete) VALUES (?, ?, ?, ?)", + project, s.At.UnixMilli(), s.KeysAt.UnixMilli(), s.Complete) + return err +} + +// ProjectCards is every indexed card of project, most recently updated +// first. +func (ix *Index) ProjectCards(project string) ([]jira.Card, error) { + if ix == nil { + return nil, nil + } + rows, err := ix.db.Query("SELECT card FROM issues WHERE project = ? ORDER BY updated DESC", project) + if err != nil { + return nil, err + } + defer rows.Close() + var out []jira.Card + for rows.Next() { + var raw string + if err := rows.Scan(&raw); err != nil { + return nil, err + } + var c jira.Card + if json.Unmarshal([]byte(raw), &c) == nil { + out = append(out, c) + } + } + return out, rows.Err() +} + +// DropUnread drops project's issues last read before since: after a sync +// that read every issue of the project from since on, the ones it didn't +// read are gone from it (deleted, or moved to another project). +func (ix *Index) DropUnread(project string, since time.Time) (int, error) { + if ix == nil { + return 0, nil + } + ix.mu.Lock() + defer ix.mu.Unlock() + res, err := ix.db.Exec("DELETE FROM issues WHERE project = ? AND synced < ?", project, since.UnixMilli()) + if err != nil { + return 0, err + } + n, err := res.RowsAffected() + return int(n), err +} + +// KeepKeys drops project's issues whose key is not in keys: the project's +// every key, as Jira has them now. +func (ix *Index) KeepKeys(project string, keys []string) (int, error) { + if ix == nil { + return 0, nil + } + keep := make(map[string]bool, len(keys)) + for _, k := range keys { + keep[k] = true + } + ix.mu.Lock() + defer ix.mu.Unlock() + rows, err := ix.db.Query("SELECT key FROM issues WHERE project = ?", project) + if err != nil { + return 0, err + } + var gone []string + for rows.Next() { + var k string + if err := rows.Scan(&k); err != nil { + rows.Close() + return 0, err + } + if !keep[k] { + gone = append(gone, k) + } + } + rows.Close() + if err := rows.Err(); err != nil || len(gone) == 0 { + return 0, err + } + tx, err := ix.db.Begin() + if err != nil { + return 0, err + } + defer tx.Rollback() + for _, k := range gone { + if _, err := tx.Exec("DELETE FROM issues WHERE key = ?", k); err != nil { + return 0, err + } + } + return len(gone), tx.Commit() +} diff --git a/internal/index/project_test.go b/internal/index/project_test.go new file mode 100644 index 00000000..a29e7c03 --- /dev/null +++ b/internal/index/project_test.go @@ -0,0 +1,73 @@ +package index + +import ( + "testing" + "time" + + "github.com/cornedor/laneway/internal/jira" +) + +func TestProjectSyncRoundTrip(t *testing.T) { + ix, path := openTemp(t) + if s := ix.ProjectSync("ABC"); s.Complete || !s.At.IsZero() { + t.Fatalf("never synced: %+v, want zero", s) + } + at := time.Date(2026, 11, 12, 9, 30, 0, 0, time.UTC) + if err := ix.SetProjectSync("ABC", ProjectSync{At: at, KeysAt: at.Add(-time.Hour), Complete: true}); err != nil { + t.Fatal(err) + } + ix.Close() + ix, err := Open(path) + if err != nil { + t.Fatal(err) + } + defer ix.Close() + s := ix.ProjectSync("ABC") + if !s.Complete || !s.At.Equal(at) || !s.KeysAt.Equal(at.Add(-time.Hour)) { + t.Errorf("after reopening: %+v", s) + } + if ix.ProjectSync("XY").Complete { + t.Error("another project: want it unsynced") + } +} + +func TestProjectCardsNewestFirst(t *testing.T) { + ix, _ := openTemp(t) + day := func(d int) time.Time { return time.Date(2026, 11, d, 0, 0, 0, 0, time.UTC) } + ix.PutCards([]jira.Card{ + {Key: "ABC-1", Updated: day(1), Resolution: "Done", Resolved: day(2)}, + {Key: "ABC-2", Updated: day(9)}, + {Key: "XY-1", Updated: day(5)}, + }) + cards, err := ix.ProjectCards("ABC") + if err != nil || len(cards) != 2 || cards[0].Key != "ABC-2" || cards[1].Key != "ABC-1" { + t.Fatalf("ProjectCards = %+v, %v; want ABC-2, ABC-1", cards, err) + } + if cards[1].Resolution != "Done" || !cards[1].Resolved.Equal(day(2)) { + t.Errorf("resolution lost: %+v", cards[1]) + } +} + +func TestDropUnreadAndKeepKeys(t *testing.T) { + ix, _ := openTemp(t) + ix.PutCards([]jira.Card{{Key: "ABC-1"}, {Key: "ABC-2"}, {Key: "XY-1"}}) + since := time.Now().Add(time.Millisecond) + time.Sleep(2 * time.Millisecond) + ix.PutCards([]jira.Card{{Key: "ABC-2"}}) + if n, err := ix.DropUnread("ABC", since); err != nil || n != 1 { + t.Fatalf("DropUnread = %d, %v; want ABC-1 dropped", n, err) + } + if _, ok := ix.Get("ABC-1"); ok { + t.Error("ABC-1 still there") + } + if _, ok := ix.Get("XY-1"); !ok { + t.Error("another project's issue was dropped") + } + ix.PutCards([]jira.Card{{Key: "ABC-3"}}) + if n, err := ix.KeepKeys("ABC", []string{"ABC-3"}); err != nil || n != 1 { + t.Fatalf("KeepKeys = %d, %v; want ABC-2 dropped", n, err) + } + if cards, _ := ix.ProjectCards("ABC"); len(cards) != 1 || cards[0].Key != "ABC-3" { + t.Errorf("left: %+v", cards) + } +} diff --git a/internal/jira/agile.go b/internal/jira/agile.go index b512dff3..b0174036 100644 --- a/internal/jira/agile.go +++ b/internal/jira/agile.go @@ -24,7 +24,7 @@ import ( const DefaultCardLimit = 500 // cardFields is what a card shows. The points field is appended per board. -const cardFields = "summary,status,assignee,issuetype,priority,parent,subtasks,duedate,statuscategorychangedate,labels,updated,created,reporter,components" +const cardFields = "summary,status,assignee,issuetype,priority,parent,subtasks,duedate,statuscategorychangedate,labels,updated,created,reporter,components,resolution,resolutiondate" // boardMetaCache keeps what a board is made of — a project's boards, a // board's columns and quick filters — for the session: they change about as @@ -158,6 +158,10 @@ type Card struct { Updated, Created time.Time // Reporter is the reporter's name, "" for none. Reporter string + // Resolution is how it was resolved ("Done", "Won't Do"), "" while + // unresolved; Resolved when, zero while unresolved. + Resolution string + Resolved time.Time // Components are its components' names joined by ExtraSep (names hold // spaces), so a Card stays comparable. Components string @@ -607,6 +611,10 @@ func toCard(key string, f map[string]json.RawMessage, pointsField string) Card { card.Created, _ = time.Parse(jiraTime, created) } _, card.Reporter = obj("reporter") + _, card.Resolution = obj("resolution") + if resolved := str("resolutiondate"); resolved != "" { + card.Resolved, _ = time.Parse(jiraTime, resolved) + } var comps []struct { Name string `json:"name"` } diff --git a/internal/jira/search.go b/internal/jira/search.go index 8cf6d3bf..d8587ba2 100644 --- a/internal/jira/search.go +++ b/internal/jira/search.go @@ -36,40 +36,119 @@ func (c *Client) searchCards(ctx context.Context, jql string) ([]Card, bool, err if !c.Enabled() { return nil, false, errNotConfigured } - sp := c.resolveStoryPointFields(ctx) - fields := append(strings.Split(cardFields, ","), sp...) - dev, flag := c.devField(ctx), c.flagField(ctx) - more := c.moreCardFields(ctx) - for _, id := range append([]string{dev, flag}, more.ids()...) { - if id != "" { - fields = append(fields, id) - } - } - issues, cut, err := c.searchUpTo(ctx, jql, fields, "", c.cardLimit) + cf := c.cardSearchFields(ctx) + issues, cut, err := c.searchUpTo(ctx, jql, cf.fields, "", c.cardLimit) if err != nil { return nil, false, err } + return c.toCards(issues, cf), cut, nil +} + +// SearchCardsPage is one page of the cards jql finds, from token ("" for the +// first page), with the token of the next page, "" after the last. Unlike +// SearchCards no card limit applies: the caller pages as far as it wants. +func (c *Client) SearchCardsPage(ctx context.Context, jql, token string) (cards []Card, next string, err error) { + if !c.Enabled() { + return nil, "", errNotConfigured + } + cf := c.cardSearchFields(ctx) + body := map[string]any{"jql": jql, "fields": cf.fields, "maxResults": cardPage} + if token != "" { + body["nextPageToken"] = token + } + var resp struct { + Issues []rawIssue `json:"issues"` + NextPageToken string `json:"nextPageToken"` + } + if err := c.do(ctx, http.MethodPost, "/rest/api/3/search/jql", "search", body, &resp); err != nil { + return nil, "", err + } + if len(resp.Issues) == 0 { + resp.NextPageToken = "" + } + return c.toCards(resp.Issues, cf), resp.NextPageToken, nil +} + +// keysPage is how many keys one Keys request asks for: Jira answers up to +// 5000 a page when only keys are asked, fewer otherwise (then it pages). +const keysPage = 5000 + +// Keys is every key jql finds, without the card limit: only keys are read, +// which Jira pages thousands at a time. +func (c *Client) Keys(ctx context.Context, jql string) ([]string, error) { + if !c.Enabled() { + return nil, errNotConfigured + } + var out []string + token := "" + for { + body := map[string]any{"jql": jql, "fields": []string{"key"}, "maxResults": keysPage} + if token != "" { + body["nextPageToken"] = token + } + var resp struct { + Issues []struct { + Key string `json:"key"` + } `json:"issues"` + NextPageToken string `json:"nextPageToken"` + } + if err := c.do(ctx, http.MethodPost, "/rest/api/3/search/jql", "search", body, &resp); err != nil { + return nil, err + } + for _, is := range resp.Issues { + if ValidKey(is.Key) { + out = append(out, is.Key) + } + } + if token = resp.NextPageToken; token == "" || len(resp.Issues) == 0 { + return out, nil + } + } +} + +// cardSearch is what a card search asks for: the fields, and the ids of the +// story point, development and flag fields among them. +type cardSearch struct { + fields []string + sp []string + dev, flag string + more moreFields +} + +func (c *Client) cardSearchFields(ctx context.Context) cardSearch { + cf := cardSearch{sp: c.resolveStoryPointFields(ctx), dev: c.devField(ctx), flag: c.flagField(ctx), more: c.moreCardFields(ctx)} + cf.fields = append(strings.Split(cardFields, ","), cf.sp...) + for _, id := range append([]string{cf.dev, cf.flag}, cf.more.ids()...) { + if id != "" { + cf.fields = append(cf.fields, id) + } + } + return cf +} + +// toCards turns search hits into cards, and mirrors them in the index. +func (c *Client) toCards(issues []rawIssue, cf cardSearch) []Card { out := make([]Card, 0, len(issues)) for _, is := range issues { if !ValidKey(is.Key) { continue } card := toCard(is.Key, is.Fields, "") - for _, id := range sp { + for _, id := range cf.sp { if card = toCard(is.Key, is.Fields, id); card.Points != "" { break } } - card.PR = prState(is.Fields[dev]) - card.Deploy = deployEnv(is.Fields[dev]) - card.Flagged = flagSet(is.Fields[flag]) - more.fill(&card, is.Fields) + card.PR = prState(is.Fields[cf.dev]) + card.Deploy = deployEnv(is.Fields[cf.dev]) + card.Flagged = flagSet(is.Fields[cf.flag]) + cf.more.fill(&card, is.Fields) out = append(out, card) } if c.index != nil { c.index.PutCards(out) } - return out, cut, nil + return out } // rawIssue is a search hit with its fields undecoded. diff --git a/internal/jira/search_test.go b/internal/jira/search_test.go index 9b0fb858..28fb0df2 100644 --- a/internal/jira/search_test.go +++ b/internal/jira/search_test.go @@ -134,3 +134,42 @@ func TestSearchCardsTotal(t *testing.T) { } } } + +// TestSearchCardsPageAndKeys: a page past the card limit carries resolution +// fields; Keys asks for keys only, thousands a page, and pages on. +func TestSearchCardsPageAndKeys(t *testing.T) { + var asked []map[string]any + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/rest/api/3/field" { + fmt.Fprint(w, `[]`) + return + } + var body map[string]any + _ = json.NewDecoder(r.Body).Decode(&body) + asked = append(asked, body) + switch { + case fmt.Sprint(body["fields"]) == "[key]" && body["nextPageToken"] == nil: + fmt.Fprint(w, `{"issues": [{"key": "ABC-1"}, {"key": "../x"}], "nextPageToken": "k2"}`) + case fmt.Sprint(body["fields"]) == "[key]": + fmt.Fprint(w, `{"issues": [{"key": "ABC-2"}]}`) + default: + fmt.Fprint(w, `{"issues": [{"key": "ABC-1", "fields": {"resolution": {"id": "1", "name": "Won't Do"}, "resolutiondate": "2026-11-02T10:00:00.000+0000"}}], "nextPageToken": "p2"}`) + } + })) + defer srv.Close() + c := New(Config{BaseURL: srv.URL, Email: "me@x.test", APIToken: "tok", CardLimit: 50}) + cards, next, err := c.SearchCardsPage(context.Background(), "project = ABC", "") + if err != nil || next != "p2" || len(cards) != 1 || cards[0].Resolution != "Won't Do" || cards[0].Resolved.Day() != 2 { + t.Fatalf("SearchCardsPage = %+v, %q, %v", cards, next, err) + } + if fmt.Sprint(asked[0]["maxResults"]) != fmt.Sprint(cardPage) { + t.Errorf("a page asked %v, want %d whatever the card limit", asked[0]["maxResults"], cardPage) + } + keys, err := c.Keys(context.Background(), "project = ABC") + if err != nil || fmt.Sprint(keys) != "[ABC-1 ABC-2]" { + t.Fatalf("Keys = %v, %v; want both pages, the bad key dropped", keys, err) + } + if fmt.Sprint(asked[1]["maxResults"]) != fmt.Sprint(keysPage) || asked[2]["nextPageToken"] != "k2" { + t.Errorf("keys asked %v then %v", asked[1], asked[2]) + } +} diff --git a/internal/mirror/mirror.go b/internal/mirror/mirror.go new file mode 100644 index 00000000..96d5348a --- /dev/null +++ b/internal/mirror/mirror.go @@ -0,0 +1,164 @@ +// Package mirror keeps every issue of a project in the local index, so a +// view of all of them sorts and filters locally, offline too, over the whole +// project rather than the first page Jira answers. +// +// The first time, Run reads the project page by page, newest first. After +// that Refresh reads only what changed since the last sync, and now and then +// the project's keys, to drop the issues deleted or moved out of it. +package mirror + +import ( + "context" + "errors" + "fmt" + "slices" + "strings" + "time" + + "github.com/cornedor/laneway/internal/i18n" + "github.com/cornedor/laneway/internal/index" + "github.com/cornedor/laneway/internal/jira" +) + +// overlap reads a little before the last sync, for clock skew and writes in +// flight while it ran. +const overlap = 2 * time.Minute + +// keysEvery is how often Refresh reads the project's keys to drop the issues +// gone from it: issues are seldom deleted, and a key pass reads them all. +const keysEvery = time.Hour + +// errNoIndex is a mirror asked for without an index (it failed to open). +func errNoIndex() error { return errors.New(i18n.T("the issue index is not available")) } + +// projectJQL is project's issues, quoted as JQL. +func projectJQL(project string) string { return fmt.Sprintf("project = %q", project) } + +// Run is a whole read of a project into the index, a page at a time, so the +// caller can show the issues as they arrive. +type Run struct { + Project string + // Started is when the run began: issues changed after it are left to the + // next Refresh, and those not read by the end are gone from the project. + Started time.Time + // Done is how many issues were read, Total how many Jira counted. + Done, Total int + token string + finished bool +} + +// Start begins a run of project, counting its issues. +func Start(ctx context.Context, c *jira.Client, ix *index.Index, project string) (*Run, error) { + if ix == nil { + return nil, errNoIndex() + } + r := &Run{Project: project, Started: time.Now()} + n, err := c.Count(ctx, projectJQL(project)) + if err != nil { + return nil, err + } + r.Total = n + return r, nil +} + +// Finished is whether the run read the last page. +func (r *Run) Finished() bool { return r.finished } + +// Next reads the next page into the index. After the last it drops the +// project's issues the run didn't read and marks the mirror complete. +// Newest created first, an order changes don't move issues in. +func (r *Run) Next(ctx context.Context, c *jira.Client, ix *index.Index) error { + if r.finished { + return nil + } + cards, next, err := c.SearchCardsPage(ctx, projectJQL(r.Project)+" ORDER BY created DESC, key DESC", r.token) + if err != nil { + return err + } + ix.PutCards(cards) + r.Done += len(cards) + r.Total = max(r.Total, r.Done) + if r.token = next; next != "" { + return nil + } + r.finished = true + if _, err := ix.DropUnread(r.Project, r.Started); err != nil { + return err + } + return ix.SetProjectSync(r.Project, index.ProjectSync{At: r.Started, KeysAt: r.Started, Complete: true}) +} + +// Refresh brings a complete mirror of project up to date: the issues updated +// since its last sync, and past keysEvery the keys, dropping the gone ones. +// A mirror never completed is left alone: that is Run's. +func Refresh(ctx context.Context, c *jira.Client, ix *index.Index, project string) error { + if ix == nil { + return errNoIndex() + } + s := ix.ProjectSync(project) + if !s.Complete { + return nil + } + started := time.Now() + mins := int(started.Sub(s.At.Add(-overlap)).Minutes()) + 1 + jql := fmt.Sprintf("%s AND updated >= -%dm ORDER BY updated DESC", projectJQL(project), mins) + for token := ""; ; { + cards, next, err := c.SearchCardsPage(ctx, jql, token) + if err != nil { + return err + } + ix.PutCards(cards) + if token = next; token == "" { + break + } + } + s.At = started + if started.Sub(s.KeysAt) >= keysEvery { + keys, err := c.Keys(ctx, projectJQL(project)) + if err != nil { + return err + } + if _, err := ix.KeepKeys(project, keys); err != nil { + return err + } + s.KeysAt = started + } + return ix.SetProjectSync(project, s) +} + +// Cards is project's mirrored issues, most recently updated first, narrowed +// by filter (JQL, "" for none): Jira says which keys it finds, so filter +// means what it means on the board. Offline the index answers the parts of +// filter it can; note then says what it left out. +func Cards(ctx context.Context, c *jira.Client, ix *index.Index, project, filter, me string) (cards []jira.Card, note string, err error) { + if ix == nil { + return nil, "", errNoIndex() + } + all, err := ix.ProjectCards(project) + if err != nil || filter == "" { + return all, "", err + } + q := projectJQL(project) + " AND (" + filter + ")" + keys, err := c.Keys(ctx, q) + if err != nil { + if !jira.Offline(err) { + return nil, "", err + } + hits, dropped, ierr := ix.JQL(q, me, len(all)+1) + if ierr != nil { + return nil, "", ierr + } + keys = make([]string, len(hits)) + for i, h := range hits { + keys[i] = h.Card.Key + } + if len(dropped) > 0 { + note = i18n.Tf("offline: left out %s", strings.Join(dropped, ", ")) + } + } + in := make(map[string]bool, len(keys)) + for _, k := range keys { + in[k] = true + } + return slices.DeleteFunc(all, func(c jira.Card) bool { return !in[c.Key] }), note, nil +} diff --git a/internal/mirror/mirror_test.go b/internal/mirror/mirror_test.go new file mode 100644 index 00000000..2647fa28 --- /dev/null +++ b/internal/mirror/mirror_test.go @@ -0,0 +1,179 @@ +package mirror + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "path/filepath" + "regexp" + "slices" + "strings" + "sync" + "testing" + "time" + + "github.com/cornedor/laneway/internal/index" + "github.com/cornedor/laneway/internal/jira" +) + +// fakeJira answers searches from issues (key → summary), two to a page, and +// keeps the JQL it was asked. +type fakeJira struct { + mu sync.Mutex + issues map[string]string + // match narrows a search by its JQL; nil finds every issue. + match func(jql, key string) bool + asked []string +} + +func (f *fakeJira) serve(t *testing.T) *jira.Client { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + f.mu.Lock() + defer f.mu.Unlock() + switch r.URL.Path { + case "/rest/api/3/field": + fmt.Fprint(w, `[]`) + return + case "/rest/api/3/search/approximate-count": + fmt.Fprintf(w, `{"count": %d}`, len(f.issues)) + return + } + var body struct { + JQL string `json:"jql"` + Token string `json:"nextPageToken"` + } + _ = json.NewDecoder(r.Body).Decode(&body) + f.asked = append(f.asked, body.JQL) + var keys []string + for k := range f.issues { + if f.match == nil || f.match(body.JQL, k) { + keys = append(keys, k) + } + } + slices.Sort(keys) + from := 0 + fmt.Sscan(body.Token, &from) + to := min(from+2, len(keys)) + var out []map[string]any + for _, k := range keys[from:to] { + out = append(out, map[string]any{"key": k, "fields": map[string]any{"summary": f.issues[k]}}) + } + resp := map[string]any{"issues": out} + if to < len(keys) { + resp["nextPageToken"] = fmt.Sprint(to) + } + _ = json.NewEncoder(w).Encode(resp) + })) + t.Cleanup(srv.Close) + return jira.New(jira.Config{BaseURL: srv.URL, Email: "me@x.test", APIToken: "tok"}) +} + +func openIndex(t *testing.T) *index.Index { + ix, err := index.Open(filepath.Join(t.TempDir(), "ix.db")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { ix.Close() }) + return ix +} + +// TestRunMirrorsWholeProject: a run reads every page, drops what the project +// no longer has, and leaves the mirror complete. +func TestRunMirrorsWholeProject(t *testing.T) { + ctx := context.Background() + f := &fakeJira{issues: map[string]string{"ABC-1": "one", "ABC-2": "two", "ABC-3": "three"}} + c, ix := f.serve(t), openIndex(t) + ix.PutCards([]jira.Card{{Key: "ABC-9", Summary: "deleted since"}, {Key: "XY-1", Summary: "another project"}}) + time.Sleep(2 * time.Millisecond) + r, err := Start(ctx, c, ix, "ABC") + if err != nil || r.Total != 3 { + t.Fatalf("Start = %+v, %v", r, err) + } + pages := 0 + for !r.Finished() { + if err := r.Next(ctx, c, ix); err != nil { + t.Fatal(err) + } + if pages++; pages == 1 && ix.ProjectSync("ABC").Complete { + t.Error("complete after the first of two pages") + } + } + if pages != 2 || r.Done != 3 { + t.Errorf("pages %d, done %d; want 2 and 3", pages, r.Done) + } + if !strings.HasPrefix(f.asked[0], `project = "ABC"`) || !strings.Contains(f.asked[0], "ORDER BY created DESC") { + t.Errorf("asked %q", f.asked[0]) + } + cards, _ := ix.ProjectCards("ABC") + if len(cards) != 3 || slices.ContainsFunc(cards, func(c jira.Card) bool { return c.Key == "ABC-9" }) { + t.Errorf("mirror = %+v; want ABC-1..3, ABC-9 dropped", cards) + } + if _, ok := ix.Get("XY-1"); !ok { + t.Error("another project's issue was dropped") + } + if s := ix.ProjectSync("ABC"); !s.Complete || !s.At.Equal(r.Started.Truncate(time.Millisecond)) { + t.Errorf("sync = %+v", s) + } +} + +// TestRefreshReadsChangesAndDropsGone: a refresh asks only what changed since +// the last sync, and past keysEvery drops keys Jira no longer has. +func TestRefreshReadsChangesAndDropsGone(t *testing.T) { + ctx := context.Background() + f := &fakeJira{issues: map[string]string{"ABC-1": "renamed", "ABC-3": "new"}} + c, ix := f.serve(t), openIndex(t) + if err := Refresh(ctx, c, ix, "ABC"); err != nil || len(f.asked) != 0 { + t.Fatalf("an incomplete mirror: Refresh asked %q, %v; want nothing", f.asked, err) + } + ix.PutCards([]jira.Card{{Key: "ABC-1", Summary: "one"}, {Key: "ABC-2", Summary: "deleted"}}) + last := time.Now().Add(-10 * time.Minute) + _ = ix.SetProjectSync("ABC", index.ProjectSync{At: last, KeysAt: last, Complete: true}) + if err := Refresh(ctx, c, ix, "ABC"); err != nil { + t.Fatal(err) + } + if m := regexp.MustCompile(`updated >= -(\d+)m`).FindStringSubmatch(f.asked[0]); m == nil || m[1] != "13" { + t.Errorf("asked %q; want the 10 minutes since, 2 overlap and 1 rounding", f.asked[0]) + } + if len(f.asked) != 1 { + t.Errorf("asked %q; want the changes only, the keys within the hour", f.asked) + } + if got, _ := ix.Get("ABC-1"); got.Summary != "renamed" { + t.Errorf("ABC-1 = %+v; want the change read", got) + } + if _, ok := ix.Get("ABC-2"); !ok { + t.Error("ABC-2 dropped without a key pass") + } + _ = ix.SetProjectSync("ABC", index.ProjectSync{At: last, KeysAt: last.Add(-2 * time.Hour), Complete: true}) + f.asked = nil + if err := Refresh(ctx, c, ix, "ABC"); err != nil { + t.Fatal(err) + } + if _, ok := ix.Get("ABC-2"); ok { + t.Errorf("ABC-2 kept after a key pass; asked %q", f.asked) + } + if s := ix.ProjectSync("ABC"); time.Since(s.At) > time.Minute || time.Since(s.KeysAt) > time.Minute { + t.Errorf("sync not moved on: %+v", s) + } +} + +// TestCardsFilterByJiraKeys: a filter narrows the mirror to the keys Jira +// finds for it within the project. +func TestCardsFilterByJiraKeys(t *testing.T) { + ctx := context.Background() + f := &fakeJira{issues: map[string]string{"ABC-2": "mine"}} + c, ix := f.serve(t), openIndex(t) + ix.PutCards([]jira.Card{{Key: "ABC-1"}, {Key: "ABC-2"}}) + all, note, err := Cards(ctx, c, ix, "ABC", "", "") + if err != nil || len(all) != 2 || note != "" || len(f.asked) != 0 { + t.Fatalf("no filter: %d cards, %q, %v, asked %q", len(all), note, err, f.asked) + } + got, _, err := Cards(ctx, c, ix, "ABC", "assignee = currentUser()", "") + if err != nil || len(got) != 1 || got[0].Key != "ABC-2" { + t.Fatalf("filtered = %+v, %v", got, err) + } + if f.asked[0] != `project = "ABC" AND (assignee = currentUser())` { + t.Errorf("asked %q", f.asked[0]) + } +} diff --git a/internal/ui/allissues.go b/internal/ui/allissues.go new file mode 100644 index 00000000..9517bc80 --- /dev/null +++ b/internal/ui/allissues.go @@ -0,0 +1,183 @@ +package ui + +import ( + "context" + "slices" + "time" + + tea "charm.land/bubbletea/v2" + + "github.com/cornedor/laneway/internal/i18n" + "github.com/cornedor/laneway/internal/index" + "github.com/cornedor/laneway/internal/jira" + "github.com/cornedor/laneway/internal/mirror" +) + +// The all issues view: every issue of the board's project, open and closed, +// from a mirror of the project in the index (internal/mirror). Search, the +// filter builder and sort work locally on all of them; the assignee and +// quick filters ask Jira which keys they find, so they mean what they mean +// on the board. The first time a project is opened here a run reads it +// whole in the background, the issues showing as they arrive. + +// AllIssues is the all issues view's cards: project's mirror brought up to +// date (once complete) and narrowed by filter, the JQL of the assignee and +// quick filters. note says what an offline answer left out. +func AllIssues(ctx context.Context, c *jira.Client, ix *index.Index, project, filter string) (cards []jira.Card, note string, err error) { + if err := mirror.Refresh(ctx, c, ix, project); err != nil { + if !jira.Offline(err) { + return nil, "", err + } + note = i18n.Tf("offline: %s", err.Error()) + } + me := "" + if filter != "" { + if u, err := c.Myself(ctx); err == nil { + me = u.AccountID + } + } + cards, fnote, err := mirror.Cards(ctx, c, ix, project, filter, me) + if fnote != "" { + note = fnote + } + return cards, note, err +} + +// allIssuesCached is the all issues view as the index holds it, to show +// while the fresh copy loads; not when filtered, as only Jira can say which +// issues the filters find. +func allIssuesCached(ix *index.Index, project, filter string) ([]jira.Card, bool) { + if ix == nil || filter != "" { + return nil, false + } + cards, err := ix.ProjectCards(project) + return cards, err == nil && len(cards) > 0 +} + +// allIssuesView is the all issues view of a board. +func allIssuesView() jiraView { + return jiraView{kind: jiraViewAll, name: i18n.T("All issues")} // a list only: thousands of issues make no lanes +} + +// inAllIssues is whether the all issues view shows. +func (m *Model) inAllIssues() bool { + v, ok := m.jiraCurrentView() + return ok && v.kind == jiraViewAll +} + +// allIssuesSort is the all issues view's first order: most recently updated +// first, as rank means nothing across a project. +var allIssuesSort = viewSort{by: jiraSortUpdated} + +// seedAllIssuesSort gives the all issues view its first order, once. +func (m *Model) seedAllIssuesSort() { + t := m.jiraTab + i := slices.IndexFunc(t.views, func(v jiraView) bool { return v.kind == jiraViewAll }) + if i < 0 { + return + } + if _, ok := t.sorts[t.views[i].name]; !ok { + m.setViewSort(i, allIssuesSort) + } +} + +// mirrorProgress is a mirror run under way, as its last page left it. +type mirrorProgress struct { + project string + done, total int + // shown is when the view last showed what the run has read. + shown time.Time +} + +// mirrorMsg is a mirror run's page read (or its start). +type mirrorMsg struct { + run *mirror.Run + done, total int + finished bool + err error +} + +// mirrorShowEvery is how often a run under way shows the issues read since. +const mirrorShowEvery = 2 * time.Second + +// startMirror starts reading the shown project whole, when the all issues +// view shows and its mirror was never completed. +func (m *Model) startMirror() tea.Cmd { + t := m.jiraTab + if !m.inAllIssues() || m.index == nil || t.mirror != nil || t.project == "" || m.index.ProjectSync(t.project).Complete { + return nil + } + t.mirror = &mirrorProgress{project: t.project, shown: time.Now()} + ctx, c, ix, project := m.ctx, m.jiraClient, m.index, t.project + return func() tea.Msg { + ctx, cancel := context.WithTimeout(ctx, c.Scaled(60*time.Second)) + defer cancel() + run, err := mirror.Start(ctx, c, ix, project) + if err != nil { + return mirrorMsg{err: err} + } + return mirrorMsg{run: run, total: run.Total} + } +} + +// nextMirrorPage reads run's next page. +func (m *Model) nextMirrorPage(run *mirror.Run) tea.Cmd { + ctx, c, ix := m.ctx, m.jiraClient, m.index + return func() tea.Msg { + ctx, cancel := context.WithTimeout(ctx, c.Scaled(60*time.Second)) + defer cancel() + err := run.Next(ctx, c, ix) + return mirrorMsg{run: run, done: run.Done, total: run.Total, finished: run.Finished(), err: err} + } +} + +func (m Model) handleMirror(msg mirrorMsg) (tea.Model, tea.Cmd) { + t := m.jiraTab + if t.mirror == nil { + return m, nil + } + if msg.err != nil { + t.mirror = nil + m.fail(i18n.Tf("all issues: %s", msg.err.Error())) + m.renderJira() + return m, nil + } + t.mirror.done, t.mirror.total = msg.done, msg.total + project := t.mirror.project + show := m.inAllIssues() && t.project == project + if msg.finished { + t.mirror = nil + if show { + return m, m.loadJiraCards(t.viewIdx, false) + } + return m, nil + } + var reload tea.Cmd + if show && time.Since(t.mirror.shown) >= mirrorShowEvery { + t.mirror.shown = time.Now() + reload = m.loadJiraCards(t.viewIdx, false) + } + m.renderJira() + return m, tea.Batch(reload, m.nextMirrorPage(msg.run)) +} + +// allIssuesCounts is the all issues view's counts for the title row: the +// issues, the open ones, and those the search leaves when it narrows them; +// while a run reads the project, how far it got. +func (m *Model) allIssuesCounts() string { + t := m.jiraTab + if t.mirror != nil && t.mirror.project == t.project { + return i18n.Tf("reading all issues: %d of %d", t.mirror.done, t.mirror.total) + } + open := 0 + for _, c := range t.cards { + if !c.Done { + open++ + } + } + s := i18n.Tf("%d issues · %d open", len(t.cards), open) + if t.jiraSearchQuery() != "" { + s += i18n.Tf(" · %d shown", len(t.order)) + } + return s +} diff --git a/internal/ui/allissues_test.go b/internal/ui/allissues_test.go new file mode 100644 index 00000000..c6a087a2 --- /dev/null +++ b/internal/ui/allissues_test.go @@ -0,0 +1,285 @@ +package ui + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "path/filepath" + "strings" + "testing" + "time" + + tea "charm.land/bubbletea/v2" + "github.com/charmbracelet/x/ansi" + + "github.com/cornedor/laneway/internal/config" + "github.com/cornedor/laneway/internal/index" + "github.com/cornedor/laneway/internal/jira" + "github.com/cornedor/laneway/internal/store" +) + +// allIssuesModel shows the all issues view of a board whose columns know +// statuses 1, 3 and 5: the cards are out of updated order, one done with +// Won't Do, one in status 9 that no column shows. +func allIssuesModel(t *testing.T) Model { + t.Helper() + m := configuredJiraModel(t, "ABC") + m.width, m.height = 160, 40 + m.resize() + day := func(d int) time.Time { return time.Now().Add(-time.Duration(d) * 24 * time.Hour) } + out, _ := m.handleJiraBoard(jiraBoardMsg{ + seq: m.jiraTab.seq, project: "ABC", + boards: []jira.Board{{ID: 1, Name: "ABC board", Type: "scrum"}}, + cfg: &jira.BoardConfig{Columns: []jira.Column{ + {Name: "To do", StatusIDs: []string{"1"}}, + {Name: "In progress", StatusIDs: []string{"3"}}, + {Name: "Done", StatusIDs: []string{"5"}}, + }}, + views: []jiraView{{kind: jiraViewSprint, name: "Sprint 1", sprint: 9, lanes: true}, allIssuesView()}, + viewIdx: 1, + cards: []jira.Card{ + {Key: "ABC-1", Summary: "Old", StatusID: "1", Status: "New", Updated: day(30)}, + {Key: "ABC-2", Summary: "Fresh", StatusID: "3", Status: "In progress", Updated: day(1)}, + {Key: "ABC-3", Summary: "Dropped", StatusID: "5", Status: "Closed", Done: true, Resolution: "Won't Do", Resolved: day(3), Updated: day(3)}, + {Key: "ABC-4", Summary: "Parked", StatusID: "9", Status: "Parked", Updated: day(10)}, + }, + total: 4, + }) + return out.(Model) +} + +// TestAllIssuesView: a list only, of every issue, also the ones in a status +// the board has no column for; most recently updated first, and the title +// row counts them. +func TestAllIssuesView(t *testing.T) { + m := allIssuesModel(t) + if !m.inAllIssues() { + t.Fatal("not on the all issues view") + } + if m.jiraTab.wantLanes = true; m.jiraShowsLanes() { + t.Error("all issues shows as lanes; want a list whatever the mode") + } + var keys []string + for _, i := range m.jiraTab.order { + keys = append(keys, m.jiraTab.cards[i].Key) + } + if got := strings.Join(keys, " "); got != "ABC-2 ABC-3 ABC-4 ABC-1" { + t.Errorf("list = %s; want most recently updated first", got) + } + m.renderJira() + view := ansi.Strip(m.View().Content) + for _, want := range []string{"4 issues · 3 open", "All issues", "✕ Won't Do"} { + if !strings.Contains(view, want) { + t.Errorf("view lacks %q:\n%s", want, view) + } + } + m.jiraTab.search.SetValue("-is:done") + m.applyJiraSearch() + if got := m.allIssuesCounts(); got != "4 issues · 3 open · 3 shown" { + t.Errorf("searched counts = %q", got) + } +} + +// TestResolvedSearchAndSort: resolution and resolved search, and resolved +// sorts newest first with the unresolved last. +func TestResolvedSearchAndSort(t *testing.T) { + now := time.Now() + cards := []jira.Card{ + {Key: "A-1"}, + {Key: "A-2", Resolution: "Duplicate", Resolved: now.Add(-48 * time.Hour)}, + {Key: "A-3", Resolution: "Done", Resolved: now.Add(-10 * 24 * time.Hour)}, + } + env := jiraQueryEnv{now: now} + for q, want := range map[string]string{ + "resolution:dup": "A-2", "resolved<7d": "A-2", "resolved>7d": "A-3", "resolution:": "A-1", "-resolution:": "A-2 A-3", + } { + var got []string + for _, c := range cards { + if jiraCardMatches(c, jiraParseQuery(q), env) { + got = append(got, c.Key) + } + } + if strings.Join(got, " ") != want { + t.Errorf("%s finds %v, want %s", q, got, want) + } + } + order := []int{0, 1, 2} + jiraSortResolved.apply(order, cards) + if fmt.Sprint(order) != "[1 2 0]" { + t.Errorf("resolved order = %v, want A-2, A-3, then the unresolved", order) + } +} + +// TestFilterBuilderCountsFollowSearch: a value's count is of the cards the +// search's other terms leave; terms on the same field don't narrow it. +func TestFilterBuilderCountsFollowSearch(t *testing.T) { + m := jiraTabModel(t) + m.jiraTab.search.SetValue("status:new") + m.applyJiraSearch() + m.openFilterBuilder(false) + labels := func(field string) string { + var out []string + for _, it := range m.filterValueItems(field) { + out = append(out, it.label) + } + return strings.Join(out, ", ") + } + if got := labels("assignee"); got != "Ada · 1" { + t.Errorf("assignee values = %q; want only the New cards' assignee", got) + } + if got := labels("status"); !strings.Contains(got, "New · 2") || !strings.Contains(got, "In progress · 1") { + t.Errorf("status values = %q; want every status, the search's own term left out", got) + } +} + +// fakeProjectJira answers a project of n issues, two to a page. +func fakeProjectJira(t *testing.T, n int) *httptest.Server { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/rest/api/3/search/approximate-count": + fmt.Fprintf(w, `{"count": %d}`, n) + return + case "/rest/api/3/search/jql": + default: + fmt.Fprint(w, `[]`) + return + } + var body struct { + Token string `json:"nextPageToken"` + } + _ = json.NewDecoder(r.Body).Decode(&body) + from := 0 + fmt.Sscan(body.Token, &from) + var issues []string + for i := from; i < min(from+2, n); i++ { + issues = append(issues, fmt.Sprintf(`{"key":"ABC-%d","fields":{"summary":"Issue %d","status":{"id":"1","name":"New"}}}`, i+1, i+1)) + } + next := "" + if from+2 < n { + next = fmt.Sprintf(`,"nextPageToken":"%d"`, from+2) + } + fmt.Fprintf(w, `{"issues":[%s]%s}`, strings.Join(issues, ","), next) + })) + t.Cleanup(srv.Close) + return srv +} + +// TestAllIssuesReadsProjectWhole: the view on a project never mirrored +// starts a run that reads it page by page into the index, says how far it +// got, and shows the issues once done. +func TestAllIssuesReadsProjectWhole(t *testing.T) { + srv := fakeProjectJira(t, 5) + st, err := store.Open(filepath.Join(t.TempDir(), "state.json")) + if err != nil { + t.Fatal(err) + } + ix, err := index.Open(filepath.Join(t.TempDir(), "ix.db")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { ix.Close() }) + m := New(context.Background(), config.JiraConfig{BaseURL: srv.URL, Email: "me@x.test", APIToken: "tok", Projects: []string{"ABC"}}, + config.UIConfig{}, nil, "", st).WithIndex(ix) + m.herdr = nil + m.width, m.height = 160, 40 + m.resize() + out, _ := m.handleJiraBoard(jiraBoardMsg{ + seq: m.jiraTab.seq, project: "ABC", + boards: []jira.Board{{ID: 1, Name: "ABC board", Type: "scrum"}}, + cfg: &jira.BoardConfig{Columns: []jira.Column{{Name: "To do", StatusIDs: []string{"1"}}}}, + views: []jiraView{allIssuesView()}, + viewIdx: 0, + }) + m = out.(Model) + if m.jiraTab.mirror == nil { + t.Fatal("no run started on a project never mirrored") + } + // The load's own commands (rules, home) stay unrun: start the run again + // to have its command alone. + m.jiraTab.mirror = nil + queue := runCmd(m.startMirror()) + pages := 0 + for len(queue) > 0 { + var cmd tea.Cmd + switch msg := queue[0].(type) { + case mirrorMsg: + out, cmd = m.handleMirror(msg) + m = out.(Model) + if msg.err != nil { + t.Fatal(msg.err) + } + if msg.done > 0 && !msg.finished { + pages++ + if got := m.allIssuesCounts(); got != fmt.Sprintf("reading all issues: %d of 5", msg.done) { + t.Errorf("counts mid-run = %q", got) + } + } + case jiraCardsMsg: + out, _ = m.handleJiraCards(msg) + m = out.(Model) + } + queue = append(queue[1:], runCmd(cmd)...) + } + if pages != 2 || !ix.ProjectSync("ABC").Complete || m.jiraTab.mirror != nil { + t.Fatalf("%d pages shown, sync %+v; want 2 and complete", pages, ix.ProjectSync("ABC")) + } + if len(m.jiraTab.cards) != 5 { + t.Errorf("%d cards shown after the run, want 5", len(m.jiraTab.cards)) + } +} + +// runCmd runs cmd and every batch in it, returning their messages. +func runCmd(cmd tea.Cmd) []tea.Msg { + if cmd == nil { + return nil + } + switch msg := cmd().(type) { + case nil: + return nil + case tea.BatchMsg: + var out []tea.Msg + for _, c := range msg { + out = append(out, runCmd(c)...) + } + return out + default: + return []tea.Msg{msg} + } +} + +// TestLongListDrawsRowsOnSight: a list of thousands draws the rows near the +// screen only; the wheel and the cursor bring the others in drawn. +func TestLongListDrawsRowsOnSight(t *testing.T) { + m := bigJiraModel(nil, 3000) + m.jiraTab.wantLanes = false + m.buildJiraLanes() + m.renderJira() + drawn := 0 + for _, r := range m.jiraTab.rows { + if r != "" { + drawn++ + } + } + if drawn == 0 || drawn > 3*m.jiraTab.view.Height() { + t.Errorf("%d of 3000 rows drawn; want about three screens", drawn) + } + for range 40 { + out, _ := m.Update(tea.MouseWheelMsg{X: 5, Y: 10, Button: tea.MouseWheelDown}) + m = out.(Model) + } + top := m.jiraTab.view.YOffset() + if top == 0 { + t.Fatal("the wheel didn't scroll") + } + for _, l := range strings.Split(m.jiraTab.view.View(), "\n")[:5] { + if strings.TrimSpace(ansi.Strip(l)) == "" { + t.Fatalf("a blank row at offset %d:\n%s", top, m.jiraTab.view.View()) + } + } + m.moveJiraCursor(2500) + if !strings.Contains(ansi.Strip(m.jiraTab.view.View()), m.jiraTab.cards[m.jiraTab.order[m.jiraTab.idx]].Key) { + t.Error("the cursor's row isn't on screen after a long move") + } +} diff --git a/internal/ui/filter_builder.go b/internal/ui/filter_builder.go index 4d16034c..e6283e03 100644 --- a/internal/ui/filter_builder.go +++ b/internal/ui/filter_builder.go @@ -24,7 +24,7 @@ import ( var filterFields = []struct{ name, label string }{ {"status", i18n.N("Status")}, {"assignee", i18n.N("Assignee")}, {"type", i18n.N("Type")}, {"prio", i18n.N("Priority")}, {"points", i18n.N("Story points")}, {"label", i18n.N("Label")}, {"epic", i18n.N("Epic")}, {"pr", i18n.N("Pull request")}, {"deploy", i18n.N("Deployed to")}, - {"component", i18n.N("Component")}, {"reporter", i18n.N("Reporter")}, + {"component", i18n.N("Component")}, {"reporter", i18n.N("Reporter")}, {"resolution", i18n.N("Resolution")}, {"is", i18n.N("Is: mine, overdue, flagged…")}, } @@ -66,9 +66,12 @@ func filterOps(field string) []jiraPickerItem { func opNoValue(op string) bool { return op == "empty" || op == "-empty" } // filterValueItems are field's values on the loaded cards with their -// counts, most common first (priorities by rank). +// counts, most common first (priorities by rank). The counts are of the +// cards the query's other terms leave, so each says what picking it finds, +// and a value nothing would match isn't offered. func (m *Model) filterValueItems(field string) []jiraPickerItem { - counts, labels := filterValues(m.builderCards(), field, m.jiraQueryEnv()) + env := m.jiraQueryEnv() + counts, labels := filterValues(facetCards(m.builderCards(), m.builderQuery(), field, env), field, env) values := slices.Collect(maps.Keys(counts)) slices.SortFunc(values, func(x, y string) int { if field == "prio" { @@ -293,6 +296,8 @@ func filterValues(cards []jira.Card, field string, env jiraQueryEnv) (counts map } case "reporter": add(c.Reporter, c.Reporter) + case "resolution": + add(c.Resolution, c.Resolution) case "pr": add(strings.ToLower(c.PR), strings.ToLower(c.PR)) case "deploy": @@ -310,6 +315,16 @@ func filterValues(cards []jira.Card, field string, env jiraQueryEnv) (counts map return counts, labels } +// facetCards are the cards q's terms on fields other than field leave: a +// term on field joins its like as one more value, so those don't narrow. +func facetCards(cards []jira.Card, q, field string, env jiraQueryEnv) []jira.Card { + terms := slices.DeleteFunc(jiraParseQuery(q), func(t jiraTerm) bool { return t.field == jiraQueryFields[field] }) + if len(terms) == 0 { + return cards + } + return slices.DeleteFunc(slices.Clone(cards), func(c jira.Card) bool { return !jiraCardMatches(c, terms, env) }) +} + // addFilterTerm puts term in the / query: a ":" term on the same field // takes its value as one more, a comparison replaces its like. func (m *Model) addFilterTerm(term string) { diff --git a/internal/ui/header.go b/internal/ui/header.go index dc614e2f..b1801337 100644 --- a/internal/ui/header.go +++ b/internal/ui/header.go @@ -61,7 +61,9 @@ func (m *Model) jiraTitleSegs() []headSeg { segs = append(segs, plainSeg(dim(i18n.Tf(" refreshing…%s", loadingFor(t.loadingSince))))) case !t.fetched.IsZero(): segs = append(segs, plainSeg(dim(" ")), keySeg(dim(i18n.Tf("updated %s", age(t.fetched))), k.Refresh)) - if t.total > len(t.cards) { + if m.inAllIssues() { + segs = append(segs, plainSeg(dim(" · "+m.allIssuesCounts()))) + } else if t.total > len(t.cards) { segs = append(segs, plainSeg(dim(i18n.Tf(" · first %d of %d", len(t.cards), t.total)))) } } diff --git a/internal/ui/jira_cache.go b/internal/ui/jira_cache.go index a8cd9aed..994220fa 100644 --- a/internal/ui/jira_cache.go +++ b/internal/ui/jira_cache.go @@ -7,6 +7,7 @@ import ( tea "charm.land/bubbletea/v2" + "github.com/cornedor/laneway/internal/index" "github.com/cornedor/laneway/internal/jira" "github.com/cornedor/laneway/internal/store" ) @@ -81,7 +82,11 @@ func (c jiraCache) boardMsg(seq int) jiraBoardMsg { } // saveJiraCache stores c as board's view, and that view as the board's last. +// The all issues view's cards stay out: the index holds them already. func saveJiraCache(st *store.Store, board int, view string, c jiraCache) { + if c.ViewIdx < len(c.Views) && c.Views[c.ViewIdx].Kind == jiraViewAll { + c.Cards, c.Total = nil, 0 + } if raw, err := json.Marshal(c); err == nil { _ = st.SetMeta(jiraCacheKey(board, view), string(raw)) } @@ -100,7 +105,7 @@ func readJiraCache(st *store.Store, board int, view string) (jiraCache, bool) { // jiraBoardFromCache delivers the stored copy of the board loadJiraBoard is // about to fetch, resolved the same way; nil-message when there is none, or // when it is filtered and filters aren't remembered (ui.remember_filters). -func jiraBoardFromCache(st *store.Store, seq int, project string, board int, view string, configured []string, readMode, remember bool) tea.Cmd { +func jiraBoardFromCache(st *store.Store, ix *index.Index, seq int, project string, board int, view string, configured []string, readMode, remember bool) tea.Cmd { return func() tea.Msg { project, board = resolveJiraBoard(st, project, board, configured) if board == 0 { @@ -114,6 +119,13 @@ func jiraBoardFromCache(st *store.Store, seq int, project string, board int, vie return nil } msg := c.boardMsg(seq) + if msg.views[msg.viewIdx].kind == jiraViewAll { + cards, ok := allIssuesCached(ix, project, c.Filter) + if !ok { + return nil + } + msg.cards, msg.total = cards, len(cards) + } if readMode { msg.lanes = storedJiraMode(st) } diff --git a/internal/ui/jira_cache_test.go b/internal/ui/jira_cache_test.go index c19de5e2..2ce4cea2 100644 --- a/internal/ui/jira_cache_test.go +++ b/internal/ui/jira_cache_test.go @@ -70,10 +70,10 @@ func TestJiraRememberFiltersOff(t *testing.T) { msg := jiraBoardMsg{project: "ABC", boards: []jira.Board{{ID: 1, Name: "B"}}, cfg: &jira.BoardConfig{}, views: []jiraView{{name: "Board"}}, assignee: jiraAssignee{id: "me", label: "Me"}} saveJiraCache(m.store, 1, "Board", cacheOf(msg, "assignee = currentUser()")) - if got := jiraBoardFromCache(m.store, 1, "ABC", 1, "Board", nil, false, false)(); got != nil { + if got := jiraBoardFromCache(m.store, nil, 1, "ABC", 1, "Board", nil, false, false)(); got != nil { t.Errorf("filtered cache shown: %+v", got) } - if got := jiraBoardFromCache(m.store, 1, "ABC", 1, "Board", nil, false, true)(); got == nil { + if got := jiraBoardFromCache(m.store, nil, 1, "ABC", 1, "Board", nil, false, true)(); got == nil { t.Error("filtered cache dropped with remember on") } } diff --git a/internal/ui/jira_search.go b/internal/ui/jira_search.go index 4aa485c9..3f4f4d54 100644 --- a/internal/ui/jira_search.go +++ b/internal/ui/jira_search.go @@ -73,6 +73,8 @@ func jiraCardMatches(c jira.Card, terms []jiraTerm, env jiraQueryEnv) bool { // due<7d age>3d due within / after, in progress longer / shorter (h d w) // updated<1d changed within a day (updated>7d: not for a week) // created<7d made within a week +// resolved<7d resolved within a week +// resolution:dup how it was resolved // reporter:ada who reported it // component:api one of its components // pr:open deploy:prod the Development field @@ -95,6 +97,7 @@ var jiraQueryFields = map[string]string{ "label": "label", "labels": "label", "key": "key", "points": "points", "sp": "points", "is": "is", "has": "is", "due": "due", "age": "age", "updated": "updated", "pr": "pr", "deploy": "deploy", "sprint": "sprint", "created": "created", "reporter": "reporter", "component": "component", "components": "component", + "resolution": "resolution", "resolved": "resolved", } // jiraParseQuery splits q into terms. A word that doesn't parse as a field @@ -196,7 +199,7 @@ func (t jiraTerm) match(c jira.Card, env jiraQueryEnv) bool { } } return false - case "due", "age", "updated", "created": + case "due", "age", "updated", "created", "resolved": return jiraMatchSpan(t, c, env.now) } var have string @@ -227,6 +230,8 @@ func (t jiraTerm) match(c jira.Card, env jiraQueryEnv) bool { have = c.Reporter case "component": have = c.Components + case "resolution": + have = c.Resolution default: if name, ok := strings.CutPrefix(t.field, "custom:"); ok { have = jiraExtra(c)[name] @@ -297,6 +302,8 @@ func jiraMatchSpan(t jiraTerm, c jira.Card, now time.Time) bool { have = now.Sub(c.Updated) case t.field == "created" && !c.Created.IsZero(): have = now.Sub(c.Created) + case t.field == "resolved" && !c.Resolved.IsZero(): + have = now.Sub(c.Resolved) default: return false } diff --git a/internal/ui/jira_sort.go b/internal/ui/jira_sort.go index ef846fb9..fe06aa60 100644 --- a/internal/ui/jira_sort.go +++ b/internal/ui/jira_sort.go @@ -22,11 +22,12 @@ const ( jiraSortUpdated jiraSortDue jiraSortCreated + jiraSortResolved jiraSortCount ) func (s jiraSort) String() string { - return [...]string{"rank", "priority", "points", "assignee", "epic", "key", "status", "updated", "due", "created"}[s] + return [...]string{"rank", "priority", "points", "assignee", "epic", "key", "status", "updated", "due", "created", "resolved"}[s] } // jiraPriorityRank orders priorities highest first; unknown ones sit with @@ -164,6 +165,18 @@ func (s jiraSort) cmp() func(a, b jira.Card) int { cmp = func(a, b jira.Card) int { return b.Updated.Compare(a.Updated) } case jiraSortCreated: // newest first cmp = func(a, b jira.Card) int { return b.Created.Compare(a.Created) } + case jiraSortResolved: // newest first, unresolved last + cmp = func(a, b jira.Card) int { + switch { + case a.Resolved.IsZero() && b.Resolved.IsZero(): + return 0 + case a.Resolved.IsZero(): + return 1 + case b.Resolved.IsZero(): + return -1 + } + return b.Resolved.Compare(a.Resolved) + } case jiraSortDue: // soonest first, none last cmp = func(a, b jira.Card) int { switch { diff --git a/internal/ui/jiratab.go b/internal/ui/jiratab.go index 538abd44..a35b98e9 100644 --- a/internal/ui/jiratab.go +++ b/internal/ui/jiratab.go @@ -224,6 +224,9 @@ const ( // jiraViewFilter is a starred Jira filter (or a service desk queue): // its own search, any board. jiraViewFilter + // jiraViewAll is every issue of the board's project, open and closed, + // from its mirror in the index (allissues.go). + jiraViewAll ) // jiraView is one issue list of a board: a sprint, the kanban board, the @@ -444,6 +447,10 @@ type jiraTabState struct { // buildJiraLanes drops it. rows []string rowsFor listCols + // listLines are the list's lines as last shown ("" for a row not drawn + // yet), listOrder its rows' order (jiraListOrder). + listLines []string + listOrder []int // lineOf is each order entry's line in the list, which group headers // (sorted by assignee or priority) push down. lineOf []int @@ -523,6 +530,9 @@ type jiraTabState struct { // agentsView is the agents screen (agents_screen.go). agentsView *agentsScreen mrs *mrScreen // mr_screen.go + // mirror is a read of the project whole under way (allissues.go), nil + // when none is. + mirror *mirrorProgress // planSeq and chartsSeq outlive a close, so a reply for a view since // closed never matches the one reopened. planSeq, chartsSeq int @@ -564,6 +574,8 @@ type jiraBoardMsg struct { cards []jira.Card total int err error + // note says what an offline answer of the all issues view left out. + note string } // jiraCardsMsg carries one view's cards, after a view switch or a move. @@ -647,9 +659,10 @@ func (m *Model) loadJiraBoard(project string, boardID int, view string, fromCach readMode := !t.modeRead assignee, quickOn, quickBoard, local, localViews := t.assignee, t.quickOn, m.jiraBoardID(), m.opts.quick, append(slices.Clone(m.opts.views), m.savedJQLViews()...) withSaved, withQuick, doneDays, remember := m.opts.savedFilters, m.opts.boardQuick, m.opts.kanbanDoneDays, m.opts.rememberFilters + ix := m.index var cached tea.Cmd if fromCache { - cached = jiraBoardFromCache(st, seq, project, boardID, view, configured, readMode, remember) + cached = jiraBoardFromCache(st, ix, seq, project, boardID, view, configured, readMode, remember) } return tea.Batch(cached, func() tea.Msg { ctx, cancel := context.WithTimeout(ctx, c.Scaled(60*time.Second)) @@ -738,6 +751,9 @@ func (m *Model) loadJiraBoard(project string, boardID int, view string, fromCach msg.views = append(msg.views, jiraView{kind: jiraViewBacklog, name: i18n.T("Backlog")}) } } + if ix != nil { + msg.views = append(msg.views, allIssuesView()) + } msg.views = append(msg.views, localViews...) for _, f := range saved { msg.views = append(msg.views, jiraView{kind: jiraViewFilter, name: f.Name, jql: f.JQL}) @@ -766,7 +782,12 @@ func (m *Model) loadJiraBoard(project string, boardID int, view string, fromCach msg.assignee, msg.quickOn = assignee, quickOn msg.quick = withLocalQuick(msg.quick, local) filter := jiraFilterJQL(assignee, msg.quick, quickOn) - msg.cards, msg.total, msg.err = fetchJiraView(ctx, c, board.ID, cfg, msg.views[msg.viewIdx], filter) + if v := msg.views[msg.viewIdx]; v.kind == jiraViewAll { + msg.cards, msg.note, msg.err = AllIssues(ctx, c, ix, project, filter) + msg.total = len(msg.cards) + } else { + msg.cards, msg.total, msg.err = fetchJiraView(ctx, c, board.ID, cfg, v, filter) + } _ = st.SetMeta(jiraMetaPrefix+"project", project) _ = st.SetMeta(jiraMetaPrefix+"board:"+project, strconv.Itoa(board.ID)) if msg.err == nil { @@ -859,12 +880,18 @@ func (m *Model) loadJiraCards(idx int, fromCache bool) tea.Cmd { t.err = "" seq, ctx, c, board, cfg, v, ix := t.seq, m.ctx, m.jiraClient, m.jiraBoardID(), t.cfg, t.views[idx], m.index filter := jiraFilterJQL(t.assignee, t.quick, t.quickOn) - st := m.store + st, project := m.store, t.project base := cacheOf(jiraBoardMsg{project: t.project, boards: t.boards, board: t.board, cfg: cfg, views: t.views, viewIdx: idx, quick: t.quick, quickOn: t.quickOn, assignee: t.assignee, statusNames: t.statusNames}, filter) var cached tea.Cmd if fromCache { cached = func() tea.Msg { + if v.kind == jiraViewAll { + if cards, ok := allIssuesCached(ix, project, filter); ok { + return jiraCardsMsg{seq: seq, cached: true, viewIdx: idx, cards: cards, total: len(cards)} + } + return nil + } if c, ok := readJiraCache(st, board, v.name); ok && c.Filter == filter { return jiraCardsMsg{seq: seq, cached: true, viewIdx: idx, cards: c.Cards, total: c.Total} } @@ -874,6 +901,13 @@ func (m *Model) loadJiraCards(idx int, fromCache bool) tea.Cmd { return tea.Batch(cached, func() tea.Msg { ctx, cancel := context.WithTimeout(ctx, c.Scaled(60*time.Second)) defer cancel() + if v.kind == jiraViewAll { + cards, note, err := AllIssues(ctx, c, ix, project, filter) + if err == nil { + saveJiraCache(st, board, v.name, base) + } + return jiraCardsMsg{seq: seq, viewIdx: idx, cards: cards, total: len(cards), err: err, note: note} + } cards, total, err := fetchJiraView(ctx, c, board, cfg, v, filter) if err == nil { base.Cards, base.Total = cards, total @@ -927,7 +961,15 @@ func (m Model) handleJiraBoard(msg jiraBoardMsg) (tea.Model, tea.Cmd) { if msg.statusNames != nil { t.statusNames = msg.statusNames } + m.seedAllIssuesSort() + if v, ok := m.jiraCurrentView(); ok && v.kind == jiraViewAll { + vs := t.sorts[v.name] + t.sort, t.desc = vs.by, vs.desc + } m.installJiraCards(msg.cards, msg.total, msg.err, keep) + if msg.note != "" { + m.status = msg.note + } if msg.cached || msg.err != nil { return m, nil } @@ -939,7 +981,7 @@ func (m Model) handleJiraBoard(msg jiraBoardMsg) (tea.Model, tea.Cmd) { m.fail(warn) } t.fullAt, t.fullKey = time.Now(), m.jiraFetchKey(t.viewIdx) - return m, tea.Batch(m.runRules(msg.cards), m.startHome()) + return m, tea.Batch(m.runRules(msg.cards), m.startHome(), m.startMirror()) } func (m Model) handleJiraCards(msg jiraCardsMsg) (tea.Model, tea.Cmd) { @@ -993,7 +1035,7 @@ func (m Model) handleJiraCards(msg jiraCardsMsg) (tea.Model, tea.Cmd) { } return m, m.openTimeMachineAt(v.closed) } - return m, m.runRules(msg.cards) + return m, tea.Batch(m.runRules(msg.cards), m.startMirror()) } // setViewSort keeps sort as view i's. @@ -2193,8 +2235,15 @@ func (m *Model) openJiraAssigneeFilter() tea.Cmd { } c, ctx, board, cfg, v, seq := m.jiraClient, m.ctx, m.jiraBoardID(), t.cfg, t.views[t.viewIdx], m.jiraPicker.fetchSeq filter := jiraFilterJQL(jiraAssignee{}, t.quick, t.quickOn) + ix, project := m.index, t.project return func() tea.Msg { - cards, _, err := fetchJiraView(ctx, c, board, cfg, v, filter) + var cards []jira.Card + var err error + if v.kind == jiraViewAll { + cards, _, err = AllIssues(ctx, c, ix, project, filter) + } else { + cards, _, err = fetchJiraView(ctx, c, board, cfg, v, filter) + } if err != nil { return nil } @@ -2498,10 +2547,15 @@ func (m *Model) renderJira() { title, hint = i18n.T("The backlog is empty"), []headSeg{keySeg(dim(i18n.Tf("%s adds an issue", helpKey(m.keys.Create))), m.keys.Create)} case jiraViewSprint: title, hint = i18n.T("Nothing in this sprint yet"), []headSeg{keySeg(dim(i18n.Tf("%s plans it from the backlog", helpKey(m.keys.Plan))), m.keys.Plan)} + case jiraViewAll: + if t.mirror != nil { + title, hint = i18n.Tf("Reading every issue of %s…", t.project), nil + } } } msg, t.empty = jiraEmptyState(title, w, h, hint...) } + t.listLines = nil if msg != "" { t.view.SetContent(msg) t.lanesOut = msg @@ -2515,50 +2569,46 @@ func (m *Model) renderJira() { t.lanesOut = m.renderJiraLanes(w, h) return } - cols := listCols{width: w} - for _, ci := range t.order { - c := t.cards[ci] - cols.key = max(cols.key, len(c.Key)) - cols.status = max(cols.status, visualWidth(c.Status)) - cols.who = max(cols.who, visualWidth(m.jiraListWho(c))) - cols.marks = max(cols.marks, visualWidth(m.jiraListMarks(c))) + // The columns' widths are measured over every row, as the rows are + // drawn: only when those are (cards, order or width changed), as a list + // of thousands would take its time over each cursor move. + cols := t.rowsFor + if t.rows == nil || len(t.rows) != len(t.order) || cols.width != w { + cols = listCols{width: w} + for _, ci := range t.order { + c := t.cards[ci] + cols.key = max(cols.key, len(c.Key)) + cols.status = max(cols.status, visualWidth(c.Status)) + cols.who = max(cols.who, visualWidth(m.jiraListWho(c))) + cols.marks = max(cols.marks, visualWidth(m.jiraListMarks(c))) + } + cols.status, cols.who = min(cols.status, 20), min(cols.who, 24) } - cols.status, cols.who = min(cols.status, 20), min(cols.who, 24) t.listHead, t.headAt, t.headCols = m.jiraListHeader(cols) h-- // the header sits over the viewport's top line + // Rows are drawn as they come near the screen and kept (t.rows, "" for + // one not drawn yet) until the cards, their order or the widths change: + // a list of thousands draws a screenful, not every row, per change. if t.rowsFor != cols || len(t.rows) != len(t.order) { t.rows = make([]string, len(t.order)) - for i, ci := range t.order { - t.rows[i] = m.jiraListRow(t.cards[ci], false, cols) - if i%2 == 1 { // zebra: every other row faintly shaded - t.rows[i] = shade(t.rows[i], w) - } - } t.rowsFor = cols } - var lines []string + order := t.jiraListOrder() t.lineOf = t.lineOf[:0] - group := "" - for i, at := range t.jiraListOrder() { - ci := t.order[at] - if g, ok := jiraGroupOf(t.sort, t.cards[ci]); ok && (i == 0 || g != group) { + var heads map[int]string // group headers by the line they take + group, n := "", 0 + for i, at := range order { + if g, ok := jiraGroupOf(t.sort, t.cards[t.order[at]]); ok && (i == 0 || g != group) { group = g - lines = append(lines, m.jiraGroupHeader(g, i)) - } - t.lineOf = append(t.lineOf, len(lines)) - row := t.rows[at] - switch c := t.cards[ci]; { - case t.drag.active && c.Key == t.drag.key: // being dragged: faint, where it would land - row = jiraGhostStyle.Render(ansi.Truncate("┊ "+c.Key+" "+c.Summary, w, "…")) - case at == t.idx: - row = m.jiraListRow(c, true, cols) + if heads == nil { + heads = map[int]string{} + } + heads[n] = m.jiraGroupHeader(g, i) + n++ } - lines = append(lines, row) + t.lineOf = append(t.lineOf, n) + n++ } - // One line past the end, as the header covers one: the last row scrolls - // into sight. - lines = append(lines, "") - t.view.SetContentLinesWidth(lines, w) top := t.view.YOffset() r := 0 if t.idx < len(t.lineOf) { @@ -2571,9 +2621,56 @@ func (m *Model) renderJira() { switch { case t.drag.active: // the pointer scrolls it case head < top: - t.view.SetYOffset(head) + top = head case r >= top+h: - t.view.SetYOffset(r - h + 1) + top = r - h + 1 + } + // One line past the end, as the header covers one: the last row scrolls + // into sight. + t.listLines = make([]string, n+1) + for line, s := range heads { + t.listLines[line] = s + } + for i, at := range order { + c := t.cards[t.order[at]] + switch { + case t.drag.active && c.Key == t.drag.key: // being dragged: faint, where it would land + t.listLines[t.lineOf[i]] = jiraGhostStyle.Render(ansi.Truncate("┊ "+c.Key+" "+c.Summary, w, "…")) + case at == t.idx: + t.listLines[t.lineOf[i]] = m.jiraListRow(c, true, cols) + } + } + t.listOrder = order + m.fillJiraList(top) +} + +// fillJiraList draws the list's rows within a screen of top that aren't +// yet, and shows the list from top: after a change, or the wheel. +func (m *Model) fillJiraList(top int) { + t := m.jiraTab + if t.listLines == nil { // not the list: an empty state, say + t.view.SetYOffset(top) + return + } + h, w := t.view.Height(), t.rowsFor.width + top = min(top, max(len(t.listLines)-h, 0)) + from, to := top-h, top+2*h // a screen either side: the wheel's next steps + for i, at := range t.listOrder { + line := t.lineOf[i] + if line < from || line >= to || t.listLines[line] != "" { + continue + } + if t.rows[at] == "" { + t.rows[at] = m.jiraListRow(t.cards[t.order[at]], false, t.rowsFor) + if at%2 == 1 { // zebra: every other row faintly shaded + t.rows[at] = shade(t.rows[at], w) + } + } + t.listLines[line] = t.rows[at] + } + t.view.SetContentLinesWidth(t.listLines, w) + if top != t.view.YOffset() { + t.view.SetYOffset(top) } } @@ -2675,9 +2772,21 @@ func (m *Model) jiraListMarks(c jira.Card) string { if a := jiraAgeMark(c, time.Now(), m.opts.staleDays); f.age && a != "" { marks = append(marks, a) } + if r := jiraResolutionMark(c); r != "" { + marks = append(marks, r) + } return strings.Join(marks, " ") } +// jiraResolutionMark names how a closed issue was resolved, when it says +// more than its status: Won't Do, Duplicate. +func jiraResolutionMark(c jira.Card) string { + if c.Resolution == "" || strings.EqualFold(c.Resolution, c.Status) || strings.EqualFold(c.Resolution, "done") { + return "" + } + return "✕ " + c.Resolution +} + func (m *Model) jiraListRow(c jira.Card, selected bool, cols listCols) string { width, keyW := cols.width, cols.key status := ansi.Truncate(c.Status, cols.status, "…") @@ -3957,9 +4066,9 @@ func (m Model) dragJiraList(y int) (tea.Model, tea.Cmd) { } switch line := y - jiraBodyTop; { case line < 1: // over the column header too - t.view.ScrollUp(1) + m.fillJiraList(max(t.view.YOffset()-1, 0)) case line >= t.view.Height(): - t.view.ScrollDown(1) + m.fillJiraList(t.view.YOffset() + 1) } slot := t.drag.slot if h := m.hitJira(1, min(max(y, jiraBodyTop+1), jiraBodyTop+t.view.Height()-1)); h.line >= 0 { @@ -4177,7 +4286,8 @@ func (m *Model) jiraFetchKey(idx int) string { func (m *Model) loadJiraDelta() tea.Cmd { t := m.jiraTab idx := t.viewIdx - if len(t.cards) == 0 || t.fullKey != m.jiraFetchKey(idx) || time.Since(t.fullAt) >= m.opts.fullRefresh || idx >= len(t.views) { + // The all issues view's whole load reads only what changed already. + if len(t.cards) == 0 || t.fullKey != m.jiraFetchKey(idx) || time.Since(t.fullAt) >= m.opts.fullRefresh || idx >= len(t.views) || m.inAllIssues() { return m.loadJiraCards(idx, false) } t.seq++ diff --git a/internal/ui/model.go b/internal/ui/model.go index d7065045..e0fb5e50 100644 --- a/internal/ui/model.go +++ b/internal/ui/model.go @@ -813,6 +813,8 @@ func (m Model) update(msg tea.Msg) (tea.Model, tea.Cmd) { out, cmd := m.handleJiraBoard(msg) mm := out.(Model) return mm, tea.Batch(cmd, mm.fetchAvatars(), mm.fetchCardColors()) + case mirrorMsg: + return m.handleMirror(msg) case jiraCardsMsg: out, cmd := m.handleJiraCards(msg) mm := out.(Model) @@ -1319,7 +1321,7 @@ func (m Model) handleWheel(msg tea.MouseWheelMsg) (tea.Model, tea.Cmd) { m.moveJiraCursor(delta / 3) return m, nil } - m.jiraTab.view.SetYOffset(m.jiraTab.view.YOffset() + delta) + m.fillJiraList(max(m.jiraTab.view.YOffset()+delta, 0)) return m, nil } diff --git a/internal/web/README.md b/internal/web/README.md index 20de4499..19b96ac8 100644 --- a/internal/web/README.md +++ b/internal/web/README.md @@ -53,7 +53,7 @@ Scopes stack: global < view < panel (beside a board the panel's keys fire only w | Global extra | `ctrl+e` refine: the view's issues one at a time in the panel, unestimated first (`J` `K`, `esc`) | | Picker | type to filter, `↑↓`/`ctrl+n` `ctrl+p` move, `enter` picks, `esc`; one that takes several (labels, list columns, assignee filter): `space` (nothing typed), `tab` (clears what was typed; typed text matching nothing becomes a new label) or a click ticks, `enter` or Apply takes them | | Go | `g h` home, `g p` planning, `g r` reports, `g m` roadmap, `g w` my work, `g i` inbox, `g s` standup, `g a` agents, `g ,` settings, `g t` next theme, `g l` rules, `@` switch or add a Jira site (also with one site; the last row asks what `laneway setup` does, `POST /api/sites`, and the server starts again on it), `ctrl+a` ask the LLM about the issue (Edit as comment puts the answer in its comment composer, as the TUI; nothing posts until you do), your `ui.actions` keys | -| Board | `hjkl`/arrows move, `enter` open, `t` lanes/list, `s` status, `e` summary, `a` assign, `p` priority, `P` points, `H`/`L` move column (several statuses: pick one; a move the workflow wants fields for opens its form; `u` undoes; dragging splits such a lane into one drop zone per status, unreachable ones dimmed), `J`/`K` rank, `M` to a sprint or the backlog (marked cards, else the selected; `.` repeats it, `u` undoes), `E` quick edit (the card menu at the card; a right-click opens it at the pointer: current values, submenus for status, assignee, priority and sprint (the board's open sprints and the backlog), points, labels, pin, open in a new tab; `↑↓` `enter`/`→` `←`/`esc`; a long press on a phone keeps the sheet), `x` mark (`ctrl+click` too, the opened card with it; `shift+click` marks the run within the lane or list), `X` bulk, `o` Jira, `y` copy key, `r` refresh, `O` swimlanes (lanes) / sort (list; by assignee, priority, epic or status it heads each group with its count and points; a drop into another swimlane gives the card its assignee or epic, `u` undoes), `z`/`Z` fold/unfold swimlane, `c` compact (one-line) cards, `alt+e` hide/show empty lanes, `alt+l` next lane layout (`ui.lane_layouts`, arranged server-side by internal/lanes; stacked columns get a header over their first card), `C` list columns (drag a header onto another to move its column, its right edge to resize it, a double-click on the edge resets it; pref `board.colw`), `V` releases (the report), `F` filter builder (as the TUI's: `↑↓` pick, `→`/`tab` and `←`/`shift+tab` column, `enter` deeper then add, `ctrl+x` drop last term, `esc`), `*` pin, `.` repeat, `alt+j`/`alt+k` rank bottom/top, `ctrl+a` mark all, `ctrl+y` copy branch, `Y` copy link, `alt+t` time machine (`←` `→` `esc`), `alt+o` closed sprints, `B` board, `alt+p` project, `v` view, `[`/`]` previous/next view (sprints, backlog, `ui.views`, starred filters), `f` filter, `m` mine, `A` assignee (the project's assignable people; `space` (nothing typed), `tab` (clears what was typed) or a click ticks several, `enter` or Apply takes them, `enter` without a tick takes the row; `m` stays one toggle), `0` clear, `1`-`9` quick filters; the bar lists Mine, Assignee, then the quick filters (`ui.quick_filters` first); `is:notes` in `f` finds cards with private notes | +| Board | `hjkl`/arrows move, `enter` open, `t` lanes/list, `s` status, `e` summary, `a` assign, `p` priority, `P` points, `H`/`L` move column (several statuses: pick one; a move the workflow wants fields for opens its form; `u` undoes; dragging splits such a lane into one drop zone per status, unreachable ones dimmed), `J`/`K` rank, `M` to a sprint or the backlog (marked cards, else the selected; `.` repeats it, `u` undoes), `E` quick edit (the card menu at the card; a right-click opens it at the pointer: current values, submenus for status, assignee, priority and sprint (the board's open sprints and the backlog), points, labels, pin, open in a new tab; `↑↓` `enter`/`→` `←`/`esc`; a long press on a phone keeps the sheet), `x` mark (`ctrl+click` too, the opened card with it; `shift+click` marks the run within the lane or list), `X` bulk, `o` Jira, `y` copy key, `r` refresh, `O` swimlanes (lanes) / sort (list; by assignee, priority, epic or status it heads each group with its count and points; a drop into another swimlane gives the card its assignee or epic, `u` undoes), `z`/`Z` fold/unfold swimlane, `c` compact (one-line) cards, `alt+e` hide/show empty lanes, `alt+l` next lane layout (`ui.lane_layouts`, arranged server-side by internal/lanes; stacked columns get a header over their first card), `C` list columns (drag a header onto another to move its column, its right edge to resize it, a double-click on the edge resets it; pref `board.colw`), `V` releases (the report), `F` filter builder (as the TUI's: `↑↓` pick, `→`/`tab` and `←`/`shift+tab` column, `enter` deeper then add, `ctrl+x` drop last term, `esc`), `*` pin, `.` repeat, `alt+j`/`alt+k` rank bottom/top, `ctrl+a` mark all, `ctrl+y` copy branch, `Y` copy link, `alt+t` time machine (`←` `→` `esc`), `alt+o` closed sprints, `B` board, `alt+p` project, `v` view, `[`/`]` previous/next view (sprints, backlog, all issues, `ui.views`, starred filters; all issues, a list only and the page `/issues/PROJECT/BOARD` (`views/issues.js`), is the project whole from the site's index, `GET /boards/{board}/cards?allissues=1&project=`, whose `sync` says how far a first read got while the page asks again; `Options.Index` nil, as hosted, leaves it out), `f` filter, `m` mine, `A` assignee (the project's assignable people; `space` (nothing typed), `tab` (clears what was typed) or a click ticks several, `enter` or Apply takes them, `enter` without a tick takes the row; `m` stays one toggle), `0` clear, `1`-`9` quick filters; the bar lists Mine, Assignee, then the quick filters (`ui.quick_filters` first); `is:notes` in `f` finds cards with private notes | | Issue panel | A link to an issue on this site (descriptions, comments, notes, inbox, merge requests) draws as a pill, status, key, summary and assignee (`lib/issuepill.js`, `GET /api/issues/{key}/card`), and opens it here (a link with its own text keeps it); Details lists the edit screen's other fields (components, custom fields) after its own, a click edits one: the pinned ones (the pin on hover, the site's every project, shared with the TUI; its own fields pinned till unpinned; `PUT /api/fields/pinned/{id}`, `jira.FieldPins`), the rest under More (remembered open), `ui.empty_fields: hide` folds their empty ones; a filled rich-text field is a section under the description, edited like it (`GET`/`PUT /api/issues/{key}/doc/{field}`); a closed issue's fields come read-only from the screen last seen for its project and type (`jira/layout.go`); that screen also comes with the issue (`Issue.Screen`, its fields asked along), so the fields draw at once and editmeta changes nothing in place; `j`/`k` comment or scroll, `c` comment (`ctrl+o` in it: who sees it, everyone, an internal note or a project role), `R` reply, `e` summary (own focused comment), `E` description, `a` `p` `P` `l` assignee, priority, points, labels (Jira's complete as you type), `s` status, `L` link issue, `A` actions (subtask, link, each agent of the issue: type into, prompt, stop, a new agent in its directory; on a done issue remove its worktree once merged and clean; draft pull request when `jira.repos` has the project; clone (opens the create form filled in; creating links it), type, move, original estimate, dependency tree, delete, watchers, vote, flag, upload), `G` go to a linked issue (a Confluence page of this site, there or in Links, opens in place as markdown, its macros named, its images through `GET /api/confluence/images/{id}`, `GET /confluence/pages/{id}`; `ctrl+click` opens Confluence), `Backspace` back along the visible trail of followed issues (links, children, parent, `G`, palette; click a row to jump back; opening from the board resets it), `i` image viewer (arrows, `d` download), `d` delete own comment (`u` posts it back), a comment's Copy link copies its Jira link (`?focusedCommentId=`), `[`/`]` previous/next issue of the list beside, `1`-`4` tabs (`3` again steps History through Changes, Work log and All: comments, changes and work together; its box filters; a changed description shows the lines it changed), `4` Terminal (only while a herdr agent works on the issue: its live terminal; `4` again the next agent, `enter` or `ctrl+\` type, `t` take over input, `j`/`k` scroll back), `N` private notes, `D` development (in Details: pull requests, deploys, builds, branches, commits; `j`/`k` rows, `enter` open (a GitLab merge request unfolds: state, pipeline by stage, approvals, description; `GET /api/gitlab/mr?url=`; `d` its diff), `o` open in the browser, `r` reload, `y` copy branch/hash, `esc` back), `y`/`Y` copy key/link, `ctrl+y` copy branch, `*` pin, `<`/`>` widen/narrow (5%, stopping at `ui.panel_width`; a drag snaps to it within 3%, 20–80%), `o` Jira, `r` refresh, `ctrl+enter` save; a comment, comment edit or description being written is kept as a draft in the state file (shared with the TUI) and comes back after a reload; esc or Cancel with changes asks first; expands start folded | | Home | `ui.home`'s widgets, in its order, as the TUI's `~` (`internal/home`; with none set, `g h` shows every one and the board stays the start): my work (open), inbox (unread), the remembered board's active sprint (done against the time gone, behind when it trails by a tenth), the timer, waiting on my review, a count per starred Jira filter and starred search (`GET /home/sprint/{board}`, `GET /home/filters`); `jk`, `enter` opens the row (a search as a board view), a head's Open its view, `esc` the board, `r` refresh | | Planning | `jk` move, `Home`/`End`, `PgUp`/`PgDn`, `J`/`K` rank, `m` move to sprint, `[`/`]` previous/next sprint, `x`/`space` select (`ctrl+click` too, the opened issue with it; `shift+click` selects the run), `z` fold, `enter` open, `P` points, `a` assignee, `e` quick edit, `X` bulk edit the marked, `o` Jira, `y` copy the section as a markdown table, `u` undoes a move, `N` `Z` `C` `E` new, start, complete (with `ui.delight` it says how the sprint ranks), edit sprint, `B` board, `alt+p` project, `f` filter (the board's query language), `F` filter builder, `A` filter by assignee (several, as on the board); the board's chips: Mine (no key: `m` moves), Assignee, `1`-`9` its quick filters (Jira narrows every section: `GET /plan/{board}?jql=`, which lists them as `QuickFilters`), `0` clear, `|` split (a second pane keeps one sprint or the backlog in view, picked in its bar and remembered per board, and leaves it out of the list; `>` moves there, `tab` or `h`/`l` switches pane, drag between panes; wide screens only), `O` sort, `R` reload; the list is the board's (`lib/cardlist.js`): a header click sorts within each section, again reverses, a third time back to rank (sorted, `J`/`K` don't rank and a drop goes to the section's end), each pane of the split sorts on its own (prefs `planning.sort.`, `planning.sort.split.`), Columns picks them, a dragged header moves one and its dragged edge resizes it (prefs `planning.cols`, `planning.colw`); a head counts the unestimated; each sprint head shows the points per assignee against `ui.capacity`, yours less your `ui.calendar` meetings in the sprint (the hours in its tooltip; `GET /plan/{board}` `Sprints[].Mine`) | diff --git a/internal/web/api_allissues.go b/internal/web/api_allissues.go new file mode 100644 index 00000000..32d545c6 --- /dev/null +++ b/internal/web/api_allissues.go @@ -0,0 +1,125 @@ +package web + +import ( + "context" + "net/http" + "regexp" + "sync" + "time" + + "github.com/cornedor/laneway/internal/i18n" + "github.com/cornedor/laneway/internal/index" + "github.com/cornedor/laneway/internal/mirror" + "github.com/cornedor/laneway/internal/ui" +) + +// The all issues view (the TUI's, internal/ui/allissues.go): every issue of +// a project from its mirror in the site's index. The first time, a run in +// the background reads the project whole; until it is done every answer +// carries how far it got (Sync), and the page asks again. + +// projectKeyRe is what a Jira project key looks like. +var projectKeyRe = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9_]{0,63}$`) + +// mirrorRuns are the runs under way, by site and project; err keeps how the +// last one of a project failed, for its next answer. +type mirrorRuns struct { + mu sync.Mutex + runs map[string]*mirrorRun +} + +type mirrorRun struct { + done, total int + err error +} + +// syncOut is a run's progress in an answer. +type syncOut struct { + Done, Total int +} + +// allIssuesCards: GET /boards/{board}/cards?allissues=1&project=ABC, jql= +// the quick filters. {cards, total, sync?, note?}. +func allIssuesCards(ctx context.Context, s *Server, r *http.Request) (any, error) { + ix := s.opt.Index + if ix == nil { + return nil, badRequest(i18n.T("this site keeps no issue index")) + } + project := Q(r, "project") + if !projectKeyRe.MatchString(project) { + return nil, badRequest(i18n.T("bad project key")) + } + cards, note, err := ui.AllIssues(ctx, s.Client(), ix, project, Q(r, "jql")) + if err != nil { + return nil, err + } + observeRules(s, r, cards) + out := map[string]any{"cards": cards, "total": len(cards)} + if note != "" { + out["note"] = note + } + sync, err := s.mirrorProject(project, ix) + if err != nil { + return nil, err + } + if sync != nil { + out["sync"] = sync + } + return out, nil +} + +// mirrorProject is how far the run reading project is, starting one when +// its mirror was never completed; nil once it is. A failed run's error comes +// back once, and the next ask starts again. +func (s *Server) mirrorProject(project string, ix *index.Index) (*syncOut, error) { + if ix.ProjectSync(project).Complete { + return nil, nil + } + m := &s.sites.mirrors + k := s.opt.Site + "\x1f" + project + m.mu.Lock() + defer m.mu.Unlock() + if m.runs == nil { + m.runs = map[string]*mirrorRun{} + } + run := m.runs[k] + switch { + case run != nil && run.err != nil: + delete(m.runs, k) + return nil, run.err + case run != nil: + return &syncOut{Done: run.done, Total: run.total}, nil + } + run = &mirrorRun{} + m.runs[k] = run + go s.runMirror(k, run, project, ix) + return &syncOut{}, nil +} + +// runMirror reads project whole, a page at a time, keeping run's progress. +func (s *Server) runMirror(k string, run *mirrorRun, project string, ix *index.Index) { + m, c := &s.sites.mirrors, s.Client() + step := func(f func(ctx context.Context) error) error { + ctx, cancel := context.WithTimeout(s.ctx, c.Scaled(60*time.Second)) + defer cancel() + return f(ctx) + } + var r *mirror.Run + err := step(func(ctx context.Context) (err error) { + r, err = mirror.Start(ctx, c, ix, project) + return err + }) + for err == nil && !r.Finished() { + m.mu.Lock() + run.done, run.total = r.Done, r.Total + m.mu.Unlock() + err = step(func(ctx context.Context) error { return r.Next(ctx, c, ix) }) + } + m.mu.Lock() + defer m.mu.Unlock() + if err != nil { + run.err = err + return + } + delete(m.runs, k) +} diff --git a/internal/web/api_allissues_test.go b/internal/web/api_allissues_test.go new file mode 100644 index 00000000..0542c17e --- /dev/null +++ b/internal/web/api_allissues_test.go @@ -0,0 +1,82 @@ +package web + +import ( + "context" + "net/http/httptest" + "path/filepath" + "testing" + "time" + + "github.com/cornedor/laneway/internal/config" + "github.com/cornedor/laneway/internal/demo" + "github.com/cornedor/laneway/internal/index" + "github.com/cornedor/laneway/internal/jira" + "github.com/cornedor/laneway/internal/store" +) + +// TestAllIssuesCards: the first ask starts reading the project whole and +// says so; asked again once it's read, every issue comes, open and closed, +// and the quick filters' JQL narrows them. Without an index there is no +// such view. +func TestAllIssuesCards(t *testing.T) { + base, stop, err := demo.New(time.Now()).Start() + if err != nil { + t.Fatal(err) + } + t.Cleanup(stop) + st, err := store.Open(filepath.Join(t.TempDir(), "state.json")) + if err != nil { + t.Fatal(err) + } + ix, err := index.Open(filepath.Join(t.TempDir(), "ix.db")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { ix.Close() }) + cl := jira.New(jira.Config{BaseURL: base, Email: "d@example.com", APIToken: "x", Projects: []string{"DEMO"}}) + ts := httptest.NewServer(New(context.Background(), Options{Client: cl, Jira: config.JiraConfig{}, Store: st, Site: "demo", Demo: true, Index: ix})) + t.Cleanup(ts.Close) + type answer struct { + Cards []jira.Card + Total int + Sync *syncOut + } + var a answer + u := ts.URL + "/api/boards/1/cards?allissues=1&project=DEMO" + if code := issueCall(t, "GET", u, nil, &a); code != 200 || a.Sync == nil { + t.Fatalf("first ask: %d %+v; want a sync under way", code, a) + } + for deadline := time.Now().Add(5 * time.Second); a.Sync != nil && time.Now().Before(deadline); time.Sleep(20 * time.Millisecond) { + a = answer{} + issueCall(t, "GET", u+"&fresh=1", nil, &a) + } + done := 0 + for _, c := range a.Cards { + if c.Done { + done++ + } + } + if a.Sync != nil || a.Total < 20 || done == 0 || done == len(a.Cards) { + t.Fatalf("after the read: sync %+v, %d cards, %d done; want every issue, open and closed", a.Sync, a.Total, done) + } + var bugs answer + issueCall(t, "GET", u+"&jql="+"issuetype%20%3D%20Bug", nil, &bugs) + if bugs.Total == 0 || bugs.Total >= a.Total { + t.Errorf("filtered by type = Bug: %d of %d", bugs.Total, a.Total) + } + for _, c := range bugs.Cards { + if c.Type != "Bug" { + t.Errorf("%s is a %s", c.Key, c.Type) + } + } + if code := issueCall(t, "GET", ts.URL+"/api/boards/1/cards?allissues=1&project=DE%22MO", nil, nil); code != 400 { + t.Errorf("a bad project key: %d, want 400", code) + } + noIx := httptest.NewServer(New(context.Background(), Options{Client: cl, Jira: config.JiraConfig{}, Store: st, Site: "demo", Demo: true})) + t.Cleanup(noIx.Close) + var b map[string]any + issueCall(t, "GET", noIx.URL+"/api/boards/1", nil, &b) + if b["allIssues"] != false { + t.Errorf("without an index allIssues = %v", b["allIssues"]) + } +} diff --git a/internal/web/api_core.go b/internal/web/api_core.go index 15800349..f13f63cf 100644 --- a/internal/web/api_core.go +++ b/internal/web/api_core.go @@ -159,7 +159,8 @@ func boardBundle(ctx context.Context, s *Server, r *http.Request) (any, error) { return nil, err } names, _ := c.StatusNames(ctx) - out := map[string]any{"id": id, "config": cfg, "layouts": boardLayouts(s.UIConfig().LaneLayouts, lanes.Site(c.BaseURL()), id, cfg.Columns, names)} + out := map[string]any{"id": id, "config": cfg, "layouts": boardLayouts(s.UIConfig().LaneLayouts, lanes.Site(c.BaseURL()), id, cfg.Columns, names), + "allIssues": s.opt.Index != nil} out["quickFilters"] = boardQuick(ctx, s, id) if sp, err := c.Sprints(ctx, id); err == nil { out["sprints"] = sp @@ -270,6 +271,8 @@ func boardCards(ctx context.Context, s *Server, r *http.Request) (any, error) { var cards []jira.Card var total int switch { + case Q(r, "allissues") != "": + return allIssuesCards(ctx, s, r) case Q(r, "sprint") != "": sp, e := strconv.Atoi(Q(r, "sprint")) if e != nil { diff --git a/internal/web/api_sites.go b/internal/web/api_sites.go index 8f39c85d..248030ef 100644 --- a/internal/web/api_sites.go +++ b/internal/web/api_sites.go @@ -28,6 +28,8 @@ type siteSet struct { rules map[string]*ruleRunner // projects are sites' projects: saved since the start, over the config's. projects map[string][]string + // mirrors are the all issues view's runs reading a project whole. + mirrors mirrorRuns } func newSiteSet(base Options) *siteSet { diff --git a/internal/web/jstest/cardquery.test.mjs b/internal/web/jstest/cardquery.test.mjs new file mode 100644 index 00000000..156b95ce --- /dev/null +++ b/internal/web/jstest/cardquery.test.mjs @@ -0,0 +1,28 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { compile, builderValues } from '../static/js/lib/cardquery.js'; + +const day = 864e5, zero = '0001-01-01T00:00:00Z'; +const ago = d => new Date(Date.now() - d * day).toISOString(); +const cards = [ + { Key: 'A-1', Status: 'New', Assignee: 'Ada', Resolution: '', Resolved: zero }, + { Key: 'A-2', Status: 'Closed', Assignee: 'Bob', Resolution: 'Duplicate', Resolved: ago(2), Done: true }, + { Key: 'A-3', Status: 'Closed', Assignee: 'Ada', Resolution: 'Done', Resolved: ago(10), Done: true }, +]; +const find = q => cards.filter(compile(q)).map(c => c.Key).join(' '); + +test('resolution and resolved, as the TUI reads them', () => { + assert.equal(find('resolution:dup'), 'A-2'); + assert.equal(find('resolved<7d'), 'A-2'); + assert.equal(find('resolved>7d'), 'A-3'); + assert.equal(find('resolution:'), 'A-1'); +}); + +test('the builder counts a value over what the query\'s other terms leave', () => { + const vals = (field, q) => builderValues(cards, field, {}, q).map(v => v.label + ' ' + v.n).join(', '); + assert.equal(vals('assignee', 'status:closed'), 'Ada 1, Bob 1'); + assert.equal(vals('assignee', ''), 'Ada 2, Bob 1'); + // its own field's terms don't narrow it: picking another value adds to them + assert.equal(vals('status', 'status:closed'), 'Closed 2, New 1'); + assert.equal(vals('resolution', '-is:done'), ''); +}); diff --git a/internal/web/jstest/cardsort.test.mjs b/internal/web/jstest/cardsort.test.mjs index 74e71e98..71a166e6 100644 --- a/internal/web/jstest/cardsort.test.mjs +++ b/internal/web/jstest/cardsort.test.mjs @@ -35,3 +35,9 @@ test('age sorts stalest first, done last', () => { const done = { Key: 'A-3', Done: true, Since: '2025-01-01T00:00:00Z', Created: '2025-01-01T00:00:00Z' }; assert.equal(keys(sortCards([done, fresh, old], cmps, 'age', 1)), 'A-1 A-2 A-3'); }); + +test('resolved sorts the newest first, the unresolved last', () => { + const zero = '0001-01-01T00:00:00Z'; + const old = { Key: 'R-1', Resolved: '2026-10-01T00:00:00Z' }, open = { Key: 'R-2', Resolved: zero }, recent = { Key: 'R-3', Resolved: '2026-11-01T00:00:00Z' }; + assert.equal(keys(sortCards([old, open, recent], cmps, 'resolved', 1)), 'R-3 R-1 R-2'); +}); diff --git a/internal/web/server.go b/internal/web/server.go index 5b429375..13d71208 100644 --- a/internal/web/server.go +++ b/internal/web/server.go @@ -30,6 +30,7 @@ import ( "github.com/cornedor/laneway/internal/config" "github.com/cornedor/laneway/internal/forge/gitlab" "github.com/cornedor/laneway/internal/i18n" + "github.com/cornedor/laneway/internal/index" "github.com/cornedor/laneway/internal/jira" "github.com/cornedor/laneway/internal/rules" "github.com/cornedor/laneway/internal/store" @@ -87,6 +88,9 @@ type Options struct { GitLabRepos map[string]string // Timesheet is where time is logged; nil: the site's Jira worklogs. Timesheet timesheet.Provider + // Index is the site's issue index (nil: none, as when hosted), which + // the all issues view reads from. + Index *index.Index } // Server is the HTTP handler: API under /api, assets everywhere else. diff --git a/internal/web/static/js/lib/cardlist.js b/internal/web/static/js/lib/cardlist.js index 7fe99315..7c2e42af 100644 --- a/internal/web/static/js/lib/cardlist.js +++ b/internal/web/static/js/lib/cardlist.js @@ -13,13 +13,14 @@ import { T } from './i18n.js'; export { fixCols }; // What O steps the list's order through. -export const SORTS = ['rank', 'priority', 'points', 'assignee', 'epic', 'key', 'status', 'updated', 'due', 'created']; +export const SORTS = ['rank', 'priority', 'points', 'assignee', 'epic', 'key', 'status', 'updated', 'due', 'created', 'resolved']; export const PRIO_ICON = ['chevrons-up', 'chevron-up', 'equal', 'chevron-down', 'chevrons-down']; // List columns: id, header, width. export const COLS = { mark: ['', '1.857rem'], key: [T('Key'), '6.143rem'], summary: [T('Summary'), 'minmax(8.571rem, 1fr)'], status: [T('Status'), '8.429rem'], priority: [T('Prio'), '2.429rem'], points: [T('Pts'), '2.857rem'], assignee: [T('Assignee'), '10.714rem'], epic: [T('Epic'), '9.286rem'], labels: [T('Labels'), '7.857rem'], reporter: [T('Reporter'), '7.857rem'], due: [T('Due'), '4.571rem'], updated: [T('Updated'), '6rem'], created: [T('Created'), '6rem'], age: [T('Age'), '3.143rem'], + resolution: [T('Resolution'), '7.143rem'], resolved: [T('Resolved'), '6rem'], }; export const DEFAULT_COLS = ['mark', 'key', 'summary', 'status', 'priority', 'points', 'assignee', 'due', 'updated']; @@ -186,4 +187,6 @@ const FILL = { updated: (e, c, o) => { e.textContent = ago(c.Updated); e.title = o.fdate(c.Updated, ''); }, created: (e, c, o) => { e.textContent = ago(c.Created); e.title = o.fdate(c.Created, ''); }, age: (e, c) => { e.textContent = ageText(c); }, + resolution: (e, c) => { e.textContent = c.Resolution || ''; }, + resolved: (e, c, o) => { e.textContent = isZero(c.Resolved) ? '' : ago(c.Resolved); e.title = o.fdate(c.Resolved, ''); }, }; diff --git a/internal/web/static/js/lib/cardquery.js b/internal/web/static/js/lib/cardquery.js index f2e34d83..ae5b111b 100644 --- a/internal/web/static/js/lib/cardquery.js +++ b/internal/web/static/js/lib/cardquery.js @@ -1,7 +1,7 @@ // The board's search language, the TUI's (internal/ui/jira_search.go), on loaded cards: // login "log in" status:review,test epic: points>2 prio>=high is:flagged is:notes -// due<7d age>3d updated<1d created<7d reporter:ada component:api pr:open -// deploy:prod sprint:4 "test type":e2e -label:ui +// due<7d age>3d updated<1d created<7d resolved<7d resolution:duplicate reporter:ada +// component:api pr:open deploy:prod sprint:4 "test type":e2e -label:ui import { isZero } from './fmt.js'; import { T } from './i18n.js'; @@ -10,6 +10,7 @@ const FIELDS = { epic: 'parent', parent: 'parent', label: 'label', labels: 'label', key: 'key', points: 'points', sp: 'points', is: 'is', has: 'is', due: 'due', age: 'age', updated: 'updated', pr: 'pr', deploy: 'deploy', sprint: 'sprint', created: 'created', reporter: 'reporter', component: 'component', components: 'component', + resolution: 'resolution', resolved: 'resolved', }; const PRIO = { highest: 0, blocker: 0, critical: 0, high: 1, major: 1, low: 3, minor: 3, lowest: 4, trivial: 4 }; const prioRank = p => { const v = PRIO[String(p).toLowerCase()]; return v == null ? 2 : v; }; @@ -94,6 +95,7 @@ function span(term, c, now) { else if (term.field === 'age' && c.InProgress && t(c.Since)) have = now - t(c.Since); else if (term.field === 'updated' && t(c.Updated)) have = now - t(c.Updated); else if (term.field === 'created' && t(c.Created)) have = now - t(c.Created); + else if (term.field === 'resolved' && t(c.Resolved)) have = now - t(c.Resolved); else return false; const want = spanMs(term.values[0]); return want != null && order(term.op, have, want); @@ -114,6 +116,7 @@ const HAVE = { status: c => c.Status, assignee: c => c.Assignee, type: c => c.Type, priority: c => c.Priority, parent: c => c.ParentKey + ' ' + c.ParentSummary, label: c => c.Labels, key: c => c.Key, points: c => c.Points, pr: c => c.PR, deploy: c => c.Deploy, sprint: c => c.Sprint, reporter: c => c.Reporter, component: c => c.Components, + resolution: c => c.Resolution, }; function matchTerm(term, c, env) { @@ -123,7 +126,7 @@ function matchTerm(term, c, env) { return [c.Key, c.Summary, c.Assignee, c.ParentKey, c.ParentSummary].some(s => String(s || '').toLowerCase().includes(q)); } if (term.field === 'is') return term.values.some(v => is(c, v, env)); - if (['due', 'age', 'updated', 'created'].includes(term.field)) return span(term, c, env.now); + if (['due', 'age', 'updated', 'created', 'resolved'].includes(term.field)) return span(term, c, env.now); let have = ''; if (HAVE[term.field]) have = HAVE[term.field](c); else if (term.field.startsWith('custom:')) have = extraOf(c)[term.field.slice(7)] || ''; @@ -147,7 +150,7 @@ export function compile(q, env = {}) { export const BUILDER_FIELDS = [ ['status', T('Status')], ['assignee', T('Assignee')], ['type', T('Type')], ['prio', T('Priority')], ['points', T('Story points')], ['label', T('Label')], ['epic', T('Epic')], ['pr', T('Pull request')], ['deploy', T('Deployed to')], ['component', T('Component')], - ['reporter', T('Reporter')], ['is', T('Is: mine, overdue, flagged…')], + ['reporter', T('Reporter')], ['resolution', T('Resolution')], ['is', T('Is: mine, overdue, flagged…')], ]; export function builderOps(field) { if (field === 'is') return [[':', T('is')]]; @@ -155,8 +158,15 @@ export function builderOps(field) { if (field === 'points') return [['>=', T('at least')], ['<=', T('at most')], ['=', T('exactly')], ['empty', T('is empty')], ['-empty', T('is not empty')]]; return [[':', T('is')], ['-:', T('is not')], ['empty', T('is empty')]]; } -// Values of a field on the loaded cards: [{id, label, n}], most common first. -export function builderValues(cards, field, env) { +// Values of a field on the loaded cards: [{id, label, n}], most common first. With q (the board's query) the +// counts are of the cards its terms on other fields leave (TUI facetCards): what picking a value finds, and a +// value nothing would match isn't offered. +export function builderValues(cards, field, env, q = '') { + const others = parse(q).filter(tm => tm.field !== FIELDS[field]); + if (others.length) { + const e = { ...env, now: env.now || Date.now() }; + cards = cards.filter(c => others.every(tm => matchTerm(tm, c, e) !== tm.not)); + } const n = new Map(), label = new Map(); const add = (v, l) => { if (v) { n.set(v, (n.get(v) || 0) + 1); label.set(v, l || v); } }; for (const c of cards) { @@ -169,6 +179,7 @@ export function builderValues(cards, field, env) { case 'label': for (const l of (c.Labels || '').split(/\s+/)) add(l); break; case 'component': for (const l of (c.Components || '').split(SEP)) add(l); break; case 'reporter': add(c.Reporter); break; + case 'resolution': add(c.Resolution); break; case 'pr': add((c.PR || '').toLowerCase()); break; case 'deploy': add(c.Deploy); break; case 'epic': add(c.ParentKey, (c.ParentKey + ' ' + (c.ParentSummary || '')).trim()); break; diff --git a/internal/web/static/js/lib/cardsort.js b/internal/web/static/js/lib/cardsort.js index 6bcf83a0..a66b794f 100644 --- a/internal/web/static/js/lib/cardsort.js +++ b/internal/web/static/js/lib/cardsort.js @@ -24,6 +24,9 @@ export const comparators = colIndexOf => ({ reporter: text(c => c.Reporter), updated: (a, b) => time(b.Updated) - time(a.Updated) || 0, created: (a, b) => time(b.Created) - time(a.Created) || 0, + // newest first, the unresolved last + resolved: (a, b) => (time(a.Resolved) === time(b.Resolved) ? 0 : time(a.Resolved) === Infinity ? 1 : time(b.Resolved) === Infinity ? -1 : time(b.Resolved) - time(a.Resolved)), + resolution: text(c => c.Resolution), due: (a, b) => (time(a.Due) === time(b.Due) ? 0 : time(a.Due) < time(b.Due) ? -1 : 1), age: (a, b) => (ageFrom(a) === ageFrom(b) ? 0 : ageFrom(a) < ageFrom(b) ? -1 : 1), // stalest first }); diff --git a/internal/web/static/js/lib/chrome.js b/internal/web/static/js/lib/chrome.js index 756cc616..90f315fb 100644 --- a/internal/web/static/js/lib/chrome.js +++ b/internal/web/static/js/lib/chrome.js @@ -11,9 +11,9 @@ import { routeTitle } from './nav.js'; import { T, Tn } from './i18n.js'; // Most used first: a narrow bar folds the tail into More. The open view always stays. -const PRIORITY = ['board', 'work', 'inbox', 'planning', 'reports', 'standup', 'roadmap', 'review', 'mrs', 'agents']; +const PRIORITY = ['board', 'issues', 'work', 'inbox', 'planning', 'reports', 'standup', 'roadmap', 'review', 'mrs', 'agents']; const GROUPS = ['Project', 'You', 'Tools']; -const GROUP = { board: 'Project', planning: 'Project', reports: 'Project', roadmap: 'Project', work: 'You', inbox: 'You', standup: 'You', mrs: 'You' }; +const GROUP = { board: 'Project', issues: 'Project', planning: 'Project', reports: 'Project', roadmap: 'Project', work: 'You', inbox: 'You', standup: 'You', mrs: 'You' }; const groupName = g => ({ Project: T('Project'), You: T('You'), Tools: T('Tools') }[g] || g); export const groupOf = r => r.group || GROUP[r.name] || 'Tools'; const rank = n => { const i = PRIORITY.indexOf(n); return i < 0 ? PRIORITY.length : i; }; diff --git a/internal/web/static/js/views/board.js b/internal/web/static/js/views/board.js index aa8a16e9..7e864b37 100644 --- a/internal/web/static/js/views/board.js +++ b/internal/web/static/js/views/board.js @@ -46,7 +46,9 @@ function parseEvery(s) { // embed mounts the board inside another view (the standup's): it leaves the URL, the context crumbs, the palette's // board commands and app.lastView alone, takes its keys from the scope it is given (none, for the standup), and // gains embed.setWho(ids) and embed.select(key); embed.onWho(ids) hears the assignee filter change on it. -export default function mount(el, { app, params, query, scope, context, toolbar, embed }) { +// issues mounts it as the All issues page (views/issues.js): the all issues view under /issues/PROJECT/BOARD, the +// other views a step to the Board page, and back. +export default function mount(el, { app, params, query, scope, context, toolbar, embed, issues }) { const { api, bus, ui } = app; const me = (app.session.me && app.session.me.AccountID) || ''; const UI = app.session.ui || {}; @@ -61,7 +63,7 @@ export default function mount(el, { app, params, query, scope, context, toolbar, const S = { project: '', boards: [], board: null, bundle: null, people: new Map(), cards: [], total: 0, loaded: false, path: '', fetched: 0, busy: 0, - scope: query.sprint || 'active', saved: [], queues: [], + scope: issues ? 'issues' : query.sprint || 'active', saved: [], queues: [], qf: new Set(), mine: false, who: null, text: '', textFn: null, mode: 'lanes', sort: 'rank', dir: 1, swim: 'none', fold: new Set(), compact: false, hideEmpty: false, hidden: 0, cols: DEFAULT_COLS, colw: readWidths(app.prefs.get('board.colw', '')), layout: '', layoutHidden: 0, secFold: new Set(), laneFold: new Set(), @@ -91,7 +93,7 @@ export default function mount(el, { app, params, query, scope, context, toolbar, // ---- toolbar const projectBtn = app.chrome.crumb(T('Project (alt+p): switches to its last board'), () => openProject(app)); const boardBtn = app.chrome.crumb(T('Board (B)'), () => pickBoard()); - const sprintBtn = app.chrome.crumb(T('View: sprint, backlog, whole board, your views (v, [ ])'), () => pickSprint()); + const sprintBtn = app.chrome.crumb(T('View: sprint, backlog, whole board, all issues, your views (v, [ ])'), () => pickSprint()); const modeBtn = h('button.btn', { title: T('Lanes / list (t)'), onclick: () => setMode(S.mode === 'lanes' ? 'list' : 'lanes') }); const swimBtn = h('button.btn', { title: T('Swimlanes: none, assignee, epic, priority (O)'), onclick: () => cycleSwim() }); const colsBtn = h('button.btn', { title: T('List columns (C)'), onclick: () => pickCols() }, icon('columns-3'), T('Columns'), keyHint('C')); @@ -110,6 +112,7 @@ export default function mount(el, { app, params, query, scope, context, toolbar, sprintBtn.textContent = sp; sprintBtn.hidden = !isScrum() && !viewItems().some(i => i.id.includes(':') && !i.id.startsWith('closed')); if (S.mode === 'lanes') keyIcon(modeBtn, 't', 'kanban', T('Lanes')); else keyIcon(modeBtn, 't', 'list', T('List')); + modeBtn.hidden = !!issues; swimBtn.hidden = S.mode !== 'lanes'; colsBtn.hidden = S.mode !== 'list'; compactBtn.hidden = S.mode !== 'lanes'; emptyBtn.hidden = S.mode !== 'lanes'; keyIcon(swimBtn, 'O', 'rows-3', S.swim === 'none' ? T('Swimlanes') : T('by %s', SWIMNAME[S.swim])); if (S.compact) keyIcon(compactBtn, 'c', 'fold-vertical', T('Compact')); else keyIcon(compactBtn, 'c', 'unfold-vertical', T('Full')); @@ -122,7 +125,8 @@ export default function mount(el, { app, params, query, scope, context, toolbar, } const isScrum = () => !!S.board && S.board.Type !== 'kanban'; const sprints = () => (S.bundle && S.bundle.sprints) || []; - // Everything [ ] walks through: sprints, backlog, whole board, then ui.views, starred filters and service desk queues. + // Everything [ ] walks through: sprints, backlog, whole board, all issues, then ui.views, starred filters and + // service desk queues. function viewItems() { const out = []; if (isScrum()) { @@ -133,6 +137,8 @@ export default function mount(el, { app, params, query, scope, context, toolbar, const cols = (S.bundle && S.bundle.config && S.bundle.config.Columns) || []; if (cols.length && cols[0].Name.toLowerCase() === 'backlog') out.push({ id: 'backlog', name: T('Backlog') }); } + // The project's every issue, open and closed, from the site's index (the TUI's jiraViewAll). + if (S.bundle && S.bundle.allIssues) out.push({ id: 'issues', name: T('All issues'), info: T('the whole project') }); ((app.session.ui && app.session.ui.Views) || []).forEach((v, i) => { if (v.Name && v.JQL) out.push({ id: 'view:' + i, name: v.Name, info: T('view'), jql: v.JQL, kind: 'jql' }); }); for (const f of S.saved) out.push({ id: 'filter:' + f.ID, name: f.Name, info: T('saved filter'), jql: f.JQL, kind: 'filter' }); for (const qu of S.queues) out.push({ id: 'queue:' + qu.Desk + ':' + qu.ID, name: qu.Project + ' · ' + qu.Name, info: T('queue · %d', qu.Count), jql: qu.JQL, kind: 'filter' }); @@ -147,6 +153,7 @@ export default function mount(el, { app, params, query, scope, context, toolbar, if (it && (it.kind || !isScrum())) return it.name; if (S.scope === 'backlog') return T('Backlog'); if (S.scope === 'all') return T('Whole board'); + if (S.scope === 'issues') return T('All issues'); const r = resolveScope(); const sp = r.sprint && sprints().find(s => s.ID === r.sprint); return sp ? sp.Name : T('Whole board'); @@ -228,6 +235,14 @@ export default function mount(el, { app, params, query, scope, context, toolbar, let pts = 0; for (const c of vis) pts += Number(c.Points) || 0; const filtered = vis.length !== S.cards.length; + if (resolveScope().issues) { + // All issues: how many, how many open, how many the search leaves (TUI allIssuesCounts); while the + // project is read whole the first time, how far that got. + if (S.sync) { stats.textContent = T('reading all issues: %d of %d', S.sync.Done, S.sync.Total); return; } + const open = S.cards.reduce((n, c) => n + (c.Done ? 0 : 1), 0); + stats.textContent = T('%d issues · %d open', S.cards.length, open) + (filtered ? T(' · %d shown', vis.length) : ''); + return; + } stats.textContent = (filtered ? T('%d of %d issues', vis.length, S.cards.length) : Tn(S.cards.length, '%d issue', '%d issues', S.cards.length)) + (pts ? ' · ' + T('%s pts', Math.round(pts * 10) / 10) : '') + (S.total > S.cards.length ? ' ' + T('(%d in Jira)', S.total) : ''); } @@ -237,6 +252,7 @@ export default function mount(el, { app, params, query, scope, context, toolbar, if (S.closed) return { sprint: S.closed.ID }; const vi = viewItems().find(i => i.id === S.scope); if (vi && vi.kind) return { view: vi }; + if (S.scope === 'issues') return { issues: 1 }; if (S.scope === 'backlog') return { backlog: 1 }; if (!isScrum()) return { kanban: 1 }; if (S.scope === 'all') return {}; @@ -266,6 +282,7 @@ export default function mount(el, { app, params, query, scope, context, toolbar, if (r.sprint) p.set('sprint', r.sprint); if (r.backlog) p.set('backlog', 1); if (r.kanban) p.set('kanban', 1); + if (r.issues) { p.set('allissues', 1); p.set('project', S.project); } const jql = qfs().filter(q => S.qf.has(q.ID)).map(q => '(' + q.JQL + ')').join(' AND '); if (r.view) { p.set('kind', r.view.kind); p.set('jql', r.view.jql); @@ -336,12 +353,22 @@ export default function mount(el, { app, params, query, scope, context, toolbar, } function setCards(d) { const list = d.cards || []; - if (S.loaded && sameCards(S.cards, list)) { S.total = d.total || list.length; return; } + syncing(d); + if (S.loaded && sameCards(S.cards, list)) { S.total = d.total || list.length; renderStats(); return; } S.cards = list; S.total = d.total || list.length; S.loaded = true; layout(); renderBar(); if (S.past && !S.past.moves && !S.past.fetching) { S.past.fetching = true; fetchMoves(); } } + // All issues on a project read whole for the first time: the answer says how far that got, and the page asks + // again until it's done. Offline, note says what the filters' answer left out. + function syncing(d) { + const was = S.sync; + S.sync = d.sync || null; + if (S.sync) later(() => { if (S.sync && !S.dead) refresh(false); }, 1500); + if (d.note && !was && d.note !== S.note) ui.toast(d.note); + S.note = d.note || ''; + } function sameCards(a, b) { if (a.length !== b.length) return false; for (let i = 0; i < a.length; i++) if (a[i] !== b[i] && JSON.stringify(a[i]) !== JSON.stringify(b[i])) return false; @@ -1258,6 +1285,7 @@ export default function mount(el, { app, params, query, scope, context, toolbar, } function setMode(m) { if (m === S.mode) return; + if (issues) return ui.toast(T('All issues is a list only')); S.mode = m; S.built = ''; app.prefs.set('board.mode.' + S.board.ID, m); renderToolbar(); layout(); @@ -1309,7 +1337,7 @@ export default function mount(el, { app, params, query, scope, context, toolbar, // ---- filter builder, named filters function openBuilder() { if (!S.loaded) return; - openFilterBuilder({ app, cards: S.base || S.cards, env: env(), query: S.text, apply: t => { filterIn.value = t; setText(t); } }); + openFilterBuilder({ app, cards: (S.base || S.cards).filter(c => (!S.mine || c.AssigneeID === me) && passesWho(S.who, c)), env: env(), query: S.text, apply: t => { filterIn.value = t; setText(t); } }); } let filterCmds = []; function registerNamedFilters() { @@ -1441,7 +1469,7 @@ export default function mount(el, { app, params, query, scope, context, toolbar, // ---- pickers async function pickBoard() { const b = await pickBoardOf(app, S.project, { scrum: false }); - if (b) app.go('/board/' + S.project + '/' + b.ID); + if (b) app.go((issues ? '/issues/' : '/board/') + S.project + '/' + b.ID); } async function pickSprint() { const items = viewItems(); @@ -1449,7 +1477,13 @@ export default function mount(el, { app, params, query, scope, context, toolbar, const r = await ui.pick({ title: T('View'), items, label: i => i.name, detail: i => i.info || '' }); if (r) setScope(r.id); } + // The page a view lives on: all issues on the All issues page, the rest on the Board page. + const pageOf = id => (id === 'issues' ? '/issues/' : '/board/') + S.project + '/' + S.board.ID; function setScope(id) { + if (!embed && S.board && (id === 'issues') !== !!issues) { + app.go(pageOf(id) + (id === 'issues' || id === 'active' ? '' : '?sprint=' + encodeURIComponent(id))); + return; + } if (S.past) leavePast(true); S.closed = null; S.scope = id; @@ -1691,13 +1725,14 @@ export default function mount(el, { app, params, query, scope, context, toolbar, } if (S.dead) return; if (!embed) { - const canon = '/board/' + S.project + '/' + S.board.ID; + const canon = (issues ? '/issues/' : '/board/') + S.project + '/' + S.board.ID; if (location.pathname !== canon) app.setURL(canon + location.search); if (app.route) { app.route.params.project = S.project; app.route.params.board = String(S.board.ID); } setCtx(app, S.project, S.board); } const dm = app.session.ui && app.session.ui.DefaultMode; S.mode = app.prefs.get('board.mode.' + S.board.ID, app.prefs.get('board.mode', dm === 'list' ? 'list' : 'lanes')) === 'list' ? 'list' : 'lanes'; + if (issues) S.mode = 'list'; // thousands of issues: a list only const [sort, dir] = String(app.prefs.get('board.sort.' + S.board.ID, 'rank:1')).split(':'); S.sort = SORTS.includes(sort) || allCols().includes(sort) ? sort : 'rank'; S.dir = dir === '-1' ? -1 : 1; const sw = app.prefs.get('board.swim.' + S.board.ID, 'none'); S.swim = SWIMS.includes(sw) ? sw : 'none'; diff --git a/internal/web/static/js/views/board_filter.js b/internal/web/static/js/views/board_filter.js index f23f28e8..31dbe503 100644 --- a/internal/web/static/js/views/board_filter.js +++ b/internal/web/static/js/views/board_filter.js @@ -17,7 +17,7 @@ export function openFilterBuilder({ app, cards, env, query, apply }) { const base = c => { if (c === 0) return cq.BUILDER_FIELDS.map(([id, label]) => ({ id, label })); if (c === 1) return cq.builderOps(pick(0).id).map(([id, label]) => ({ id, label })); - return noValue(pick(1).id) ? [] : cq.builderValues(cards, pick(0).id, env).map(v => ({ id: v.id, label: v.label + ' · ' + v.n })); + return noValue(pick(1).id) ? [] : cq.builderValues(cards, pick(0).id, env, q).map(v => ({ id: v.id, label: v.label + ' · ' + v.n })); }; const rows = c => { const all = base(c); diff --git a/internal/web/static/js/views/index.js b/internal/web/static/js/views/index.js index ba320e7b..b280653d 100644 --- a/internal/web/static/js/views/index.js +++ b/internal/web/static/js/views/index.js @@ -8,6 +8,7 @@ export const routes = [ { path: '/home', name: 'home', title: T('Home'), key: 'h', bar: false, load: () => import('./home.js') }, { path: '/board/:project?/:board?', name: 'board', title: T('Board'), key: 'b', load: () => import('./board.js') }, { path: '/issue/:key', name: 'issue', title: T('Issue'), nav: false, bar: false, load: () => import('./issue_page.js') }, + { path: '/issues/:project?/:board?', name: 'issues', title: T('All issues'), load: () => import('./issues.js') }, { path: '/planning/:project?/:board?', name: 'planning', title: T('Planning'), key: 'p', load: () => import('./planning.js') }, { path: '/reports/:kind?/:project?/:board?', name: 'reports', title: T('Reports'), key: 'r', load: () => import('./reports.js') }, { path: '/roadmap/:project?', name: 'roadmap', title: T('Roadmap'), key: 'm', load: () => import('./roadmap.js') }, diff --git a/internal/web/static/js/views/issues.js b/internal/web/static/js/views/issues.js new file mode 100644 index 00000000..f2b13867 --- /dev/null +++ b/internal/web/static/js/views/issues.js @@ -0,0 +1,5 @@ +// The All issues page: the board's all issues view as a place of its own (/issues/PROJECT/BOARD; the TUI's +// jiraViewAll), on the remembered project and board when none is named. +import mount from './board.js'; + +export default (el, ctx) => mount(el, { ...ctx, issues: true }); diff --git a/main.go b/main.go index 003ddcf9..6ce2dd23 100644 --- a/main.go +++ b/main.go @@ -267,10 +267,16 @@ func runDemo(cfgPath string) error { if err != nil { return err } + // An index of its own, gone with the rest, for the all issues view. + ix, err := index.Open(filepath.Join(dir, "index.db")) + if err != nil { + return err + } + defer ix.Close() ctx, cancel := context.WithCancel(context.Background()) defer cancel() jc := config.JiraConfig{BaseURL: baseURL, Email: "demo@example.com", APIToken: "demo", Projects: []string{"DEMO"}} - m := ui.New(ctx, jc, uiCfg, nil, filepath.Join(dir, "rules.log"), st).WithVersion(version, "").WithDemo().WithGitLab(demoGitLab(baseURL)) + m := ui.New(ctx, jc, uiCfg, nil, filepath.Join(dir, "rules.log"), st).WithVersion(version, "").WithDemo().WithGitLab(demoGitLab(baseURL)).WithIndex(ix) final, err := newProgram(m).Run() if fm, ok := final.(ui.Model); ok { fmt.Fprint(os.Stdout, fm.ReleasePointer()) diff --git a/webcmd.go b/webcmd.go index 00cc01b0..70e48d1b 100644 --- a/webcmd.go +++ b/webcmd.go @@ -26,6 +26,7 @@ import ( "github.com/cornedor/laneway/internal/autostart" "github.com/cornedor/laneway/internal/config" "github.com/cornedor/laneway/internal/i18n" + "github.com/cornedor/laneway/internal/index" "github.com/cornedor/laneway/internal/jira" "github.com/cornedor/laneway/internal/offline" "github.com/cornedor/laneway/internal/store" @@ -148,6 +149,11 @@ func serveWeb(parent context.Context, cfgPath, site, addr, cert, key, token stri opt.Client = webClient(jc, uiCfg) opt.Client.SetQueue(offline.To(opt.Store)) opt.Client.SetLayouts(opt.Store) + // An index of its own, gone with the rest, for the all issues view. + if opt.Index, err = index.Open(filepath.Join(dir, "index.db")); err != nil { + return end, bound, err + } + defer opt.Index.Close() } else { cfg, loaded, err := config.Load(cfgPath) if err != nil && !errors.Is(err, config.ErrNoConfig) { @@ -443,6 +449,13 @@ func webSite(cfg config.Config, site string, opt web.Options) (web.Options, erro opt.Timesheet, _ = timesheetProvider(cfg) opt.Client.SetQueue(offline.To(opt.Store)) opt.Client.SetLayouts(opt.Store) + // The index is a mirror, shared with the terminal app: without it the + // all issues view is left out. + if p, err := index.Path(site); err == nil { + if opt.Index, err = index.Open(p); err != nil { + fmt.Fprintln(os.Stderr, "laneway: index:", err) + } + } return opt, nil } From ef1199f2b6601b1586ae270c7518f986029144ae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Corn=C3=A9=20Dorrestijn?= Date: Thu, 12 Nov 2026 13:37:30 +0100 Subject: [PATCH 2/3] fix(LAN-0): add a filter builder button to the filter inputs --- internal/i18n/nl/web-lib.json | 4 +++- internal/web/static/css/base.css | 7 +++++++ internal/web/static/css/board.css | 4 ++-- internal/web/static/css/mobile.css | 2 +- internal/web/static/css/planning.css | 4 ++-- internal/web/static/css/work.css | 2 +- internal/web/static/js/lib/filterbox.js | 14 ++++++++++++++ internal/web/static/js/lib/icons.js | 1 + internal/web/static/js/views/board.js | 3 ++- internal/web/static/js/views/planning.js | 3 ++- internal/web/static/js/views/work.js | 4 +++- 11 files changed, 38 insertions(+), 10 deletions(-) create mode 100644 internal/web/static/js/lib/filterbox.js diff --git a/internal/i18n/nl/web-lib.json b/internal/i18n/nl/web-lib.json index 6c193e69..6c2180a5 100644 --- a/internal/i18n/nl/web-lib.json +++ b/internal/i18n/nl/web-lib.json @@ -586,5 +586,7 @@ "Key or words of its summary": "Key of woorden uit de samenvatting", "No issue matches “%s”": "Geen issue past bij “%s”", "Not an issue key": "Geen issue-key", - "Nothing open to choose": "Niets open om te kiezen" + "Nothing open to choose": "Niets open om te kiezen", + "Filter builder": "Filterbouwer", + "Filter builder (F)": "Filterbouwer (F)" } diff --git a/internal/web/static/css/base.css b/internal/web/static/css/base.css index 1f2c8dcb..9f36ef47 100644 --- a/internal/web/static/css/base.css +++ b/internal/web/static/css/base.css @@ -43,6 +43,13 @@ body.panel-focus #panel { border-left-color: var(--accent); box-shadow: inset 1p html[data-btn-keys="hide"] .btn-key, html[data-btn-keys="hide"] .key-off, html:not([data-btn-keys="hide"]) .key-dup { display: none; } /* .key-dup: a hint the buttons' keys repeat; .key-off: its stand-in */ .input, select.input, textarea.input { font: inherit; color: var(--fg); background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius); padding: .357rem .571rem; width: 100%; } .input:focus { border-color: var(--accent); outline: none; } +/* a filter input with the filter builder's button at its end (lib/filterbox.js) */ +.fbox { position: relative; display: inline-flex; align-items: center; min-width: 0; } +.fbox > .fbox-in { padding-right: 2rem; } +.fbox-btn { position: absolute; right: .214rem; top: 50%; transform: translateY(-50%); display: inline-flex; align-items: center; justify-content: center; + width: 1.571rem; height: 1.571rem; padding: 0; border: 0; border-radius: var(--radius); background: transparent; color: var(--fg-3); cursor: pointer; } +.fbox-btn:hover { background: var(--bg-3); color: var(--fg); } +.fbox-btn:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; } kbd { font-size: .85em; background: var(--bg-3); border: 1px solid var(--border); border-bottom-width: 2px; border-radius: 4px; padding: 0 .357rem; color: var(--fg-2); } .row { display: flex; gap: var(--pad); align-items: center; } .row.end { justify-content: flex-end; margin-top: var(--pad); } .spacer { flex: 1; } diff --git a/internal/web/static/css/board.css b/internal/web/static/css/board.css index 544ee326..de1f57cf 100644 --- a/internal/web/static/css/board.css +++ b/internal/web/static/css/board.css @@ -10,7 +10,7 @@ .fchip.on { background: var(--on-bg); color: var(--on-fg); border-color: var(--on-border); } .fchip.clear { padding-left: .643rem; color: var(--fg-3); background: transparent; } .fchip kbd { font-size: .714rem; padding: 0 .286rem; line-height: 1rem; } -.bd-filter { flex: 0 1 15.714rem; min-width: 7rem; max-width: 40vw; } +.bd-filter-box { flex: 0 1 15.714rem; min-width: 7rem; max-width: 40vw; } .bd-stats { flex: none; font-size: .857rem; white-space: nowrap; } /* the sprint line, beside its crumb: it takes what the toolbar leaves, the goal giving way first, then the days left; short of its minimum the toolbar wraps under it. A class, not :has(:not(:empty)): WebKit misses that as the line fills. */ @@ -136,7 +136,7 @@ body.panel-focus .lrow.sel { background: var(--bg-3); box-shadow: inset 3px 0 va @media (max-width: 700px) { .bd-bar { flex-wrap: wrap; } .bd-chips { flex-wrap: wrap; } - .bd-filter { flex-basis: 100%; max-width: none; } + .bd-filter-box { flex-basis: 100%; max-width: none; } .bd-lhead, .lrow { grid-template-columns: 1.857rem 5.714rem minmax(7.143rem, 1fr) 7.143rem; } .bd-lhead > :not(.lh-mark, .lh-key, .lh-summary, .lh-status), .lrow > :not(.l-mark, .l-key, .l-summary, .l-status) { display: none; } .lh-key, .l-key { order: 1; } .lh-summary, .l-summary { order: 2; } .lh-status, .l-status { order: 3; } diff --git a/internal/web/static/css/mobile.css b/internal/web/static/css/mobile.css index 2f1c4683..7f227e88 100644 --- a/internal/web/static/css/mobile.css +++ b/internal/web/static/css/mobile.css @@ -13,7 +13,7 @@ /* lanes: one column per screen, swipe to snap between them */ .bd-lanes { scroll-snap-type: x mandatory; scroll-padding-inline: var(--pad); overscroll-behavior-x: contain; } .bd-lanes .bd-lane { flex: 0 0 calc(100vw - var(--pad) * 4); min-width: 0; scroll-snap-align: center; scroll-snap-stop: always; } - .bd-filter { max-width: none; flex: 1; } + .bd-filter-box { max-width: none; flex: 1; } /* pickers and dialogs become bottom sheets */ .overlay { align-items: flex-end; padding-top: 0; } .modal, .modal.wide, .modal.pick-modal { width: 100vw; max-width: none; max-height: 85vh; max-height: 85dvh; border-radius: 14px 14px 0 0; border-bottom: 0; padding-bottom: calc(var(--pad) * 1.5 + env(safe-area-inset-bottom)); animation: lw-sheet .18s ease-out; } diff --git a/internal/web/static/css/planning.css b/internal/web/static/css/planning.css index 4a3288b7..a78d6b66 100644 --- a/internal/web/static/css/planning.css +++ b/internal/web/static/css/planning.css @@ -2,7 +2,7 @@ .pl-scroll { flex: 1; min-height: 0; overflow: auto; position: relative; outline: none; } .pl-space { position: relative; width: 100%; } .pl-abs { position: absolute; left: 0; right: 0; top: 0; will-change: transform; } -.pl-filter { width: 15.714rem; flex: none; } +.pl-filter-box { width: 15.714rem; flex: none; } #toolbar > .pl-chips { flex-shrink: 1; } /* the board's chips scroll when the toolbar is short */ .pl-panes { flex-direction: row; } .pl-pane { flex: 1; min-width: 0; min-height: 0; display: flex; flex-direction: column; container-type: inline-size; } @@ -51,5 +51,5 @@ #viewbar:has(> #toolbar > .pl-chips) { flex-wrap: wrap; row-gap: .286rem; } #toolbar:has(> .pl-chips) { flex: 1 1 100%; flex-wrap: wrap; row-gap: .286rem; } #toolbar > .pl-chips { flex: 1 1 100%; flex-wrap: nowrap; order: 1; } - .pl-filter { flex: 1; width: auto; } + .pl-filter-box { flex: 1; width: auto; } } diff --git a/internal/web/static/css/work.css b/internal/web/static/css/work.css index f62e9332..3d4fca1c 100644 --- a/internal/web/static/css/work.css +++ b/internal/web/static/css/work.css @@ -5,7 +5,7 @@ .work { height: 100%; display: flex; flex-direction: column; position: relative; } .work-scroll { flex: 1; min-height: 0; overflow: auto; } .work-body { flex: 1; overflow: auto; padding: var(--pad) calc(var(--pad) * 2); } -.work-filter { flex: 0 1 14.286rem; min-width: 5.714rem; } .work-group { width: auto; flex: none; } #toolbar .btn { white-space: nowrap; flex: none; } +.work-filter-box { flex: 0 1 14.286rem; min-width: 5.714rem; } .work-group { width: auto; flex: none; } #toolbar .btn { white-space: nowrap; flex: none; } .seg { display: inline-flex; } .seg .btn { border-radius: 0; margin-left: -1px; } .seg .btn:first-child { border-radius: var(--radius) 0 0 var(--radius); margin-left: 0; } .seg .btn:last-child { border-radius: 0 var(--radius) var(--radius) 0; } .seg .btn.on, .btn.ghost.on { background: var(--on-bg); color: var(--on-fg); border-color: var(--on-border); } diff --git a/internal/web/static/js/lib/filterbox.js b/internal/web/static/js/lib/filterbox.js new file mode 100644 index 00000000..d0a3078a --- /dev/null +++ b/internal/web/static/js/lib/filterbox.js @@ -0,0 +1,14 @@ +// A filter input with a button at its end that opens the filter builder (F, views/board_filter.js): the board's, +// planning's and my work's. The input keeps its class (bd-filter…); the wrapper is sized as -box. +import { h } from './dom.js'; +import { icon } from './icons.js'; +import { T } from './i18n.js'; + +export function filterBox(input, open) { + const own = [...input.classList].filter(c => c !== 'input'); + input.classList.add('fbox-in'); + const btn = h('button.fbox-btn', { type: 'button', title: T('Filter builder (F)'), 'aria-label': T('Filter builder'), onclick: () => open() }, icon('list-filter')); + const box = h('span.fbox', input, btn); + box.classList.add(...own.map(c => c + '-box')); + return box; +} diff --git a/internal/web/static/js/lib/icons.js b/internal/web/static/js/lib/icons.js index 1d664d85..15a98216 100644 --- a/internal/web/static/js/lib/icons.js +++ b/internal/web/static/js/lib/icons.js @@ -3,6 +3,7 @@ // icon('star') → , icon('star', true) filled. To add one, copy the inner markup of // lucide-static's icons/.svg into P. const P = { + 'list-filter': '', 'align-center': '', 'align-left': '', 'align-right': '', diff --git a/internal/web/static/js/views/board.js b/internal/web/static/js/views/board.js index 7e864b37..291947ec 100644 --- a/internal/web/static/js/views/board.js +++ b/internal/web/static/js/views/board.js @@ -15,6 +15,7 @@ import * as pins from '../lib/pins.js'; import { confetti } from '../lib/delight.js'; import { passesWho, pickWho as pickPeople, whoLabel } from '../lib/who.js'; import { openFilterBuilder } from './board_filter.js'; +import { filterBox } from '../lib/filterbox.js'; import { comparators, sortCards, prioOrd, num } from '../lib/cardsort.js'; import { selBar } from '../lib/selbar.js'; import { COLS, DEFAULT_COLS, SORTS, gridCols, fixCols, nextSort, listHead, paintHead as paintListHead, pickCols as pickListCols, dragCols, resizeCols, readWidths, buildRow as listRow, fillCells } from '../lib/cardlist.js'; @@ -82,7 +83,7 @@ export default function mount(el, { app, params, query, scope, context, toolbar, const filterIn = h('input.input.bd-filter', { type: 'text', placeholder: T('Filter (f)'), spellcheck: false, autocomplete: 'off', title: T('words, status:review,test points>2 prio>=high is:flagged age>3d due<7d epic: -negate (F builds a query)') }); const chips = h('div.bd-chips'); const stats = h('span.bd-stats.dim'), sprintEl = h('span.bd-sprint.dim'); - const bar = h('div.bd-bar', chips, h('span.sp'), filterIn, stats); + const bar = h('div.bd-bar', chips, h('span.sp'), filterBox(filterIn, () => openBuilder()), stats); const banner = h('div.bd-banner', { hidden: true }); const main = h('div.bd-main'); const selbar = selBar({ edit: () => bulk(), clear: () => clearMarks() }); diff --git a/internal/web/static/js/views/planning.js b/internal/web/static/js/views/planning.js index 161a3599..47736adc 100644 --- a/internal/web/static/js/views/planning.js +++ b/internal/web/static/js/views/planning.js @@ -7,6 +7,7 @@ import { rowPx, px14, onChange as onMetrics } from '../lib/metrics.js'; import { isZero, shortDate } from '../lib/fmt.js'; import { resolve, switcher, noBoard } from './plan_ctx.js'; import { openFilterBuilder } from './board_filter.js'; +import { filterBox } from '../lib/filterbox.js'; import * as cq from '../lib/cardquery.js'; import * as pins from '../lib/pins.js'; import { whoOf, passesWho, whoKey, whoLabel, pickWho as pickPeople } from '../lib/who.js'; @@ -118,7 +119,7 @@ export default async function mount(el, { app, params, scope, context, toolbar } }); const colsBtn = h('button.btn.ghost', { title: T('List columns'), onclick: () => pickCols() }, icon('columns-3'), T('Columns')); const splitBtn = h('button.btn.ghost.pl-split', { title: T('Keep a sprint in view beside the list (|)'), onclick: () => setSide(side == null ? defaultSide() : null) }, T('Split'), keyHint('|')); - toolbar.append(filterIn, chips, h('span.spacer'), colsBtn, splitBtn, h('button.btn.nw', { title: T('New sprint (N)'), onclick: () => newSprint() }, T('+ Sprint'), keyHint('N'))); + toolbar.append(filterBox(filterIn, () => openBuilder()), chips, h('span.spacer'), colsBtn, splitBtn, h('button.btn.nw', { title: T('New sprint (N)'), onclick: () => newSprint() }, T('+ Sprint'), keyHint('N'))); // ---- data const secOf = id => sections.find(s => s.id === id); diff --git a/internal/web/static/js/views/work.js b/internal/web/static/js/views/work.js index d9c89f5d..6f23b138 100644 --- a/internal/web/static/js/views/work.js +++ b/internal/web/static/js/views/work.js @@ -10,6 +10,7 @@ import * as cq from '../lib/cardquery.js'; import * as pins from '../lib/pins.js'; import { check, setCheck, selBar } from '../lib/selbar.js'; import { openFilterBuilder } from './board_filter.js'; +import { filterBox } from '../lib/filterbox.js'; import { T, Tn } from '../lib/i18n.js'; import { keyHint } from '../lib/keys.js'; @@ -54,13 +55,14 @@ export default function mount(el, { app, scope, toolbar, query }) { openFilterBuilder({ app, cards, env: env(), query: filterIn.value.trim(), apply: t => { filterIn.value = t; setFilter(t); } }); } filterIn.addEventListener('keydown', e => { if (e.key === 'Escape' || e.key === 'Enter') { e.stopPropagation(); filterIn.blur(); } }); + const filterEl = filterBox(filterIn, () => openBuilder()); const groupSel = h('select.input.work-group', { title: T('Group by (v)'), onchange: () => { group = groupSel.value; prefs.set('work_group', group); buildRows(); } }, ['status', 'project', 'none'].map(g => h('option', { value: g, selected: g === group }, g === 'none' ? T('No grouping') : g === 'status' ? T('By status') : T('By project')))); const doneBtn = h('button.btn.ghost', { title: T('Hide done (d)'), onclick: () => toggleDone() }); function paintToolbar() { clear(toolbar).append(...kids([ h('div.seg', [['issues', T('Issues'), '1'], ['day', T('Day'), '2'], ['week', T('Week'), '3']].map(([t, n, k]) => h('button.btn' + (tab === t ? '.on' : ''), { onclick: () => setTab(t), title: k }, n))), - tab === 'issues' && [filterIn, groupSel, doneBtn], h('span.spacer')])); + tab === 'issues' && [filterEl, groupSel, doneBtn], h('span.spacer')])); doneBtn.replaceChildren(hideDone ? T('Done hidden') : T('Hide done'), keyHint('d')); doneBtn.classList.toggle('on', hideDone); } From 4d0427fea4e971ee7cda6afb23ef45ce5e174569 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Corn=C3=A9=20Dorrestijn?= Date: Thu, 12 Nov 2026 13:47:37 +0100 Subject: [PATCH 3/3] test(LAN-0): allow the card resolution fields the contract recording predates --- internal/contract/testdata/allow.txt | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/internal/contract/testdata/allow.txt b/internal/contract/testdata/allow.txt index d56d1020..fecc89be 100644 --- a/internal/contract/testdata/allow.txt +++ b/internal/contract/testdata/allow.txt @@ -5,3 +5,13 @@ # The recording account sees only the system dashboard, which has no owner; # Jira sends the owner of any other (expand=owner). GET /rest/api/3/dashboard/search 200 | values[].owner + +# Cards ask for resolution and resolutiondate (the all issues view) since +# the recording was made; re-recording on corne-team verifies them. +GET /rest/agile/1.0/board/{id}/backlog 200 | issues[].fields.resolution +GET /rest/agile/1.0/board/{id}/backlog 200 | issues[].fields.resolutiondate +GET /rest/agile/1.0/board/{id}/issue 200 | issues[].fields.resolution +GET /rest/agile/1.0/board/{id}/issue 200 | issues[].fields.resolutiondate +GET /rest/agile/1.0/board/{id}/sprint/{id}/issue 200 | issues[].fields.resolution +GET /rest/agile/1.0/board/{id}/sprint/{id}/issue 200 | issues[].fields.resolutiondate +POST /rest/api/3/search/jql 200 | issues[].fields.resolution