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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ config:
- 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
- A home screen of your day: your work, inbox, sprint health, timer and saved searches
- Time tracking: log work, a timer, the day's and the week's worklogs, proposals from git, your agents' activity and your calendar
- Time tracking: log work, a timer, the day's and the week's worklogs, proposals from git, your agents' activity and your calendar; to Jira or to your own time registration
- Inbox: a thread per issue others changed, on every site; a standup that walks the board, with a timer
- Git: branch keys in commits, draft pull requests, a prompt segment
- GitLab merge requests: the ones waiting on you; pipelines and job logs, a diff to review line by line with notes and suggestions, or a coding agent reviews it for you; merge and edit them (reviewers, assignees, labels, draft)
Expand Down
9 changes: 9 additions & 0 deletions docs/guide/05-your-day.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,15 @@ totals and how short each past workday is of 8h. Log on any cell.
> **Try it:** on Friday, open the week, fill the gaps, `y`, paste it into
> the time registration.

### Your own time registration

Time goes to Jira's worklogs unless `timesheet:` in the config points at
a server speaking laneway's [timesheet API](../reference/timesheet-api.md):
a time registration tool of your own, or an adapter in front of one. The
timer, the day, the week and proposals then use it. When that server runs
a timer itself, `T` starts and stops that one, and a timer started there
shows in laneway within half a minute. `left:` is Jira's only.

## Standup

