From cb86003657e2bcbf5af74747b6f38a55d37c42d3 Mon Sep 17 00:00:00 2001 From: Bakhomious Basta Date: Thu, 29 Oct 2026 15:13:59 +0100 Subject: [PATCH] feat(time): serve the time report from my Jira worklogs 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). --- .gitea/workflows/images.yml | 2 +- docs/plan.md | 12 +- docs/setup.md | 18 ++- mise.local.example.toml | 6 +- process-compose.yaml | 13 ++ services/time/Dockerfile | 15 ++ services/time/internal/api/api.go | 25 ++++ services/time/internal/api/api_test.go | 82 +++++++++++ services/time/internal/report/report.go | 7 +- services/time/internal/report/report_test.go | 11 ++ services/time/internal/worklogs/fake_test.go | 85 +++++++++++ services/time/internal/worklogs/poll.go | 44 ++++++ services/time/internal/worklogs/poll_test.go | 145 +++++++++++++++++++ services/time/jira.go | 24 +++ services/time/jira_test.go | 125 ++++++++++++++++ services/time/main.go | 52 +++++++ services/time/main_test.go | 56 +++++++ 17 files changed, 705 insertions(+), 17 deletions(-) create mode 100644 services/time/Dockerfile create mode 100644 services/time/internal/api/api.go create mode 100644 services/time/internal/api/api_test.go create mode 100644 services/time/internal/worklogs/fake_test.go create mode 100644 services/time/internal/worklogs/poll.go create mode 100644 services/time/internal/worklogs/poll_test.go create mode 100644 services/time/jira.go create mode 100644 services/time/jira_test.go create mode 100644 services/time/main.go create mode 100644 services/time/main_test.go diff --git a/.gitea/workflows/images.yml b/.gitea/workflows/images.yml index 7bf3f86..36db09c 100644 --- a/.gitea/workflows/images.yml +++ b/.gitea/workflows/images.yml @@ -51,7 +51,7 @@ jobs: - name: Build and push run: | # One tag, main: Flux follows its digest (decision 156). - for service in gateway home notes notify nudge pulls todos web; do + for service in gateway home notes notify nudge pulls time todos web; do buildctl build --frontend dockerfile.v0 \ --local context=. --local "dockerfile=services/$service" \ --opt platform=linux/amd64 \ diff --git a/docs/plan.md b/docs/plan.md index 9836cc7..d8cc7ba 100644 --- a/docs/plan.md +++ b/docs/plan.md @@ -163,6 +163,7 @@ The v1.2 design in §4 to §6 stays as reference. A piece of it is built only wh | `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) | | `time-report` | A package of the new `time` service, `report`, works out the time report from my worklogs: the hours I logged on each of the last 7 days, the weekdays before today with nothing logged marked as missing, and this week's hours against its 40-hour target. The third of `time-tracking`'s twelve steps (decision 181) | Done (#149) | +| `time-service` | 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`, with when the latest poll to succeed began and why the latest poll failed, if it did. It reads todos' Jira settings and token: without them it answers 404, and with only some it doesn't start. `mise run up` runs it on port 8088 and the Gitea workflow builds its image; the gateway reaches it with `time-gateway`. The fourth of `time-tracking`'s twelve steps (decision 182) | Done (#150) | | `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. @@ -287,7 +288,7 @@ MacBook ────┼── HTTPS (tailnet) ──▶ Bakhyard on the host lap - Signing out (`signout`, decision 88): the header's Sign out posts to `POST /auth/logout`, which deletes the session in the request's cookie, makes the browser drop the cookie and redirects to `/`, where the app shows its sign-in screen. Other devices stay signed in. The route needs the Tailscale identity alone, so signing out without a live session still drops the cookie; a database error gets a plain-text 500 and keeps the cookie, so that trying again can end the session. - CSRF protection: Go's `http.CrossOriginProtection` wraps the whole gateway, so every request but GET, HEAD and OPTIONS must come from the app's own origin; anything else gets a plain-text 403 (decision 88). -**Polling defaults**: Jira 5 min; Bitbucket mine 5 min, review scan 15 min (the 100 latest updated repositories, listed on each scan; decision 171); GitHub 5 min; logged time 15 min + on demand. Not polled: Exophase (on request, cached for an hour) and IGDB (on demand, cached). +**Polling defaults**: Jira 5 min; Bitbucket mine 5 min, review scan 15 min (the 100 latest updated repositories, listed on each scan; decision 171); GitHub 5 min; logged time 5 min, and again after each worklog Bakhyard adds (decisions 180 and 182). Not polled: Exophase (on request, cached for an hour) and IGDB (on demand, cached). **Default reminder rule**: PR `OPEN`, not a draft, age > 24 h, approvals < required (from the PR's merge checks, else 1) → notify at 10:00 and 15:00 Mon–Fri (local time zone). Each slot sends at most one push, which lists every such PR and opens the PRs page; a slot missed while pulls is down isn't made up (decisions 175 and 177). Later, from the PRs page: snooze (2 h / tomorrow / until the PR is updated) and mark as nudged; a snoozed or nudged PR is left out. @@ -1197,7 +1198,7 @@ Every item is pending unless it says done. The service an item lands in is in br - `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. Done (#148). - `time-report`: a package of the `time` service, `report`, works out the time report from my worklogs: the hours I logged on each of the last 7 days, with every weekday before today that has none marked as missing, plus this week's total against the target. Done (#149). - - `time-service`: a new `time` service polls the last 7 days' worklogs every 5 minutes and answers the time report at `GET /time/report`. + - `time-service`: a new `time` service polls the last 7 days' worklogs every 5 minutes and answers the time report at `GET /time/report` (decision 182). Done (#150). - `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`. - `time-page`: the Time page shows the time report. @@ -1545,6 +1546,13 @@ Made on 2026-10-23: - **Hours**: the JSON gives hours as numbers, `loggedHours` and `targetHours`, the shape that the web app's `Week` type already reads, so the Time page and the Home's "This week" can share its formatting. `encoding/json/v2` has no default encoding for a `time.Duration`. The sums are kept as durations and turned into hours once, so 6 and 12 minutes make 0.3 hours rather than 0.30000000000000004. - **Nothing reads the clock or the zone**: the time it's given brings both, and the service will give `time.Now()`, in `time.Local` (decision 180). - **Rejected**: minutes or seconds in the JSON; a week through Sunday, which needs a longer span; a day under 8 hours counting as missing; `time.Date`'s midnight in every zone. +182. **The time service** (`time-service`): the lead's choices (2026-10-29), for the user to review. + - **Polling**: the poller is a package of its own, `services/time/internal/worklogs`, so that `report` stays free of Jira and the clock, as todos' `tickets` poller stays apart from the client (decision 180). Its `NewPoller` takes the read, such as the client's `Worklogs`, the zone and the logger, and makes a `poll.Poller` of reports (decision 135): each poll takes the clock's time in the zone, reads that time's span (decision 181) and works the report out at that same time, so the span and the days always agree. It polls as the service starts and every 5 minutes after, as todos' Jira search does, which amends §3's "logged time 15 min + on demand": decision 180 already polls every 5 minutes and again after a write, and nothing else asks for a poll on demand yet. A poll that fails is logged as `poll Jira worklogs`. The zone is a parameter so that tests can use a zone far from the Mac's and CI's (Kiritimati, UTC+14); the service gives `time.Local`. + - **The answer**: `GET /time/report` answers what the polls found through the poller's own handler (decision 145): `{"days":[…],"week":{"loggedHours":…,"targetHours":40},"searched":"…"}`, with `failure` when the latest poll failed. The report's `days` and `week` are `omitzero`, so the zero report, which the poller holds until a poll succeeds, adds nothing: a first poll that fails answers `{"failure":"…"}`, and a request that waits while the service stops gets `{}`, as todos' and pulls' do. A real report always has both. HEAD answers as GET does, other methods get 405. + - **Settings**: none of its own. It reads the three Jira settings and the `jira-token` with `jira.FromEnv` (decision 180), so without any of them it runs without Jira and answers 404 `Jira is not set up`, and with only some it exits 1 and says which, as todos does. `mise run up` runs it on 127.0.0.1:8088; the gateway's peer comes with `time-gateway`. + - **The zone**: days follow `time.Local`, the Mac's zone or the container's `TZ` (decision 180). The distroless static image holds tzdata (its README lists it), so `TZ` works without importing `time/tzdata`, which would add about 450 KB to the binary. Without `TZ` a container keeps UTC, so `homelab-time` sets it, as `homelab-pulls-notify` did for pulls. + - **The image**: `services/time/Dockerfile` builds as pulls' does, and the Gitea workflow builds and pushes `time` with the other services. + - **Rejected**: the poller inside `report`, which would bring the clock and Jira into it; a Refresh that polls on demand; embedding `time/tzdata`; `time.Now()` read twice, once for the span and once for the report, which could disagree across midnight. ## 10. Risks diff --git a/docs/setup.md b/docs/setup.md index 9bcb4c8..40888b2 100644 --- a/docs/setup.md +++ b/docs/setup.md @@ -18,17 +18,17 @@ cp mise.local.example.toml mise.local.toml | `BAKHYARD_GOOGLE_CLIENT_ID` | gateway | The Google OAuth client's ID | | `BAKHYARD_GOOGLE_EMAIL` | gateway | The one email let in; its first sign-in pins its Google account ([below](#the-pinned-google-account)) | | `BAKHYARD_VAULT` | notes | The Obsidian vault's folder, as an absolute path | -| `BAKHYARD_JIRA_SITE` | todos | The Jira site, `https://.atlassian.net`, whose open tickets todos lists ([below](#jira)) | -| `BAKHYARD_JIRA_API` | todos | Jira's REST API for a token with scopes, `https://api.atlassian.com/ex/jira/` | -| `BAKHYARD_JIRA_EMAIL` | todos | The email of the Atlassian account that owns the token | +| `BAKHYARD_JIRA_SITE` | todos, time | The Jira site, `https://.atlassian.net`, whose open tickets todos lists and whose worklogs time reads ([below](#jira)) | +| `BAKHYARD_JIRA_API` | todos, time | Jira's REST API for a token with scopes, `https://api.atlassian.com/ex/jira/` | +| `BAKHYARD_JIRA_EMAIL` | todos, time | The email of the Atlassian account that owns the token | | `BAKHYARD_BITBUCKET_API` | pulls | Bitbucket's REST API, `https://api.bitbucket.org/2.0`, where pulls lists your open pull requests ([below](#bitbucket)) | | `BAKHYARD_BITBUCKET_EMAIL` | pulls | The email of the Atlassian account that owns the Bitbucket token | | `BAKHYARD_BITBUCKET_WORKSPACES` | pulls | The slugs of the workspaces to list your open pull requests in, separated by commas | | `BAKHYARD_TEST_DATABASE` | Go tests | The test database's URL, password included ([below](#the-test-database)) | -The gateway doesn't start without its four settings, nor notes without its vault: each exits 1 and says what's missing. The gateway also waits for notes, notify, nudge, pulls and todos to start. Todos runs without Jira when none of the three Jira settings is set, and pulls without Bitbucket when none of the three Bitbucket settings is set. +The gateway doesn't start without its four settings, nor notes without its vault: each exits 1 and says what's missing. The gateway also waits for notes, notify, nudge, pulls and todos to start. Todos and time run without Jira when none of the three Jira settings is set, and pulls without Bitbucket when none of the three Bitbucket settings is set. -On the Mac, the gateway, todos and pulls read their secrets from the login Keychain, as the sections below show. On the laptop, which has no Keychain, `BAKHYARD_SECRETS` names a folder of files instead, each named as its secret's Keychain account (`google-client-secret`, `postgres-gateway`, `postgres-todos`, `postgres-notify`, `postgres-nudge`, `vapid-key`, `jira-token`, `bitbucket-token`) and holding the secret exactly, with no newline at the end: the files of the Kubernetes Secrets mounted there (decision 154). Leave it unset on the Mac. +On the Mac, the gateway, todos, pulls and time read their secrets from the login Keychain, as the sections below show. On the laptop, which has no Keychain, `BAKHYARD_SECRETS` names a folder of files instead, each named as its secret's Keychain account (`google-client-secret`, `postgres-gateway`, `postgres-todos`, `postgres-notify`, `postgres-nudge`, `vapid-key`, `jira-token`, `bitbucket-token`) and holding the secret exactly, with no newline at the end: the files of the Kubernetes Secrets mounted there (decision 154). Leave it unset on the Mac. ## Tailscale @@ -131,16 +131,18 @@ Todos lists your open Jira tickets: those assigned to you whose status isn't in curl https://.atlassian.net/_edge/tenant_info ``` -2. On [your Atlassian account's API tokens](https://id.atlassian.com/manage-profile/security/api-tokens), select Create API token with scopes. Pick an expiry date, at most a year away, the Jira app, and the scopes `read:jira-work`, `read:jira-user` and `write:jira-work`; time tracking needs the last two later. +2. On [your Atlassian account's API tokens](https://id.atlassian.com/manage-profile/security/api-tokens), select Create API token with scopes. Pick an expiry date, at most a year away, the Jira app, and the scopes `read:jira-work`, `read:jira-user` and `write:jira-work`: time reads your worklogs with the first two, and will add them with the last. 3. Put the token in the Keychain. With `-w` last, `security` asks for it: ```sh security add-generic-password -s bakhyard -a jira-token -w ``` -4. In `mise.local.toml`, set `BAKHYARD_JIRA_SITE` to `https://.atlassian.net`, `BAKHYARD_JIRA_API` to `https://api.atlassian.com/ex/jira/` and `BAKHYARD_JIRA_EMAIL` to your account's email, then restart todos. +4. In `mise.local.toml`, set `BAKHYARD_JIRA_SITE` to `https://.atlassian.net`, `BAKHYARD_JIRA_API` to `https://api.atlassian.com/ex/jira/` and `BAKHYARD_JIRA_EMAIL` to your account's email, then restart todos and time. -Both URLs must be https, so the token never travels in the clear; only a loopback IP address, such as `http://127.0.0.1:8090`, may take http, for a stand-in Jira in tests. With only some of the settings, a URL that isn't https or no token in the Keychain, todos exits 1 and says which: `BAKHYARD_JIRA_EMAIL is not set`, `BAKHYARD_JIRA_API: "…" is not an https URL` or `read the Jira token: no Keychain item with service bakhyard and account jira-token`. Without any of the settings, `GET /api/todos/tickets` answers 404 `Jira is not set up`. Todos polls Jira as it starts and every 5 minutes after (decisions 122 and 133): it searches your open tickets, then fetches the other tickets that your todos left to do name. When Jira refuses the token, for example once it has expired, `GET /api/todos/tickets` keeps answering what the last poll to succeed found, adds Jira's answer as its `failure`, such as `search Jira: 401 Unauthorized` or `fetch Jira tickets: 401 Unauthorized`, and todos logs `poll Jira` with it. To replace the token, add `-U` to the command in step 3, then restart todos. +Both URLs must be https, so the token never travels in the clear; only a loopback IP address, such as `http://127.0.0.1:8090`, may take http, for a stand-in Jira in tests. With only some of the settings, a URL that isn't https or no token in the Keychain, todos exits 1 and says which: `BAKHYARD_JIRA_EMAIL is not set`, `BAKHYARD_JIRA_API: "…" is not an https URL` or `read the Jira token: no Keychain item with service bakhyard and account jira-token`. Without any of the settings, `GET /api/todos/tickets` answers 404 `Jira is not set up`. Todos polls Jira as it starts and every 5 minutes after (decisions 122 and 133): it searches your open tickets, then fetches the other tickets that your todos left to do name. When Jira refuses the token, for example once it has expired, `GET /api/todos/tickets` keeps answering what the last poll to succeed found, adds Jira's answer as its `failure`, such as `search Jira: 401 Unauthorized` or `fetch Jira tickets: 401 Unauthorized`, and todos logs `poll Jira` with it. To replace the token, add `-U` to the command in step 3, then restart todos and time. + +Time reads your worklogs with the same settings and token (decisions 180 to 182). As it starts and every 5 minutes after, it reads the worklogs you logged in the last 7 days, today included, and works out the time report: the hours you logged on each day, the weekdays before today with nothing logged, and this week's hours against 40. Its days follow its time zone: the Mac's, or a container's `TZ`, without which a container keeps UTC. `mise run up` runs it on port 8088, where `GET /time/report` answers the report, with when the latest poll to succeed began and why the latest poll failed, if it did; the gateway doesn't forward it yet. Without any of the Jira settings, time answers 404 `Jira is not set up`; with only some, it exits 1 and says which, as todos does. When Jira refuses the token, time keeps answering the last report it worked out, adds Jira's answer as its `failure`, such as `read the Jira account: 401 Unauthorized`, and logs `poll Jira worklogs` with it. ## Bitbucket diff --git a/mise.local.example.toml b/mise.local.example.toml index d1f0ac0..fa9187e 100644 --- a/mise.local.example.toml +++ b/mise.local.example.toml @@ -14,9 +14,9 @@ BAKHYARD_GOOGLE_CLIENT_ID = "1234567890-abcdef.apps.googleusercontent.com" BAKHYARD_GOOGLE_EMAIL = "you@example.com" # The Obsidian vault's folder, as an absolute path. The notes service lists its files. BAKHYARD_VAULT = "/Users/you/Documents/Vault" -# The Jira site whose open tickets the todos service lists, Jira's REST API for a token with -# scopes, and the email of the token's account; the token goes into the Keychain. Leave all -# three out to run without Jira. +# The Jira site whose open tickets the todos service lists and whose worklogs the time service +# reads, Jira's REST API for a token with scopes, and the email of the token's account; the token +# goes into the Keychain. Leave all three out to run without Jira. # BAKHYARD_JIRA_SITE = "https://your-site.atlassian.net" # BAKHYARD_JIRA_API = "https://api.atlassian.com/ex/jira/" # BAKHYARD_JIRA_EMAIL = "you@example.com" diff --git a/process-compose.yaml b/process-compose.yaml index f633efa..4364779 100644 --- a/process-compose.yaml +++ b/process-compose.yaml @@ -115,6 +115,19 @@ processes: initial_delay_seconds: 1 period_seconds: 2 + time: + <<: *service + description: Works out the time report from my Jira worklogs + command: bin/time + environment: + - BAKHYARD_LISTEN=tcp://127.0.0.1:8088 + readiness_probe: + http_get: + port: 8088 + path: /healthz + initial_delay_seconds: 1 + period_seconds: 2 + gateway: <<: *service description: "The only way in: /api to the services, everything else to web" diff --git a/services/time/Dockerfile b/services/time/Dockerfile new file mode 100644 index 0000000..c554726 --- /dev/null +++ b/services/time/Dockerfile @@ -0,0 +1,15 @@ +# Build from the repo's root: docker build -f services/time/Dockerfile . (docs/plan.md, decision 138) +FROM --platform=$BUILDPLATFORM golang:1.27.3-alpine@sha256:d200d2dd76b03a99b52651fd1325aff7dcd35422c35c01bac56571b0507f3deb AS build +WORKDIR /src +COPY go.mod go.sum ./ +RUN --mount=type=cache,target=/go/pkg/mod go mod download +COPY internal/ internal/ +COPY services/time/ services/time/ +ARG TARGETOS TARGETARCH +RUN --mount=type=cache,target=/go/pkg/mod --mount=type=cache,target=/root/.cache/go-build \ + CGO_ENABLED=0 GOOS=$TARGETOS GOARCH=$TARGETARCH \ + go build -trimpath -ldflags="-s -w" -o /out/time ./services/time + +FROM gcr.io/distroless/static-debian13:nonroot@sha256:18f4cc26c9ffa7f20a359298be7deadac5c607c8e03a02b8119f1a5fc2f82c99 +COPY --from=build /out/time /usr/local/bin/time +ENTRYPOINT ["/usr/local/bin/time"] diff --git a/services/time/internal/api/api.go b/services/time/internal/api/api.go new file mode 100644 index 0000000..e3337de --- /dev/null +++ b/services/time/internal/api/api.go @@ -0,0 +1,25 @@ +// Package api serves the user's time report over HTTP. +package api + +import ( + "net/http" + + "github.com/go-chi/chi/v5" + + "github.com/codedogapp/bakhyard/services/time/internal/worklogs" +) + +// Register serves the time report on router: GET /time/report answers what reports has found in +// Jira, once its first poll has ended. Without Jira, reports is nil and the client gets a 404. +// Other methods get 405. +func Register(router chi.Router, reports *worklogs.Poller) { + router.Get("/time/report", func(w http.ResponseWriter, r *http.Request) { + if reports == nil { + http.Error(w, "Jira is not set up", http.StatusNotFound) + + return + } + + reports.ServeHTTP(w, r) + }) +} diff --git a/services/time/internal/api/api_test.go b/services/time/internal/api/api_test.go new file mode 100644 index 0000000..f7d6a2a --- /dev/null +++ b/services/time/internal/api/api_test.go @@ -0,0 +1,82 @@ +package api_test + +import ( + "context" + "encoding/json/v2" + "log/slog" + "net/http" + "net/http/httptest" + "testing" + "testing/synctest" + "time" + + "github.com/go-chi/chi/v5" + + "github.com/codedogapp/bakhyard/internal/jira" + "github.com/codedogapp/bakhyard/services/time/internal/api" + "github.com/codedogapp/bakhyard/services/time/internal/worklogs" +) + +func TestReportAnswersWhatThePollerFound(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + reports := worklogs.NewPoller(func(context.Context, time.Time, time.Time) ( + []jira.Worklog, error, + ) { + return []jira.Worklog{{Started: time.Now(), Spent: time.Hour}}, nil + }, time.UTC, slog.New(slog.DiscardHandler)) + go reports.Run(t.Context()) + + snapshot, err := reports.Latest(t.Context()) + if err != nil { + t.Fatal(err) + } + + want, err := json.Marshal(snapshot) + if err != nil { + t.Fatal(err) + } + + rec := serve(t, routes(reports), http.MethodGet) + sent := rec.Result().Header.Get("Content-Type") + + if rec.Code != http.StatusOK || rec.Body.String() != string(want) || + sent != "application/json" { + t.Errorf("got %d %s (%s), want 200 %s as JSON", rec.Code, rec.Body, sent, want) + } + }) +} + +func TestReportIsNotFoundWithoutJira(t *testing.T) { + rec := serve(t, routes(nil), http.MethodGet) + + want := "Jira is not set up\n" + if rec.Code != http.StatusNotFound || rec.Body.String() != want { + t.Errorf("got %d %q, want 404 %q", rec.Code, rec.Body, want) + } +} + +func TestReportTakesOnlyGET(t *testing.T) { + rec := serve(t, routes(nil), http.MethodPost) + if rec.Code != http.StatusMethodNotAllowed { + t.Errorf("got %d, want 405", rec.Code) + } +} + +// routes returns the routes that Register gives reports. +func routes(reports *worklogs.Poller) http.Handler { + router := chi.NewRouter() + api.Register(router, reports) + + return router +} + +// serve returns router's answer to a request for the report with method. +func serve(t *testing.T, router http.Handler, method string) *httptest.ResponseRecorder { + t.Helper() + + req := httptest.NewRequestWithContext(t.Context(), method, "/time/report", nil) + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + + return rec +} diff --git a/services/time/internal/report/report.go b/services/time/internal/report/report.go index 447b7a8..2ce4fa4 100644 --- a/services/time/internal/report/report.go +++ b/services/time/internal/report/report.go @@ -16,12 +16,13 @@ const ( weekTarget = 40 * time.Hour ) -// Report is what the user logged in the last 7 days. +// Report is what the user logged in the last 7 days. A report that New works out always has both +// fields; the zero Report, which a poller keeps until a poll succeeds, encodes as {}. type Report struct { // Days are the 7 days, the earliest first and today last. - Days []Day `json:"days"` + Days []Day `json:"days,omitzero"` // Week is what the user logged this week, from Monday through today. - Week Week `json:"week"` + Week Week `json:"week,omitzero"` } // Day is what the user logged on one day. diff --git a/services/time/internal/report/report_test.go b/services/time/internal/report/report_test.go index 619e918..8ea730d 100644 --- a/services/time/internal/report/report_test.go +++ b/services/time/internal/report/report_test.go @@ -144,3 +144,14 @@ func TestReportJSON(t *testing.T) { t.Errorf("got %s, want %s", got, want) } } + +func TestZeroReportJSON(t *testing.T) { + got, err := json.Marshal(Report{}) + if err != nil { + t.Fatal(err) + } + + if string(got) != "{}" { + t.Errorf("got %s, want {}", got) + } +} diff --git a/services/time/internal/worklogs/fake_test.go b/services/time/internal/worklogs/fake_test.go new file mode 100644 index 0000000..877a495 --- /dev/null +++ b/services/time/internal/worklogs/fake_test.go @@ -0,0 +1,85 @@ +package worklogs_test + +import ( + "bytes" + "context" + "encoding/json/v2" + "log/slog" + "slices" + "sync" + "testing" + "time" + + "github.com/codedogapp/bakhyard/internal/jira" + "github.com/codedogapp/bakhyard/services/time/internal/worklogs" +) + +// fakeJira is a fake Jira, whose reads of worklogs all reply the same, and which keeps the span +// of each read. +type fakeJira struct { + // logged are the worklogs that each read finds, when err is nil. + logged []jira.Worklog + // err fails each read when it isn't nil. + err error + mu sync.Mutex + spans []string +} + +// worklogs reads the worklogs from start up to end, as a jira.Client's Worklogs does. +func (f *fakeJira) worklogs(_ context.Context, start, end time.Time) ([]jira.Worklog, error) { + f.mu.Lock() + defer f.mu.Unlock() + + f.spans = append(f.spans, + start.UTC().Format(time.RFC3339Nano)+" "+end.UTC().Format(time.RFC3339Nano)) + if f.err != nil { + return nil, f.err + } + + return append([]jira.Worklog{}, f.logged...), nil +} + +// read returns the span of each read so far, in turn, in UTC. +func (f *fakeJira) read() []string { + f.mu.Lock() + defer f.mu.Unlock() + + return slices.Clone(f.spans) +} + +// polling starts a poller in Kiritimati's zone, which reads worklogs from fake until the test +// ends, and returns it and its logs. +func polling(t *testing.T, fake *fakeJira) (*worklogs.Poller, *bytes.Buffer) { + t.Helper() + + kiritimati, err := time.LoadLocation("Pacific/Kiritimati") + if err != nil { + t.Fatal(err) + } + + var logs bytes.Buffer + + logger := slog.New(slog.NewTextHandler(&logs, nil)) + poller := worklogs.NewPoller(fake.worklogs, kiritimati, logger) + + go poller.Run(t.Context()) + + return poller, &logs +} + +// latest returns what poller has found, as JSON. +func latest(t *testing.T, poller *worklogs.Poller) string { + t.Helper() + + snapshot, err := poller.Latest(t.Context()) + if err != nil { + t.Fatal(err) + } + + encoded, err := json.Marshal(snapshot) + if err != nil { + t.Fatal(err) + } + + return string(encoded) +} diff --git a/services/time/internal/worklogs/poll.go b/services/time/internal/worklogs/poll.go new file mode 100644 index 0000000..264b710 --- /dev/null +++ b/services/time/internal/worklogs/poll.go @@ -0,0 +1,44 @@ +// Package worklogs polls Jira for the user's worklogs of the last 7 days and keeps the time report +// that they make (docs/plan.md decisions 180 to 182). +package worklogs + +import ( + "context" + "log/slog" + "time" + + "github.com/codedogapp/bakhyard/internal/jira" + "github.com/codedogapp/bakhyard/internal/poll" + "github.com/codedogapp/bakhyard/services/time/internal/report" +) + +// pollInterval is the time between two polls (plan §3's polling defaults). +const pollInterval = 5 * time.Minute + +// Poller polls Jira in the background and keeps the latest time report. +type Poller = poll.Poller[report.Report] + +// NewPoller returns a poller that reads the user's worklogs with read, such as a jira.Client's +// Worklogs, and works the time report out from them. Each poll takes the clock's time in loc, +// whose days and weeks the report follows, reads the worklogs of the report's span at that time +// and works the report out at that same time. It polls every five minutes once Run runs, and logs +// the polls that fail to logger. +func NewPoller( + read func(ctx context.Context, start, end time.Time) ([]jira.Worklog, error), + loc *time.Location, + logger *slog.Logger, +) *Poller { + look := func(ctx context.Context) (report.Report, error) { + now := time.Now().In(loc) + start, end := report.Span(now) + + worklogs, err := read(ctx, start, end) + if err != nil { + return report.Report{}, err + } + + return report.New(worklogs, now), nil + } + + return poll.New("Jira worklogs", pollInterval, look, logger) +} diff --git a/services/time/internal/worklogs/poll_test.go b/services/time/internal/worklogs/poll_test.go new file mode 100644 index 0000000..feb84ea --- /dev/null +++ b/services/time/internal/worklogs/poll_test.go @@ -0,0 +1,145 @@ +package worklogs_test + +import ( + "context" + "errors" + "log/slog" + "strings" + "testing" + "testing/synctest" + "time" + + "github.com/codedogapp/bakhyard/internal/jira" + "github.com/codedogapp/bakhyard/services/time/internal/worklogs" +) + +// interval is the poller's time between two polls. A bubble's clock starts at midnight UTC on +// 1 January 2000, which is 14:00 on Saturday in Kiritimati (UTC+14), the pollers' zone. +const interval = 5 * time.Minute + +func TestPollerReadsAtOnceAndEveryFiveMinutes(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + fake := &fakeJira{} + polling(t, fake) + + for _, test := range []struct { + wait time.Duration + reads int + }{ + {0, 1}, {interval - time.Nanosecond, 1}, {time.Nanosecond, 2}, {interval, 3}, + } { + time.Sleep(test.wait) + synctest.Wait() + + if got := len(fake.read()); got != test.reads { + t.Errorf("at %s: got %d reads, want %d", time.Now().UTC(), got, test.reads) + } + } + }) +} + +func TestPollerReadsTheLastSevenDaysOfItsZone(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + fake := &fakeJira{} + poller, _ := polling(t, fake) + + // Sunday, 2 January, starts at 10:00 UTC in Kiritimati. + for _, test := range []struct { + wait time.Duration + span, first string + }{ + {0, "1999-12-25T10:00:00Z 2000-01-01T10:00:00Z", "1999-12-26"}, + {10*time.Hour - interval, "1999-12-25T10:00:00Z 2000-01-01T10:00:00Z", "1999-12-26"}, + {interval, "1999-12-26T10:00:00Z 2000-01-02T10:00:00Z", "1999-12-27"}, + } { + time.Sleep(test.wait) + synctest.Wait() + + spans := fake.read() + if got := spans[len(spans)-1]; got != test.span { + t.Errorf("at %s: got the span %s, want %s", time.Now().UTC(), got, test.span) + } + + want := `{"days":[{"date":"` + test.first + `",` + if got := latest(t, poller); !strings.HasPrefix(got, want) { + t.Errorf("at %s: got %s, want it to start %s", time.Now().UTC(), got, want) + } + } + }) +} + +func TestPollerWorksTheReportOutInItsZone(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + poller, _ := polling(t, &fakeJira{logged: []jira.Worklog{ + // 00:30 on Thursday, 30 December, in Kiritimati. + {Started: time.Date(1999, 12, 29, 10, 30, 0, 0, time.UTC), Spent: time.Hour}, + // 12:00 on Saturday, 1 January: today. + {Started: time.Date(1999, 12, 31, 22, 0, 0, 0, time.UTC), Spent: 90 * time.Minute}, + }}) + + synctest.Wait() + + want := `{"days":[` + + `{"date":"1999-12-26","loggedHours":0,"missing":false},` + + `{"date":"1999-12-27","loggedHours":0,"missing":true},` + + `{"date":"1999-12-28","loggedHours":0,"missing":true},` + + `{"date":"1999-12-29","loggedHours":0,"missing":true},` + + `{"date":"1999-12-30","loggedHours":1,"missing":false},` + + `{"date":"1999-12-31","loggedHours":0,"missing":true},` + + `{"date":"2000-01-01","loggedHours":1.5,"missing":false}],` + + `"week":{"loggedHours":2.5,"targetHours":40},"searched":"2000-01-01T00:00:00Z"}` + if got := latest(t, poller); got != want { + t.Errorf("got %s, want %s", got, want) + } + }) +} + +func TestPollerKeepsWhyAReadFailed(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + poller, logs := polling(t, &fakeJira{ + err: errors.New("read the Jira account: 401 Unauthorized"), + }) + + synctest.Wait() + + want := `{"failure":"read the Jira account: 401 Unauthorized"}` + if got := latest(t, poller); got != want { + t.Errorf("got %s, want %s", got, want) + } + + line := `level=ERROR msg="poll Jira worklogs" ` + + `err="read the Jira account: 401 Unauthorized"` + "\n" + if !strings.HasSuffix(logs.String(), line) || strings.Count(logs.String(), "\n") != 1 { + t.Errorf("got the logs %q, want one line that ends %q", logs, line) + } + }) +} + +func TestPollerCutsAReadShortWhenItStops(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + cut := make(chan error, 1) + read := func(ctx context.Context, _, _ time.Time) ([]jira.Worklog, error) { + <-ctx.Done() + + cut <- ctx.Err() + + return nil, ctx.Err() + } + + ctx, stop := context.WithCancel(t.Context()) + go worklogs.NewPoller(read, time.UTC, slog.New(slog.DiscardHandler)).Run(ctx) + + synctest.Wait() + stop() + synctest.Wait() + + select { + case err := <-cut: + if !errors.Is(err, context.Canceled) { + t.Errorf("got %v, want the read cut short by the stop", err) + } + default: + t.Error("the read goes on after the poller stopped") + } + }) +} diff --git a/services/time/jira.go b/services/time/jira.go new file mode 100644 index 0000000..3e72663 --- /dev/null +++ b/services/time/jira.go @@ -0,0 +1,24 @@ +package main + +import ( + "context" + "log/slog" + "time" + + "github.com/codedogapp/bakhyard/internal/jira" + "github.com/codedogapp/bakhyard/services/time/internal/worklogs" +) + +// poll starts polling Jira with client for the user's worklogs, until ctx ends, and returns the +// poller that keeps the time report that they make, or nil when Jira is off and client is nil. +// The report's days follow the local zone: the Mac's, or the one that the container's TZ names. +func poll(ctx context.Context, client *jira.Client, logger *slog.Logger) *worklogs.Poller { + if client == nil { + return nil + } + + poller := worklogs.NewPoller(client.Worklogs, time.Local, logger) + go poller.Run(ctx) + + return poller +} diff --git a/services/time/jira_test.go b/services/time/jira_test.go new file mode 100644 index 0000000..fdb4ecf --- /dev/null +++ b/services/time/jira_test.go @@ -0,0 +1,125 @@ +package main + +import ( + "context" + "encoding/json/v2" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/codedogapp/bakhyard/services/time/internal/report" +) + +// startedLayout is the layout of the time that a worklog starts in Jira's answers. +const startedLayout = "2006-01-02T15:04:05.000-0700" + +// answer is time's answer to GET /time/report. +type answer struct { + report.Report + + Searched time.Time `json:"searched"` + Failure string `json:"failure"` +} + +func TestTimeReportsMyWorklogsInJira(t *testing.T) { + rec := getReport(t.Context(), t, jiraServer(t)) + + var got answer + + err := json.Unmarshal(rec.Body.Bytes(), &got) + if err != nil || rec.Code != http.StatusOK { + t.Fatalf("got %d %s (%v), want 200 and a report", rec.Code, rec.Body, err) + } + + var logged float64 + for _, day := range got.Days { + logged += day.LoggedHours + } + + // The worklog started a minute ago, which may be yesterday, or last week. + if len(got.Days) != 7 || logged != 1.5 || got.Week.TargetHours != 40 || + got.Searched.IsZero() || got.Failure != "" { + t.Errorf("got %s, want 7 days with 1.5 hours logged, a target of 40 hours, the time "+ + "of the search and no failure", rec.Body) + } +} + +func TestTimeStopsReadingWorklogsWhenItStops(t *testing.T) { + stopped, stop := context.WithCancel(t.Context()) + stop() + + // Nothing listens on port 1, so a read that went ahead would fail. + rec := getReport(stopped, t, "http://127.0.0.1:1") + if rec.Code != http.StatusOK || rec.Body.String() != "{}" { + t.Errorf("got %d %s, want 200 {}: no read, and no failure", rec.Code, rec.Body) + } +} + +func TestTimeStopsOnABadJiraSetting(t *testing.T) { + settings := jiraSettings("https://api.atlassian.com/ex/jira/cloud-id") + settings["BAKHYARD_JIRA_EMAIL"] = "" + + _, err := routes(t.Context(), envOf(settings), secretsWith(nil)) + if err == nil || err.Error() != "BAKHYARD_JIRA_EMAIL is not set" { + t.Errorf("got %v, want the error BAKHYARD_JIRA_EMAIL is not set", err) + } +} + +// jiraServer returns the URL of a stand-in for Jira's REST API, for the email me@example.com with +// the token secret, where the user logged an hour and a half on one ticket, starting a minute +// before each answer. +func jiraServer(t *testing.T) string { + t.Helper() + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + email, token, ok := r.BasicAuth() + if !ok || email != "me@example.com" || token != "secret" { + w.WriteHeader(http.StatusUnauthorized) + + return + } + + switch r.URL.Path { + case "/rest/api/3/myself": + _, _ = w.Write([]byte(`{"accountId":"me"}`)) + case "/rest/api/3/search/jql": + _, _ = w.Write([]byte(`{"isLast":true,"issues":[{"id":"10001"}]}`)) + case "/rest/api/3/issue/10001/worklog": + started := time.Now().Add(-time.Minute).Format(startedLayout) + _, _ = w.Write([]byte(`{"total":1,"worklogs":[{"author":{"accountId":"me"},` + + `"started":"` + started + `","timeSpentSeconds":5400}]}`)) + default: + w.WriteHeader(http.StatusNotFound) + } + })) + t.Cleanup(server.Close) + + return server.URL +} + +// getReport returns the answer to GET /time/report of time with Jira's REST API at address, +// whose routes run until ctx ends. +func getReport(ctx context.Context, t *testing.T, address string) *httptest.ResponseRecorder { + t.Helper() + + router, err := routes(ctx, envOf(jiraSettings(address)), + secretsWith(map[string]string{"jira-token": "secret"})) + if err != nil { + t.Fatal(err) + } + + req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/time/report", nil) + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + + return rec +} + +// jiraSettings returns the Jira settings, with the REST API at address. +func jiraSettings(address string) map[string]string { + return map[string]string{ + "BAKHYARD_JIRA_SITE": "https://acme.atlassian.net", "BAKHYARD_JIRA_API": address, + "BAKHYARD_JIRA_EMAIL": "me@example.com", + } +} diff --git a/services/time/main.go b/services/time/main.go new file mode 100644 index 0000000..634362b --- /dev/null +++ b/services/time/main.go @@ -0,0 +1,52 @@ +package main + +import ( + "context" + "log/slog" + "net/http" + "os" + + "github.com/go-chi/chi/v5" + "github.com/go-chi/chi/v5/middleware" + + "github.com/codedogapp/bakhyard/internal/health" + "github.com/codedogapp/bakhyard/internal/jira" + "github.com/codedogapp/bakhyard/internal/keychain" + "github.com/codedogapp/bakhyard/internal/secrets" + "github.com/codedogapp/bakhyard/internal/service" + "github.com/codedogapp/bakhyard/services/time/internal/api" +) + +func main() { + readSecret := secrets.New(os.Getenv, keychain.NewReader(keychain.Security).Password) + served := func(ctx context.Context, getenv func(string) string) (http.Handler, error) { + return routes(ctx, getenv, readSecret) + } + + err := service.Run(os.Getenv, served) + if err != nil { + slog.Error("failed", "err", err) + os.Exit(1) + } +} + +// routes returns time's handler, which serves its API. With Jira set up, it polls Jira for the +// user's worklogs in the background until ctx ends. +func routes( + ctx context.Context, + getenv func(string) string, + readSecret secrets.Reader, +) (http.Handler, error) { + client, err := jira.FromEnv(getenv, readSecret) + if err != nil { + return nil, err + } + + router := chi.NewRouter() + // Answer HEAD wherever GET is served. + router.Use(middleware.GetHead) + health.Register(router) + api.Register(router, poll(ctx, client, slog.Default())) + + return router, nil +} diff --git a/services/time/main_test.go b/services/time/main_test.go new file mode 100644 index 0000000..62fcace --- /dev/null +++ b/services/time/main_test.go @@ -0,0 +1,56 @@ +package main + +import ( + "context" + "fmt" + "net/http" + "net/http/httptest" + "testing" + + "github.com/codedogapp/bakhyard/internal/secrets" +) + +func TestTimeServesWithoutJira(t *testing.T) { + router, err := routes(t.Context(), envOf(nil), secretsWith(nil)) + if err != nil { + t.Fatal(err) + } + + // A recorder keeps what the GET handler writes for HEAD; a server would drop it. + for _, test := range []struct { + method, target string + code int + body string + }{ + {http.MethodGet, "/time/report", http.StatusNotFound, "Jira is not set up\n"}, + {http.MethodHead, "/time/report", http.StatusNotFound, "Jira is not set up\n"}, + {http.MethodPost, "/time/report", http.StatusMethodNotAllowed, ""}, + {http.MethodGet, "/healthz", http.StatusOK, `{"status":"ok"}`}, + } { + req := httptest.NewRequestWithContext(t.Context(), test.method, test.target, nil) + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + + if rec.Code != test.code || rec.Body.String() != test.body { + t.Errorf("%s %s: got %d %q, want %d %q", test.method, test.target, rec.Code, + rec.Body, test.code, test.body) + } + } +} + +// envOf returns an environment that holds only settings. +func envOf(settings map[string]string) func(string) string { + return func(name string) string { return settings[name] } +} + +// secretsWith returns a fake secrets reader that has only items, by name. +func secretsWith(items map[string]string) secrets.Reader { + return func(_ context.Context, name string) (string, error) { + secret, ok := items[name] + if !ok { + return "", fmt.Errorf("no secret %s", name) + } + + return secret, nil + } +}