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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .gitea/workflows/images.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 \
Expand Down
12 changes: 10 additions & 2 deletions docs/plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.

Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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

Expand Down
18 changes: 10 additions & 8 deletions docs/setup.md
Original file line number Diff line number Diff line change
Expand Up @@ -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://<site>.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/<cloud ID>` |
| `BAKHYARD_JIRA_EMAIL` | todos | The email of the Atlassian account that owns the token |
| `BAKHYARD_JIRA_SITE` | todos, time | The Jira site, `https://<site>.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/<cloud ID>` |
| `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

Expand Down Expand Up @@ -131,16 +131,18 @@ Todos lists your open Jira tickets: those assigned to you whose status isn't in
curl https://<site>.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://<site>.atlassian.net`, `BAKHYARD_JIRA_API` to `https://api.atlassian.com/ex/jira/<cloud ID>` and `BAKHYARD_JIRA_EMAIL` to your account's email, then restart todos.
4. In `mise.local.toml`, set `BAKHYARD_JIRA_SITE` to `https://<site>.atlassian.net`, `BAKHYARD_JIRA_API` to `https://api.atlassian.com/ex/jira/<cloud ID>` 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

Expand Down
6 changes: 3 additions & 3 deletions mise.local.example.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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/<cloud ID>"
# BAKHYARD_JIRA_EMAIL = "you@example.com"
Expand Down
13 changes: 13 additions & 0 deletions process-compose.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
15 changes: 15 additions & 0 deletions services/time/Dockerfile
Original file line number Diff line number Diff line change
@@ -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"]
Loading
Loading