The standup since the previous workday (Friday on a Monday;
Expand Down
16 changes: 16 additions & 0 deletions docs/reference/config.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,22 @@ when the issue opens and kept a day; a list opened before that ranks by
the picks alone, and a list never reorders once open. A GitLab person is matched to Jira by `git_people`, then by
email, then by a name exactly one Jira user has.

## Timesheet

Time goes to Jira's worklogs unless `timesheet:` names another place: a
server speaking laneway's [timesheet API](timesheet-api.md).

```yaml
timesheet:
provider: http # jira (default) or http
url: http://127.0.0.1:29174/laneway # the API's root
token_cmd: [pass, show, timesheet] # optional bearer token, or token:
```

The timer, the timesheet, the week view and proposals then use that
server, on every site. `left:` (Jira's remaining estimate) is Jira's only.
laneway web for many people always uses Jira.

## ui options

Every option is optional. `,` (terminal) and `g ,` (browser) list them by
Expand Down
1 change: 1 addition & 0 deletions docs/reference/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,4 @@ Every option, key and command. New to laneway? Start with the
- [Markdown editor](editor.md): how Jira's rich text edits as markdown.
- [Rules](rules.md): react to changes on your boards.
- [Command line](cli.md): flags, scripts, the prompt, git hooks.
- [Timesheet API](timesheet-api.md): log time somewhere other than Jira.
98 changes: 98 additions & 0 deletions docs/reference/timesheet-api.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
# Timesheet API

laneway logs time to Jira's worklogs by default. With `timesheet: provider: http` it logs to any
server that speaks the small JSON API below instead: a time registration tool of your own, or an
adapter in front of one. One provider is active at a time.

```yaml
timesheet:
provider: http
url: http://127.0.0.1:29174/laneway # the API's root, without /v1
token_cmd: [pass, show, timesheet] # optional: a bearer token (or token:)
```

The timer (`T`), the timesheet (`W`), the week view and proposals then read and
write through the server. When the server runs a timer itself (`serverTimer`), laneway's timer is
that one: started in laneway it runs on the server, started elsewhere it shows in laneway.

## Conventions

- JSON in and out. Times are RFC 3339 with an offset; durations are whole seconds. A server
that stores minutes may round; laneway reads the entries again after each write.
- Requests carry `Authorization: Bearer <token>` when one is configured.
- An error is any non-2xx status with `{"error": "what went wrong", "hint": "…", "url": "…"}`;
`hint` and `url` are optional and shown with the error. `401`: the server needs a sign-in
of its own (`url` is where); `404`: no such entry; `400`, `409`, `422`: refused.
- An entry the server accepted but has not stored yet is `pending`. Its `id` may be temporary:
the server must accept that id on later calls (an edit or delete right after a create).

## Entry

```json
{
"id": "425824",
"key": "ABC-123",
"summary": "Make the image configurable",
"description": "first draft",
"start": "2026-11-03T07:15:00Z",
"seconds": 5400,
"running": false,
"pending": false
}
```

| Field | |
|---|---|
| `id` | the server's id, a string |
| `key` | the Jira issue key; `""` for an entry without an issue (`keyless`) |
| `summary` | the issue's title, when the server knows it |
| `description` | what you did; laneway's worklog comment |
| `start` | when the work started |
| `seconds` | time logged; for a running entry the time so far |
| `running` | the server's running timer |
| `pending` | accepted, not stored yet |

## Endpoints

All under the configured root.

### `GET /v1/info`

```json
{"name": "easyreg", "caps": {"serverTimer": true, "move": true, "keyless": true}}
```

`name` labels the provider in laneway. `caps`, each `false` when left out:

- `serverTimer`: the server runs a timer (`GET /v1/running`, `POST` without `seconds`).
- `move`: an entry's `key` can change (`PATCH` with `key`).
- `keyless`: entries without an issue exist. The server may still refuse to create one (`422`).

### `GET /v1/entries?from=…&to=…`

Your entries that start in `[from, to)`, a running one included, by start:
`{"entries": [Entry, …]}`. laneway asks for a day or a week.

### `GET /v1/running`

The running entry, or `null`. Only with `serverTimer`. laneway polls it about twice a minute.

### `POST /v1/entries`

```json
{"key": "ABC-123", "description": "…", "start": "2026-11-03T09:00:00+01:00", "seconds": 1800}
```

Logs an entry; answers `201` with the Entry. Without `seconds` it starts the timer at `start`
(`serverTimer`), and a timer already running ends there.

### `PATCH /v1/entries/{id}`

Any of `key`, `description`, `start`, `seconds`; answers the Entry. `seconds` sets the end to
the start (the new one when `start` is given) plus `seconds`: on a running entry that stops it. `start`
without `seconds` keeps the end.
`key` moves the entry to another issue (`move`).

### `DELETE /v1/entries/{id}`

Answers `204`. laneway uses it for the timer's discard too (`ctrl+d` at its stop prompt).
8 changes: 4 additions & 4 deletions internal/calendar/calendar.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,8 @@ import (

"github.com/emersion/go-ical"

"github.com/cornedor/laneway/internal/jira"
"github.com/cornedor/laneway/internal/safeterm"
"github.com/cornedor/laneway/internal/timesheet"
"github.com/cornedor/laneway/internal/work"
)

Expand Down Expand Up @@ -226,10 +226,10 @@ func localZones(e *ical.Event) {

// Proposals are the meetings as worklogs on key, each its own with its
// summary as the comment; one a worklog on key already starts in is left out.
func Proposals(ms []Meeting, key string, logs []jira.Worklog) []work.Proposal {
func Proposals(ms []Meeting, key string, logs []timesheet.Entry) []work.Proposal {
out := []work.Proposal{}
for _, m := range ms {
if slices.ContainsFunc(logs, func(w jira.Worklog) bool {
if slices.ContainsFunc(logs, func(w timesheet.Entry) bool {
return w.Key == key && !w.Started.Before(m.Start) && w.Started.Before(m.End)
}) {
continue
Expand All @@ -241,7 +241,7 @@ func Proposals(ms []Meeting, key string, logs []jira.Worklog) []work.Proposal {
}

// Day is source's meetings on day as proposals on key; none without either.
func Day(ctx context.Context, source, key string, day time.Time, logs []jira.Worklog) ([]work.Proposal, error) {
func Day(ctx context.Context, source, key string, day time.Time, logs []timesheet.Entry) ([]work.Proposal, error) {
if strings.TrimSpace(source) == "" || key == "" {
return nil, nil
}
Expand Down
4 changes: 2 additions & 2 deletions internal/calendar/calendar_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import (
"testing"
"time"

"github.com/cornedor/laneway/internal/jira"
"github.com/cornedor/laneway/internal/timesheet"
)

// A week of a calendar as apps export it: a daily standup (not on the 2nd,
Expand Down Expand Up @@ -143,7 +143,7 @@ func TestRead(t *testing.T) {
func TestProposals(t *testing.T) {
at := time.Date(2026, 10, 1, 11, 0, 0, 0, time.Local)
ms := []Meeting{{Start: at, End: at.Add(15 * time.Minute), Summary: "Standup"}, {Start: at.Add(2 * time.Hour), End: at.Add(3 * time.Hour), Summary: "Review"}}
logs := []jira.Worklog{{Key: "OPS-1", Started: at.Add(5 * time.Minute)}, {Key: "ABC-1", Started: at.Add(2 * time.Hour)}}
logs := []timesheet.Entry{{Key: "OPS-1", Started: at.Add(5 * time.Minute)}, {Key: "ABC-1", Started: at.Add(2 * time.Hour)}}
ps := Proposals(ms, "OPS-1", logs)
if len(ps) != 1 || ps[0].Key != "OPS-1" || ps[0].Seconds != 3600 || ps[0].Comment != "Review" || !ps[0].Start.Equal(at.Add(2*time.Hour)) {
t.Errorf("proposals %+v", ps)
Expand Down
45 changes: 44 additions & 1 deletion internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,10 @@ type Config struct {
// GitLab are the GitLab instances merge requests are read from; a host
// none names uses glab's login there.
GitLab []GitLabConfig `yaml:"gitlab"`
UI UIConfig `yaml:"ui"`
// Timesheet is where time is logged: Jira's worklogs, or a server
// speaking laneway's timesheet API.
Timesheet TimesheetConfig `yaml:"timesheet"`
UI UIConfig `yaml:"ui"`
// Rules fire on the changes a board refresh shows; see internal/rules.
Rules []rules.Rule `yaml:"rules"`
// RulesTest overrides the issue type and status `laneway rules test`
Expand All @@ -105,6 +108,44 @@ type GitLabConfig struct {
Repos map[string]string `yaml:"repos,omitempty"`
}

// TimesheetConfig picks the timesheet provider
// (docs/reference/timesheet-api.md).
type TimesheetConfig struct {
// Provider is "jira" (the default: worklogs on the site) or "http".
Provider string `yaml:"provider,omitempty"`
// URL is the http provider's API root, without /v1.
URL string `yaml:"url,omitempty"`
// Token is sent as a bearer token; unset, token_cmd prints it.
Token string `yaml:"token,omitempty"`
TokenCmd []string `yaml:"token_cmd,omitempty"`
}

// IsHTTP reports whether time goes to an http provider; an error is a
// provider laneway doesn't know or an http one without a URL.
func (t TimesheetConfig) IsHTTP() (bool, error) {
switch strings.ToLower(strings.TrimSpace(t.Provider)) {
case "", "jira":
return false, nil
case "http":
u, err := url.Parse(strings.TrimSpace(t.URL))
if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" {
return false, fmt.Errorf("timesheet.url: %q is not an http(s) URL", t.URL)
}
return true, nil
}
return false, fmt.Errorf("timesheet.provider: %q is not jira or http", t.Provider)
}

// WithToken runs token_cmd for the token when token is unset.
func (t TimesheetConfig) WithToken() (TimesheetConfig, error) {
if strings.TrimSpace(t.Token) != "" || len(t.TokenCmd) == 0 {
return t, nil
}
tok, err := runTokenCmd("timesheet token_cmd", t.TokenCmd)
t.Token = tok
return t, err
}

// WithToken runs token_cmd for the token when token is unset.
func (g GitLabConfig) WithToken() (GitLabConfig, error) {
if strings.TrimSpace(g.Token) != "" || len(g.TokenCmd) == 0 {
Expand Down Expand Up @@ -805,6 +846,8 @@ func unknownKeys(raw []byte) []string {
check("ui.", v, reflect.TypeFor[UIConfig]())
case "rules_test":
check("rules_test.", v, reflect.TypeFor[RulesTest]())
case "timesheet":
check("timesheet.", v, reflect.TypeFor[TimesheetConfig]())
case "gitlab":
if v.Kind == yaml.SequenceNode {
for j, it := range v.Content {
Expand Down
24 changes: 23 additions & 1 deletion internal/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -57,16 +57,38 @@ func TestRequestTimeout(t *testing.T) {
}
}

// TestTimesheetIsHTTP: jira by default, http with an http(s) URL, and
// anything else an error.
func TestTimesheetIsHTTP(t *testing.T) {
for _, tc := range []struct {
in TimesheetConfig
is, bad bool
}{
{TimesheetConfig{}, false, false},
{TimesheetConfig{Provider: "Jira"}, false, false},
{TimesheetConfig{Provider: "http", URL: "http://127.0.0.1:29174/laneway"}, true, false},
{TimesheetConfig{Provider: "http"}, false, true},
{TimesheetConfig{Provider: "http", URL: "file:///tmp/x"}, false, true},
{TimesheetConfig{Provider: "statz"}, false, true},
} {
is, err := tc.in.IsHTTP()
if is != tc.is || (err != nil) != tc.bad {
t.Errorf("%+v = %v %v", tc.in, is, err)
}
}
}

// TestUnknownKeys: a key no option reads warns, with the nearest known one.
func TestUnknownKeys(t *testing.T) {
raw := []byte("jira:\n base_url: x\n emial: a@b\nui:\n panel_widht: 40\n card_colors: off\n keys: {search: f}\nsites:\n work:\n api_tokn: t\ngitlab:\n - base_url: x\n tokn: t\nbogus: 1\n")
raw := []byte("jira:\n base_url: x\n emial: a@b\nui:\n panel_widht: 40\n card_colors: off\n keys: {search: f}\nsites:\n work:\n api_tokn: t\ngitlab:\n - base_url: x\n tokn: t\ntimesheet:\n ur: x\nbogus: 1\n")
got := unknownKeys(raw)
want := []string{
"bogus: unknown option",
"jira.emial: unknown option, did you mean email?",
"ui.panel_widht: unknown option, did you mean panel_width?",
"sites.work.api_tokn: unknown option, did you mean api_token?",
"gitlab[0].tokn: unknown option, did you mean token?",
"timesheet.ur: unknown option, did you mean url?",
}
if !slices.Equal(got, want) {
t.Errorf("got %q\nwant %q", got, want)
Expand Down
3 changes: 3 additions & 0 deletions internal/i18n/nl/ui-shell.json
Original file line number Diff line number Diff line change
Expand Up @@ -479,6 +479,9 @@
"the whole file / the changes only": "het hele bestand / alleen de wijzigingen",
"the worktrees without an agent too / not": "ook de worktrees zonder agent / niet",
"this Jira can't move worklogs": "deze Jira kan geen werklogs verplaatsen",
"%s can't move worklogs": "%s kan geen werklogs verplaatsen",
"the timer on %s no longer runs in %s": "de timer op %s loopt niet meer in %s",
"left: sets Jira's estimate; %s has none": "left: zet de schatting in Jira; %s heeft die niet",
"time machine: the board on earlier days": "tijdmachine: het bord op eerdere dagen",
"time machine: ← → the board a day earlier / later, esc now": "tijdmachine: ← → het bord een dag eerder / later, esc nu",
"timer / today's worklogs": "timer / werklogs van vandaag",
Expand Down
Loading
Loading