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
5 changes: 3 additions & 2 deletions docs/plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,7 @@ The v1.2 design in §4 to §6 stays as reference. A piece of it is built only wh
| `icon-button` | The theme toggle's look, an icon with the header's hover underline under it, moves to a shared stylesheet as a class, `.icon-button`, which `notify-header`'s notifications bell will use too; nothing changes on screen. The first of the two steps that finish `notifications` (decision 178) | Done (#145) |
| `notify-header` | The control that turns notifications on moves from the Nudge page to a bell in the header, before the theme toggle: its name and look say how this device's notifications stand, and its panel says it in words and turns them on. The last step of `notifications` (decision 179) | Done (#146) |
| `shared-jira` | todos' Jira client moves to the root `internal/jira`, with the code that reads its settings and token, for the `time` service; todos' poller of my tickets moves to a package of its own, `tickets`. Nothing changes in behaviour. The first of `time-tracking`'s eleven steps (decision 180) | Done (#147) |
| `jira-worklogs` | The shared Jira client reads my worklogs in a span of time: my account from `/myself`, the tickets I logged work on from a `worklogAuthor` search, then each ticket's worklogs, keeping mine that start in the span, the earliest first. The second of `time-tracking`'s eleven steps (decision 180) | Done (#148) |
| `nudge-merge` | Nudge, the user's phone-questions tool for agents (`codedogapp/nudge`), moves into Bakhyard: one app, not two (decision 157). The nudge session does the work, one small PR per step, from its plan, [`bakhyard-merge.md`](https://github.com/codedogapp/nudge/blob/main/docs/bakhyard-merge.md); this session doesn't stop its own steps for it | In review |

After these: the product phases (§8.2), each split into steps.
Expand Down Expand Up @@ -1193,7 +1194,7 @@ Every item is pending unless it says done. The service an item lands in is in br
- Later, with a database for pulls: snooze (2 h / tomorrow / until updated) and mark as nudged, from the PRs page.
- `time-tracking` [time]. Needs `todos-jira`. Reads and writes standard Jira worklogs (§2, decision 46), with no timers: Clockwork already has them inside Jira. Eleven steps (decision 180):
- `shared-jira`: todos' Jira client moves to the root `internal/jira`, with the code that reads its settings and token, for the `time` service; todos' poller of my tickets moves to a package of its own, `tickets`. Nothing changes in behaviour. Done (#147).
- `jira-worklogs`: the client reads my worklogs in a span of days: my account from `/myself`, the tickets I logged on from a `worklogAuthor` search, and each ticket's worklogs.
- `jira-worklogs`: the client reads my worklogs in a span of days: my account from `/myself`, the tickets I logged on from a `worklogAuthor` search, and each ticket's worklogs. Done (#148).
- `time-service`: a new `time` service polls the last 7 days' worklogs every 5 minutes and answers the day report at `GET /time/report`: the hours I logged on each day, with every weekday that has none marked as missing, plus the week's total against the target.
- `homelab-time`: in the homelab repo, `time` runs on the laptop with todos' Jira settings and token, and the gateway's and home's peers name it.
- `time-gateway`: the gateway forwards `/api/time` to `time`.
Expand Down Expand Up @@ -1530,7 +1531,7 @@ Made on 2026-10-23:
- **Rejected**: an icon with a `title` and no panel; words in the header; a `<details>` menu, which a tap outside closes only by script; placing the panel by script; a button that turns notifications off; a centred panel for browsers without anchor positioning, which CI's browsers can't show.
180. **Time tracking in eleven steps**: the lead's choices (2026-10-29), for the user to review. `time-tracking` is far more than one step's 150 hand-written lines, so it splits in eleven (§8.2), each service step before the page step that shows it, as `bitbucket-prs`' went.
- **The Jira client is shared.** `time` is the second service that reads Jira, with the same site, account and scoped token as todos (decision 103), so `shared-jira` moves todos' client to the root `internal/jira`, a step ahead of its second user, as `shared-poller` (#109) moved the poller. `jira.FromEnv` reads the three Jira settings and the `jira-token` secret, as todos did, and gives no client when no setting is set. todos' poller of my tickets moves to `services/todos/internal/tickets`, as `tickets.Poller`, with its `Finder`, so `internal/jira` keeps only what talks to Jira. This amends decision 135, under which the `jira` package kept the `Finder` and `jira.Poller` stayed the poller's name.
- **Reading worklogs** (`jira-worklogs`): my account ID from `GET /rest/api/3/myself`; the tickets I logged on from `/rest/api/3/search/jql` with `worklogAuthor = currentUser()` and `worklogDate` bounds a day wider than the span on each side, since JQL dates are in the Jira profile's time zone (`fields=summary`, 100 a page, at most 10 pages); then each ticket's worklogs from `GET /rest/api/3/issue/{key}/worklog` with `startedAfter` and `startedBefore`, 5,000 a page, paging on `total`. Only my own worklogs count, by `author.accountId`, within the span. A worklog's `started` isn't RFC 3339 (`2006-01-02T15:04:05.000-0700`). §2's risk stays: should `worklogAuthor` miss worklogs, the fallback is `/worklog/updated` with `/worklog/list`.
- **Reading worklogs** (`jira-worklogs`): `Client.Worklogs(ctx, start, end)` returns my worklogs that start from `start` up to, but not including, `end`, each with its start and the time spent, the earliest first, and those that start together in Jira's order. My account ID comes from `GET /rest/api/3/myself`. The tickets I logged on come from `/rest/api/3/search/jql` with `worklogAuthor = currentUser() AND worklogDate >= "<the start's UTC date, less a day>" AND worklogDate < "<the end's UTC date, plus two days>" ORDER BY key`: JQL reads dates in the Jira profile's time zone, which the client doesn't know, and any zone's date is within a day of the UTC date, so the search holds every day of the span whether JQL compares days or midnights. It asks for IDs alone (`fields=id`), 100 a page, at most 10 pages, as todos' search does. Then each ticket's worklogs come from `GET /rest/api/3/issue/{id}/worklog`, by the ticket's ID, with `startedAfter` a millisecond before the start, since the spec doesn't say whether its bounds count, and `startedBefore` the end; 1,000 a page rather than the 5,000 allowed, since a worklog with its two users' avatars takes about 2.4 KB and an answer must stay under the client's 4 MiB; paging on `total` until an empty page, at most 10 pages a ticket. Only my own worklogs count, by `author.accountId`, and only those that start within the span. A worklog's `started` isn't RFC 3339 (`2006-01-02T15:04:05.000-0700`); one of mine that doesn't parse fails the call, while another author's is skipped unread. The requests run one after another, and any failure fails the whole call, as decision 133's polls do. This amends the step's first plan, which asked for `fields=summary`, read the worklogs by key and took 5,000 a page. §2's risk stays: should `worklogAuthor` miss worklogs (JQL searches only each ticket's 1,000 latest), the fallback is `/worklog/updated` with `/worklog/list`.
- **The `time` service** polls the last 7 days every 5 minutes (§3) through `internal/poll`, keeps them in memory with no database, as `pulls` does, and listens on 127.0.0.1:8088 locally. It needs todos' Jira settings and token, so on the laptop it reuses todos' `todos-jira` settings and the `jira-token` in `todos-secrets` rather than copying them; the user said yes to that homelab PR being merged once its check is green (2026-10-29).
- **Days and the target**: days and weeks follow `time.Local`, the pod's `TZ`, as decision 175's reminders do. A week runs Monday to Sunday, and a worklog counts on the day it starts. The target is 8 hours a weekday, Monday to Friday, 40 a week, fixed in code. Today isn't missing until it's over; holidays show as missing until a setting for them (§0 idea).
- **Writing worklogs** (`worklog-add`, `fill-day`): `POST /rest/api/3/issue/{key}/worklog` with `started` and `timeSpentSeconds`, keeping Jira's defaults (`notifyUsers`, `adjustEstimate=auto`); it answers 201, so the client takes a 201 there. A new worklog starts at 09:00 local time, and `time` polls again after a write.
Expand Down
65 changes: 43 additions & 22 deletions internal/jira/client.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// Package jira reads the user's tickets on a Jira Cloud site, through Jira's REST API v3 and a
// scoped API token: it finds the open tickets and fetches tickets by key, for each service that
// the Jira settings set up (docs/plan.md §2, decisions 103, 121, 133 and 180).
// Package jira reads the user's tickets and worklogs on a Jira Cloud site, through Jira's REST
// API v3 and a scoped API token: it finds the open tickets, fetches tickets by key and reads the
// user's worklogs, for each service that the Jira settings set up (docs/plan.md §2, decisions
// 103, 121, 133 and 180).
package jira

import (
Expand Down Expand Up @@ -41,35 +42,49 @@ type Settings struct {
Token string
}

// Client finds tickets on a Jira site.
// Client reads tickets and worklogs on a Jira site.
type Client struct {
settings Settings
client *http.Client
}

// New returns the client of the site in settings. It reaches Jira only when it searches or
// fetches.
// New returns the client of the site in settings. It reaches Jira only when it's asked for
// something.
func New(settings Settings) *Client {
return &Client{settings: settings, client: &http.Client{Timeout: requestTimeout}}
}

// Search returns the user's open tickets, the latest updated first.
func (c *Client) Search(ctx context.Context) ([]Ticket, error) {
tickets := []Ticket{}
issues, err := c.search(ctx, openTickets, "summary,status")
if err != nil {
return nil, err
}

tickets := make([]Ticket, 0, len(issues))
for _, issue := range issues {
tickets = append(tickets, c.ticket(issue))
}

return tickets, nil
}

// search returns the tickets that jql finds, with the fields that fields lists, page after page.
func (c *Client) search(ctx context.Context, jql, fields string) ([]issue, error) {
var issues []issue

token := ""

for range maxPages {
found, err := c.page(ctx, token)
found, err := c.page(ctx, jql, fields, token)
if err != nil {
return nil, err
}

for _, issue := range found.Issues {
tickets = append(tickets, c.ticket(issue))
}
issues = append(issues, found.Issues...)

if found.IsLast || found.NextPageToken == "" {
return tickets, nil
return issues, nil
}

token = found.NextPageToken
Expand All @@ -87,6 +102,7 @@ type page struct {

// issue is the part of a ticket in Jira's answer that the client reads.
type issue struct {
ID string `json:"id"`
Key string `json:"key"`
Fields fields `json:"fields"`
}
Expand All @@ -109,11 +125,11 @@ type statusCategory struct {
Key string `json:"key"`
}

// page returns the page of the search that token names, or the first page without a token.
func (c *Client) page(ctx context.Context, token string) (page, error) {
// page returns the page of jql's search that token names, or the first page without a token.
func (c *Client) page(ctx context.Context, jql, fields, token string) (page, error) {
query := url.Values{
"jql": {openTickets},
"fields": {"summary,status"},
"jql": {jql},
"fields": {fields},
"maxResults": {strconv.Itoa(pageSize)},
}
if token != "" {
Expand All @@ -123,21 +139,26 @@ func (c *Client) page(ctx context.Context, token string) (page, error) {
target := c.settings.API.JoinPath("rest/api/3/search/jql")
target.RawQuery = query.Encode()

req, err := http.NewRequestWithContext(ctx, http.MethodGet, target.String(), nil)
if err != nil {
return page{}, fmt.Errorf("search Jira: %w", err)
}

var found page

err = c.do(req, "search Jira", &found)
err := c.get(ctx, target, "search Jira", &found)
if err != nil {
return page{}, err
}

return found, nil
}

// get asks Jira for target and reads Jira's answer into answer, as do does.
func (c *Client) get(ctx context.Context, target *url.URL, action string, answer any) error {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, target.String(), nil)
if err != nil {
return fmt.Errorf("%s: %w", action, err)
}

return c.do(req, action, answer)
}

// do sends req to Jira as the user and reads Jira's answer into answer. Its errors start with
// action, such as "search Jira", or with "read Jira's answer" when the answer is no use.
func (c *Client) do(req *http.Request, action string, answer any) error {
Expand Down
194 changes: 194 additions & 0 deletions internal/jira/worklog.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
package jira

import (
"context"
"errors"
"fmt"
"net/url"
"slices"
"strconv"
"time"
)

const (
// worklogPageSize is the number of worklogs that the client asks for in each page. A worklog
// takes about 2.4 KB with its two users' avatars, so a page stays under maxAnswer.
worklogPageSize = 1000
// startedLayout is the layout of a worklog's start in Jira's answers, which isn't RFC 3339.
startedLayout = "2006-01-02T15:04:05.000-0700"
)

// Worklog is time that the user logged on a ticket.
type Worklog struct {
// Started is when the work started.
Started time.Time
// Spent is how long the work took.
Spent time.Duration
}

// user is the part of an account in Jira's answers that the client reads.
type user struct {
AccountID string `json:"accountId"`
}

// worklogPage is the part of a page of a ticket's worklogs that the client reads.
type worklogPage struct {
Worklogs []worklog `json:"worklogs"`
// Total is the number of worklogs in all the pages.
Total int `json:"total"`
}

// worklog is the part of a worklog in Jira's answer that the client reads.
type worklog struct {
Author user `json:"author"`
Started string `json:"started"`
TimeSpentSeconds int64 `json:"timeSpentSeconds"`
}

// span is the user's account and the span of time whose worklogs the client reads.
type span struct {
account string
start, end time.Time
}

// Worklogs returns the user's worklogs that start from start up to, but not including, end, the
// earliest first, and those that start together in the order Jira gave them.
func (c *Client) Worklogs(ctx context.Context, start, end time.Time) ([]Worklog, error) {
account, err := c.account(ctx)
if err != nil {
return nil, err
}

issues, err := c.search(ctx, loggedOn(start, end), "id")
if err != nil {
return nil, err
}

worklogs := []Worklog{}

for _, issue := range issues {
found, err := c.worklogs(ctx, issue.ID, span{account: account, start: start, end: end})
if err != nil {
return nil, err
}

worklogs = append(worklogs, found...)
}

slices.SortStableFunc(worklogs, func(a, b Worklog) int { return a.Started.Compare(b.Started) })

return worklogs, nil
}

// loggedOn returns the JQL that finds the tickets on which the user logged work from start up
// to end. JQL reads dates in the zone of the user's Jira profile, and any zone's date is within a
// day of the UTC date, so the search takes in a day more on each side; Worklogs keeps the span.
func loggedOn(start, end time.Time) string {
return fmt.Sprintf(
`worklogAuthor = currentUser() AND worklogDate >= "%s" AND worklogDate < "%s" ORDER BY key`,
start.UTC().AddDate(0, 0, -1).Format(time.DateOnly),
end.UTC().AddDate(0, 0, 2).Format(time.DateOnly),
)
}

// account returns the ID of the user's Atlassian account.
func (c *Client) account(ctx context.Context) (string, error) {
var myself user

err := c.get(
ctx,
c.settings.API.JoinPath("rest/api/3/myself"),
"read the Jira account",
&myself,
)
if err != nil {
return "", err
}

if myself.AccountID == "" {
return "", errors.New("read Jira's answer: no account ID")
}

return myself.AccountID, nil
}

// worklogs returns the user's worklogs in the span on the ticket whose ID is issueID, page after
// page.
func (c *Client) worklogs(ctx context.Context, issueID string, mine span) ([]Worklog, error) {
var worklogs []Worklog

startAt := 0

for range maxPages {
found, err := c.worklogPage(ctx, issueID, mine, startAt)
if err != nil {
return nil, err
}

kept, err := mine.pick(found.Worklogs)
if err != nil {
return nil, err
}

worklogs = append(worklogs, kept...)
startAt += len(found.Worklogs)

if len(found.Worklogs) == 0 || startAt >= found.Total {
return worklogs, nil
}
}

return nil, fmt.Errorf("read Jira worklogs: more than %d pages", maxPages)
}

// worklogPage returns the page of the ticket's worklogs in the span that starts at startAt.
func (c *Client) worklogPage(ctx context.Context, issueID string, mine span, startAt int) (
worklogPage, error,
) {
query := url.Values{
// Jira doesn't say whether it keeps a worklog that starts at a bound, so the answer
// starts a millisecond early, and pick keeps the span.
"startedAfter": {strconv.FormatInt(mine.start.UnixMilli()-1, 10)},
"startedBefore": {strconv.FormatInt(mine.end.UnixMilli(), 10)},
"startAt": {strconv.Itoa(startAt)},
"maxResults": {strconv.Itoa(worklogPageSize)},
}

// The ID is one path segment, whatever Jira sends.
target := c.settings.API.JoinPath("rest/api/3/issue", url.PathEscape(issueID), "worklog")
target.RawQuery = query.Encode()

var found worklogPage

err := c.get(ctx, target, "read Jira worklogs", &found)
if err != nil {
return worklogPage{}, err
}

return found, nil
}

// pick returns the worklogs in found that are the user's and start in the span.
func (s span) pick(found []worklog) ([]Worklog, error) {
var kept []Worklog

for _, entry := range found {
if entry.Author.AccountID != s.account {
continue
}

started, err := time.Parse(startedLayout, entry.Started)
if err != nil {
return nil, fmt.Errorf("read Jira's answer: %w", err)
}

if started.Before(s.start) || !started.Before(s.end) {
continue
}

spent := time.Duration(entry.TimeSpentSeconds) * time.Second
kept = append(kept, Worklog{Started: started, Spent: spent})
}

return kept, nil
}
Loading
Loading