Skip to content

feat(time): serve the time report from my Jira worklogs - #150

Merged
Bakhomious merged 1 commit into
mainfrom
step/time-service
Oct 29, 2026
Merged

Bakhomious merged 1 commit into
mainfrom
step/time-service

Conversation

@Bakhomious

Copy link
Copy Markdown
Contributor

Brief

Goal. A new time service that polls my worklogs of the last 7 days on Jira, as it starts and every 5 minutes after, and answers the time report at GET /time/report. The fourth of time-tracking's twelve steps (decisions 180 to 182).

Scope.

  • services/time/main.go: the service's wiring, as pulls' is: Jira's settings and token through jira.FromEnv, chi with GetHead, /healthz, the API.
  • services/time/jira.go: starts the poller when Jira is set up.
  • services/time/internal/worklogs: the poller. Each poll reads the clock in the service's zone, reads the worklogs of report.Span(now) and keeps report.New(worklogs, now), through internal/poll.
  • services/time/internal/api: GET /time/report, or 404 Jira is not set up.
  • services/time/internal/report/report.go: omitzero on the report's two fields, so the answer before the first poll to succeed has neither.
  • services/time/Dockerfile, .gitea/workflows/images.yml, process-compose.yaml (port 8088).
  • docs/setup.md, mise.local.example.toml: time reads Jira's settings and token too.
  • docs/plan.md: decision 182, §0 row, §8.2 marked done, §3's polling default for logged time.

Out of scope.

  • The gateway's peer (time-gateway) and the laptop (homelab-time).
  • The page, the week's RPC, the attention contract, writing worklogs.
  • Polling again after a write (worklog-add).

Acceptance criteria.

  1. Without any Jira setting, time starts: GET and HEAD /time/report answer 404 Jira is not set up, other methods 405, and /healthz 200 {"status":"ok"}.
  2. With only some Jira settings, a URL that isn't https or no token, time exits 1 and says which, as todos does.
  3. With Jira, time polls as it starts and every 5 minutes after. Each poll reads my worklogs from the start of the report's span up to its end, at the clock's time in the service's zone, and works the report out at that same time.
  4. GET /time/report waits for the first poll to end, then answers {"days":[…],"week":{…},"searched":"…"}. A failed poll keeps the last report, adds its failure and is logged as poll Jira worklogs. Until a poll succeeds there's no days or week: {} when the service stops first, {"failure":"…"} when the poll fails.
  5. A zero report encodes as {}; a report that New works out encodes as before.
  6. The image builds from services/time/Dockerfile on distroless static, whose tzdata lets TZ name the zone; Gitea's images workflow builds and pushes time; mise run up runs time on 127.0.0.1:8088.
  7. The docs say that time reads Jira's settings and token, what it answers, and how it fails.
  8. mise run check passes.

What changed

  • services/time/main.go and jira.go (new): the service, wired as pulls' and todos' are. routes reads the Jira settings and token with jira.FromEnv, then serves /healthz and the API behind chi's GetHead. poll starts the poller with the client's Worklogs and time.Local when Jira is set up, and returns nil when it isn't.
  • services/time/internal/worklogs/poll.go (new): NewPoller(read, loc, logger), a poll.Poller of reports (decision 135). Each poll takes time.Now().In(loc) once, reads report.Span(now) and keeps report.New(worklogs, now). It polls every 5 minutes and logs a failed poll as poll Jira worklogs.
  • services/time/internal/api/api.go (new): GET /time/report answers through the poller's handler (decision 145), or 404 Jira is not set up without Jira.
  • services/time/internal/report/report.go: days and week are omitzero, so the zero report encodes as {}.
  • services/time/Dockerfile (new), as pulls'. .gitea/workflows/images.yml builds and pushes time. process-compose.yaml runs it on 127.0.0.1:8088.
  • docs/setup.md and mise.local.example.toml: time reads the Jira settings and token too; what it answers and how it fails.
  • docs/plan.md: decision 182, the §0 row, §8.2's time-service marked done, and §3's polling default for logged time (5 minutes, which amends "15 min + on demand").

