chore: set up branching, CI, and PR workflow #1
@@ -0,0 +1,27 @@
|
|||||||
|
## What and why
|
||||||
|
|
||||||
|
<!-- What changed, and what problem it solves. Link the issue if there is one. -->
|
||||||
|
|
||||||
|
## How this was verified
|
||||||
|
|
||||||
|
<!-- Be specific and honest. "Ran the tests" is not enough — say which, and what they proved.
|
||||||
|
If something is untested, say so here rather than leaving the reviewer to find out. -->
|
||||||
|
|
||||||
|
- [ ] CI is green
|
||||||
|
- [ ] Tests added or updated for the behaviour that changed
|
||||||
|
- [ ] Verified manually (describe how):
|
||||||
|
|
||||||
|
## Invariants
|
||||||
|
|
||||||
|
<!-- Tick only what applies to this change. See CLAUDE.md. -->
|
||||||
|
|
||||||
|
- [ ] Raw ingested bytes remain immutable; derived tables stay rebuildable
|
||||||
|
- [ ] No stored odometer added; wear still derived from installs
|
||||||
|
- [ ] Physical quantities stored as SI integers
|
||||||
|
- [ ] New user-owned tables have `user_id` + RLS policy + repository scope
|
||||||
|
- [ ] No secret can reach a response model, log line, or error message
|
||||||
|
- [ ] Migration survives `upgrade -> downgrade -1 -> upgrade`
|
||||||
|
|
||||||
|
## Risks and follow-ups
|
||||||
|
|
||||||
|
<!-- What might this break? What did you deliberately leave out? -->
|
||||||
@@ -0,0 +1,204 @@
|
|||||||
|
name: CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
pull_request:
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: ci-${{ gitea.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
meta:
|
||||||
|
name: Repo hygiene
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Branch name follows convention
|
||||||
|
if: gitea.event_name == 'pull_request'
|
||||||
|
run: |
|
||||||
|
BRANCH="${{ gitea.head_ref }}"
|
||||||
|
echo "Branch: $BRANCH"
|
||||||
|
if echo "$BRANCH" | grep -Eq '^(feat|fix|refactor|test|docs|chore|ci)/[a-z0-9._-]+$'; then
|
||||||
|
echo "OK"
|
||||||
|
else
|
||||||
|
echo "::error::Branch '$BRANCH' does not match <type>/<desc>."
|
||||||
|
echo "Valid types: feat fix refactor test docs chore ci"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: No secrets committed
|
||||||
|
run: |
|
||||||
|
# Deliberately narrow: high-signal patterns only, so this never cries wolf.
|
||||||
|
PATTERN='BEGIN (RSA|OPENSSH|EC|PGP) PRIVATE KEY|xoxb-[0-9A-Za-z-]{10,}|AKIA[0-9A-Z]{16}'
|
||||||
|
if git grep -nIE "$PATTERN" -- . ':!.gitea/workflows/*' ; then
|
||||||
|
echo "::error::Possible secret committed (see matches above)."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "No secret patterns found."
|
||||||
|
|
||||||
|
- name: No ride data committed
|
||||||
|
run: |
|
||||||
|
if git ls-files | grep -E '\.(fit|gpx|tcx)$' | grep -v '^apps/api/tests/fixtures/fit/'; then
|
||||||
|
echo "::error::Ride files belong in the blob store, not git."
|
||||||
|
echo "Only apps/api/tests/fixtures/fit/ may contain .fit files (golden fixtures)."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "Clean."
|
||||||
|
|
||||||
|
api:
|
||||||
|
name: API (lint, types, tests)
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgis/postgis:16-3.4
|
||||||
|
env:
|
||||||
|
POSTGRES_USER: velodrome
|
||||||
|
POSTGRES_PASSWORD: velodrome
|
||||||
|
POSTGRES_DB: velodrome_test
|
||||||
|
options: >-
|
||||||
|
--health-cmd pg_isready
|
||||||
|
--health-interval 10s
|
||||||
|
--health-timeout 5s
|
||||||
|
--health-retries 10
|
||||||
|
env:
|
||||||
|
DATABASE_URL: postgresql+asyncpg://velodrome:velodrome@postgres:5432/velodrome_test
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Is there API code yet?
|
||||||
|
id: guard
|
||||||
|
run: |
|
||||||
|
if [ -n "$(find apps/api -name '*.py' -not -path '*/.*' 2>/dev/null | head -1)" ]; then
|
||||||
|
echo "present=true" >> "$GITHUB_OUTPUT"
|
||||||
|
else
|
||||||
|
echo "present=false" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "No Python sources under apps/api yet — skipping."
|
||||||
|
fi
|
||||||
|
|
||||||
|
- uses: actions/setup-python@v5
|
||||||
|
if: steps.guard.outputs.present == 'true'
|
||||||
|
with:
|
||||||
|
python-version: '3.12'
|
||||||
|
|
||||||
|
- name: Cache uv
|
||||||
|
if: steps.guard.outputs.present == 'true'
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: ~/.cache/uv
|
||||||
|
key: uv-${{ runner.os }}-${{ hashFiles('apps/api/pyproject.toml') }}
|
||||||
|
restore-keys: uv-${{ runner.os }}-
|
||||||
|
|
||||||
|
- name: Install
|
||||||
|
if: steps.guard.outputs.present == 'true'
|
||||||
|
working-directory: apps/api
|
||||||
|
run: |
|
||||||
|
curl -LsSf https://astral.sh/uv/install.sh | sh
|
||||||
|
export PATH="$HOME/.local/bin:$PATH"
|
||||||
|
uv sync --all-extras
|
||||||
|
|
||||||
|
- name: Lint (ruff)
|
||||||
|
if: steps.guard.outputs.present == 'true'
|
||||||
|
working-directory: apps/api
|
||||||
|
run: |
|
||||||
|
export PATH="$HOME/.local/bin:$PATH"
|
||||||
|
uv run ruff check .
|
||||||
|
uv run ruff format --check .
|
||||||
|
|
||||||
|
- name: Types (mypy --strict)
|
||||||
|
if: steps.guard.outputs.present == 'true'
|
||||||
|
working-directory: apps/api
|
||||||
|
run: |
|
||||||
|
export PATH="$HOME/.local/bin:$PATH"
|
||||||
|
uv run mypy --strict velodrome
|
||||||
|
|
||||||
|
- name: Tests
|
||||||
|
if: steps.guard.outputs.present == 'true'
|
||||||
|
working-directory: apps/api
|
||||||
|
run: |
|
||||||
|
export PATH="$HOME/.local/bin:$PATH"
|
||||||
|
uv run pytest -q
|
||||||
|
|
||||||
|
web:
|
||||||
|
name: Web (lint, typecheck, build)
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Is there web code yet?
|
||||||
|
id: guard
|
||||||
|
run: |
|
||||||
|
if [ -f apps/web/package.json ]; then
|
||||||
|
echo "present=true" >> "$GITHUB_OUTPUT"
|
||||||
|
else
|
||||||
|
echo "present=false" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "No apps/web/package.json yet — skipping."
|
||||||
|
fi
|
||||||
|
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
if: steps.guard.outputs.present == 'true'
|
||||||
|
with:
|
||||||
|
node-version: '22'
|
||||||
|
|
||||||
|
- name: Install, lint, build
|
||||||
|
if: steps.guard.outputs.present == 'true'
|
||||||
|
working-directory: apps/web
|
||||||
|
run: |
|
||||||
|
corepack enable
|
||||||
|
pnpm install --frozen-lockfile
|
||||||
|
pnpm run lint
|
||||||
|
pnpm run check
|
||||||
|
pnpm run build
|
||||||
|
|
||||||
|
migrations:
|
||||||
|
name: Migrations reversible
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgis/postgis:16-3.4
|
||||||
|
env:
|
||||||
|
POSTGRES_USER: velodrome
|
||||||
|
POSTGRES_PASSWORD: velodrome
|
||||||
|
POSTGRES_DB: velodrome_mig
|
||||||
|
options: >-
|
||||||
|
--health-cmd pg_isready
|
||||||
|
--health-interval 10s
|
||||||
|
--health-timeout 5s
|
||||||
|
--health-retries 10
|
||||||
|
env:
|
||||||
|
DATABASE_URL: postgresql+asyncpg://velodrome:velodrome@postgres:5432/velodrome_mig
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Are there migrations yet?
|
||||||
|
id: guard
|
||||||
|
run: |
|
||||||
|
if [ -d apps/api/alembic/versions ] && [ -n "$(ls -A apps/api/alembic/versions/*.py 2>/dev/null)" ]; then
|
||||||
|
echo "present=true" >> "$GITHUB_OUTPUT"
|
||||||
|
else
|
||||||
|
echo "present=false" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "No migrations yet — skipping."
|
||||||
|
fi
|
||||||
|
|
||||||
|
- uses: actions/setup-python@v5
|
||||||
|
if: steps.guard.outputs.present == 'true'
|
||||||
|
with:
|
||||||
|
python-version: '3.12'
|
||||||
|
|
||||||
|
- name: upgrade -> downgrade -> upgrade, and check for model drift
|
||||||
|
if: steps.guard.outputs.present == 'true'
|
||||||
|
working-directory: apps/api
|
||||||
|
run: |
|
||||||
|
curl -LsSf https://astral.sh/uv/install.sh | sh
|
||||||
|
export PATH="$HOME/.local/bin:$PATH"
|
||||||
|
uv sync --all-extras
|
||||||
|
uv run alembic upgrade head
|
||||||
|
uv run alembic downgrade -1
|
||||||
|
uv run alembic upgrade head
|
||||||
|
# Fails if the models have drifted from the migrations.
|
||||||
|
uv run alembic check
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
# CLAUDE.md
|
||||||
|
|
||||||
|
Conventions for this repo. Agents and humans both follow these. Read before making changes.
|
||||||
|
|
||||||
|
## What this is
|
||||||
|
|
||||||
|
A self-hosted cycling app: ride sync from a Bryton Rider 650, mileage tracking, spare-parts
|
||||||
|
inventory, maintenance records with mileage-milestone reminders. See `docs/PLAN.md` for the full
|
||||||
|
design, `docs/DECISIONS.md` for settled decisions, `docs/RESEARCH.md` for source material.
|
||||||
|
|
||||||
|
**Read `docs/DECISIONS.md` before proposing an architectural change.** If a decision there is wrong,
|
||||||
|
say so and argue it — but don't silently contradict it.
|
||||||
|
|
||||||
|
## Layout
|
||||||
|
|
||||||
|
```
|
||||||
|
apps/api/ Python 3.12 / FastAPI / SQLAlchemy async / Alembic
|
||||||
|
apps/web/ SvelteKit static SPA (installable PWA)
|
||||||
|
packages/openapi/ openapi.json — COMMITTED contract artefact, CI enforces it matches the code
|
||||||
|
deploy/ docker-compose, Caddyfile, systemd units, backup scripts
|
||||||
|
docs/ plan, decisions, research
|
||||||
|
scripts/ repo tooling (PR helpers, etc.)
|
||||||
|
.gitea/workflows/ CI
|
||||||
|
```
|
||||||
|
|
||||||
|
## Non-negotiable invariants
|
||||||
|
|
||||||
|
These are load-bearing. Breaking one is a correctness bug, not a style choice.
|
||||||
|
|
||||||
|
1. **Raw bytes are the only truth.** Every ingested file is written to the content-addressed blob
|
||||||
|
store *before* parsing, and is never mutated or deleted. Every table is a rebuildable projection:
|
||||||
|
deleting everything derived from a `raw_file_id` and re-parsing must be semantically a no-op.
|
||||||
|
2. **No odometer columns.** Component wear is always derived by replaying activities against
|
||||||
|
time-ranged `component_installs`. Never add a stored running total to a component.
|
||||||
|
3. **All physical quantities are SI integers** in storage — metres, seconds, mm/s, centimetres,
|
||||||
|
grams, minor currency units. Imperial is display-only. Never store a float mile.
|
||||||
|
4. **Every user-owned table has `user_id`, an RLS policy, and a repository-layer scope.** Both
|
||||||
|
layers, always. Never rely on the query alone.
|
||||||
|
5. **Secrets never leave the server.** The Bryton credential is password-equivalent. It must not
|
||||||
|
appear in any API response model, any log line, or any error message.
|
||||||
|
6. **One ingestion path.** All sources funnel through `ingest_bytes()`. Never add a second parse
|
||||||
|
path for a new source.
|
||||||
|
|
||||||
|
## Style
|
||||||
|
|
||||||
|
- **Python:** `ruff` (lint + format), `mypy --strict`. Type everything. Async throughout; no sync DB
|
||||||
|
calls in request handlers.
|
||||||
|
- **SQL:** migrations via Alembic only, never manual DDL. Every migration must survive
|
||||||
|
`upgrade -> downgrade -1 -> upgrade`.
|
||||||
|
- **Tests:** pytest against a real Postgres service container, never mocks for DB behaviour.
|
||||||
|
Parser changes need a golden fixture in `apps/api/tests/fixtures/fit/`.
|
||||||
|
- **Commits:** imperative mood, explain *why* in the body. Conventional-commit prefixes
|
||||||
|
(`feat:`, `fix:`, `refactor:`, `test:`, `docs:`, `chore:`, `ci:`).
|
||||||
|
- Match surrounding code. Don't introduce a new pattern when one exists.
|
||||||
|
|
||||||
|
## Branching
|
||||||
|
|
||||||
|
**Never commit directly to `main`.** `main` is protected and only moves via merged PRs.
|
||||||
|
|
||||||
|
```
|
||||||
|
feat/<scope>-<short-desc> new capability feat/ingest-fit-parser
|
||||||
|
fix/<scope>-<short-desc> bug fix fix/wear-wet-multiplier
|
||||||
|
refactor/<scope>-<desc> no behaviour change
|
||||||
|
test/<scope>-<desc> tests only
|
||||||
|
docs/<desc> documentation
|
||||||
|
chore/<desc> tooling, deps, CI
|
||||||
|
```
|
||||||
|
|
||||||
|
Scope is usually the area: `ingest`, `wear`, `auth`, `notify`, `web`, `deploy`, `ci`.
|
||||||
|
|
||||||
|
One branch = one reviewable change. If a branch grows past roughly 400 changed lines, it should
|
||||||
|
probably have been two.
|
||||||
|
|
||||||
|
## PR workflow
|
||||||
|
|
||||||
|
1. Branch from an up-to-date `main`.
|
||||||
|
2. Commit in logical steps. Push the branch.
|
||||||
|
3. Open a PR with `scripts/pr.sh` (see below) or the web UI. Fill in the template honestly —
|
||||||
|
especially "How this was verified."
|
||||||
|
4. CI must be green. A red PR is not ready for review, and shouldn't be requested.
|
||||||
|
5. Review happens on the PR. Address feedback with new commits, don't force-push over review history
|
||||||
|
unless asked.
|
||||||
|
6. Squash-merge into `main`. Delete the branch.
|
||||||
|
|
||||||
|
**Agents:** you open PRs, you do not merge them. Merging is a human decision.
|
||||||
|
|
||||||
|
## Model allocation
|
||||||
|
|
||||||
|
The orchestrator runs **Opus 5**. Worker agents do not default to it.
|
||||||
|
|
||||||
|
The rule is **match the model to the cost of being wrong, not the size of the task.** A big,
|
||||||
|
well-specified job with obvious failure modes is cheap work. A small change to dedupe logic that
|
||||||
|
silently corrupts data for six months is expensive work.
|
||||||
|
|
||||||
|
### Opus 5 — orchestration, and anything subtly wrong-able
|
||||||
|
|
||||||
|
- Task decomposition, planning, and **all code review** (see below — this is where it pays for itself)
|
||||||
|
- Anything touching the six invariants above
|
||||||
|
- `ingest/` — FIT parsing, the three dedupe layers, activity-vs-course discrimination. Errors here
|
||||||
|
are silent and corrupt the archive.
|
||||||
|
- `wear/` — the wear SQL. Wrong numbers that still look plausible are the worst failure mode in the
|
||||||
|
product, because nobody notices.
|
||||||
|
- `auth/`, RLS policies — security, and a mistake exposes another user's data.
|
||||||
|
- `sources/bryton/` — a reverse-engineered protocol with no spec to check against.
|
||||||
|
- Schema migrations that alter or drop existing columns.
|
||||||
|
- Debugging anything that two Sonnet attempts have already failed to fix.
|
||||||
|
|
||||||
|
### Sonnet — the bulk of implementation
|
||||||
|
|
||||||
|
Default for normal feature work. `docs/PLAN.md` is detailed enough that most implementation is
|
||||||
|
careful transcription plus ordinary judgement, and CI catches the rest:
|
||||||
|
|
||||||
|
- CRUD endpoints, Pydantic models, repository methods
|
||||||
|
- SvelteKit components, routes, styling, the service worker
|
||||||
|
- Tests against an already-decided behaviour
|
||||||
|
- Additive migrations, docker-compose and Caddy config, CI workflows
|
||||||
|
- Documentation
|
||||||
|
|
||||||
|
### Haiku — mechanical work
|
||||||
|
|
||||||
|
- Dependency bumps, formatting, renames, changelog entries
|
||||||
|
- Log triage, "find every call site of X"
|
||||||
|
- Anything where a script would also work
|
||||||
|
|
||||||
|
### The economics
|
||||||
|
|
||||||
|
**Sonnet implements, Opus reviews** is the default pairing, and it is much cheaper than Opus
|
||||||
|
implementing while catching most of the same problems. Review reads a focused diff; implementation
|
||||||
|
reads the whole repo and writes for hours. If budget is tight, cut Opus from implementation before
|
||||||
|
you cut it from review.
|
||||||
|
|
||||||
|
Escalate a task to a stronger model when it has actually failed, not preemptively. Two failed Sonnet
|
||||||
|
attempts is a real signal; "this feels hard" is not.
|
||||||
|
|
||||||
|
Never run more agents in parallel than there are genuinely independent branches of work. Parallel
|
||||||
|
agents that touch the same files cost more than one agent working in sequence, because the merge
|
||||||
|
conflicts and re-review are paid twice.
|
||||||
|
|
||||||
|
## Working as a parallel agent
|
||||||
|
|
||||||
|
Each agent works in its own git worktree so parallel branches don't collide:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git worktree add ../bike-app-<branch> -b feat/<scope>-<desc>
|
||||||
|
# work there, push, open PR
|
||||||
|
git worktree remove ../bike-app-<branch>
|
||||||
|
```
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
- **Stay in your lane.** Touch only files your task needs. If you need a change in someone else's
|
||||||
|
area, note it in the PR rather than making it.
|
||||||
|
- **Never rebase or force-push another agent's branch.**
|
||||||
|
- **Rebase on `main` before opening the PR**, so the reviewer sees a clean diff.
|
||||||
|
- **Don't invent scope.** If the task is ambiguous, ask rather than guess — a wrong guess costs a
|
||||||
|
full review cycle.
|
||||||
|
- **Report honestly.** If tests fail, say so with the output. If you skipped something, say that.
|
||||||
|
"Done" means done and verified.
|
||||||
|
|
||||||
|
## What needs a human decision
|
||||||
|
|
||||||
|
Don't do these autonomously:
|
||||||
|
- Merging a PR
|
||||||
|
- Anything touching `docs/DECISIONS.md` (propose it in a PR, argue the case)
|
||||||
|
- Schema changes that drop or rewrite existing columns
|
||||||
|
- Anything that sends data off the server, or adds a third-party runtime dependency on one
|
||||||
|
- Rotating or changing secrets
|
||||||
|
- Force-pushing anything, ever, to `main`
|
||||||
|
|
||||||
|
## Environment
|
||||||
|
|
||||||
|
- Gitea at `http://192.168.0.3:3000`, repo `BBergle/bike-app`, act_runner on the same host.
|
||||||
|
- SSH remote `git@192.168.0.3:BBergle/bike-app.git`, key pinned in `~/.ssh/config`.
|
||||||
|
- **`secrets.GITEA_TOKEN` cannot push to the Gitea container registry** — use the `REGISTRY_TOKEN`
|
||||||
|
PAT secret (`package:write`). This is a documented Gitea limitation, not a misconfiguration.
|
||||||
|
- `jobs.*.environment` is silently ignored by Gitea Actions. Don't build a gate on it.
|
||||||
|
- Always pair `schedule:` with `workflow_dispatch:` — Gitea's cron has shipped flaky.
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
# Contributing
|
||||||
|
|
||||||
|
Short version: branch, commit, push, open a PR, get it reviewed, squash-merge.
|
||||||
|
`main` is never committed to directly.
|
||||||
|
|
||||||
|
Conventions live in [`CLAUDE.md`](CLAUDE.md) — branch naming, style, the non-negotiable
|
||||||
|
invariants, and the rules for agents working in parallel. Read that first.
|
||||||
|
|
||||||
|
## One-time setup
|
||||||
|
|
||||||
|
SSH to Gitea should already be pinned in `~/.ssh/config`:
|
||||||
|
|
||||||
|
```
|
||||||
|
Host 192.168.0.3
|
||||||
|
HostName 192.168.0.3
|
||||||
|
User git
|
||||||
|
IdentityFile ~/.ssh/gitea
|
||||||
|
IdentitiesOnly yes
|
||||||
|
```
|
||||||
|
|
||||||
|
`IdentitiesOnly yes` matters: without it, SSH may offer an older deploy key first and Gitea will
|
||||||
|
authorize against *that* key's narrower permissions.
|
||||||
|
|
||||||
|
For opening PRs from the command line, create a Gitea token at
|
||||||
|
http://192.168.0.3:3000/user/settings/applications with scopes `read:user`, `write:repository`,
|
||||||
|
`write:issue`, then:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export GITEA_TOKEN=<token> # add to your shell profile
|
||||||
|
export GITEA_URL=http://192.168.0.3:3000
|
||||||
|
```
|
||||||
|
|
||||||
|
## Day-to-day
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git switch main && git pull
|
||||||
|
git switch -c feat/ingest-fit-parser
|
||||||
|
|
||||||
|
# work, commit in logical steps
|
||||||
|
git push -u origin feat/ingest-fit-parser
|
||||||
|
|
||||||
|
scripts/pr.sh "feat(ingest): parse Bryton FIT activities"
|
||||||
|
```
|
||||||
|
|
||||||
|
`scripts/pr.sh` opens the PR against `main` and prints its URL. Pass `--draft` if it isn't ready.
|
||||||
|
|
||||||
|
## Parallel work
|
||||||
|
|
||||||
|
Use worktrees so branches don't fight over one checkout:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git worktree add ../bike-app-ingest -b feat/ingest-fit-parser
|
||||||
|
cd ../bike-app-ingest
|
||||||
|
# ...
|
||||||
|
git worktree remove ../bike-app-ingest
|
||||||
|
```
|
||||||
|
|
||||||
|
## Review
|
||||||
|
|
||||||
|
- CI must be green before review is requested. A red PR isn't ready.
|
||||||
|
- Reviewers look for: correctness, the invariants in `CLAUDE.md`, whether the tests actually prove
|
||||||
|
what the PR claims, and whether it reuses what already exists.
|
||||||
|
- Address feedback with new commits. Don't force-push over review history unless asked.
|
||||||
|
- **Agents open PRs; humans merge them.**
|
||||||
|
|
||||||
|
## Commits
|
||||||
|
|
||||||
|
Conventional prefixes, imperative mood, and a body that explains *why*:
|
||||||
|
|
||||||
|
```
|
||||||
|
feat(ingest): discriminate activities from Bryton course files
|
||||||
|
|
||||||
|
Bryton writes routes as .fit too, so a naive importer turns saved routes
|
||||||
|
into phantom rides. Check file_id.type first, falling back to message-shape
|
||||||
|
inspection since Bryton's encoder omits it on some firmware.
|
||||||
|
```
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
Python 3.12 / FastAPI / SQLAlchemy async / Alembic. Not yet scaffolded — Phase 0.
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
Golden FIT fixtures for parser tests. Real Rider 650 files go here.
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
SvelteKit static SPA, installable PWA. Not yet scaffolded — Phase 0.
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
docker-compose, Caddyfile, systemd backup units. Not yet written — Phase 0.
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
openapi.json lives here, committed. CI enforces it matches the code.
|
||||||
Executable
+64
@@ -0,0 +1,64 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Shared Gitea helpers. Source this; don't execute it.
|
||||||
|
#
|
||||||
|
# Token resolution, in order:
|
||||||
|
# 1. $GITEA_TOKEN
|
||||||
|
# 2. ~/.config/gitea/token (chmod 600)
|
||||||
|
# 3. macOS Keychain, service "gitea-bike-app"
|
||||||
|
#
|
||||||
|
# Store it in the Keychain (recommended — not readable as a plain file):
|
||||||
|
# security add-generic-password -a "$USER" -s gitea-bike-app -w '<token>' -U
|
||||||
|
#
|
||||||
|
# Or as a file:
|
||||||
|
# mkdir -p ~/.config/gitea && printf '%s' '<token>' > ~/.config/gitea/token
|
||||||
|
# chmod 600 ~/.config/gitea/token
|
||||||
|
|
||||||
|
GITEA_URL="${GITEA_URL:-http://192.168.0.3:3000}"
|
||||||
|
GITEA_OWNER="${GITEA_OWNER:-BBergle}"
|
||||||
|
GITEA_REPO="${GITEA_REPO:-bike-app}"
|
||||||
|
|
||||||
|
gitea_token() {
|
||||||
|
if [ -n "${GITEA_TOKEN:-}" ]; then
|
||||||
|
printf '%s' "$GITEA_TOKEN"; return 0
|
||||||
|
fi
|
||||||
|
if [ -r "$HOME/.config/gitea/token" ]; then
|
||||||
|
tr -d '\r\n' < "$HOME/.config/gitea/token"; return 0
|
||||||
|
fi
|
||||||
|
if command -v security >/dev/null 2>&1; then
|
||||||
|
if t="$(security find-generic-password -s gitea-bike-app -w 2>/dev/null)"; then
|
||||||
|
printf '%s' "$t"; return 0
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
gitea_require_token() {
|
||||||
|
if ! GITEA_TOKEN="$(gitea_token)" || [ -z "$GITEA_TOKEN" ]; then
|
||||||
|
cat >&2 <<'MSG'
|
||||||
|
error: no Gitea token found.
|
||||||
|
|
||||||
|
Store it once, either way:
|
||||||
|
|
||||||
|
security add-generic-password -a "$USER" -s gitea-bike-app -w '<token>' -U
|
||||||
|
|
||||||
|
# or
|
||||||
|
mkdir -p ~/.config/gitea && printf '%s' '<token>' > ~/.config/gitea/token
|
||||||
|
chmod 600 ~/.config/gitea/token
|
||||||
|
|
||||||
|
Create the token at http://192.168.0.3:3000/user/settings/applications
|
||||||
|
with scopes: read:user, write:repository, write:issue
|
||||||
|
MSG
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
export GITEA_TOKEN
|
||||||
|
}
|
||||||
|
|
||||||
|
# gitea_api <method> <path> [curl args...] — path is relative to the repo
|
||||||
|
gitea_api() {
|
||||||
|
local method="$1" path="$2"; shift 2
|
||||||
|
curl -sS -X "$method" \
|
||||||
|
-H "Authorization: token $GITEA_TOKEN" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
"$@" \
|
||||||
|
"$GITEA_URL/api/v1/repos/$GITEA_OWNER/$GITEA_REPO/$path"
|
||||||
|
}
|
||||||
Executable
+47
@@ -0,0 +1,47 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Open a pull request against main from the current branch.
|
||||||
|
#
|
||||||
|
# scripts/pr.sh "feat(ingest): parse Bryton FIT activities" [--draft]
|
||||||
|
set -euo pipefail
|
||||||
|
cd "$(dirname "$0")/.."
|
||||||
|
. scripts/lib/gitea.sh
|
||||||
|
|
||||||
|
die() { echo "error: $*" >&2; exit 1; }
|
||||||
|
|
||||||
|
gitea_require_token || exit 1
|
||||||
|
command -v jq >/dev/null || die "jq is required (brew install jq)."
|
||||||
|
|
||||||
|
BASE="${PR_BASE:-main}"
|
||||||
|
TITLE="${1:-}"
|
||||||
|
[ -n "$TITLE" ] || die "usage: scripts/pr.sh \"<title>\" [--draft]"
|
||||||
|
DRAFT=false
|
||||||
|
[ "${2:-}" = "--draft" ] && DRAFT=true
|
||||||
|
|
||||||
|
BRANCH="$(git rev-parse --abbrev-ref HEAD)"
|
||||||
|
[ "$BRANCH" != "$BASE" ] || die "refusing to open a PR from $BASE onto itself."
|
||||||
|
git diff --quiet && git diff --cached --quiet || die "working tree is dirty; commit or stash first."
|
||||||
|
|
||||||
|
if ! git rev-parse --verify "origin/$BRANCH" >/dev/null 2>&1; then
|
||||||
|
echo "Branch not on the remote yet; pushing."
|
||||||
|
git push -u origin "$BRANCH"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -f .gitea/PULL_REQUEST_TEMPLATE.md ]; then
|
||||||
|
BODY="$(cat .gitea/PULL_REQUEST_TEMPLATE.md)"
|
||||||
|
else
|
||||||
|
BODY="$(git log --format='- %s' "origin/$BASE..$BRANCH")"
|
||||||
|
fi
|
||||||
|
|
||||||
|
RESPONSE="$(jq -n \
|
||||||
|
--arg title "$TITLE" --arg body "$BODY" \
|
||||||
|
--arg head "$BRANCH" --arg base "$BASE" \
|
||||||
|
'{title:$title, body:$body, head:$head, base:$base}' \
|
||||||
|
| gitea_api POST pulls -d @-)"
|
||||||
|
|
||||||
|
if URL="$(echo "$RESPONSE" | jq -er '.html_url' 2>/dev/null)"; then
|
||||||
|
echo "PR opened: $URL"
|
||||||
|
else
|
||||||
|
echo "Gitea rejected the request:" >&2
|
||||||
|
echo "$RESPONSE" | jq . >&2 2>/dev/null || echo "$RESPONSE" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
Executable
+31
@@ -0,0 +1,31 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Inspect a PR for review.
|
||||||
|
#
|
||||||
|
# scripts/review.sh 7 diff
|
||||||
|
# scripts/review.sh 7 --meta title, author, size, mergeability, CI state
|
||||||
|
# scripts/review.sh --list open PRs
|
||||||
|
set -euo pipefail
|
||||||
|
cd "$(dirname "$0")/.."
|
||||||
|
. scripts/lib/gitea.sh
|
||||||
|
gitea_require_token || exit 1
|
||||||
|
|
||||||
|
if [ "${1:-}" = "--list" ]; then
|
||||||
|
gitea_api GET "pulls?state=open&limit=30" \
|
||||||
|
| jq -r '.[] | "#\(.number) \(.title) [\(.head.ref)] by \(.user.login)"'
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
PR="${1:-}"
|
||||||
|
[ -n "$PR" ] || { echo "usage: scripts/review.sh <pr-number> [--meta] | --list" >&2; exit 1; }
|
||||||
|
|
||||||
|
if [ "${2:-}" = "--meta" ]; then
|
||||||
|
gitea_api GET "pulls/$PR" | jq '{number, title, user: .user.login, head: .head.ref,
|
||||||
|
base: .base.ref, draft, mergeable,
|
||||||
|
additions, deletions, changed_files}'
|
||||||
|
echo "--- CI ---"
|
||||||
|
SHA="$(gitea_api GET "pulls/$PR" | jq -r '.head.sha')"
|
||||||
|
gitea_api GET "commits/$SHA/status" \
|
||||||
|
| jq '{state, checks: [.statuses[]? | {context, state}]}'
|
||||||
|
else
|
||||||
|
gitea_api GET "pulls/$PR.diff"
|
||||||
|
fi
|
||||||
Reference in New Issue
Block a user