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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
21 changes: 21 additions & 0 deletions docs/guide/02-board-and-panel.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
10 changes: 7 additions & 3 deletions docs/reference/search.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -26,16 +28,18 @@ 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
progress only.
- `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
Expand Down
4 changes: 3 additions & 1 deletion e2e/tui/bulk_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
10 changes: 10 additions & 0 deletions internal/contract/testdata/allow.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
7 changes: 6 additions & 1 deletion internal/demo/json.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
13 changes: 12 additions & 1 deletion internal/i18n/nl/ui-board.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
6 changes: 4 additions & 2 deletions internal/i18n/nl/web-board-issue.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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"
}
4 changes: 3 additions & 1 deletion internal/i18n/nl/web-lib.json
Original file line number Diff line number Diff line change
Expand Up @@ -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)"
}
8 changes: 7 additions & 1 deletion internal/index/index.go
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
}
Expand Down
134 changes: 134 additions & 0 deletions internal/index/project.go
Original file line number Diff line number Diff line change
@@ -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()
}
73 changes: 73 additions & 0 deletions internal/index/project_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading
Loading