diff --git a/docs/plan.md b/docs/plan.md index 8ce3451..c606a3c 100644 --- a/docs/plan.md +++ b/docs/plan.md @@ -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. @@ -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`. @@ -1530,7 +1531,7 @@ Made on 2026-10-23: - **Rejected**: an icon with a `title` and no panel; words in the header; a `
` 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 >= "" AND worklogDate < "" 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. diff --git a/internal/jira/client.go b/internal/jira/client.go index 579fdcf..f8e4df1 100644 --- a/internal/jira/client.go +++ b/internal/jira/client.go @@ -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 ( @@ -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 @@ -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"` } @@ -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 != "" { @@ -123,14 +139,9 @@ 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 } @@ -138,6 +149,16 @@ func (c *Client) page(ctx context.Context, token string) (page, error) { 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 { diff --git a/internal/jira/worklog.go b/internal/jira/worklog.go new file mode 100644 index 0000000..61683ce --- /dev/null +++ b/internal/jira/worklog.go @@ -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 +} diff --git a/internal/jira/worklog_test.go b/internal/jira/worklog_test.go new file mode 100644 index 0000000..4e73901 --- /dev/null +++ b/internal/jira/worklog_test.go @@ -0,0 +1,174 @@ +package jira_test + +import ( + "fmt" + "net/url" + "slices" + "strconv" + "testing" + "time" + + "github.com/codedogapp/bakhyard/internal/jira" +) + +func TestWorklogsReturnsMyWorklogsInTheSpan(t *testing.T) { + fake := &fakeJira{ + t: t, + pages: map[string]map[string]any{"": last(found("10001"), found("10002"))}, + worklogs: map[string]map[string]any{ + "10001@0": worklogs(5, + logged(myAccount, "2026-10-29T09:00:00.000+0900", 600), + logged(myAccount, "2026-10-26T00:00:00.000+0900", 3600), + logged(myAccount, "2026-10-25T23:59:59.999+0900", 60), + logged(myAccount, "2026-11-02T00:00:00.000+0900", 60), + logged(otherAccount, "2026-10-27T10:00:00.000+0900", 60), + ), + "10002@0": worklogs(2, + logged(myAccount, "2026-10-27T01:30:00.000+0000", 5400), + logged(myAccount, "2026-10-26T12:00:00.000+0900", 1800), + ), + }, + } + start, end := aWeek(t) + + got, err := clientOf(t, fake.ServeHTTP).Worklogs(t.Context(), start, end) + if err != nil { + t.Fatal(err) + } + + want := []jira.Worklog{ + {Started: at(t, "2026-10-26T00:00:00+09:00"), Spent: time.Hour}, + {Started: at(t, "2026-10-26T12:00:00+09:00"), Spent: 30 * time.Minute}, + {Started: at(t, "2026-10-27T01:30:00Z"), Spent: 90 * time.Minute}, + {Started: at(t, "2026-10-29T09:00:00+09:00"), Spent: 10 * time.Minute}, + } + if !same(got, want) { + t.Errorf("got %v, want %v", got, want) + } +} + +func TestWorklogsAsksJiraAsTheUser(t *testing.T) { + fake := &fakeJira{ + t: t, + pages: map[string]map[string]any{"": last(found("10001"))}, + worklogs: map[string]map[string]any{"10001@0": worklogs(0)}, + } + start, end := aWeek(t) + + _, err := clientOf(t, fake.ServeHTTP).Worklogs(t.Context(), start, end) + if err != nil { + t.Fatal(err) + } + + search := url.Values{ + "jql": {`worklogAuthor = currentUser() AND worklogDate >= "2026-10-24" AND ` + + `worklogDate < "2026-11-03" ORDER BY key`}, + "fields": {"id"}, + "maxResults": {"100"}, + } + wants := []struct{ path, query string }{ + {"/rest/api/3/myself", ""}, + {"/rest/api/3/search/jql", search.Encode()}, + { + "/rest/api/3/issue/10001/worklog", + "maxResults=1000&startAt=0&startedAfter=1792940399999&startedBefore=1793545200000", + }, + } + + if len(fake.asked) != len(wants) { + t.Fatalf("got %d requests, want %d", len(fake.asked), len(wants)) + } + + for index, want := range wants { + asked := fake.asked[index] + user, password, ok := asked.BasicAuth() + + got := fmt.Sprintf("%s %s?%s as %s:%s (%t), Accept %s", asked.Method, + asked.URL.EscapedPath(), asked.URL.RawQuery, user, password, ok, + asked.Header.Get("Accept")) + if wanted := fmt.Sprintf("GET %s?%s as %s:%s (true), Accept application/json", + apiPath+want.path, want.query, email, token); got != wanted { + t.Errorf("request %d: got %s, want %s", index, got, wanted) + } + } +} + +func TestWorklogsSearchesADayMoreOnEachSide(t *testing.T) { + for _, test := range []struct{ start, end, from, until string }{ + {"2026-10-23T00:00:00+14:00", "2026-10-30T00:00:00+14:00", "2026-10-21", "2026-10-31"}, + {"2026-10-23T00:00:00-12:00", "2026-10-30T00:00:00-12:00", "2026-10-22", "2026-11-01"}, + {"2026-10-26T00:00:00Z", "2026-11-02T00:00:00Z", "2026-10-25", "2026-11-04"}, + } { + fake := &fakeJira{t: t, pages: map[string]map[string]any{"": last()}} + + _, err := clientOf(t, fake.ServeHTTP).Worklogs(t.Context(), at(t, test.start), + at(t, test.end)) + if err != nil || len(fake.asked) != 2 { + t.Fatalf("%s: got %v after %d requests, want two requests", test.start, err, + len(fake.asked)) + } + + got := fake.asked[1].URL.Query().Get("jql") + if want := fmt.Sprintf(`worklogAuthor = currentUser() AND worklogDate >= "%s" AND `+ + `worklogDate < "%s" ORDER BY key`, test.from, test.until); got != want { + t.Errorf("from %s to %s: got %s, want %s", test.start, test.end, got, want) + } + } +} + +func TestWorklogsThatStartTogetherKeepJirasOrder(t *testing.T) { + fake := &fakeJira{ + t: t, + pages: map[string]map[string]any{}, + worklogs: map[string]map[string]any{}, + } + + var tickets []map[string]any + + byHour := map[string][]jira.Worklog{} + + // More than 12, and not in order: Go's unstable sort keeps ties in place for 12 or fewer, + // and for a list that's already sorted. + for order := range 14 { + id := strconv.Itoa(order) + tickets = append(tickets, found(id)) + + hour := []string{"10", "09"}[order%2] + fake.worklogs[id+"@0"] = worklogs( + 1, + logged(myAccount, "2026-10-26T"+hour+":00:00.000+0900", int64(order)), + ) + byHour[hour] = append(byHour[hour], jira.Worklog{ + Started: at(t, "2026-10-26T"+hour+":00:00+09:00"), + Spent: time.Duration(order) * time.Second, + }) + } + + fake.pages[""] = last(tickets...) + start, end := aWeek(t) + + got, err := clientOf(t, fake.ServeHTTP).Worklogs(t.Context(), start, end) + if want := slices.Concat(byHour["09"], byHour["10"]); err != nil || !same(got, want) { + t.Errorf("got %v, %v, want each hour's worklogs in the tickets' order", got, err) + } +} + +func TestWorklogsWithoutMineAreEmpty(t *testing.T) { + for name, fake := range map[string]*fakeJira{ + "no tickets": {pages: map[string]map[string]any{"": last()}}, + "someone else's": { + pages: map[string]map[string]any{"": last(found("1"))}, + worklogs: map[string]map[string]any{ + "1@0": worklogs(1, logged(otherAccount, "2026-10-26T09:00:00.000+0900", 60)), + }, + }, + } { + fake.t = t + start, end := aWeek(t) + + got, err := clientOf(t, fake.ServeHTTP).Worklogs(t.Context(), start, end) + if err != nil || got == nil || len(got) != 0 { + t.Errorf("%s: got %#v, %v, want an empty list", name, got, err) + } + } +} diff --git a/internal/jira/worklogfail_test.go b/internal/jira/worklogfail_test.go new file mode 100644 index 0000000..8df0875 --- /dev/null +++ b/internal/jira/worklogfail_test.go @@ -0,0 +1,138 @@ +package jira_test + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/codedogapp/bakhyard/internal/jira" +) + +func TestWorklogsFailOnABadAnswer(t *testing.T) { + for _, test := range []struct { + name, path, body string + status int + want string + }{ + { + "account 401", "myself", "", http.StatusUnauthorized, + "read the Jira account: 401 Unauthorized", + }, + { + "no account ID", "myself", `{"displayName":"Me"}`, http.StatusOK, + "read Jira's answer: no account ID", + }, + { + "blank account ID", "myself", `{"accountId":""}`, http.StatusOK, + "read Jira's answer: no account ID", + }, + { + "account not JSON", "myself", "Sign in", http.StatusOK, + "read Jira's answer: ", + }, + { + "search 500", "search/jql", `{"errorMessages":["Broke"]}`, + http.StatusInternalServerError, "search Jira: 500 Internal Server Error", + }, + { + "worklogs 404", "issue/1/worklog", "", http.StatusNotFound, + "read Jira worklogs: 404 Not Found", + }, + { + "worklogs not JSON", "issue/1/worklog", `{"worklogs":[`, http.StatusOK, + "read Jira's answer: ", + }, + } { + fake := &fakeJira{ + t: t, + pages: map[string]map[string]any{"": last(found("1"))}, + worklogs: map[string]map[string]any{"1@0": worklogs(0)}, + } + client := clientOf(t, func(w http.ResponseWriter, r *http.Request) { + if pathOf(r) != test.path { + fake.ServeHTTP(w, r) + + return + } + + w.WriteHeader(test.status) + _, _ = w.Write([]byte(test.body)) + }) + start, end := aWeek(t) + + got, err := client.Worklogs(t.Context(), start, end) + if got != nil || err == nil || !strings.HasPrefix(err.Error(), test.want) || + strings.Contains(err.Error(), token) { + t.Errorf("%s: got %v, %v, want an error that starts with %q", test.name, got, err, + test.want) + } + } +} + +func TestWorklogsReadOnlyMyWorklogsStarts(t *testing.T) { + for _, test := range []struct { + author, want string + }{ + {myAccount, `read Jira's answer: parsing time "2026-10-26T09:00:00+09:00"`}, + {otherAccount, ""}, + } { + fake := &fakeJira{ + t: t, + pages: map[string]map[string]any{"": last(found("1"))}, + worklogs: map[string]map[string]any{ + "1@0": worklogs(1, logged(test.author, "2026-10-26T09:00:00+09:00", 60)), + }, + } + start, end := aWeek(t) + + got, err := clientOf(t, fake.ServeHTTP).Worklogs(t.Context(), start, end) + if test.want == "" { + if err != nil || len(got) != 0 { + t.Errorf("%s: got %v, %v, want no worklogs and no error", test.author, got, err) + } + + continue + } + + if got != nil || err == nil || !strings.HasPrefix(err.Error(), test.want) { + t.Errorf("%s: got %v, %v, want an error that starts with %q", test.author, got, err, + test.want) + } + } +} + +func TestWorklogsFailWithoutJira(t *testing.T) { + server := httptest.NewServer(http.NotFoundHandler()) + server.Close() + + client := jira.New(jira.Settings{ + Site: parse(t, "https://acme.example"), API: parse(t, server.URL), + Email: email, Token: token, + }) + start, end := aWeek(t) + + got, err := client.Worklogs(t.Context(), start, end) + if got != nil || err == nil || !strings.HasPrefix(err.Error(), "read the Jira account: ") || + strings.Contains(err.Error(), token) { + t.Errorf("got %v, %v, want an error that names the account", got, err) + } +} + +func TestWorklogsStopWhenTheirContextEnds(t *testing.T) { + requests := 0 + client := clientOf(t, func(_ http.ResponseWriter, _ *http.Request) { requests++ }) + + ctx, cancel := context.WithCancel(t.Context()) + cancel() + + start, end := aWeek(t) + + got, err := client.Worklogs(ctx, start, end) + if got != nil || !errors.Is(err, context.Canceled) || requests != 0 { + t.Errorf("got %v, %v after %d requests, want context.Canceled before any", got, err, + requests) + } +} diff --git a/internal/jira/worklogfake_test.go b/internal/jira/worklogfake_test.go new file mode 100644 index 0000000..fe122c5 --- /dev/null +++ b/internal/jira/worklogfake_test.go @@ -0,0 +1,126 @@ +package jira_test + +import ( + "net/http" + "slices" + "strings" + "testing" + "time" + + "github.com/codedogapp/bakhyard/internal/jira" +) + +const ( + // myAccount and otherAccount are the account IDs of the user and of another user. + myAccount = "account-me" + otherAccount = "account-someone" +) + +// fakeJira answers as Jira does for the account myAccount: the search pages by their tokens, and each +// ticket's worklog pages by the ticket's escaped ID and their startAt, such as "10001@0". +type fakeJira struct { + t *testing.T + pages map[string]map[string]any + worklogs map[string]map[string]any + asked []*http.Request +} + +func (f *fakeJira) ServeHTTP(w http.ResponseWriter, r *http.Request) { + f.asked = append(f.asked, r) + query := r.URL.Query() + + var ( + body map[string]any + known bool + ) + + switch path := pathOf(r); path { + case "myself": + body, known = map[string]any{ + "accountId": myAccount, + "displayName": "Me", + "active": true, + }, true + case "search/jql": + body, known = f.pages[query.Get("nextPageToken")] + default: + id, _ := strings.CutPrefix(path, "issue/") + id, _ = strings.CutSuffix(id, "/worklog") + body, known = f.worklogs[id+"@"+query.Get("startAt")] + } + + if !known { + f.t.Errorf("unexpected request for %s", r.URL) + http.NotFound(w, r) + + return + } + + answer(f.t, w, body) +} + +// trail returns each request that the fake answered as its path, then its page token or startAt. +func (f *fakeJira) trail() []string { + var trail []string + + for _, r := range f.asked { + query := r.URL.Query() + trail = append(trail, pathOf(r)+" "+query.Get("nextPageToken")+query.Get("startAt")) + } + + return trail +} + +// pathOf returns the path of r under Jira's REST API, escaped. +func pathOf(r *http.Request) string { + return strings.TrimPrefix(r.URL.EscapedPath(), apiPath+"/rest/api/3/") +} + +// found returns a ticket as a search for IDs alone finds it. +func found(id string) map[string]any { + return map[string]any{"id": id} +} + +// logged returns a worklog as Jira's answers hold it, with fields the client doesn't read too. +func logged(author, started string, seconds int64) map[string]any { + account := map[string]any{ + "accountId": author, "displayName": "Someone", "active": true, + "avatarUrls": map[string]any{"48x48": "https://example.invalid/avatar.png"}, + } + + return map[string]any{ + "id": "100028", "issueId": "10002", "self": "https://example.invalid/worklog/100028", + "author": account, "updateAuthor": account, "started": started, + "created": started, "updated": started, "timeSpent": "1h", "timeSpentSeconds": seconds, + } +} + +// worklogs returns a page of a ticket's worklogs, of total worklogs in all its pages. +func worklogs(total int, entries ...map[string]any) map[string]any { + return map[string]any{"maxResults": 1000, "total": total, "worklogs": entries} +} + +// aWeek returns the start and end of a week in Tokyo. +func aWeek(t *testing.T) (start, end time.Time) { + t.Helper() + + return at(t, "2026-10-26T00:00:00+09:00"), at(t, "2026-11-02T00:00:00+09:00") +} + +func at(t *testing.T, value string) time.Time { + t.Helper() + + parsed, err := time.Parse(time.RFC3339Nano, value) + if err != nil { + t.Fatal(err) + } + + return parsed +} + +// same reports whether got and want hold the same worklogs in the same order. +func same(got, want []jira.Worklog) bool { + return slices.EqualFunc(got, want, func(a, b jira.Worklog) bool { + return a.Started.Equal(b.Started) && a.Spent == b.Spent + }) +} diff --git a/internal/jira/worklogpage_test.go b/internal/jira/worklogpage_test.go new file mode 100644 index 0000000..1164ac4 --- /dev/null +++ b/internal/jira/worklogpage_test.go @@ -0,0 +1,108 @@ +package jira_test + +import ( + "fmt" + "slices" + "testing" +) + +func TestWorklogsReadEveryPage(t *testing.T) { + fake := &fakeJira{ + t: t, + pages: map[string]map[string]any{ + "": next("p2", found("1")), + "p2": last(found("2")), + }, + worklogs: map[string]map[string]any{ + "1@0": worklogs(3, + logged(myAccount, "2026-10-26T09:00:00.000+0900", 60), + logged(myAccount, "2026-10-26T10:00:00.000+0900", 60), + ), + "1@2": worklogs(3, logged(myAccount, "2026-10-26T11:00:00.000+0900", 60)), + "2@0": worklogs(1, logged(myAccount, "2026-10-26T12:00:00.000+0900", 60)), + }, + } + start, end := aWeek(t) + + got, err := clientOf(t, fake.ServeHTTP).Worklogs(t.Context(), start, end) + if err != nil || len(got) != 4 { + t.Errorf("got %v, %v, want four worklogs", got, err) + } + + want := []string{ + "myself ", "search/jql ", "search/jql p2", + "issue/1/worklog 0", "issue/1/worklog 2", "issue/2/worklog 0", + } + if trail := fake.trail(); !slices.Equal(trail, want) { + t.Errorf("got the requests %q, want %q", trail, want) + } +} + +func TestWorklogsStopAtTheLastWorklogPage(t *testing.T) { + mine := logged(myAccount, "2026-10-26T09:00:00.000+0900", 60) + + for _, test := range []struct { + name string + page map[string]any + kept int + }{ + {"all read", worklogs(2, mine, mine), 2}, + {"more than the total", worklogs(1, mine, mine), 2}, + {"an empty page", worklogs(5), 0}, + {"no worklogs", map[string]any{"worklogs": nil, "total": 3}, 0}, + {"nothing", map[string]any{}, 0}, + } { + fake := &fakeJira{ + t: t, + pages: map[string]map[string]any{"": last(found("1"))}, + worklogs: map[string]map[string]any{"1@0": test.page}, + } + start, end := aWeek(t) + + got, err := clientOf(t, fake.ServeHTTP).Worklogs(t.Context(), start, end) + if err != nil || len(got) != test.kept || len(fake.asked) != 3 { + t.Errorf("%s: got %v, %v after %d requests, want %d worklogs after three", + test.name, got, err, len(fake.asked), test.kept) + } + } +} + +func TestWorklogsGiveUpAfterTenPages(t *testing.T) { + fake := &fakeJira{ + t: t, + pages: map[string]map[string]any{"": last(found("1"))}, + worklogs: map[string]map[string]any{}, + } + for i := range 10 { + fake.worklogs[fmt.Sprintf("1@%d", i)] = worklogs(100, + logged(myAccount, "2026-10-26T09:00:00.000+0900", 60)) + } + + start, end := aWeek(t) + + got, err := clientOf(t, fake.ServeHTTP).Worklogs(t.Context(), start, end) + if got != nil || err == nil || err.Error() != "read Jira worklogs: more than 10 pages" || + len(fake.asked) != 12 { + t.Errorf("got %v, %v after %d requests, want an error after 12", got, err, + len(fake.asked)) + } +} + +func TestWorklogsAskForTheTicketByItsID(t *testing.T) { + fake := &fakeJira{ + t: t, + pages: map[string]map[string]any{"": last(found("1/2"))}, + worklogs: map[string]map[string]any{"1%2F2@0": worklogs(0)}, + } + start, end := aWeek(t) + + _, err := clientOf(t, fake.ServeHTTP).Worklogs(t.Context(), start, end) + if err != nil { + t.Fatal(err) + } + + got := fake.asked[len(fake.asked)-1].URL.EscapedPath() + if want := apiPath + "/rest/api/3/issue/1%2F2/worklog"; got != want { + t.Errorf("got the path %s, want %s", got, want) + } +}