diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..04cd3ad --- /dev/null +++ b/.editorconfig @@ -0,0 +1,11 @@ +root = true + +[*] +end_of_line = lf +insert_final_newline = true +charset = utf-8 +trim_trailing_whitespace = true + +[*.go] +indent_style = tab +indent_size = 2 diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml index 7072b34..7c784d4 100644 --- a/.gitea/workflows/ci.yaml +++ b/.gitea/workflows/ci.yaml @@ -20,7 +20,79 @@ jobs: go install mvdan.cc/gofumpt@latest test -z "$(gofumpt -l .)" - name: Run tests - run: go test -race ./... + run: go test -race -coverprofile=coverage.txt ./... + + - name: Filter test files from coverage + run: | + grep -v -E '_test\.go:' coverage.txt > coverage.filtered.txt || true + mv coverage.filtered.txt coverage.txt + + - name: Check coverage + id: coverage + run: | + go install github.com/vladopajic/go-test-coverage/v2@latest + go-test-coverage --config ./.testcoverage.yml --github-action-output + - name: Restore baseline coverage + uses: actions/cache/restore@v5 + with: + path: coverage-baseline.txt + key: coverage-baseline-${{ gitea.run_id }} + restore-keys: | + coverage-baseline- + - name: Compare coverage + run: | + CURRENT="${{ steps.coverage.outputs.total-coverage }}" + if [ -f coverage-baseline.txt ]; then + BASE=$(cat coverage-baseline.txt) + echo "Base coverage: ${BASE}%" + echo "Current coverage: ${CURRENT}%" + if [ "$(echo "$CURRENT < $BASE" | bc -l)" -eq 1 ]; then + echo "::error::Coverage decreased from ${BASE}% to ${CURRENT}%" + exit 1 + fi + echo "Coverage maintained or improved: ${BASE}% -> ${CURRENT}%" + else + echo "No baseline coverage found yet, skipping comparison" + echo "Current coverage: ${CURRENT}%" + fi + - name: Post coverage comment + env: + GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }} + GITEA_URL: ${{ gitea.server_url }} + run: | + COVERAGE="${{ steps.coverage.outputs.total-coverage }}" + curl -X POST "${GITEA_URL}/api/v1/repos/${{ gitea.repository }}/issues/${{ gitea.event.pull_request.number }}/comments" \ + -H "Authorization: token ${GITEA_TOKEN}" \ + -H "Content-Type: application/json" \ + -d "{\"body\": \"## Coverage Report\n\nTotal coverage: **${COVERAGE}%**\"}" + + coverage-baseline: + # Records main's coverage into the Actions cache for the next PR's + # regression gate to read. Post-merge only, not a required check, blocks + # nothing (cf. ADR-0010). + if: gitea.event_name == 'push' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-go@v6 + with: + go-version: 'stable' + - name: Compute coverage + id: coverage + run: | + go install github.com/vladopajic/go-test-coverage/v2@latest + go test -coverprofile=coverage.txt ./... + grep -v -E '_test\.go:' coverage.txt > coverage.filtered.txt || true + mv coverage.filtered.txt coverage.txt + go-test-coverage --config ./.testcoverage.yml --github-action-output + - name: Write baseline file + run: echo "${{ steps.coverage.outputs.total-coverage }}" > coverage-baseline.txt + - name: Save baseline to cache + uses: actions/cache/save@v5 + with: + path: coverage-baseline.txt + key: coverage-baseline-${{ gitea.run_id }} + vulnerabilities: if: gitea.event_name == 'pull_request' runs-on: ubuntu-latest diff --git a/.gitea/workflows/pre-commit.yaml b/.gitea/workflows/pre-commit.yaml new file mode 100644 index 0000000..e427748 --- /dev/null +++ b/.gitea/workflows/pre-commit.yaml @@ -0,0 +1,25 @@ +name: pre-commit +permissions: read-all + +on: + pull_request: + push: + branches: + - main + +jobs: + pre-commit: + runs-on: ubuntu-latest + env: + SKIP: no-commit-to-branch + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-go@v6 + with: + go-version: stable + - uses: actions/setup-python@v6 + with: + python-version: '3.14' + - name: Install goimports + run: go install golang.org/x/tools/cmd/goimports@latest + - uses: pre-commit/action@v3.0.1 diff --git a/.gitea/workflows/release.yaml b/.gitea/workflows/release.yaml new file mode 100644 index 0000000..ef6ec99 --- /dev/null +++ b/.gitea/workflows/release.yaml @@ -0,0 +1,9 @@ +name: Release + +on: + push: + branches: [main] + +jobs: + release: + uses: unboundsoftware/shared-workflows/.gitea/workflows/Release.yml@main diff --git a/.gitignore b/.gitignore index 31bb341..ab2f1aa 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ .claude /release coverage.txt +coverage-baseline.txt diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 0000000..5381cc5 --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,22 @@ +version: "2" +run: + allow-parallel-runners: true +linters: + exclusions: + generated: lax + presets: + - comments + - common-false-positives + - legacy + - std-error-handling + paths: + - third_party$ + - builtin$ + - examples$ +formatters: + exclusions: + generated: lax + paths: + - third_party$ + - builtin$ + - examples$ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..c9ea692 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,39 @@ +# See https://pre-commit.com for more information +# See https://pre-commit.com/hooks.html for more hooks +repos: +- repo: https://github.com/pre-commit/pre-commit-hooks + rev: v6.0.0 + hooks: + - id: trailing-whitespace + - id: end-of-file-fixer + - id: check-yaml + args: + - --allow-multiple-documents + - id: check-added-large-files +- repo: https://github.com/alessandrojcm/commitlint-pre-commit-hook + rev: v9.25.0 + hooks: + - id: commitlint + stages: [ commit-msg ] + additional_dependencies: [ '@commitlint/config-conventional' ] +- repo: https://github.com/dnephin/pre-commit-golang + rev: v0.5.1 + hooks: + - id: go-mod-tidy + - id: go-imports + args: + - -local + - gitea.unbound.se/shiny/logging +- repo: https://github.com/lietu/go-pre-commit + rev: v1.0.0 + hooks: + - id: go-test + - id: gofumpt +- repo: https://github.com/golangci/golangci-lint + rev: v2.12.2 + hooks: + - id: golangci-lint-full +- repo: https://github.com/gitleaks/gitleaks + rev: v8.30.1 + hooks: + - id: gitleaks diff --git a/.testcoverage.yml b/.testcoverage.yml new file mode 100644 index 0000000..7aec8b6 --- /dev/null +++ b/.testcoverage.yml @@ -0,0 +1,13 @@ +# Coverage configuration for go-test-coverage +# https://github.com/vladopajic/go-test-coverage + +profile: coverage.txt + +threshold: + file: 0 + package: 0 + total: 0 + +exclude: + paths: + - _test\.go$ diff --git a/.version b/.version new file mode 100644 index 0000000..557859c --- /dev/null +++ b/.version @@ -0,0 +1,3 @@ +{ + "version": "v0.1.0" +} diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..3bccd61 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,11 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +## [0.1.0] - 2026-06-15 + +### ๐Ÿš€ Features + +- Initial version: `SetupLogger` + context-logger helpers, the `MockLogger` test helper, and a `middleware` request-logger sub-package, extracted from the per-service copies. + + diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..3029df6 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,67 @@ +# logging + +Shared Go library with logging primitives for all Shiny backend services. + +## Shared Documentation + +@../docs/claude/architecture.md +@../docs/claude/go-services.md +@../docs/claude/conventions.md + +## Library Information + +### Purpose + +Single home for the `slog` setup, context-logger helpers, request-logging +middleware and the `MockLogger` test helper that were previously copied (with +drift) into every backend service. + +### Usage + +```go +import ( + "gitea.unbound.se/shiny/logging" + logmw "gitea.unbound.se/shiny/logging/middleware" +) + +// Configure the slog default logger (format: text | json | otel). +logger := logging.SetupLogger(cli.LogLevel, cli.LogFormat, serviceName, buildVersion) + +// Carry a logger on the request context. +ctx = logging.ContextWithLogger(ctx, logger) +logger = logging.LoggerFromContext(ctx) + +// Debug-log request/response bodies. +handler = logmw.RequestLogger(logger)(handler) +``` + +In tests: + +```go +m := logging.NewMockLogger() +// ... exercise code with m.Logger() ... +m.Check(t, []string{"level=INFO msg=\"...\""}) +``` + +### Exported API + +- `SetupLogger(logLevel, logFormat, serviceName, buildVersion string) *slog.Logger` +- `ContextWithLogger(ctx, *slog.Logger) context.Context` / `LoggerFromContext(ctx) *slog.Logger` +- `Logger` interface; `NewMockLogger() *MockLogger` (+ `MockLogger.Logger()`, `MockLogger.Check(t, want)`). +- `logging/middleware.RequestLogger(logger) func(http.Handler) http.Handler`. + +### Notes + +- `MockLogger` currently lives in the main package, so `testify` is a non-test + dependency of the module. Moving it to a `logging/logtest` sub-package is a + tracked low-priority follow-up โ€” it's a breaking import change for the ~13 + services that reference `logging.NewMockLogger`, so it is deferred until a + coordinated bump (Ambix 019ecabc). + +### Conventions + +Standard Shiny library scaffolding: `gofumpt`/`goimports -local`, golangci-lint, +gitleaks and conventional-commit checks via pre-commit; coverage-regression gate +in CI (`.testcoverage.yml`); releases auto-tagged from conventional commits by +the shared Release workflow. Bump the consuming services' `go.mod` after a +release. diff --git a/cliff.toml b/cliff.toml new file mode 100644 index 0000000..ac04085 --- /dev/null +++ b/cliff.toml @@ -0,0 +1,80 @@ +# git-cliff ~ default configuration file +# https://git-cliff.org/docs/configuration +# +# Lines starting with "#" are comments. +# Configuration options are organized into tables and keys. +# See documentation for more information on available options. + +[changelog] +# template for the changelog header +header = """ +# Changelog\n +All notable changes to this project will be documented in this file.\n +""" +# template for the changelog body +# https://keats.github.io/tera/docs/#introduction +body = """ +{% if version %}\ + ## [{{ version | trim_start_matches(pat="v") }}] - {{ timestamp | date(format="%Y-%m-%d") }} +{% else %}\ + ## [unreleased] +{% endif %}\ +{% for group, commits in commits | group_by(attribute="group") %} + ### {{ group | striptags | trim | upper_first }} + {% for commit in commits %} + - {% if commit.scope %}*({{ commit.scope }})* {% endif %}\ + {% if commit.breaking %}[**breaking**] {% endif %}\ + {{ commit.message | upper_first }}\ + {% endfor %} +{% endfor %}\n +""" +# template for the changelog footer +footer = """ + +""" +# remove the leading and trailing s +trim = true +# postprocessors +postprocessors = [ + # { pattern = '', replace = "https://github.com/orhun/git-cliff" }, # replace repository URL +] +# render body even when there are no releases to process +# render_always = true +# output file path +# output = "test.md" + +[git] +# parse the commits based on https://www.conventionalcommits.org +conventional_commits = true +# filter out the commits that are not conventional +filter_unconventional = true +# process each line of a commit as an individual commit +split_commits = false +# regex for preprocessing the commit messages +commit_preprocessors = [ + # Replace issue numbers + #{ pattern = '\((\w+\s)?#([0-9]+)\)', replace = "([#${2}](/issues/${2}))"}, + # Check spelling of the commit with https://github.com/crate-ci/typos + # If the spelling is incorrect, it will be automatically fixed. + #{ pattern = '.*', replace_command = 'typos --write-changes -' }, +] +# regex for parsing and grouping commits +commit_parsers = [ + { message = "^feat", group = "๐Ÿš€ Features" }, + { message = "^fix", group = "๐Ÿ› Bug Fixes" }, + { message = "^doc", group = "๐Ÿ“š Documentation" }, + { message = "^perf", group = "โšก Performance" }, + { message = "^refactor", group = "๐Ÿšœ Refactor" }, + { message = "^style", group = "๐ŸŽจ Styling" }, + { message = "^test", group = "๐Ÿงช Testing" }, + { message = "^chore\\(release\\): prepare for", skip = true }, + { message = "^chore|^ci", group = "โš™๏ธ Miscellaneous Tasks" }, + { body = ".*security", group = "๐Ÿ›ก๏ธ Security" }, + { message = "^revert", group = "โ—€๏ธ Revert" }, +] +# filter out the commits that are not matched by commit parsers +filter_commits = false +# sort the tags topologically +topo_order = false +# sort the commits inside sections by oldest/newest order +sort_commits = "oldest"