QA

  • Every acceptance criterion has a unit test, a live check or both.
  • Tests: the poller (synctest), the API, and the service against a stand-in Jira. Coverage: api, report and worklogs 100%, Go 95.9% total.
  • Mutations: 30 of 31 caught (go test ./services/time/...). The first run left three:
    • The read not cut short when the service stops (read(context.Background(), …)). A cancelled poll counts as no news, so no test saw it. QA added TestPollerCutsAReadShortWhenItStops.
    • A mutant that didn't build (an unused variable). It was rewritten and is caught.
    • Days in UTC (time.UTC for time.Local in jira.go). A unit test can't catch it, because time.Local is fixed once per process. A self-exec test with another TZ would need a //nolint:gosec (G204). The live check catches it instead.
  • Live, 60 of 60, against real builds and a stand-in Jira:
    • Run in Pacific/Kiritimati (UTC+14), Pacific/Pago_Pago (UTC−11) and the Mac's zone (Europe/Budapest, whose span holds the clocks going back on 25 October): each answers the zone's 7 days with the hours on the right days, the week, searched in UTC to the second, and no failure. The read spans the zone's 7 days. HEAD answers 200 with no body, POST answers 405, /healthz answers ok, and SIGTERM ends it with exit 0.
    • The zone mutant, built and run in Kiritimati, answers other days, so it's caught.
    • A refused token answers {"failure":"read the Jira account: 401 Unauthorized"} and logs poll Jira worklogs. With a 3-second poll, a later failure keeps the days and searched and adds the failure. The next poll to succeed drops it, and the only ERROR lines are the polls'.
    • Without Jira, GET and HEAD answer 404 Jira is not set up and POST 405. With only BAKHYARD_JIRA_SITE it exits 1, saying BAKHYARD_JIRA_API is not set; with no token file it says no secret file jira-token.
    • process-compose: time comes up healthy on 8088, answers without Jira and stops with it.
    • The image builds for linux/amd64 and linux/arm64. It runs as 65532 with /usr/local/bin/time as its entrypoint, and holds usr/share/zoneinfo. The arm64 container is healthy and answers without Jira. The distroless base has no /etc/localtime, so without TZ the service keeps UTC, as decision 182 says.

Review

No blocking findings. Non-blocking:

  • Lines of code (without blanks and comments): main.go 41, jira.go 16, poll.go 27, api.go 15; tests 375.
  • The zone is pinned by the live check alone (above).
  • docs/setup.md's list of the services that read secrets leaves out notify and nudge, which read theirs too. This predates this step.
  • The gateway doesn't reach time yet (time-gateway), and the laptop doesn't run it yet (homelab-time, which sets TZ).

Gates

mise run --jobs 1 check passed locally:

  • Go fmt; golangci-lint (0 issues); buf; govulncheck (none); actionlint; process-compose's config;
  • Go tests with -race, Go coverage 95.9% total;
  • Biome, svelte-check, tsc, knip, pnpm audit;
  • Vitest, 1,082 tests;
  • Playwright E2E, 319 passed and 7 skipped.

SonarQube Cloud runs on demand (decision 132).

Merged under the user's standing OK (2026-10-26): merge once CI's check is green, and review everything at the end.

A new time service polls Jira for my worklogs of the last 7 days as it
starts and every 5 minutes after, and answers the time report at
GET /time/report. It reads todos' Jira settings and token, runs on
port 8088 under process-compose, and Gitea's workflow builds its image.
The gateway and the laptop come with later steps (decision 182).
@Bakhomious
Bakhomious merged commit d376ef9 into main Oct 29, 2026
2 checks passed
@Bakhomious
Bakhomious deleted the step/time-service branch October 29, 2026 14:18
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant