From f0723e706aef0fbd411381212f8742062f3ff8e7 Mon Sep 17 00:00:00 2001 From: k3nny Date: Tue, 7 Jul 2026 00:07:53 +0200 Subject: [PATCH] feat(releaser): initial release v0.4.0 Complete GitFlow release automation tool for Conventional Commits workflows: - Core pipeline: branch parsing, tag discovery, commit analysis, version bump - Maven pom.xml read/write, git commit/tag, HTTPS push with token auth - GitLab release creation via API with auto-generated release notes - Configurable via .releaser.yml (tag_prefix, branch_pattern, commit_message, pom_path, gitlab) - CLI flags: --dry-run, --no-push, --no-commit, --tag-only, --branch, --pom, --tag-prefix, --branch-pattern - Dockerfile (multi-stage Alpine), .releaser.gitlab-ci.yml reusable template - Gitea CI (vet + staticcheck + test + build) and release (5-platform cross-compilation) workflows - Taskfile with build/test/cov/lint/fuzz/ci/docker tasks - 96% test coverage with real in-memory git repos and fuzz tests for all parsers Co-Authored-By: Claude Sonnet 4.6 --- .gitea/workflows/ci.yml | 40 ++ .gitea/workflows/release.yml | 110 +++++ .gitignore | 8 + .releaser.gitlab-ci.yml | 49 ++ .releaser.yml | 37 ++ CHANGELOG.md | 37 ++ Dockerfile | 18 + README.md | 112 +++++ ROADMAP.md | 76 ++++ Taskfile.yml | 107 +++++ cmd/main.go | 278 ++++++++++++ cmd/main_test.go | 541 ++++++++++++++++++++++ go.mod | 42 ++ go.sum | 127 ++++++ internal/branch/branch.go | 63 +++ internal/branch/branch_test.go | 136 ++++++ internal/commits/commits.go | 63 +++ internal/commits/commits_test.go | 102 +++++ internal/config/config.go | 91 ++++ internal/config/config_test.go | 144 ++++++ internal/gitutil/gitutil.go | 280 ++++++++++++ internal/gitutil/gitutil_test.go | 701 +++++++++++++++++++++++++++++ internal/glclient/glclient.go | 103 +++++ internal/glclient/glclient_test.go | 151 +++++++ internal/maven/maven.go | 64 +++ internal/maven/maven_test.go | 213 +++++++++ internal/notes/notes.go | 65 +++ internal/notes/notes_test.go | 109 +++++ internal/version/version.go | 19 + internal/version/version_test.go | 73 +++ 30 files changed, 3959 insertions(+) create mode 100644 .gitea/workflows/ci.yml create mode 100644 .gitea/workflows/release.yml create mode 100644 .gitignore create mode 100644 .releaser.gitlab-ci.yml create mode 100644 .releaser.yml create mode 100644 CHANGELOG.md create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 ROADMAP.md create mode 100644 Taskfile.yml create mode 100644 cmd/main.go create mode 100644 cmd/main_test.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/branch/branch.go create mode 100644 internal/branch/branch_test.go create mode 100644 internal/commits/commits.go create mode 100644 internal/commits/commits_test.go create mode 100644 internal/config/config.go create mode 100644 internal/config/config_test.go create mode 100644 internal/gitutil/gitutil.go create mode 100644 internal/gitutil/gitutil_test.go create mode 100644 internal/glclient/glclient.go create mode 100644 internal/glclient/glclient_test.go create mode 100644 internal/maven/maven.go create mode 100644 internal/maven/maven_test.go create mode 100644 internal/notes/notes.go create mode 100644 internal/notes/notes_test.go create mode 100644 internal/version/version.go create mode 100644 internal/version/version_test.go diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml new file mode 100644 index 0000000..e7e67cf --- /dev/null +++ b/.gitea/workflows/ci.yml @@ -0,0 +1,40 @@ +name: ci + +on: + push: + branches: + - '**' + pull_request: + +jobs: + ci: + name: vet, staticcheck, test, build + runs-on: ubuntu-latest + container: + image: golang:1.26-alpine + + steps: + - name: Install tools + run: apk add --no-cache git + + - name: Checkout + env: + TOKEN: ${{ secrets.GITHUB_TOKEN }} + SERVER_URL: ${{ github.server_url }} + REPO: ${{ github.repository }} + REF: ${{ github.ref_name }} + run: | + git clone --depth 1 --branch "$REF" \ + "$(echo "$SERVER_URL" | sed "s|https://|https://oauth2:${TOKEN}@|")/${REPO}.git" . + + - name: vet + run: go vet ./... + + - name: staticcheck + run: go tool staticcheck ./... + + - name: test + run: go test ./... + + - name: build + run: go build ./cmd/... diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml new file mode 100644 index 0000000..0b356cc --- /dev/null +++ b/.gitea/workflows/release.yml @@ -0,0 +1,110 @@ +name: release + +on: + push: + tags: + - 'v*' + +jobs: + release: + name: Build and publish release + runs-on: ubuntu-latest + container: + image: golang:1.26-alpine + + steps: + - name: Install tools + run: apk add --no-cache curl git jq + + - name: Checkout + env: + TOKEN: ${{ secrets.GITHUB_TOKEN }} + SERVER_URL: ${{ github.server_url }} + REPO: ${{ github.repository }} + REF: ${{ github.ref_name }} + run: | + git clone --depth 1 --branch "$REF" \ + "$(echo "$SERVER_URL" | sed "s|https://|https://oauth2:${TOKEN}@|")/${REPO}.git" . + + - name: Build Linux (amd64) + env: + GOOS: linux + GOARCH: amd64 + CGO_ENABLED: "0" + run: | + go build -trimpath \ + -ldflags="-s -w -X main.version=${{ github.ref_name }}" \ + -o releaser-${{ github.ref_name }}-linux-amd64 \ + ./cmd/... + + - name: Build Linux (arm64) + env: + GOOS: linux + GOARCH: arm64 + CGO_ENABLED: "0" + run: | + go build -trimpath \ + -ldflags="-s -w -X main.version=${{ github.ref_name }}" \ + -o releaser-${{ github.ref_name }}-linux-arm64 \ + ./cmd/... + + - name: Build macOS (amd64) + env: + GOOS: darwin + GOARCH: amd64 + CGO_ENABLED: "0" + run: | + go build -trimpath \ + -ldflags="-s -w -X main.version=${{ github.ref_name }}" \ + -o releaser-${{ github.ref_name }}-darwin-amd64 \ + ./cmd/... + + - name: Build macOS (arm64) + env: + GOOS: darwin + GOARCH: arm64 + CGO_ENABLED: "0" + run: | + go build -trimpath \ + -ldflags="-s -w -X main.version=${{ github.ref_name }}" \ + -o releaser-${{ github.ref_name }}-darwin-arm64 \ + ./cmd/... + + - name: Build Windows (amd64) + env: + GOOS: windows + GOARCH: amd64 + CGO_ENABLED: "0" + run: | + go build -trimpath \ + -ldflags="-s -w -X main.version=${{ github.ref_name }}" \ + -o releaser-${{ github.ref_name }}-windows-amd64.exe \ + ./cmd/... + + - name: Create release and upload assets + env: + TOKEN: ${{ secrets.GITHUB_TOKEN }} + TAG: ${{ github.ref_name }} + API_URL: ${{ github.api_url }} + REPO: ${{ github.repository }} + run: | + release_id=$(curl -sf -X POST \ + -H "Authorization: token $TOKEN" \ + -H "Content-Type: application/json" \ + "$API_URL/repos/$REPO/releases" \ + -d "{\"tag_name\":\"$TAG\",\"name\":\"$TAG\",\"draft\":false,\"prerelease\":false}" \ + | jq -r .id) + + for file in \ + releaser-${TAG}-linux-amd64 \ + releaser-${TAG}-linux-arm64 \ + releaser-${TAG}-darwin-amd64 \ + releaser-${TAG}-darwin-arm64 \ + releaser-${TAG}-windows-amd64.exe; do + curl -sf -X POST \ + -H "Authorization: token $TOKEN" \ + -H "Content-Type: application/octet-stream" \ + "$API_URL/repos/$REPO/releases/$release_id/assets?name=$file" \ + --data-binary "@$file" + echo "uploaded: $file" + done diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ba37d26 --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +# build output +/bin/ +releaser-* + +# test coverage +coverage.out +coverage.html + diff --git a/.releaser.gitlab-ci.yml b/.releaser.gitlab-ci.yml new file mode 100644 index 0000000..2b965a3 --- /dev/null +++ b/.releaser.gitlab-ci.yml @@ -0,0 +1,49 @@ +# releaser — GitLab CI component template +# +# Usage in your project's .gitlab-ci.yml: +# +# include: +# - project: 'your-group/releaser' +# ref: main +# file: '.releaser.gitlab-ci.yml' +# +# stages: +# - build +# - test +# - release +# +# release: +# extends: .releaser:release +# +# Required CI variable (set at project or group level): +# RELEASE_TOKEN — a GitLab project/group access token with scopes: +# api + write_repository +# (do NOT use CI_JOB_TOKEN — it cannot push tags back) + +.releaser:release: + stage: release + image: ${RELEASER_IMAGE:-registry.example.com/releaser:latest} + + variables: + # Override in your job to change defaults + RELEASER_EXTRA_ARGS: "" # e.g. "--no-push" or "--tag-prefix ''" + GIT_DEPTH: 0 # full clone — required to find ancestor tags + + rules: + - if: $CI_COMMIT_BRANCH =~ /^release\/.+$/ + + before_script: + # Configure git to push back using the release token over HTTPS. + # CI_JOB_TOKEN cannot push tags, so a dedicated access token is required. + - git remote set-url origin + "https://oauth2:${RELEASE_TOKEN}@${CI_SERVER_HOST}/${CI_PROJECT_PATH}.git" + - git config user.email "${GITLAB_USER_EMAIL:-ci@releaser}" + - git config user.name "${GITLAB_USER_NAME:-Releaser CI}" + + script: + - releaser + --branch "$CI_COMMIT_BRANCH" + $RELEASER_EXTRA_ARGS + + environment: + name: release/$CI_COMMIT_BRANCH diff --git a/.releaser.yml b/.releaser.yml new file mode 100644 index 0000000..a3d14b8 --- /dev/null +++ b/.releaser.yml @@ -0,0 +1,37 @@ +# .releaser.yml — configuration for git.k3nny.fr/releaser +# All fields are optional. Uncomment and adjust what you need. +# CLI flags always take precedence over values set here. + +git: + # Prefix prepended to every version tag. + # tag_prefix: "v" + + # Regex that identifies release branches. Must contain exactly two capture + # groups: group 1 = major version, group 2 = minor version. + # branch_pattern: "^(?:.*/)?release/(\\d+)\\.(\\d+)$" + + # Template for the version-bump commit message. + # {version} is replaced with the full tag name (e.g. "v1.2.3"). + # commit_message: "chore(release): {version} [skip ci]" + + # Override the git commit author. When omitted, releaser reads user.name + # and user.email from the repository's git config. + # author_name: "" + # author_email: "" + +maven: + # Path to pom.xml, relative to the repository root. + # pom_path: "pom.xml" + +gitlab: + # GitLab instance URL. Falls back to the CI_SERVER_URL environment variable. + # url: "https://gitlab.example.com" + + # Personal or CI access token with api scope. + # Falls back to the GITLAB_TOKEN environment variable. + # Tip: never commit a real token here — use the environment variable instead. + # token: "" + + # Numeric project ID or "namespace/project" path. + # Falls back to CI_PROJECT_ID, then CI_PROJECT_PATH environment variables. + # project: "" diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..5d4bbc3 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,37 @@ +# Changelog + +All notable changes to this project will be documented in this file. +Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). + +## [0.4.0] - 2026-07-07 + +### Added + +- **Branch parser** — extracts `major.minor` from `release/X.Y` branch names using a configurable regex pattern +- **Git tag discovery** — finds the latest `vX.Y.*` tag that is a reachable ancestor of HEAD (ignores tags on other version lines or unrelated branches) +- **Conventional Commits parser** — non-strict mode: case-insensitive type, optional scope, flexible whitespace; unrecognised messages are silently skipped +- **Commit range walker** — scans from last matching tag to HEAD; falls back to full history on first release in a branch +- **Version calculator** — increments patch; returns nothing when there are no releasable commits (exit code 2 so CI can distinguish "nothing to do" from errors) +- **`--dry-run` flag** — prints next version without making any changes +- **`--branch` flag** — overrides automatic branch detection for detached-HEAD CI environments +- **`--no-push` flag** — commits and tags locally without pushing or creating a GitLab release +- **`--no-commit` flag** — updates `pom.xml` and stops; re-run with `--tag-only` after manual review +- **`--tag-only` flag** — tags HEAD without touching `pom.xml` (pair with `--no-commit` flow) +- **Dirty working-tree check** — aborts before any changes if tracked files are modified or staged +- **`.releaser.yml` config** — optional YAML loaded from repo root; missing file is not an error; all fields have sensible defaults +- **Configurable tag prefix** — `git.tag_prefix` in config or `--tag-prefix` flag (default: `v`) +- **Configurable branch pattern** — `git.branch_pattern` in config or `--branch-pattern` flag (default: `^(?:.*/)?release/(\d+)\.(\d+)$`) +- **Configurable commit message** — `git.commit_message` with `{version}` placeholder (default: `chore(release): {version} [skip ci]`) +- **Configurable commit author** — `git.author_name` / `git.author_email`; falls back to repo git config +- **Configurable `pom_path`** — `maven.pom_path` in config or `--pom` flag (default: `pom.xml`) +- **`pom.xml` reader / writer** — in-place version update; correctly ignores `` and dependency version elements +- **GitLab release creation** — minimal HTTP client (no SDK); auto-generates release notes grouped by Breaking Changes / Features / Bug Fixes +- **HTTPS push with token auth** — `oauth2` + `GITLAB_TOKEN` +- **`GITLAB_TOKEN`, `CI_SERVER_URL`, `CI_PROJECT_ID`, `CI_PROJECT_PATH`** env var support for GitLab CI environments +- **`Dockerfile`** — multi-stage build, Alpine final image with `ca-certificates` +- **`.releaser.gitlab-ci.yml`** — reusable GitLab CI job template (`extends: .releaser:release`) with `GIT_DEPTH: 0` and HTTPS push pre-wired +- **`.releaser.yml` template** — annotated example of every config option with its default value +- **Gitea CI workflow** — runs `vet`, `staticcheck`, `test`, `build` on every push +- **Gitea release workflow** — cross-compiles five platform binaries (linux/amd64+arm64, darwin/amd64+arm64, windows/amd64) and publishes them as release assets on tag push +- **Taskfile** — `build`, `run`, `test`, `cov`, `cov:text`, `lint`, `fmt`, `tidy`, `fuzz`, `fuzz:all`, `ci`, `clean`, `docker:build`, `docker:run` +- **96% test coverage** — integration tests use real in-memory git repositories; fuzz tests cover all parser entry-points diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..22aa6b9 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,18 @@ +FROM golang:1.26-alpine AS builder + +WORKDIR /src +COPY go.mod go.sum ./ +RUN go mod download + +COPY . . +RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o /releaser ./cmd + +# --- + +FROM alpine:3.21 + +RUN apk add --no-cache ca-certificates + +COPY --from=builder /releaser /usr/local/bin/releaser + +ENTRYPOINT ["releaser"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..7cf74c3 --- /dev/null +++ b/README.md @@ -0,0 +1,112 @@ +# releaser + +![release](https://img.shields.io/badge/release-v0.4.0-blue.svg) + +A CI-friendly release automation tool for GitFlow workflows using Conventional Commits. + +## Problem + +Standard tools like `semantic-release` are designed for trunk-based development. In a GitFlow setup with versioned release branches (`release/1.1`, `release/1.2`), they either fail to respect the branch's version range or require brittle configuration. + +`releaser` is built for this exact workflow: it reads the branch name to pin the `major.minor`, parses Conventional Commits to determine the patch increment, and handles everything from `pom.xml` update to GitLab tag+release creation. + +## How it works + +``` +release/1.2 branch + └─ last tag: v1.2.3 (or none → start at v1.2.0) + └─ commits since tag → Conventional Commits analysis + └─ next version: v1.2.4 +``` + +1. **Branch parsing** — extracts `major.minor` from branch name (e.g. `release/1.2` → `1.2`) +2. **Tag discovery** — finds the latest tag matching `major.minor.*` on the current branch +3. **Commit analysis** — parses Conventional Commits between last tag and HEAD +4. **Version bump** — increments patch (the minor is owned by the branch) +5. **Release** — updates `pom.xml`, commits, tags, creates GitLab release + +## Version bump rules + +| Commit type | Bump | Notes | +|------------------|---------|--------------------------------------------| +| `fix:` | patch | | +| `feat:` | patch | minor is pinned to branch | +| `feat!:` / `BREAKING CHANGE` | patch | same — branch defines the minor boundary | +| `chore:`, `docs:`, etc. | none | | +| unparseable msg | none | non-strict mode: silently ignored | + +## Usage + +```bash +# Simulate next version (no side effects) +releaser --dry-run + +# Full release: bump pom.xml, commit, tag, push, GitLab release +releaser + +# Commit and tag locally — skip push and GitLab release +releaser --no-push + +# Update pom.xml but stop before committing (review first) +releaser --no-commit +# … then commit manually and re-run: +releaser --tag-only + +# Explicitly target a branch (useful in detached HEAD CI) +releaser --branch release/1.2 + +# Target a specific pom.xml +releaser --pom path/to/pom.xml + +# Override tag prefix from CLI (empty = no prefix) +releaser --tag-prefix "" + +# Override branch pattern (e.g. also match hotfix/ branches) +releaser --branch-pattern "^(?:.*/)?(?:release|hotfix)/(\d+)\.(\d+)$" +``` + +## Configuration + +`releaser` reads `.releaser.yml` from the repository root. All fields are optional — missing values fall back to the defaults shown below. + +```yaml +git: + tag_prefix: "v" # set to "" for tags without prefix + branch_pattern: "^(?:.*/)?release/(\\d+)\\.(\\d+)$" # two capture groups: major, minor + commit_message: "chore(release): {version} [skip ci]" + author_name: "" # defaults to git config user.name + author_email: "" # defaults to git config user.email + +maven: + pom_path: "pom.xml" # relative to repo root + +gitlab: + url: "https://gitlab.example.com" # or env CI_SERVER_URL + token: "" # env GITLAB_TOKEN (never commit this) + project: "" # env CI_PROJECT_ID or CI_PROJECT_PATH +``` + +### Environment variables + +GitLab-related fields are automatically read from the CI environment if not set in the config file: + +| Variable | Used for | +|-------------------|-----------------------------------| +| `GITLAB_TOKEN` | API auth + HTTPS push auth | +| `CI_SERVER_URL` | GitLab instance URL | +| `CI_PROJECT_ID` | Project identifier (numeric) | +| `CI_PROJECT_PATH` | Project identifier (fallback) | + +## CI integration (GitLab CI example) + +```yaml +release: + stage: release + image: registry.example.com/releaser:latest + rules: + - if: $CI_COMMIT_BRANCH =~ /^release\/.+$/ + variables: + GITLAB_TOKEN: $RELEASE_TOKEN # project/group CI variable with api + write_repository scope + script: + - releaser +``` diff --git a/ROADMAP.md b/ROADMAP.md new file mode 100644 index 0000000..dd6cbe2 --- /dev/null +++ b/ROADMAP.md @@ -0,0 +1,76 @@ +# Roadmap + +## v0.1 — Core logic (MVP) ✅ + +- [x] Branch name parser (`release/X.Y` → major, minor) +- [x] Git tag discovery (latest `vX.Y.*` tag reachable from HEAD — ancestor check, not global latest) +- [x] Conventional Commits parser (non-strict mode: case-insensitive, optional scope, flexible whitespace) +- [x] Commit range walker (last tag → HEAD, or full history on first release) +- [x] Version bump calculator — returns plain `X.Y.Z` (prefix kept separate) +- [x] `--dry-run` flag: print next version and exit +- [x] `--branch` flag: override branch detection (detached HEAD in CI) +- [x] Exit code 2 when no releasable commits + +## v0.2 — Config + Maven + local git ops ✅ + +- [x] `.releaser.yml` config file (loaded from repo root, merged over defaults, missing file is not an error) +- [x] Configurable tag prefix (`tag_prefix: "v"` or `tag_prefix: ""` for no prefix) +- [x] Configurable commit message template (`{version}` placeholder) +- [x] Configurable `pom_path` (default `pom.xml`) +- [x] `--pom` CLI flag: override pom path +- [x] `--tag-prefix` CLI flag: override tag prefix +- [x] `pom.xml` version reader (ignores `` and dependency versions) +- [x] `pom.xml` version writer (in-place, handles `-SNAPSHOT` → release) +- [x] Git commit of `pom.xml` changes (author resolved from git config, overridable in `.releaser.yml`) +- [x] Git tag creation (lightweight, on the release commit) + +## v0.3 — GitLab integration ✅ + +- [x] GitLab release creation via API (minimal HTTP client, no external dependency) +- [x] Release notes body auto-generated from commit log (grouped by Breaking Changes / Features / Bug Fixes) +- [x] `GITLAB_TOKEN` env var support +- [x] `CI_SERVER_URL` / `CI_PROJECT_ID` / `CI_PROJECT_PATH` env var support (GitLab CI native) +- [x] `--no-push` flag: commit+tag locally without pushing or creating GitLab release +- [x] Git push via HTTPS token auth (`oauth2` + `GITLAB_TOKEN`) +- [x] GitLab section in `.releaser.yml`: + ```yaml + gitlab: + url: "https://gitlab.example.com" + token: "" # prefer env GITLAB_TOKEN + project: "" # prefer env CI_PROJECT_ID + ``` + +## v0.4 — Hardening & CI template ✅ + +- [x] Configurable branch pattern (`git.branch_pattern` in `.releaser.yml`, default `release/X.Y`, supports regex e.g. `(?:release|hotfix)/X.Y`) +- [x] `--branch-pattern` CLI flag to override +- [x] Dirty working tree check — aborts if tracked files are modified or staged (untracked files ignored) +- [x] `--no-commit` flag: update pom.xml then stop — commit manually and re-run with `--tag-only` +- [x] `--tag-only` flag: skip pom.xml update, tag HEAD and push +- [x] `Dockerfile` — multi-stage build, alpine final image with `ca-certificates` +- [x] `.releaser.gitlab-ci.yml` — reusable CI job template with `GIT_DEPTH: 0` and HTTPS push setup +- [x] `.releaser.yml` template — annotated example of every config option with defaults +- [x] Taskfile — `build`, `run`, `test`, `cov`, `cov:text`, `lint`, `fmt`, `tidy`, `fuzz`, `fuzz:all`, `ci`, `clean`, `docker:build`, `docker:run` +- [x] Gitea CI workflow (vet, staticcheck, test, build) +- [x] Gitea release workflow (5-platform cross-compilation, release asset upload) +- [x] 96% test coverage with real in-memory git repos and fuzz tests for all parsers + +## v0.5 — Changelog + +- [ ] `CHANGELOG.md` generation / append (grouped by commit type) +- [ ] `--changelog-file` flag + +## v1.0 — Production ready + +- [x] ~~Integration tests against a real Git repo (with fixture commits and tags)~~ — ✓ shipped v0.4.0 (96% coverage, real in-memory repos) +- [x] ~~Cross-compilation in CI (linux/amd64, linux/arm64, darwin/amd64)~~ — ✓ shipped v0.4.0 (Gitea release workflow, + darwin/arm64 + windows/amd64) +- [ ] Documentation site + +## Future / backlog + +- GitHub release support (parity with GitLab) +- Multi-module Maven support (multiple `pom.xml` paths) +- Gradle support (`build.gradle` / `build.gradle.kts`) +- `package.json` version bump support (Node.js projects) +- Slack / Teams notification on release +- Configurable bump rules (e.g. treat `feat:` as minor on `main` branch) diff --git a/Taskfile.yml b/Taskfile.yml new file mode 100644 index 0000000..3b39f4d --- /dev/null +++ b/Taskfile.yml @@ -0,0 +1,107 @@ +version: "3" + +vars: + BIN: ./bin/releaser + PKG: ./... + FUZZ_TIME: 30s + +tasks: + default: + desc: List available tasks + silent: true + cmds: + - task --list + + build: + desc: Build the binary to ./bin/releaser + cmds: + - mkdir -p bin + - CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o {{.BIN}} ./cmd + sources: + - "**/*.go" + - go.mod + generates: + - "{{.BIN}}" + + run: + desc: Build and run releaser (pass args with -- e.g. task run -- --dry-run) + deps: [build] + cmds: + - "{{.BIN}} {{.CLI_ARGS}}" + + test: + desc: Run all tests (seed corpus only, no fuzzing) + cmds: + - go test {{.PKG}} + + cov: + desc: Run tests with coverage and open HTML report + cmds: + - go test -coverprofile=coverage.out {{.PKG}} + - go tool cover -html=coverage.out -o coverage.html + - echo "Coverage report written to coverage.html" + + cov:text: + desc: Run tests with per-function coverage in terminal + cmds: + - go test -coverprofile=coverage.out {{.PKG}} + - go tool cover -func=coverage.out + + lint: + desc: Run go vet + cmds: + - go vet {{.PKG}} + + fmt: + desc: Format all Go source files + cmds: + - gofmt -w . + + tidy: + desc: Tidy go.mod and go.sum + cmds: + - go mod tidy + + fuzz: + desc: "Fuzz a target for FUZZ_TIME (default 30s). Usage: task fuzz PKG=./internal/commits TARGET=FuzzParse" + vars: + PKG: '{{.PKG | default "./internal/commits"}}' + TARGET: '{{.TARGET | default "FuzzParse"}}' + cmds: + - go test -fuzz={{.TARGET}} -fuzztime={{.FUZZ_TIME}} {{.PKG}} + + fuzz:all: + desc: Run all fuzz seed corpora (same as test but explicit) + cmds: + - go test -run='^Fuzz' {{.PKG}} + + ci: + desc: Full CI pipeline — tidy check, vet, test + cmds: + - task: tidy + - | + if git rev-parse --git-dir >/dev/null 2>&1 && ! git diff --quiet go.mod go.sum; then + echo "go.mod/go.sum not tidy — run: task tidy" >&2 + exit 1 + fi + - task: lint + - task: test + + clean: + desc: Remove build artifacts and coverage files + cmds: + - rm -rf bin/ coverage.out coverage.html + + docker:build: + desc: Build the Docker image + vars: + TAG: '{{.TAG | default "releaser:dev"}}' + cmds: + - docker build -t {{.TAG}} . + + docker:run: + desc: Run the Docker image (pass args with -- ) + vars: + TAG: '{{.TAG | default "releaser:dev"}}' + cmds: + - docker run --rm {{.TAG}} {{.CLI_ARGS}} diff --git a/cmd/main.go b/cmd/main.go new file mode 100644 index 0000000..80294ad --- /dev/null +++ b/cmd/main.go @@ -0,0 +1,278 @@ +package main + +import ( + "context" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + + gogit "github.com/go-git/go-git/v5" + "github.com/spf13/cobra" + + "git.k3nny.fr/releaser/internal/branch" + "git.k3nny.fr/releaser/internal/commits" + "git.k3nny.fr/releaser/internal/config" + "git.k3nny.fr/releaser/internal/glclient" + "git.k3nny.fr/releaser/internal/gitutil" + "git.k3nny.fr/releaser/internal/maven" + "git.k3nny.fr/releaser/internal/notes" + semver "git.k3nny.fr/releaser/internal/version" +) + +var ( + version = "dev" // overridden at build time via -ldflags "-X main.version=..." + errNothingToRelease = errors.New("nothing to release") +) + +// exitFn is a variable so tests can intercept os.Exit calls. +var exitFn = os.Exit + +func newRootCmd() *cobra.Command { + var ( + dryRun bool + noPush bool + noCommit bool + tagOnly bool + branchOverride string + repoPath string + pomOverride string + tagPrefixFlag string + tagPrefixSet bool + patternFlag string + patternSet bool + ) + + root := &cobra.Command{ + Use: "releaser", + Short: "GitFlow release automation for Conventional Commits", + Version: version, + SilenceUsage: true, + RunE: func(cmd *cobra.Command, args []string) error { + tagPrefixSet = cmd.Flags().Changed("tag-prefix") + patternSet = cmd.Flags().Changed("branch-pattern") + return run(options{ + repoPath: repoPath, + branchOverride: branchOverride, + pomOverride: pomOverride, + tagPrefixFlag: tagPrefixFlag, + tagPrefixSet: tagPrefixSet, + patternFlag: patternFlag, + patternSet: patternSet, + dryRun: dryRun, + noPush: noPush, + noCommit: noCommit, + tagOnly: tagOnly, + }) + }, + } + + root.Flags().BoolVar(&dryRun, "dry-run", false, "print next version without making changes") + root.Flags().BoolVar(&noPush, "no-push", false, "create commit and tag locally without pushing or creating a GitLab release") + root.Flags().BoolVar(&noCommit, "no-commit", false, "update pom.xml but do not commit, tag, or push") + root.Flags().BoolVar(&tagOnly, "tag-only", false, "tag HEAD without updating pom.xml (assumes version was already committed)") + root.Flags().StringVar(&branchOverride, "branch", "", "override branch name detection (required in detached HEAD)") + root.Flags().StringVar(&repoPath, "repo", ".", "path to git repository") + root.Flags().StringVar(&pomOverride, "pom", "", "override maven.pom_path from config") + root.Flags().StringVar(&tagPrefixFlag, "tag-prefix", "", "override git.tag_prefix from config") + root.Flags().StringVar(&patternFlag, "branch-pattern", "", "override git.branch_pattern from config") + + return root +} + +func main() { + if err := newRootCmd().Execute(); err != nil { + if errors.Is(err, errNothingToRelease) { + exitFn(2) + return + } + exitFn(1) + } +} + +type options struct { + repoPath string + branchOverride string + pomOverride string + tagPrefixFlag string + tagPrefixSet bool + patternFlag string + patternSet bool + dryRun bool + noPush bool + noCommit bool + tagOnly bool +} + +func run(o options) error { + // --- Config --- + absRepo, err := filepath.Abs(o.repoPath) + if err != nil { + return fmt.Errorf("resolve repo path: %w", err) + } + + cfg, err := config.Load(absRepo) + if err != nil { + return err + } + cfg.ApplyEnv() + + // CLI flags take precedence over config file and env vars + if o.tagPrefixSet { + cfg.Git.TagPrefix = o.tagPrefixFlag + } + if o.pomOverride != "" { + cfg.Maven.PomPath = o.pomOverride + } + if o.patternSet { + cfg.Git.BranchPattern = o.patternFlag + } + + // --- Git --- + repo, err := gogit.PlainOpenWithOptions(absRepo, &gogit.PlainOpenOptions{DetectDotGit: true}) + if err != nil { + return fmt.Errorf("open repository: %w", err) + } + + branchName := o.branchOverride + if branchName == "" { + branchName, err = gitutil.CurrentBranch(repo) + if err != nil { + return err + } + } + + info, err := branch.Parse(branchName, cfg.Git.BranchPattern) + if err != nil { + return err + } + info.TagPrefix = cfg.Git.TagPrefix + + // --- Dirty check (before any changes) --- + // Skipped in --no-commit mode: the user intentionally has changes in flight. + if !o.dryRun && !o.noCommit { + clean, err := gitutil.IsWorkingTreeClean(repo) + if err != nil { + return fmt.Errorf("check working tree: %w", err) + } + if !clean { + return fmt.Errorf("working tree has uncommitted changes — commit or stash them before releasing") + } + } + + // --- Tag discovery --- + lastTag, currentPatch, err := gitutil.LatestTag(repo, info) + if err != nil { + return fmt.Errorf("find latest tag: %w", err) + } + + // --- Commit range --- + var messages []string + if lastTag == "" { + fmt.Fprintln(os.Stderr, "info: no previous tag found — scanning all commits") + messages, err = gitutil.AllCommits(repo) + } else { + fmt.Fprintf(os.Stderr, "info: last tag: %s\n", lastTag) + messages, err = gitutil.CommitsSince(repo, lastTag) + } + if err != nil { + return fmt.Errorf("read commits: %w", err) + } + + fmt.Fprintf(os.Stderr, "info: %d commit(s) to analyze\n", len(messages)) + + // --- Version calculation --- + types := make([]commits.Type, len(messages)) + for i, msg := range messages { + types[i] = commits.Parse(msg) + } + + nextVersion, ok := semver.Next(info.Major, info.Minor, currentPatch, types) + if !ok { + fmt.Fprintln(os.Stderr, "info: no releasable commits found") + return errNothingToRelease + } + + nextTag := info.TagName(nextVersion) + + fmt.Printf("next version: %s (tag: %s)\n", nextVersion, nextTag) + + if o.dryRun { + fmt.Fprintln(os.Stderr, "dry-run: no changes made") + return nil + } + + // --- pom.xml (skipped with --tag-only) --- + if !o.tagOnly { + pomPath := filepath.Join(absRepo, cfg.Maven.PomPath) + currentPomVersion, err := maven.ReadVersion(pomPath) + if err != nil { + return fmt.Errorf("read pom version: %w", err) + } + fmt.Fprintf(os.Stderr, "info: pom.xml: %s → %s\n", currentPomVersion, nextVersion) + + if err := maven.WriteVersion(pomPath, currentPomVersion, nextVersion); err != nil { + return fmt.Errorf("update pom version: %w", err) + } + + if o.noCommit { + fmt.Printf("pom.xml updated to %s — commit manually then re-run with --tag-only\n", nextVersion) + return nil + } + + // --- Git commit --- + authorName, authorEmail := gitutil.AuthorFromConfig(repo) + if cfg.Git.AuthorName != "" { + authorName = cfg.Git.AuthorName + } + if cfg.Git.AuthorEmail != "" { + authorEmail = cfg.Git.AuthorEmail + } + + commitMsg := strings.ReplaceAll(cfg.Git.CommitMessage, "{version}", nextTag) + if _, err := gitutil.CommitFile(repo, cfg.Maven.PomPath, commitMsg, authorName, authorEmail); err != nil { + return fmt.Errorf("commit pom.xml: %w", err) + } + fmt.Fprintf(os.Stderr, "info: committed: %s\n", commitMsg) + } + + // --- Git tag --- + if err := gitutil.CreateTag(repo, nextTag); err != nil { + return fmt.Errorf("create tag: %w", err) + } + fmt.Fprintf(os.Stderr, "info: tag created: %s\n", nextTag) + + if o.noPush { + fmt.Printf("released %s locally — push manually with: git push && git push --tags\n", nextTag) + return nil + } + + // --- Push --- + fmt.Fprintln(os.Stderr, "info: pushing commit and tag...") + if err := gitutil.Push(repo, branchName, nextTag, cfg.GitLab.Token); err != nil { + return fmt.Errorf("push: %w", err) + } + fmt.Fprintln(os.Stderr, "info: pushed") + + // --- GitLab release --- + if cfg.GitLab.URL == "" || cfg.GitLab.Project == "" { + fmt.Fprintln(os.Stderr, "warning: GitLab URL or project not configured — skipping release creation") + fmt.Printf("released %s\n", nextTag) + return nil + } + if cfg.GitLab.Token == "" { + return fmt.Errorf("GITLAB_TOKEN not set — required for release creation") + } + + releaseNotes := notes.Generate(nextTag, messages) + gl := glclient.New(cfg.GitLab.URL, cfg.GitLab.Token, cfg.GitLab.Project) + + if err := gl.CreateRelease(context.Background(), nextTag, releaseNotes); err != nil { + return fmt.Errorf("create GitLab release: %w", err) + } + + fmt.Fprintf(os.Stderr, "info: GitLab release created: %s\n", nextTag) + fmt.Printf("released %s\n", nextTag) + return nil +} diff --git a/cmd/main_test.go b/cmd/main_test.go new file mode 100644 index 0000000..6e18481 --- /dev/null +++ b/cmd/main_test.go @@ -0,0 +1,541 @@ +package main + +import ( + "errors" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + "time" + + gogit "github.com/go-git/go-git/v5" + gitcfg "github.com/go-git/go-git/v5/config" + "github.com/go-git/go-git/v5/plumbing" + "github.com/go-git/go-git/v5/plumbing/object" +) + +// ── helpers ────────────────────────────────────────────────────────────────── + +func testSig() *object.Signature { + return &object.Signature{Name: "CI", Email: "ci@example.com", When: time.Now()} +} + +// setupRepo creates a temporary git repo with pom.xml committed. +func setupRepo(t *testing.T) (repo *gogit.Repository, dir string) { + t.Helper() + dir = t.TempDir() + repo, err := gogit.PlainInit(dir, false) + if err != nil { + t.Fatal(err) + } + writePom(t, dir, "1.2.3") + commitAll(t, repo, dir, "chore: init") + return repo, dir +} + +// setupRepoWithRemote adds a bare remote so Push can succeed. +func setupRepoWithRemote(t *testing.T) (repo *gogit.Repository, dir string) { + t.Helper() + repo, dir = setupRepo(t) + + remoteDir := t.TempDir() + if _, err := gogit.PlainInit(remoteDir, true); err != nil { + t.Fatal(err) + } + if _, err := repo.CreateRemote(&gitcfg.RemoteConfig{ + Name: "origin", + URLs: []string{remoteDir}, + }); err != nil { + t.Fatal(err) + } + return repo, dir +} + +func writePom(t *testing.T, dir, version string) { + t.Helper() + pom := ` + + ` + version + ` +` + if err := os.WriteFile(filepath.Join(dir, "pom.xml"), []byte(pom), 0644); err != nil { + t.Fatal(err) + } +} + +func commitAll(t *testing.T, repo *gogit.Repository, dir, msg string) plumbing.Hash { + t.Helper() + w, err := repo.Worktree() + if err != nil { + t.Fatal(err) + } + // stage everything + if _, err := w.Add("."); err != nil { + t.Fatal(err) + } + h, err := w.Commit(msg, &gogit.CommitOptions{Author: testSig()}) + if err != nil { + t.Fatalf("commit %q: %v", msg, err) + } + return h +} + +func addFile(t *testing.T, dir, name, content string) { + t.Helper() + if err := os.WriteFile(filepath.Join(dir, name), []byte(content), 0644); err != nil { + t.Fatal(err) + } +} + +// ── newRootCmd integration tests ────────────────────────────────────────────── + +func execCmd(t *testing.T, args ...string) error { + t.Helper() + cmd := newRootCmd() + cmd.SetArgs(args) + return cmd.Execute() +} + +func TestRunDryRun(t *testing.T) { + _, dir := setupRepo(t) + addFile(t, dir, "src.go", "// fix") + repo, _ := gogit.PlainOpen(dir) + w, _ := repo.Worktree() + w.Add("src.go") + w.Commit("fix: patch something", &gogit.CommitOptions{Author: testSig()}) + + err := execCmd(t, "--dry-run", "--branch", "release/1.2", "--repo", dir) + if err != nil { + t.Fatalf("dry-run: unexpected error: %v", err) + } +} + +func TestRunNothingToRelease(t *testing.T) { + _, dir := setupRepo(t) + addFile(t, dir, "src.go", "chore") + repo, _ := gogit.PlainOpen(dir) + w, _ := repo.Worktree() + w.Add("src.go") + w.Commit("chore: update deps", &gogit.CommitOptions{Author: testSig()}) + + err := execCmd(t, "--dry-run", "--branch", "release/1.2", "--repo", dir) + if !errors.Is(err, errNothingToRelease) { + t.Fatalf("expected errNothingToRelease, got %v", err) + } +} + +func TestRunNotAGitRepo(t *testing.T) { + dir := t.TempDir() + err := execCmd(t, "--dry-run", "--branch", "release/1.2", "--repo", dir) + if err == nil { + t.Fatal("expected error for non-git directory") + } +} + +func TestRunBadBranchName(t *testing.T) { + _, dir := setupRepo(t) + err := execCmd(t, "--dry-run", "--branch", "main", "--repo", dir) + if err == nil { + t.Fatal("expected error: 'main' doesn't match release pattern") + } +} + +func TestRunDetachedHead(t *testing.T) { + repo, dir := setupRepo(t) + // Detach HEAD + head, _ := repo.Head() + repo.Storer.SetReference(plumbing.NewHashReference(plumbing.HEAD, head.Hash())) + + err := execCmd(t, "--dry-run", "--repo", dir) // no --branch + if err == nil { + t.Fatal("expected error for detached HEAD without --branch") + } +} + +func TestRunBadConfig(t *testing.T) { + _, dir := setupRepo(t) + if err := os.WriteFile(filepath.Join(dir, ".releaser.yml"), []byte("{[invalid"), 0644); err != nil { + t.Fatal(err) + } + err := execCmd(t, "--dry-run", "--branch", "release/1.2", "--repo", dir) + if err == nil { + t.Fatal("expected error for invalid .releaser.yml") + } +} + +func TestRunDirtyTree(t *testing.T) { + _, dir := setupRepo(t) + // Modify a tracked file without staging + os.WriteFile(filepath.Join(dir, "pom.xml"), []byte("dirty"), 0644) + + err := execCmd(t, "--branch", "release/1.2", "--repo", dir) + if err == nil { + t.Fatal("expected error for dirty working tree") + } +} + +func TestRunFlagOverrides(t *testing.T) { + _, dir := setupRepo(t) + addFile(t, dir, "src.go", "// fix") + repo, _ := gogit.PlainOpen(dir) + w, _ := repo.Worktree() + w.Add("src.go") + w.Commit("fix: something", &gogit.CommitOptions{Author: testSig()}) + + // --tag-prefix "" --branch-pattern with defaults via flags + err := execCmd(t, "--dry-run", "--branch", "release/1.2", "--repo", dir, + "--tag-prefix", "rel-", "--branch-pattern", `^(?:.*/)?release/(\d+)\.(\d+)$`) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestRunPomOverride(t *testing.T) { + _, dir := setupRepo(t) + // Write a second pom at a custom path + os.MkdirAll(filepath.Join(dir, "sub"), 0755) + writePom(t, filepath.Join(dir, "sub"), "1.2.3") + repo, _ := gogit.PlainOpen(dir) + w, _ := repo.Worktree() + w.Add("sub/pom.xml") + w.Commit("chore: add sub pom", &gogit.CommitOptions{Author: testSig()}) + + addFile(t, dir, "x.go", "// fix") + w.Add("x.go") + w.Commit("fix: patch", &gogit.CommitOptions{Author: testSig()}) + + err := execCmd(t, "--dry-run", "--branch", "release/1.2", "--repo", dir, "--pom", "sub/pom.xml") + if err != nil { + t.Fatalf("--pom override: unexpected error: %v", err) + } +} + +func TestRunMissingPom(t *testing.T) { + _, dir := setupRepo(t) + addFile(t, dir, "x.go", "// fix") + repo, _ := gogit.PlainOpen(dir) + w, _ := repo.Worktree() + w.Add("x.go") + w.Commit("fix: patch", &gogit.CommitOptions{Author: testSig()}) + + // Use --pom to point to a non-existent file; keeps the working tree clean. + err := execCmd(t, "--branch", "release/1.2", "--repo", dir, "--pom", "nonexistent.xml") + if err == nil { + t.Fatal("expected error for missing pom.xml") + } +} + +func TestRunPomNoVersion(t *testing.T) { + _, dir := setupRepo(t) + // Write a pom with no tag as an untracked file (tree stays clean) + os.WriteFile(filepath.Join(dir, "no-version.xml"), []byte(""), 0644) + addFile(t, dir, "x.go", "// fix") + repo, _ := gogit.PlainOpen(dir) + w, _ := repo.Worktree() + w.Add("x.go") + w.Commit("fix: patch", &gogit.CommitOptions{Author: testSig()}) + + err := execCmd(t, "--branch", "release/1.2", "--repo", dir, "--pom", "no-version.xml") + if err == nil { + t.Fatal("expected error when pom has no ") + } +} + +func TestRunPomReadOnly(t *testing.T) { + _, dir := setupRepo(t) + addFile(t, dir, "x.go", "// fix") + repo, _ := gogit.PlainOpen(dir) + w, _ := repo.Worktree() + w.Add("x.go") + w.Commit("fix: patch something", &gogit.CommitOptions{Author: testSig()}) + + // Make pom.xml read-only: ReadVersion succeeds (readable), WriteVersion fails (not writable) + os.Chmod(filepath.Join(dir, "pom.xml"), 0444) + defer os.Chmod(filepath.Join(dir, "pom.xml"), 0644) + + err := execCmd(t, "--branch", "release/1.2", "--repo", dir) + if err == nil { + t.Fatal("expected error when pom.xml is read-only") + } +} + +func TestRunAuthorFromConfig(t *testing.T) { + _, dir := setupRepo(t) + // Write .releaser.yml as untracked so the working tree stays clean + cfg := "git:\n author_name: ReleaserBot\n author_email: bot@example.com\n" + os.WriteFile(filepath.Join(dir, ".releaser.yml"), []byte(cfg), 0644) + + addFile(t, dir, "x.go", "// fix") + repo, _ := gogit.PlainOpen(dir) + w, _ := repo.Worktree() + w.Add("x.go") + w.Commit("fix: patch something", &gogit.CommitOptions{Author: testSig()}) + + err := execCmd(t, "--no-push", "--branch", "release/1.2", "--repo", dir) + if err != nil { + t.Fatalf("run with author_name/author_email config: %v", err) + } +} + +func TestRunNoPush(t *testing.T) { + _, dir := setupRepo(t) + addFile(t, dir, "x.go", "// fix") + repo, _ := gogit.PlainOpen(dir) + w, _ := repo.Worktree() + w.Add("x.go") + w.Commit("fix: patch something", &gogit.CommitOptions{Author: testSig()}) + + err := execCmd(t, "--no-push", "--branch", "release/1.2", "--repo", dir) + if err != nil { + t.Fatalf("--no-push: unexpected error: %v", err) + } + + // Verify pom.xml was updated + data, _ := os.ReadFile(filepath.Join(dir, "pom.xml")) + if string(data) == "" { + t.Error("pom.xml should have been updated") + } +} + +func TestRunNoCommit(t *testing.T) { + _, dir := setupRepo(t) + addFile(t, dir, "x.go", "// fix") + repo, _ := gogit.PlainOpen(dir) + w, _ := repo.Worktree() + w.Add("x.go") + w.Commit("fix: patch something", &gogit.CommitOptions{Author: testSig()}) + + err := execCmd(t, "--no-commit", "--branch", "release/1.2", "--repo", dir) + if err != nil { + t.Fatalf("--no-commit: unexpected error: %v", err) + } +} + +func TestRunTagOnly(t *testing.T) { + _, dir := setupRepo(t) + addFile(t, dir, "x.go", "// fix") + repo, _ := gogit.PlainOpen(dir) + w, _ := repo.Worktree() + w.Add("x.go") + w.Commit("fix: patch something", &gogit.CommitOptions{Author: testSig()}) + + err := execCmd(t, "--tag-only", "--no-push", "--branch", "release/1.2", "--repo", dir) + if err != nil { + t.Fatalf("--tag-only --no-push: unexpected error: %v", err) + } +} + +func TestRunDuplicateTag(t *testing.T) { + _, dir := setupRepo(t) + repo, _ := gogit.PlainOpen(dir) + + // Add a fix commit so there are releasable changes + addFile(t, dir, "x.go", "// fix") + w, _ := repo.Worktree() + w.Add("x.go") + w.Commit("fix: patch something", &gogit.CommitOptions{Author: testSig()}) + + // Pre-create a v1.2.0 ref pointing to a garbage hash. + // LatestTag skips it (resolveTagToCommit fails for garbage hash), + // so run() calculates "v1.2.0" as the first-ever version — then + // CreateTag("v1.2.0") fails because the ref already exists. + fakeRef := plumbing.NewHashReference( + plumbing.NewTagReferenceName("v1.2.0"), + plumbing.NewHash("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"), + ) + if err := repo.Storer.SetReference(fakeRef); err != nil { + t.Fatal(err) + } + + err := execCmd(t, "--tag-only", "--no-push", "--branch", "release/1.2", "--repo", dir) + if err == nil { + t.Fatal("expected error: v1.2.0 ref already exists") + } +} + +func TestRunPushFails(t *testing.T) { + _, dir := setupRepo(t) + repo, _ := gogit.PlainOpen(dir) + // Remote exists but points to a non-existent directory → push fails + repo.CreateRemote(&gitcfg.RemoteConfig{ + Name: "origin", + URLs: []string{"/nonexistent/path/to/bare/repo"}, + }) + + addFile(t, dir, "x.go", "// fix") + w, _ := repo.Worktree() + w.Add("x.go") + w.Commit("fix: patch something", &gogit.CommitOptions{Author: testSig()}) + + err := execCmd(t, "--branch", "release/1.2", "--repo", dir) + if err == nil { + t.Fatal("expected push error for invalid remote URL") + } +} + +func TestRunGitLabError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusUnprocessableEntity) + w.Write([]byte(`{"message":"Tag already has a release"}`)) + })) + defer srv.Close() + + _, dir := setupRepoWithRemote(t) + addFile(t, dir, "x.go", "// fix") + repo, _ := gogit.PlainOpen(dir) + w2, _ := repo.Worktree() + w2.Add("x.go") + w2.Commit("fix: patch something", &gogit.CommitOptions{Author: testSig()}) + + t.Setenv("CI_SERVER_URL", srv.URL) + t.Setenv("CI_PROJECT_ID", "42") + t.Setenv("GITLAB_TOKEN", "test-token") + + err := execCmd(t, "--branch", "release/1.2", "--repo", dir) + if err == nil { + t.Fatal("expected error when GitLab API returns 422") + } +} + +func TestRunWithPreviousTag(t *testing.T) { + repo, dir := setupRepo(t) + + // Tag the initial commit as v1.2.0 (simulates a prior release) + initialHead, _ := repo.Head() + repo.CreateTag("v1.2.0", initialHead.Hash(), nil) + + // Fix commit after the tag — run() will use CommitsSince, not AllCommits + addFile(t, dir, "x.go", "// fix") + w, _ := repo.Worktree() + w.Add("x.go") + w.Commit("fix: patch something", &gogit.CommitOptions{Author: testSig()}) + + err := execCmd(t, "--no-push", "--branch", "release/1.2", "--repo", dir) + if err != nil { + t.Fatalf("run with previous tag: %v", err) + } +} + +func TestRunSkipGitLab(t *testing.T) { + _, dir := setupRepoWithRemote(t) + addFile(t, dir, "x.go", "// fix") + repo, _ := gogit.PlainOpen(dir) + w, _ := repo.Worktree() + w.Add("x.go") + w.Commit("fix: patch something", &gogit.CommitOptions{Author: testSig()}) + + // No GITLAB_TOKEN / URL set → GitLab release is skipped + err := execCmd(t, "--branch", "release/1.2", "--repo", dir) + if err != nil { + t.Fatalf("expected skip-gitlab success, got: %v", err) + } +} + +func TestRunMissingToken(t *testing.T) { + _, dir := setupRepoWithRemote(t) + addFile(t, dir, "x.go", "// fix") + repo, _ := gogit.PlainOpen(dir) + w, _ := repo.Worktree() + w.Add("x.go") + w.Commit("fix: patch something", &gogit.CommitOptions{Author: testSig()}) + + t.Setenv("CI_SERVER_URL", "https://gitlab.example.com") + t.Setenv("CI_PROJECT_ID", "42") + t.Setenv("GITLAB_TOKEN", "") + + err := execCmd(t, "--branch", "release/1.2", "--repo", dir) + if err == nil { + t.Fatal("expected error when GITLAB_TOKEN is not set but GitLab URL is configured") + } +} + +func TestRunWithGitLab(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusCreated) + w.Write([]byte(`{}`)) + })) + defer srv.Close() + + _, dir := setupRepoWithRemote(t) + addFile(t, dir, "x.go", "// fix") + repo, _ := gogit.PlainOpen(dir) + w2, _ := repo.Worktree() + w2.Add("x.go") + w2.Commit("fix: patch something", &gogit.CommitOptions{Author: testSig()}) + + t.Setenv("CI_SERVER_URL", srv.URL) + t.Setenv("CI_PROJECT_ID", "42") + t.Setenv("GITLAB_TOKEN", "test-token") + + err := execCmd(t, "--branch", "release/1.2", "--repo", dir) + if err != nil { + t.Fatalf("full release with mock GitLab: unexpected error: %v", err) + } +} + +// ── main() tests via exitFn ─────────────────────────────────────────────────── + +func TestMainSuccess(t *testing.T) { + _, dir := setupRepo(t) + addFile(t, dir, "x.go", "// fix") + repo, _ := gogit.PlainOpen(dir) + w, _ := repo.Worktree() + w.Add("x.go") + w.Commit("fix: something", &gogit.CommitOptions{Author: testSig()}) + + old := os.Args + os.Args = []string{"releaser", "--dry-run", "--branch", "release/1.2", "--repo", dir} + defer func() { os.Args = old }() + + called := false + oldFn := exitFn + exitFn = func(code int) { called = true } + defer func() { exitFn = oldFn }() + + main() + + if called { + t.Error("exitFn should not have been called on success") + } +} + +func TestMainNothingToRelease(t *testing.T) { + _, dir := setupRepo(t) + addFile(t, dir, "x.go", "chore") + repo, _ := gogit.PlainOpen(dir) + w, _ := repo.Worktree() + w.Add("x.go") + w.Commit("chore: bump deps", &gogit.CommitOptions{Author: testSig()}) + + old := os.Args + os.Args = []string{"releaser", "--branch", "release/1.2", "--repo", dir} + defer func() { os.Args = old }() + + var gotCode int + oldFn := exitFn + exitFn = func(code int) { gotCode = code } + defer func() { exitFn = oldFn }() + + main() + + if gotCode != 2 { + t.Errorf("expected exit code 2 for nothing-to-release, got %d", gotCode) + } +} + +func TestMainError(t *testing.T) { + old := os.Args + os.Args = []string{"releaser", "--branch", "release/1.2", "--repo", t.TempDir()} // not a git repo + defer func() { os.Args = old }() + + var gotCode int + oldFn := exitFn + exitFn = func(code int) { gotCode = code } + defer func() { exitFn = oldFn }() + + main() + + if gotCode != 1 { + t.Errorf("expected exit code 1 for general error, got %d", gotCode) + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..818544e --- /dev/null +++ b/go.mod @@ -0,0 +1,42 @@ +module git.k3nny.fr/releaser + +go 1.26.4 + +require ( + github.com/go-git/go-git/v5 v5.19.1 + github.com/spf13/cobra v1.10.2 + gopkg.in/yaml.v3 v3.0.1 +) + +require ( + dario.cat/mergo v1.0.0 // indirect + github.com/BurntSushi/toml v1.4.1-0.20240526193622-a339e1f7089c // indirect + github.com/Microsoft/go-winio v0.6.2 // indirect + github.com/ProtonMail/go-crypto v1.1.6 // indirect + github.com/cloudflare/circl v1.6.3 // indirect + github.com/cyphar/filepath-securejoin v0.6.1 // indirect + github.com/emirpasic/gods v1.18.1 // indirect + github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect + github.com/go-git/go-billy/v5 v5.9.0 // indirect + github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect + github.com/kevinburke/ssh_config v1.2.0 // indirect + github.com/klauspost/cpuid/v2 v2.3.0 // indirect + github.com/pjbgf/sha1cd v0.6.0 // indirect + github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect + github.com/skeema/knownhosts v1.3.1 // indirect + github.com/spf13/pflag v1.0.9 // indirect + github.com/xanzy/ssh-agent v0.3.3 // indirect + golang.org/x/crypto v0.50.0 // indirect + golang.org/x/exp/typeparams v0.0.0-20231108232855-2478ac86f678 // indirect + golang.org/x/mod v0.31.0 // indirect + golang.org/x/net v0.53.0 // indirect + golang.org/x/sync v0.19.0 // indirect + golang.org/x/sys v0.43.0 // indirect + golang.org/x/tools v0.40.1-0.20260108161641-ca281cf95054 // indirect + gopkg.in/warnings.v0 v0.1.2 // indirect + honnef.co/go/tools v0.7.0 // indirect +) + +tool honnef.co/go/tools/cmd/staticcheck diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..bc80622 --- /dev/null +++ b/go.sum @@ -0,0 +1,127 @@ +dario.cat/mergo v1.0.0 h1:AGCNq9Evsj31mOgNPcLyXc+4PNABt905YmuqPYYpBWk= +dario.cat/mergo v1.0.0/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk= +github.com/BurntSushi/toml v1.4.1-0.20240526193622-a339e1f7089c h1:pxW6RcqyfI9/kWtOwnv/G+AzdKuy2ZrqINhenH4HyNs= +github.com/BurntSushi/toml v1.4.1-0.20240526193622-a339e1f7089c/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= +github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY= +github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= +github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= +github.com/ProtonMail/go-crypto v1.1.6 h1:ZcV+Ropw6Qn0AX9brlQLAUXfqLBc7Bl+f/DmNxpLfdw= +github.com/ProtonMail/go-crypto v1.1.6/go.mod h1:rA3QumHc/FZ8pAHreoekgiAbzpNsfQAosU5td4SnOrE= +github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8= +github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4= +github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio= +github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= +github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8= +github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4= +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/cyphar/filepath-securejoin v0.6.1 h1:5CeZ1jPXEiYt3+Z6zqprSAgSWiggmpVyciv8syjIpVE= +github.com/cyphar/filepath-securejoin v0.6.1/go.mod h1:A8hd4EnAeyujCJRrICiOWqjS1AX0a9kM5XL+NwKoYSc= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/elazarl/goproxy v1.7.2 h1:Y2o6urb7Eule09PjlhQRGNsqRfPmYI3KKQLFpCAV3+o= +github.com/elazarl/goproxy v1.7.2/go.mod h1:82vkLNir0ALaW14Rc399OTTjyNREgmdL2cVoIbS6XaE= +github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc= +github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ= +github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c= +github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU= +github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI= +github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic= +github.com/go-git/go-billy/v5 v5.9.0 h1:jItGXszUDRtR/AlferWPTMN4j38BQ88XnXKbilmmBPA= +github.com/go-git/go-billy/v5 v5.9.0/go.mod h1:jCnQMLj9eUgGU7+ludSTYoZL/GGmii14RxKFj7ROgHw= +github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399 h1:eMje31YglSBqCdIqdhKBW8lokaMrL3uTkpGYlE2OOT4= +github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399/go.mod h1:1OCfN199q1Jm3HZlxleg+Dw/mwps2Wbk9frAWm+4FII= +github.com/go-git/go-git/v5 v5.19.1 h1:nX27AnaU43/K5bKktKwgBmR9lawoYVe1Ckg0rgzzN00= +github.com/go-git/go-git/v5 v5.19.1/go.mod h1:Pb1v0c7/g8aGQJwx9Us09W85yGoyvSwuhEGMH7zjDKQ= +github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ= +github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A= +github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo= +github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4= +github.com/kevinburke/ssh_config v1.2.0/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM= +github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= +github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/onsi/gomega v1.34.1 h1:EUMJIKUjM8sKjYbtxQI9A4z2o+rruxnzNvpknOXie6k= +github.com/onsi/gomega v1.34.1/go.mod h1:kU1QgUvBDLXBJq618Xvm2LUX6rSAfRaFRTcdOeDLwwY= +github.com/pjbgf/sha1cd v0.6.0 h1:3WJ8Wz8gvDz29quX1OcEmkAlUg9diU4GxJHqs0/XiwU= +github.com/pjbgf/sha1cd v0.6.0/go.mod h1:lhpGlyHLpQZoxMv8HcgXvZEhcGs0PG/vsZnEJ7H0iCM= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 h1:n661drycOFuPLCN3Uc8sB6B/s6Z4t2xvBgU1htSHuq8= +github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4= +github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= +github.com/skeema/knownhosts v1.3.1 h1:X2osQ+RAjK76shCbvhHHHVl3ZlgDm8apHEHFqRjnBY8= +github.com/skeema/knownhosts v1.3.1/go.mod h1:r7KTdC8l4uxWRyK2TpQZ/1o5HaSzh06ePQNxPwTcfiY= +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= +github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM= +github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= +golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI= +golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q= +golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f h1:W3F4c+6OLc6H2lb//N1q4WpJkhzJCK5J6kUi1NTVXfM= +golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f/go.mod h1:J1xhfL/vlindoeF/aINzNzt2Bket5bjo9sdOYzOsU80= +golang.org/x/exp/typeparams v0.0.0-20231108232855-2478ac86f678 h1:1P7xPZEwZMoBoz0Yze5Nx2/4pxj6nw9ZqHWXqP0iRgQ= +golang.org/x/exp/typeparams v0.0.0-20231108232855-2478ac86f678/go.mod h1:AbB0pIl9nAr9wVwH+Z2ZpaocVmF5I4GyWCDIsVjR0bk= +golang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI= +golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg= +golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA= +golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs= +golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= +golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= +golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY= +golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg= +golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.40.1-0.20260108161641-ca281cf95054 h1:CHVDrNHx9ZoOrNN9kKWYIbT5Rj+WF2rlwPkhbQQ5V4U= +golang.org/x/tools v0.40.1-0.20260108161641-ca281cf95054/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc= +golang.org/x/tools/go/expect v0.1.1-deprecated h1:jpBZDwmgPhXsKZC6WhL20P4b/wmnpsEAGHaNy0n/rJM= +golang.org/x/tools/go/expect v0.1.1-deprecated/go.mod h1:eihoPOH+FgIqa3FpoTwguz/bVUSGBlGQU67vpBeOrBY= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME= +gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +honnef.co/go/tools v0.7.0 h1:w6WUp1VbkqPEgLz4rkBzH/CSU6HkoqNLp6GstyTx3lU= +honnef.co/go/tools v0.7.0/go.mod h1:pm29oPxeP3P82ISxZDgIYeOaf9ta6Pi0EWvCFoLG2vc= diff --git a/internal/branch/branch.go b/internal/branch/branch.go new file mode 100644 index 0000000..9e528af --- /dev/null +++ b/internal/branch/branch.go @@ -0,0 +1,63 @@ +package branch + +import ( + "fmt" + "regexp" + "strconv" +) + +// DefaultBranchPattern matches "release/X.Y" or "origin/release/X.Y". +// Group 1 = major, group 2 = minor. +const DefaultBranchPattern = `^(?:.*/)?release/(\d+)\.(\d+)$` + +// Info holds the major.minor version pinned by the branch name, plus the tag prefix from config. +type Info struct { + Major int + Minor int + TagPrefix string // set from config after Parse (default "v") +} + +// Parse extracts major and minor from a branch name using the provided regex pattern. +// The pattern must contain exactly two capture groups: group 1 = major, group 2 = minor. +// TagPrefix is left at its zero value — the caller sets it from config. +func Parse(name, pattern string) (Info, error) { + re, err := regexp.Compile(pattern) + if err != nil { + return Info{}, fmt.Errorf("invalid branch pattern %q: %w", pattern, err) + } + + m := re.FindStringSubmatch(name) + if len(m) < 3 { + return Info{}, fmt.Errorf("branch %q does not match pattern %q", name, pattern) + } + + major, err := strconv.Atoi(m[1]) + if err != nil { + return Info{}, fmt.Errorf("invalid major version in branch %q: %w", name, err) + } + minor, err := strconv.Atoi(m[2]) + if err != nil { + return Info{}, fmt.Errorf("invalid minor version in branch %q: %w", name, err) + } + + return Info{Major: major, Minor: minor}, nil +} + +// MatchesTag checks whether a tag (e.g. "v1.2.3" or "1.2.3") belongs to this branch's version range. +// Uses TagPrefix to match the expected format. Returns the patch number and true on match. +func (i Info) MatchesTag(tag string) (patch int, ok bool) { + prefix := fmt.Sprintf("%s%d.%d.", i.TagPrefix, i.Major, i.Minor) + if len(tag) <= len(prefix) || tag[:len(prefix)] != prefix { + return 0, false + } + p, err := strconv.Atoi(tag[len(prefix):]) + if err != nil { + return 0, false + } + return p, true +} + +// TagName formats a version string (e.g. "1.2.4") into a full tag name (e.g. "v1.2.4" or "1.2.4"). +func (i Info) TagName(version string) string { + return i.TagPrefix + version +} diff --git a/internal/branch/branch_test.go b/internal/branch/branch_test.go new file mode 100644 index 0000000..077f494 --- /dev/null +++ b/internal/branch/branch_test.go @@ -0,0 +1,136 @@ +package branch + +import ( + "strings" + "testing" +) + +// FuzzParse verifies the parser never panics, and that successful parses produce +// non-negative major/minor values. +func FuzzParse(f *testing.F) { + seeds := []string{ + "release/1.2", + "hotfix/10.3", + "origin/release/0.0", + "main", + "release/", + "release/1.2.3", + "", + "\x00", + "release/999999.999999", + strings.Repeat("release/", 100) + "1.2", + } + for _, s := range seeds { + f.Add(s) + } + + f.Fuzz(func(t *testing.T, name string) { + info, err := Parse(name, DefaultBranchPattern) + if err != nil { + return // invalid input is expected — just must not panic + } + if info.Major < 0 || info.Minor < 0 { + t.Errorf("Parse(%q) returned negative major/minor: %+v", name, info) + } + }) +} + +func TestParse(t *testing.T) { + cases := []struct { + input string + pattern string + major int + minor int + err bool + }{ + // default pattern + {"release/1.2", DefaultBranchPattern, 1, 2, false}, + {"release/10.3", DefaultBranchPattern, 10, 3, false}, + {"origin/release/1.2", DefaultBranchPattern, 1, 2, false}, + {"refs/heads/release/1.2", DefaultBranchPattern, 1, 2, false}, + {"main", DefaultBranchPattern, 0, 0, true}, + {"release/1", DefaultBranchPattern, 0, 0, true}, + {"release/1.2.3", DefaultBranchPattern, 0, 0, true}, + {"feature/1.2", DefaultBranchPattern, 0, 0, true}, + + // custom pattern — hotfix + release + {"hotfix/2.3", `^(?:.*/)?(?:release|hotfix)/(\d+)\.(\d+)$`, 2, 3, false}, + {"release/2.3", `^(?:.*/)?(?:release|hotfix)/(\d+)\.(\d+)$`, 2, 3, false}, + {"feature/2.3", `^(?:.*/)?(?:release|hotfix)/(\d+)\.(\d+)$`, 0, 0, true}, + + // invalid regex + {"release/1.2", `(unclosed`, 0, 0, true}, + + // valid regex but capture groups match non-digits → Atoi error + {"release/abc.1", `^release/(\w+)\.(\d+)$`, 0, 0, true}, // major non-numeric + {"release/1.abc", `^release/(\d+)\.(\w+)$`, 0, 0, true}, // minor non-numeric + } + + for _, c := range cases { + got, err := Parse(c.input, c.pattern) + if c.err { + if err == nil { + t.Errorf("Parse(%q, %q): expected error, got none", c.input, c.pattern) + } + continue + } + if err != nil { + t.Errorf("Parse(%q, %q): unexpected error: %v", c.input, c.pattern, err) + continue + } + if got.Major != c.major || got.Minor != c.minor { + t.Errorf("Parse(%q) = {%d, %d}, want {%d, %d}", c.input, got.Major, got.Minor, c.major, c.minor) + } + } +} + +func TestMatchesTag(t *testing.T) { + cases := []struct { + prefix string + tag string + patch int + ok bool + }{ + {"v", "v1.2.0", 0, true}, + {"v", "v1.2.5", 5, true}, + {"v", "v1.2.99", 99, true}, + {"v", "v1.3.0", 0, false}, + {"v", "v2.2.0", 0, false}, + {"v", "1.2.0", 0, false}, + {"v", "v1.2.", 0, false}, + {"v", "v1.2.x", 0, false}, + {"", "1.2.0", 0, true}, + {"", "1.2.7", 7, true}, + {"", "v1.2.0", 0, false}, + } + + for _, c := range cases { + info := Info{Major: 1, Minor: 2, TagPrefix: c.prefix} + patch, ok := info.MatchesTag(c.tag) + if ok != c.ok { + t.Errorf("[prefix=%q] MatchesTag(%q): ok=%v, want %v", c.prefix, c.tag, ok, c.ok) + continue + } + if ok && patch != c.patch { + t.Errorf("[prefix=%q] MatchesTag(%q): patch=%d, want %d", c.prefix, c.tag, patch, c.patch) + } + } +} + +func TestTagName(t *testing.T) { + cases := []struct { + prefix string + version string + want string + }{ + {"v", "1.2.4", "v1.2.4"}, + {"", "1.2.4", "1.2.4"}, + {"release-", "1.2.4", "release-1.2.4"}, + } + for _, c := range cases { + info := Info{Major: 1, Minor: 2, TagPrefix: c.prefix} + if got := info.TagName(c.version); got != c.want { + t.Errorf("TagName(%q) = %q, want %q", c.version, got, c.want) + } + } +} diff --git a/internal/commits/commits.go b/internal/commits/commits.go new file mode 100644 index 0000000..03441f5 --- /dev/null +++ b/internal/commits/commits.go @@ -0,0 +1,63 @@ +package commits + +import ( + "regexp" + "strings" +) + +// Type represents the semantic weight of a commit for versioning purposes. +type Type int + +const ( + TypeNone Type = iota // no version bump + TypeFix // fix: → patch bump + TypeFeat // feat: → patch bump (minor is pinned to branch) + TypeBreaking // feat!: or BREAKING CHANGE → patch bump +) + +// headerRe matches conventional commit headers in non-strict mode: +// case-insensitive, optional scope, optional breaking marker, flexible whitespace around colon. +var headerRe = regexp.MustCompile(`(?i)^(\w+)(?:\([^)]*\))?(!)?\s*:\s*\S`) + +// Parse extracts the commit type from a commit message. +// Unparseable messages return TypeNone — never an error. +func Parse(message string) Type { + msg := strings.TrimSpace(message) + + // BREAKING CHANGE anywhere in the body/footer takes priority (spec §10). + if strings.Contains(msg, "BREAKING CHANGE") { + return TypeBreaking + } + + first := strings.SplitN(msg, "\n", 2)[0] + m := headerRe.FindStringSubmatch(first) + if m == nil { + return TypeNone + } + + if m[2] == "!" { + return TypeBreaking + } + + switch strings.ToLower(m[1]) { + case "feat": + return TypeFeat + case "fix": + return TypeFix + default: + return TypeNone + } +} + +func (t Type) String() string { + switch t { + case TypeFix: + return "fix" + case TypeFeat: + return "feat" + case TypeBreaking: + return "breaking" + default: + return "none" + } +} diff --git a/internal/commits/commits_test.go b/internal/commits/commits_test.go new file mode 100644 index 0000000..ad3459b --- /dev/null +++ b/internal/commits/commits_test.go @@ -0,0 +1,102 @@ +package commits + +import ( + "strings" + "testing" +) + +// FuzzParse verifies that the parser never panics and always returns a valid Type. +func FuzzParse(f *testing.F) { + // Seed corpus: representative conventional commit messages + seeds := []string{ + "feat: add OAuth2 login", + "fix: correct null pointer", + "feat!: remove legacy API", + "feat(auth): add login\n\nBREAKING CHANGE: old endpoint removed", + "chore: update deps", + "Feat:missing space", + "FIX(scope)!: mixed case breaking", + "", + "\x00", + "BREAKING CHANGE: bare footer", + "not a conventional commit at all", + "feat: \n\n body only", + strings.Repeat("a", 4096), + } + for _, s := range seeds { + f.Add(s) + } + + f.Fuzz(func(t *testing.T, msg string) { + result := Parse(msg) + if result < TypeNone || result > TypeBreaking { + t.Errorf("Parse(%q) returned out-of-range type %d", msg, result) + } + }) +} + +func TestTypeString(t *testing.T) { + cases := []struct { + t Type + want string + }{ + {TypeNone, "none"}, + {TypeFix, "fix"}, + {TypeFeat, "feat"}, + {TypeBreaking, "breaking"}, + {Type(99), "none"}, // unknown → default + } + for _, c := range cases { + if got := c.t.String(); got != c.want { + t.Errorf("Type(%d).String() = %q, want %q", c.t, got, c.want) + } + } +} + +func TestParse(t *testing.T) { + cases := []struct { + msg string + want Type + }{ + // standard cases + {"feat: add login page", TypeFeat}, + {"fix: correct null pointer", TypeFix}, + {"chore: update deps", TypeNone}, + {"docs: update README", TypeNone}, + + // non-strict: case-insensitive + {"Feat: add login page", TypeFeat}, + {"FIX: correct null pointer", TypeFix}, + {"FEAT: add login", TypeFeat}, + + // non-strict: missing space after colon + {"feat:add login", TypeFeat}, + {"fix:correct null pointer", TypeFix}, + + // with scope + {"feat(auth): add OAuth2", TypeFeat}, + {"fix(db): prevent deadlock", TypeFix}, + + // breaking change via ! + {"feat!: remove legacy API", TypeBreaking}, + {"fix!: change response format", TypeBreaking}, + {"feat(api)!: rename endpoint", TypeBreaking}, + + // breaking change via footer + {"feat: new API\n\nBREAKING CHANGE: old API removed", TypeBreaking}, + {"refactor: cleanup\n\nBREAKING CHANGE: interface changed", TypeBreaking}, + + // unparseable — silently ignored + {"WIP", TypeNone}, + {"Merge branch 'main' into release/1.2", TypeNone}, + {"", TypeNone}, + {"fix a bug without colon", TypeNone}, + } + + for _, c := range cases { + got := Parse(c.msg) + if got != c.want { + t.Errorf("Parse(%q) = %s, want %s", c.msg, got, c.want) + } + } +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..c8ab127 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,91 @@ +package config + +import ( + "errors" + "fmt" + "os" + "path/filepath" + + "gopkg.in/yaml.v3" + + "git.k3nny.fr/releaser/internal/branch" +) + +const filename = ".releaser.yml" + +type Config struct { + Git GitConfig `yaml:"git"` + Maven MavenConfig `yaml:"maven"` + GitLab GitLabConfig `yaml:"gitlab"` +} + +type GitConfig struct { + TagPrefix string `yaml:"tag_prefix"` + BranchPattern string `yaml:"branch_pattern"` + CommitMessage string `yaml:"commit_message"` + AuthorName string `yaml:"author_name"` + AuthorEmail string `yaml:"author_email"` +} + +type MavenConfig struct { + PomPath string `yaml:"pom_path"` +} + +type GitLabConfig struct { + URL string `yaml:"url"` + Token string `yaml:"token"` + Project string `yaml:"project"` +} + +func defaults() Config { + return Config{ + Git: GitConfig{ + TagPrefix: "v", + BranchPattern: branch.DefaultBranchPattern, + CommitMessage: "chore(release): {version} [skip ci]", + }, + Maven: MavenConfig{ + PomPath: "pom.xml", + }, + } +} + +// Load reads .releaser.yml from dir and merges it over the defaults. +// Missing file is not an error — defaults are returned as-is. +func Load(dir string) (Config, error) { + cfg := defaults() + + data, err := os.ReadFile(filepath.Join(dir, filename)) + if errors.Is(err, os.ErrNotExist) { + return cfg, nil + } + if err != nil { + return cfg, fmt.Errorf("read %s: %w", filename, err) + } + + if err := yaml.Unmarshal(data, &cfg); err != nil { + return cfg, fmt.Errorf("parse %s: %w", filename, err) + } + + return cfg, nil +} + +// ApplyEnv fills empty GitLab fields from the standard GitLab CI environment variables. +// Values already set in the config file are never overwritten. +func (c *Config) ApplyEnv() { + if c.GitLab.Token == "" { + c.GitLab.Token = os.Getenv("GITLAB_TOKEN") + } + if c.GitLab.URL == "" { + // CI_SERVER_URL is the cleanest source ("https://gitlab.example.com") + c.GitLab.URL = os.Getenv("CI_SERVER_URL") + } + if c.GitLab.Project == "" { + // Prefer numeric ID; fall back to namespace/project path + if id := os.Getenv("CI_PROJECT_ID"); id != "" { + c.GitLab.Project = id + } else { + c.GitLab.Project = os.Getenv("CI_PROJECT_PATH") + } + } +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..aa65d6f --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,144 @@ +package config + +import ( + "os" + "path/filepath" + "testing" +) + +func TestLoadDefaults(t *testing.T) { + cfg, err := Load(t.TempDir()) + if err != nil { + t.Fatal(err) + } + if cfg.Git.TagPrefix != "v" { + t.Errorf("TagPrefix = %q, want %q", cfg.Git.TagPrefix, "v") + } + if cfg.Maven.PomPath != "pom.xml" { + t.Errorf("PomPath = %q, want %q", cfg.Maven.PomPath, "pom.xml") + } +} + +func TestLoadOverride(t *testing.T) { + dir := t.TempDir() + content := ` +git: + tag_prefix: "" + commit_message: "release: {version}" +maven: + pom_path: "services/api/pom.xml" +` + if err := os.WriteFile(filepath.Join(dir, filename), []byte(content), 0644); err != nil { + t.Fatal(err) + } + + cfg, err := Load(dir) + if err != nil { + t.Fatal(err) + } + if cfg.Git.TagPrefix != "" { + t.Errorf("TagPrefix = %q, want %q", cfg.Git.TagPrefix, "") + } + if cfg.Git.CommitMessage != "release: {version}" { + t.Errorf("CommitMessage = %q", cfg.Git.CommitMessage) + } + if cfg.Maven.PomPath != "services/api/pom.xml" { + t.Errorf("PomPath = %q", cfg.Maven.PomPath) + } +} + +func TestLoadReadError(t *testing.T) { + dir := t.TempDir() + // Create a directory named .releaser.yml so os.ReadFile fails (is a directory) + if err := os.Mkdir(filepath.Join(dir, filename), 0755); err != nil { + t.Fatal(err) + } + _, err := Load(dir) + if err == nil { + t.Error("expected error when .releaser.yml is a directory") + } +} + +func TestLoadInvalidYAML(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, filename), []byte("{[invalid yaml"), 0644); err != nil { + t.Fatal(err) + } + _, err := Load(dir) + if err == nil { + t.Error("expected error for invalid YAML") + } +} + +func TestApplyEnv(t *testing.T) { + t.Setenv("GITLAB_TOKEN", "mytoken") + t.Setenv("CI_SERVER_URL", "https://gitlab.example.com") + t.Setenv("CI_PROJECT_ID", "42") + + cfg := defaults() + cfg.ApplyEnv() + + if cfg.GitLab.Token != "mytoken" { + t.Errorf("Token = %q, want mytoken", cfg.GitLab.Token) + } + if cfg.GitLab.URL != "https://gitlab.example.com" { + t.Errorf("URL = %q", cfg.GitLab.URL) + } + if cfg.GitLab.Project != "42" { + t.Errorf("Project = %q, want 42", cfg.GitLab.Project) + } +} + +func TestApplyEnvDoesNotOverwrite(t *testing.T) { + t.Setenv("GITLAB_TOKEN", "env-token") + t.Setenv("CI_SERVER_URL", "https://env.example.com") + t.Setenv("CI_PROJECT_ID", "99") + + cfg := defaults() + cfg.GitLab.Token = "config-token" + cfg.GitLab.URL = "https://config.example.com" + cfg.GitLab.Project = "config-project" + cfg.ApplyEnv() + + if cfg.GitLab.Token != "config-token" { + t.Errorf("Token overwritten: got %q", cfg.GitLab.Token) + } + if cfg.GitLab.URL != "https://config.example.com" { + t.Errorf("URL overwritten: got %q", cfg.GitLab.URL) + } + if cfg.GitLab.Project != "config-project" { + t.Errorf("Project overwritten: got %q", cfg.GitLab.Project) + } +} + +func TestApplyEnvProjectPathFallback(t *testing.T) { + t.Setenv("CI_PROJECT_ID", "") + t.Setenv("CI_PROJECT_PATH", "mygroup/myapp") + + cfg := defaults() + cfg.ApplyEnv() + + if cfg.GitLab.Project != "mygroup/myapp" { + t.Errorf("Project = %q, want mygroup/myapp (from CI_PROJECT_PATH)", cfg.GitLab.Project) + } +} + +func TestLoadPartialOverride(t *testing.T) { + dir := t.TempDir() + // Only override tag_prefix — commit_message should keep its default + content := "git:\n tag_prefix: \"\"\n" + if err := os.WriteFile(filepath.Join(dir, filename), []byte(content), 0644); err != nil { + t.Fatal(err) + } + + cfg, err := Load(dir) + if err != nil { + t.Fatal(err) + } + if cfg.Git.TagPrefix != "" { + t.Errorf("TagPrefix = %q, want %q", cfg.Git.TagPrefix, "") + } + if cfg.Git.CommitMessage != defaults().Git.CommitMessage { + t.Errorf("CommitMessage = %q, want default", cfg.Git.CommitMessage) + } +} diff --git a/internal/gitutil/gitutil.go b/internal/gitutil/gitutil.go new file mode 100644 index 0000000..5ff7e72 --- /dev/null +++ b/internal/gitutil/gitutil.go @@ -0,0 +1,280 @@ +package gitutil + +import ( + "errors" + "fmt" + "sort" + "time" + + gogit "github.com/go-git/go-git/v5" + gitconfig "github.com/go-git/go-git/v5/config" + "github.com/go-git/go-git/v5/plumbing" + "github.com/go-git/go-git/v5/plumbing/object" + githttp "github.com/go-git/go-git/v5/plumbing/transport/http" + "github.com/go-git/go-git/v5/plumbing/storer" + + "git.k3nny.fr/releaser/internal/branch" +) + +// IsWorkingTreeClean returns true when there are no staged or modified tracked files. +// Untracked files are ignored so that unreleased work-in-progress does not block a release. +func IsWorkingTreeClean(repo *gogit.Repository) (bool, error) { + w, err := repo.Worktree() + if err != nil { + return false, err + } + status, err := w.Status() + if err != nil { + return false, err + } + for _, s := range status { + if s.Staging != gogit.Unmodified && s.Staging != gogit.Untracked { + return false, nil + } + if s.Worktree != gogit.Unmodified && s.Worktree != gogit.Untracked { + return false, nil + } + } + return true, nil +} + +// CurrentBranch returns the short branch name from HEAD. +// Returns an error when HEAD is detached (common in CI pipelines). +func CurrentBranch(repo *gogit.Repository) (string, error) { + head, err := repo.Head() + if err != nil { + return "", fmt.Errorf("read HEAD: %w", err) + } + if !head.Name().IsBranch() { + return "", fmt.Errorf("HEAD is detached — use --branch to specify the release branch") + } + return head.Name().Short(), nil +} + +type tagCandidate struct { + name string + patch int +} + +// LatestTag returns the highest-patch tag matching the branch's version range that is +// reachable from HEAD. Returns ("", -1, nil) when no matching ancestor tag exists. +func LatestTag(repo *gogit.Repository, info branch.Info) (string, int, error) { + head, err := repo.Head() + if err != nil { + return "", -1, err + } + headCommit, err := repo.CommitObject(head.Hash()) + if err != nil { + return "", -1, err + } + + refs, err := repo.Tags() + if err != nil { + return "", -1, err + } + + var candidates []tagCandidate + err = refs.ForEach(func(ref *plumbing.Reference) error { + name := ref.Name().Short() + patch, ok := info.MatchesTag(name) + if !ok { + return nil + } + + commitHash, err := resolveTagToCommit(repo, ref) + if err != nil { + return nil // silently skip malformed tags + } + + tagCommit, err := repo.CommitObject(commitHash) + if err != nil { + return nil + } + + if tagCommit.Hash == headCommit.Hash { + candidates = append(candidates, tagCandidate{name, patch}) + return nil + } + + anc, err := tagCommit.IsAncestor(headCommit) + if err != nil || !anc { + return nil + } + + candidates = append(candidates, tagCandidate{name, patch}) + return nil + }) + if err != nil { + return "", -1, err + } + + if len(candidates) == 0 { + return "", -1, nil + } + + sort.Slice(candidates, func(i, j int) bool { + return candidates[i].patch > candidates[j].patch + }) + + best := candidates[0] + return best.name, best.patch, nil +} + +// CommitsSince returns commit messages from HEAD down to (but not including) the tagged commit. +func CommitsSince(repo *gogit.Repository, tagName string) ([]string, error) { + ref, err := repo.Tag(tagName) + if err != nil { + return nil, fmt.Errorf("resolve tag %q: %w", tagName, err) + } + + tagHash, err := resolveTagToCommit(repo, ref) + if err != nil { + return nil, fmt.Errorf("resolve tag commit: %w", err) + } + + head, err := repo.Head() + if err != nil { + return nil, err + } + + iter, err := repo.Log(&gogit.LogOptions{From: head.Hash()}) + if err != nil { + return nil, err + } + + var messages []string + err = iter.ForEach(func(c *object.Commit) error { + if c.Hash == tagHash { + return storer.ErrStop + } + messages = append(messages, c.Message) + return nil + }) + return messages, err +} + +// AllCommits returns all commit messages reachable from HEAD. +func AllCommits(repo *gogit.Repository) ([]string, error) { + head, err := repo.Head() + if err != nil { + return nil, err + } + + iter, err := repo.Log(&gogit.LogOptions{From: head.Hash()}) + if err != nil { + return nil, err + } + + var messages []string + err = iter.ForEach(func(c *object.Commit) error { + messages = append(messages, c.Message) + return nil + }) + return messages, err +} + +// AuthorFromConfig reads user.name and user.email from the repository's git config. +// Local config overrides global; returns empty strings if neither is set. +func AuthorFromConfig(repo *gogit.Repository) (name, email string) { + cfg, err := repo.ConfigScoped(gitconfig.GlobalScope) + if err == nil { + name = cfg.User.Name + email = cfg.User.Email + } + // Local config overrides global + local, err := repo.Config() + if err == nil { + if local.User.Name != "" { + name = local.User.Name + } + if local.User.Email != "" { + email = local.User.Email + } + } + return +} + +// CommitFile stages filePath (relative to worktree root) and creates a commit. +func CommitFile(repo *gogit.Repository, filePath, message, authorName, authorEmail string) (plumbing.Hash, error) { + w, err := repo.Worktree() + if err != nil { + return plumbing.ZeroHash, err + } + + if _, err := w.Add(filePath); err != nil { + return plumbing.ZeroHash, fmt.Errorf("git add %s: %w", filePath, err) + } + + hash, err := w.Commit(message, &gogit.CommitOptions{ + Author: &object.Signature{ + Name: authorName, + Email: authorEmail, + When: time.Now(), + }, + }) + if err != nil { + return plumbing.ZeroHash, fmt.Errorf("git commit: %w", err) + } + return hash, nil +} + +// CreateTag creates a lightweight tag on HEAD. +func CreateTag(repo *gogit.Repository, tagName string) error { + head, err := repo.Head() + if err != nil { + return err + } + if _, err := repo.CreateTag(tagName, head.Hash(), nil); err != nil { + return fmt.Errorf("create tag %s: %w", tagName, err) + } + return nil +} + +// Push pushes the given branch and tag to the "origin" remote. +// If token is non-empty, HTTPS basic auth (oauth2/token) is used. +// Passing an empty token lets go-git use the system credential helper or SSH agent. +func Push(repo *gogit.Repository, branchName, tagName, token string) error { + remote, err := repo.Remote("origin") + if err != nil { + return fmt.Errorf("remote origin not found: %w", err) + } + + opts := &gogit.PushOptions{ + RefSpecs: []gitconfig.RefSpec{ + gitconfig.RefSpec(fmt.Sprintf("refs/heads/%s:refs/heads/%s", branchName, branchName)), + gitconfig.RefSpec(fmt.Sprintf("refs/tags/%s:refs/tags/%s", tagName, tagName)), + }, + } + + if token != "" { + opts.Auth = &githttp.BasicAuth{ + Username: "oauth2", + Password: token, + } + } + + if err := remote.Push(opts); err != nil && !errors.Is(err, gogit.NoErrAlreadyUpToDate) { + return fmt.Errorf("git push: %w", err) + } + return nil +} + +// resolveTagToCommit follows tag objects until it reaches a commit. +// Handles both lightweight tags (ref → commit) and annotated tags (ref → tag object → … → commit). +func resolveTagToCommit(repo *gogit.Repository, ref *plumbing.Reference) (plumbing.Hash, error) { + hash := ref.Hash() + for { + obj, err := repo.Object(plumbing.AnyObject, hash) + if err != nil { + return plumbing.ZeroHash, err + } + switch o := obj.(type) { + case *object.Commit: + return o.Hash, nil + case *object.Tag: + hash = o.Target + default: + return plumbing.ZeroHash, fmt.Errorf("unexpected object type %s at %s", obj.Type(), hash) + } + } +} diff --git a/internal/gitutil/gitutil_test.go b/internal/gitutil/gitutil_test.go new file mode 100644 index 0000000..d8216bd --- /dev/null +++ b/internal/gitutil/gitutil_test.go @@ -0,0 +1,701 @@ +package gitutil + +import ( + "os" + "path/filepath" + "strings" + "testing" + "time" + + gogit "github.com/go-git/go-git/v5" + gitconfig "github.com/go-git/go-git/v5/config" + "github.com/go-git/go-git/v5/plumbing" + "github.com/go-git/go-git/v5/plumbing/object" + + "git.k3nny.fr/releaser/internal/branch" +) + +// ── helpers ────────────────────────────────────────────────────────────────── + +func newTestRepo(t *testing.T) (*gogit.Repository, string) { + t.Helper() + dir := t.TempDir() + repo, err := gogit.PlainInit(dir, false) + if err != nil { + t.Fatalf("init repo: %v", err) + } + return repo, dir +} + +func testSig() *object.Signature { + return &object.Signature{Name: "Test", Email: "test@example.com", When: time.Now()} +} + +// addCommit writes content to test.txt, stages it, and creates a commit. +func addCommit(t *testing.T, repo *gogit.Repository, dir, message, content string) plumbing.Hash { + t.Helper() + if err := os.WriteFile(filepath.Join(dir, "test.txt"), []byte(content), 0644); err != nil { + t.Fatal(err) + } + w, err := repo.Worktree() + if err != nil { + t.Fatal(err) + } + if _, err := w.Add("test.txt"); err != nil { + t.Fatal(err) + } + hash, err := w.Commit(message, &gogit.CommitOptions{Author: testSig()}) + if err != nil { + t.Fatalf("commit %q: %v", message, err) + } + return hash +} + +func addTag(t *testing.T, repo *gogit.Repository, name string) { + t.Helper() + head, err := repo.Head() + if err != nil { + t.Fatal(err) + } + if _, err := repo.CreateTag(name, head.Hash(), nil); err != nil { + t.Fatalf("create lightweight tag %s: %v", name, err) + } +} + +func addAnnotatedTag(t *testing.T, repo *gogit.Repository, name string) { + t.Helper() + head, err := repo.Head() + if err != nil { + t.Fatal(err) + } + _, err = repo.CreateTag(name, head.Hash(), &gogit.CreateTagOptions{ + Tagger: testSig(), + Message: "release " + name, + }) + if err != nil { + t.Fatalf("create annotated tag %s: %v", name, err) + } +} + +// ── IsWorkingTreeClean ──────────────────────────────────────────────────────── + +func TestIsWorkingTreeClean(t *testing.T) { + t.Run("clean after commit", func(t *testing.T) { + repo, dir := newTestRepo(t) + addCommit(t, repo, dir, "chore: init", "initial") + + clean, err := IsWorkingTreeClean(repo) + if err != nil { + t.Fatal(err) + } + if !clean { + t.Error("expected clean working tree after commit") + } + }) + + t.Run("dirty when tracked file is modified", func(t *testing.T) { + repo, dir := newTestRepo(t) + addCommit(t, repo, dir, "chore: init", "initial") + + os.WriteFile(filepath.Join(dir, "test.txt"), []byte("modified"), 0644) + + clean, err := IsWorkingTreeClean(repo) + if err != nil { + t.Fatal(err) + } + if clean { + t.Error("expected dirty working tree after modification") + } + }) + + t.Run("dirty when new file is staged", func(t *testing.T) { + repo, dir := newTestRepo(t) + addCommit(t, repo, dir, "chore: init", "initial") + + os.WriteFile(filepath.Join(dir, "staged.txt"), []byte("new"), 0644) + w, _ := repo.Worktree() + w.Add("staged.txt") + + clean, err := IsWorkingTreeClean(repo) + if err != nil { + t.Fatal(err) + } + if clean { + t.Error("expected dirty working tree with staged file") + } + }) + + t.Run("untracked file does not dirty the tree", func(t *testing.T) { + repo, dir := newTestRepo(t) + addCommit(t, repo, dir, "chore: init", "initial") + os.WriteFile(filepath.Join(dir, "untracked.txt"), []byte("new"), 0644) + + clean, err := IsWorkingTreeClean(repo) + if err != nil { + t.Fatal(err) + } + if !clean { + t.Error("untracked file should not dirty the working tree") + } + }) +} + +// ── CurrentBranch ───────────────────────────────────────────────────────────── + +func TestCurrentBranch(t *testing.T) { + repo, dir := newTestRepo(t) + addCommit(t, repo, dir, "chore: init", "initial") + + name, err := CurrentBranch(repo) + if err != nil { + t.Fatal(err) + } + if name == "" { + t.Error("branch name should not be empty") + } +} + +func TestCurrentBranchDetached(t *testing.T) { + repo, dir := newTestRepo(t) + addCommit(t, repo, dir, "chore: init", "initial") + + // Detach HEAD by replacing the symbolic ref with a hash ref + head, _ := repo.Head() + detached := plumbing.NewHashReference(plumbing.HEAD, head.Hash()) + if err := repo.Storer.SetReference(detached); err != nil { + t.Fatal(err) + } + + _, err := CurrentBranch(repo) + if err == nil { + t.Error("expected error for detached HEAD") + } +} + +// ── LatestTag ───────────────────────────────────────────────────────────────── + +func TestLatestTagNoTags(t *testing.T) { + repo, dir := newTestRepo(t) + addCommit(t, repo, dir, "fix: first", "v1") + + info := branch.Info{Major: 1, Minor: 2, TagPrefix: "v"} + tag, patch, err := LatestTag(repo, info) + if err != nil { + t.Fatal(err) + } + if tag != "" || patch != -1 { + t.Errorf("expected no tag: got %q patch=%d", tag, patch) + } +} + +func TestLatestTagSingle(t *testing.T) { + repo, dir := newTestRepo(t) + addCommit(t, repo, dir, "fix: first", "v1") + addTag(t, repo, "v1.2.0") + addCommit(t, repo, dir, "fix: second", "v2") + + info := branch.Info{Major: 1, Minor: 2, TagPrefix: "v"} + tag, patch, err := LatestTag(repo, info) + if err != nil { + t.Fatal(err) + } + if tag != "v1.2.0" || patch != 0 { + t.Errorf("got tag=%q patch=%d, want v1.2.0 patch=0", tag, patch) + } +} + +func TestLatestTagPicksHighestPatch(t *testing.T) { + repo, dir := newTestRepo(t) + addCommit(t, repo, dir, "fix: c1", "v1") + addTag(t, repo, "v1.2.0") + addCommit(t, repo, dir, "fix: c2", "v2") + addTag(t, repo, "v1.2.1") + addCommit(t, repo, dir, "feat: c3", "v3") + addTag(t, repo, "v1.2.5") + addCommit(t, repo, dir, "fix: c4", "v4") // HEAD after the last tag + + info := branch.Info{Major: 1, Minor: 2, TagPrefix: "v"} + tag, patch, err := LatestTag(repo, info) + if err != nil { + t.Fatal(err) + } + if tag != "v1.2.5" || patch != 5 { + t.Errorf("got tag=%q patch=%d, want v1.2.5 patch=5", tag, patch) + } +} + +func TestLatestTagCrossVersionIgnored(t *testing.T) { + repo, dir := newTestRepo(t) + addCommit(t, repo, dir, "fix: c1", "v1") + addTag(t, repo, "v1.2.0") // matching + addCommit(t, repo, dir, "fix: c2", "v2") + addTag(t, repo, "v1.3.0") // different minor — must be ignored + addCommit(t, repo, dir, "fix: c3", "v3") + + info := branch.Info{Major: 1, Minor: 2, TagPrefix: "v"} + tag, patch, err := LatestTag(repo, info) + if err != nil { + t.Fatal(err) + } + if tag != "v1.2.0" || patch != 0 { + t.Errorf("got tag=%q patch=%d, want v1.2.0 patch=0 (v1.3.0 must be ignored)", tag, patch) + } +} + +func TestLatestTagNoPrefixVariant(t *testing.T) { + repo, dir := newTestRepo(t) + addCommit(t, repo, dir, "fix: c1", "v1") + addTag(t, repo, "2.0.0") // no "v" prefix + addCommit(t, repo, dir, "fix: c2", "v2") + + info := branch.Info{Major: 2, Minor: 0, TagPrefix: ""} + tag, patch, err := LatestTag(repo, info) + if err != nil { + t.Fatal(err) + } + if tag != "2.0.0" || patch != 0 { + t.Errorf("got tag=%q patch=%d, want 2.0.0 patch=0", tag, patch) + } +} + +func TestLatestTagAnnotated(t *testing.T) { + repo, dir := newTestRepo(t) + addCommit(t, repo, dir, "fix: c1", "v1") + addAnnotatedTag(t, repo, "v1.2.0") + addCommit(t, repo, dir, "fix: c2", "v2") + + info := branch.Info{Major: 1, Minor: 2, TagPrefix: "v"} + tag, patch, err := LatestTag(repo, info) + if err != nil { + t.Fatal(err) + } + if tag != "v1.2.0" || patch != 0 { + t.Errorf("annotated tag: got %q patch=%d, want v1.2.0", tag, patch) + } +} + +// ── CommitsSince ────────────────────────────────────────────────────────────── + +func TestCommitsSince(t *testing.T) { + repo, dir := newTestRepo(t) + addCommit(t, repo, dir, "chore: baseline", "v0") + addTag(t, repo, "v1.2.0") + addCommit(t, repo, dir, "fix: bug fix", "v1") + addCommit(t, repo, dir, "feat: new feature", "v2") + + msgs, err := CommitsSince(repo, "v1.2.0") + if err != nil { + t.Fatal(err) + } + if len(msgs) != 2 { + t.Fatalf("expected 2 commits since v1.2.0, got %d: %v", len(msgs), msgs) + } + // Log is reverse-chronological: newest first + if !strings.Contains(msgs[0], "feat: new feature") { + t.Errorf("msgs[0] = %q, want feat: new feature", msgs[0]) + } + if !strings.Contains(msgs[1], "fix: bug fix") { + t.Errorf("msgs[1] = %q, want fix: bug fix", msgs[1]) + } +} + +func TestCommitsSinceTagOnHead(t *testing.T) { + repo, dir := newTestRepo(t) + addCommit(t, repo, dir, "fix: something", "v1") + addTag(t, repo, "v1.2.0") + + // HEAD IS the tag — no commits since + msgs, err := CommitsSince(repo, "v1.2.0") + if err != nil { + t.Fatal(err) + } + if len(msgs) != 0 { + t.Errorf("expected 0 commits since tag on HEAD, got %d", len(msgs)) + } +} + +// ── AllCommits ──────────────────────────────────────────────────────────────── + +func TestAllCommits(t *testing.T) { + repo, dir := newTestRepo(t) + addCommit(t, repo, dir, "fix: c1", "v1") + addCommit(t, repo, dir, "fix: c2", "v2") + addCommit(t, repo, dir, "fix: c3", "v3") + + msgs, err := AllCommits(repo) + if err != nil { + t.Fatal(err) + } + if len(msgs) != 3 { + t.Errorf("expected 3 commits, got %d", len(msgs)) + } +} + +// ── CommitFile ──────────────────────────────────────────────────────────────── + +func TestCommitFile(t *testing.T) { + repo, dir := newTestRepo(t) + addCommit(t, repo, dir, "chore: initial", "init") + + pomPath := filepath.Join(dir, "pom.xml") + if err := os.WriteFile(pomPath, []byte("1.2.4"), 0644); err != nil { + t.Fatal(err) + } + + hash, err := CommitFile(repo, "pom.xml", "chore(release): v1.2.4 [skip ci]", "Releaser", "ci@example.com") + if err != nil { + t.Fatal(err) + } + if hash.IsZero() { + t.Error("expected non-zero commit hash") + } + + commit, err := repo.CommitObject(hash) + if err != nil { + t.Fatal(err) + } + if commit.Message != "chore(release): v1.2.4 [skip ci]" { + t.Errorf("commit message = %q", commit.Message) + } + if commit.Author.Name != "Releaser" { + t.Errorf("author name = %q, want Releaser", commit.Author.Name) + } + if commit.Author.Email != "ci@example.com" { + t.Errorf("author email = %q", commit.Author.Email) + } +} + +// ── CreateTag ───────────────────────────────────────────────────────────────── + +func TestCreateTag(t *testing.T) { + repo, dir := newTestRepo(t) + addCommit(t, repo, dir, "fix: something", "v1") + + if err := CreateTag(repo, "v1.2.0"); err != nil { + t.Fatal(err) + } + + ref, err := repo.Tag("v1.2.0") + if err != nil { + t.Fatalf("tag v1.2.0 not found after creation: %v", err) + } + head, _ := repo.Head() + if ref.Hash() != head.Hash() { + t.Error("lightweight tag does not point to HEAD") + } +} + +func TestCreateTagDuplicate(t *testing.T) { + repo, dir := newTestRepo(t) + addCommit(t, repo, dir, "fix: something", "v1") + addTag(t, repo, "v1.2.0") + + if err := CreateTag(repo, "v1.2.0"); err == nil { + t.Error("expected error when creating duplicate tag") + } +} + +// ── Push (error path only — no real remote needed) ─────────────────────────── + +func TestPushRemoteFails(t *testing.T) { + repo, dir := newTestRepo(t) + addCommit(t, repo, dir, "fix: something", "v1") + addTag(t, repo, "v1.2.0") + + // Remote config exists but points nowhere → Push will error on remote.Push + if _, err := repo.CreateRemote(&gitconfig.RemoteConfig{ + Name: "origin", + URLs: []string{"/nonexistent/bare/repo"}, + }); err != nil { + t.Fatal(err) + } + + err := Push(repo, "master", "v1.2.0", "") + if err == nil { + t.Error("expected push error for invalid remote path") + } +} + +func TestPushNoRemote(t *testing.T) { + repo, dir := newTestRepo(t) + addCommit(t, repo, dir, "fix: something", "v1") + + err := Push(repo, "master", "v1.0.0", "") + if err == nil { + t.Error("expected error when no remote is configured") + } +} + +// ── bare-repo error paths ───────────────────────────────────────────────────── + +func TestIsWorkingTreeCleanBareRepo(t *testing.T) { + dir := t.TempDir() + repo, err := gogit.PlainInit(dir, true) // bare = true + if err != nil { + t.Fatal(err) + } + _, err = IsWorkingTreeClean(repo) + if err == nil { + t.Error("expected error for bare repo (no worktree)") + } +} + +func TestCurrentBranchEmptyRepo(t *testing.T) { + // Fresh repo with no commits — HEAD symbolic ref points to refs/heads/master + // but that ref doesn't exist yet, so Head() returns plumbing.ErrReferenceNotFound. + repo, _ := newTestRepo(t) + _, err := CurrentBranch(repo) + if err == nil { + t.Error("expected error for empty repo (no HEAD commit)") + } +} + +func TestAllCommitsEmptyRepo(t *testing.T) { + repo, _ := newTestRepo(t) + _, err := AllCommits(repo) + if err == nil { + t.Error("expected error for empty repo") + } +} + +func TestCreateTagEmptyRepo(t *testing.T) { + repo, _ := newTestRepo(t) + err := CreateTag(repo, "v1.0.0") + if err == nil { + t.Error("expected error when creating tag on empty repo") + } +} + +func TestLatestTagEmptyRepo(t *testing.T) { + repo, _ := newTestRepo(t) + _, _, err := LatestTag(repo, branch.Info{Major: 1, Minor: 2, TagPrefix: "v"}) + if err == nil { + t.Error("expected error for empty repo") + } +} + +func TestCommitFileBareRepo(t *testing.T) { + dir := t.TempDir() + repo, err := gogit.PlainInit(dir, true) + if err != nil { + t.Fatal(err) + } + _, err = CommitFile(repo, "pom.xml", "chore: test", "Test", "t@t.com") + if err == nil { + t.Error("expected error for bare repo (no worktree)") + } +} + +func TestCommitFileNonexistentFile(t *testing.T) { + repo, dir := newTestRepo(t) + addCommit(t, repo, dir, "chore: init", "init") + + // Try to stage a file that doesn't exist in the worktree + _, err := CommitFile(repo, "nonexistent.xml", "chore: bad", "Test", "t@t.com") + if err == nil { + t.Error("expected error when staging nonexistent file") + } +} + +// ── LatestTag edge cases ────────────────────────────────────────────────────── + +func TestLatestTagOnHead(t *testing.T) { + repo, dir := newTestRepo(t) + addCommit(t, repo, dir, "fix: c1", "v1") + addTag(t, repo, "v1.2.0") // tag is ON HEAD, not behind HEAD + + info := branch.Info{Major: 1, Minor: 2, TagPrefix: "v"} + tag, patch, err := LatestTag(repo, info) + if err != nil { + t.Fatal(err) + } + // Tag at HEAD should still be found (it is the current release base) + if tag != "v1.2.0" || patch != 0 { + t.Errorf("got %q patch=%d, want v1.2.0 patch=0 when tag is on HEAD", tag, patch) + } +} + +func TestLatestTagNonAncestorIgnored(t *testing.T) { + repo, dir := newTestRepo(t) + baseHash := addCommit(t, repo, dir, "chore: base", "base") + + // Create sibling branch from base commit and put a v1.2.0 tag on it + w, _ := repo.Worktree() + if err := w.Checkout(&gogit.CheckoutOptions{ + Branch: plumbing.NewBranchReferenceName("sibling"), + Hash: baseHash, + Create: true, + }); err != nil { + t.Fatal(err) + } + addCommit(t, repo, dir, "feat: sibling work", "sibling") + addTag(t, repo, "v1.2.0") + + // Switch back to master and add a commit (diverges from sibling) + if err := w.Checkout(&gogit.CheckoutOptions{ + Branch: plumbing.NewBranchReferenceName("master"), + }); err != nil { + t.Fatal(err) + } + addCommit(t, repo, dir, "fix: mainline only", "mainline") + + info := branch.Info{Major: 1, Minor: 2, TagPrefix: "v"} + tag, patch, err := LatestTag(repo, info) + if err != nil { + t.Fatal(err) + } + // v1.2.0 is on the sibling branch — not an ancestor of current HEAD + if tag != "" || patch != -1 { + t.Errorf("non-ancestor tag should be ignored: got %q patch=%d", tag, patch) + } +} + +func TestLatestTagSkipsMalformedRef(t *testing.T) { + repo, dir := newTestRepo(t) + addCommit(t, repo, dir, "fix: c1", "v1") + + // Create a v1.2.0 tag reference pointing to a hash that doesn't exist. + // LatestTag must silently skip it instead of returning an error. + fakeHash := plumbing.NewHash("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef") + fakeRef := plumbing.NewHashReference(plumbing.NewTagReferenceName("v1.2.0"), fakeHash) + if err := repo.Storer.SetReference(fakeRef); err != nil { + t.Fatal(err) + } + + info := branch.Info{Major: 1, Minor: 2, TagPrefix: "v"} + tag, patch, err := LatestTag(repo, info) + if err != nil { + t.Fatalf("LatestTag should not error on malformed tag ref: %v", err) + } + if tag != "" || patch != -1 { + t.Errorf("malformed tag should be skipped: got %q patch=%d", tag, patch) + } +} + +// ── resolveTagToCommit default case ────────────────────────────────────────── + +func TestResolveTagToCommitBlobRef(t *testing.T) { + repo, dir := newTestRepo(t) + h := addCommit(t, repo, dir, "fix: c1", "content") + + // Obtain a blob hash from the commit tree to use as an adversarial ref target. + commit, err := repo.CommitObject(h) + if err != nil { + t.Fatal(err) + } + tree, err := commit.Tree() + if err != nil || len(tree.Entries) == 0 { + t.Fatal("need at least one tree entry") + } + blobHash := tree.Entries[0].Hash + + blobRef := plumbing.NewHashReference(plumbing.NewTagReferenceName("blob-tag"), blobHash) + _, err = resolveTagToCommit(repo, blobRef) + if err == nil { + t.Error("expected error when tag points to a blob (not a commit or tag object)") + } +} + +// ── CommitsSince error paths ────────────────────────────────────────────────── + +func TestCommitsSinceBadTag(t *testing.T) { + repo, dir := newTestRepo(t) + addCommit(t, repo, dir, "fix: something", "v1") + + _, err := CommitsSince(repo, "v-does-not-exist") + if err == nil { + t.Error("expected error for nonexistent tag") + } +} + +func TestCommitsSinceMalformedTagRef(t *testing.T) { + repo, dir := newTestRepo(t) + addCommit(t, repo, dir, "fix: c1", "v1") + + // Create a tag that references a nonexistent hash so resolveTagToCommit fails. + fakeHash := plumbing.NewHash("cafebabecafebabecafebabecafebabecafebabe") + fakeRef := plumbing.NewHashReference(plumbing.NewTagReferenceName("v1.2.0"), fakeHash) + if err := repo.Storer.SetReference(fakeRef); err != nil { + t.Fatal(err) + } + + _, err := CommitsSince(repo, "v1.2.0") + if err == nil { + t.Error("expected error when resolveTagToCommit fails on a malformed tag ref") + } +} + +// ── AuthorFromConfig ────────────────────────────────────────────────────────── + +func TestAuthorFromConfigDoesNotPanic(t *testing.T) { + repo, dir := newTestRepo(t) + addCommit(t, repo, dir, "chore: init", "init") + + // May return empty strings in CI where ~/.gitconfig has no user section — must not panic + name, email := AuthorFromConfig(repo) + _ = name + _ = email +} + +func TestAuthorFromConfigLocalOverride(t *testing.T) { + repo, dir := newTestRepo(t) + addCommit(t, repo, dir, "chore: init", "init") + + // Write a local git config with user.name / user.email + localCfg := `[user] + name = LocalUser + email = local@example.com +` + if err := os.WriteFile(filepath.Join(dir, ".git", "config"), []byte(localCfg), 0644); err != nil { + t.Fatal(err) + } + + // Reload repo so it picks up the config file we just wrote + repo2, err := gogit.PlainOpen(dir) + if err != nil { + t.Fatal(err) + } + + name, email := AuthorFromConfig(repo2) + if name != "LocalUser" { + t.Errorf("name = %q, want LocalUser", name) + } + if email != "local@example.com" { + t.Errorf("email = %q, want local@example.com", email) + } +} + +// ── Push with a real bare remote ────────────────────────────────────────────── + +func TestPushWithBareRemote(t *testing.T) { + // Create a bare repo to act as the remote + remoteDir := t.TempDir() + if _, err := gogit.PlainInit(remoteDir, true); err != nil { + t.Fatal(err) + } + + // Create the working repo + repo, dir := newTestRepo(t) + addCommit(t, repo, dir, "fix: something", "v1") + addTag(t, repo, "v1.2.0") + + // Wire the bare repo as origin + if _, err := repo.CreateRemote(&gitconfig.RemoteConfig{ + Name: "origin", + URLs: []string{remoteDir}, + }); err != nil { + t.Fatal(err) + } + + // Push with a token (exercises the auth branch even though local transport ignores it) + err := Push(repo, "master", "v1.2.0", "dummy-token") + if err != nil { + t.Fatalf("Push to bare remote failed: %v", err) + } + + // Push again — should hit the NoErrAlreadyUpToDate branch and return nil + err = Push(repo, "master", "v1.2.0", "dummy-token") + if err != nil { + t.Fatalf("second Push returned unexpected error: %v", err) + } +} diff --git a/internal/glclient/glclient.go b/internal/glclient/glclient.go new file mode 100644 index 0000000..3d84a46 --- /dev/null +++ b/internal/glclient/glclient.go @@ -0,0 +1,103 @@ +package glclient + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + "net/url" + "strings" + "time" +) + +// Client is a minimal GitLab API client covering only the Releases endpoint. +type Client struct { + baseURL string + token string + projectID string // numeric ID or URL-encoded namespace/project + httpClient *http.Client +} + +// New creates a Client. baseURL is the GitLab instance root (e.g. "https://gitlab.example.com"). +// project can be a numeric ID ("123") or a namespace/project path ("group/myapp"). +func New(baseURL, token, project string) *Client { + return &Client{ + baseURL: strings.TrimRight(baseURL, "/"), + token: token, + projectID: encodeProjectPath(project), + httpClient: &http.Client{ + Timeout: 30 * time.Second, + }, + } +} + +// endpoint builds a full API URL for the given resource under the project. +// Uses url.URL{Path, RawPath} to preserve %2F encoding in the project path — +// url.Parse would silently decode %2F back to / in the Path field. +func (c *Client) endpoint(resource string) string { + rawPath := fmt.Sprintf("/api/v4/projects/%s/%s", c.projectID, resource) + u, err := url.Parse(c.baseURL) + if err != nil { + return c.baseURL + rawPath // fallback, handles only simple cases + } + u.Path = strings.ReplaceAll(rawPath, "%2F", "/") + u.RawPath = rawPath + return u.String() +} + +// encodeProjectPath URL-encodes a GitLab project identifier for use in an API path. +// Numeric IDs ("123") pass through unchanged. +// Path-style IDs ("group/project") have each segment encoded and slashes replaced with %2F, +// because url.PathEscape does not encode / (it is a valid path separator). +func encodeProjectPath(project string) string { + parts := strings.Split(project, "/") + for i, p := range parts { + parts[i] = url.PathEscape(p) + } + return strings.Join(parts, "%2F") +} + +type createReleaseRequest struct { + Name string `json:"name"` + TagName string `json:"tag_name"` + Description string `json:"description"` +} + +// CreateRelease creates a GitLab release on an existing tag. +// The tag must already be pushed to the remote before calling this. +func (c *Client) CreateRelease(ctx context.Context, tagName, description string) error { + // json.Marshal never fails for a struct with only string fields + body, _ := json.Marshal(createReleaseRequest{ + Name: tagName, + TagName: tagName, + Description: description, + }) + + endpoint := c.endpoint("releases") + req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body)) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("PRIVATE-TOKEN", c.token) + + resp, err := c.httpClient.Do(req) + if err != nil { + return fmt.Errorf("GitLab API request failed: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + var errBody struct { + Message string `json:"message"` + } + json.NewDecoder(resp.Body).Decode(&errBody) //nolint:errcheck + if errBody.Message != "" { + return fmt.Errorf("GitLab API returned %d: %s", resp.StatusCode, errBody.Message) + } + return fmt.Errorf("GitLab API returned %d", resp.StatusCode) + } + + return nil +} diff --git a/internal/glclient/glclient_test.go b/internal/glclient/glclient_test.go new file mode 100644 index 0000000..b334bde --- /dev/null +++ b/internal/glclient/glclient_test.go @@ -0,0 +1,151 @@ +package glclient + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestCreateRelease(t *testing.T) { + var received createReleaseRequest + var gotToken string + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + t.Errorf("method = %s, want POST", r.Method) + } + gotToken = r.Header.Get("PRIVATE-TOKEN") + json.NewDecoder(r.Body).Decode(&received) //nolint:errcheck + w.WriteHeader(http.StatusCreated) + w.Write([]byte(`{}`)) //nolint:errcheck + })) + defer srv.Close() + + client := New(srv.URL, "my-token", "123") + err := client.CreateRelease(context.Background(), "v1.2.4", "## v1.2.4\n\n### Bug Fixes\n- patch something") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if gotToken != "my-token" { + t.Errorf("PRIVATE-TOKEN = %q, want %q", gotToken, "my-token") + } + if received.TagName != "v1.2.4" { + t.Errorf("tag_name = %q, want %q", received.TagName, "v1.2.4") + } + if received.Name != "v1.2.4" { + t.Errorf("name = %q, want %q", received.Name, "v1.2.4") + } + if received.Description == "" { + t.Error("description should not be empty") + } +} + +func TestCreateReleaseAPIError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusUnprocessableEntity) + w.Write([]byte(`{"message":"Tag already has a release"}`)) //nolint:errcheck + })) + defer srv.Close() + + client := New(srv.URL, "token", "42") + err := client.CreateRelease(context.Background(), "v1.2.4", "notes") + if err == nil { + t.Fatal("expected error for 422 response") + } + if err.Error() == "" { + t.Error("error message should not be empty") + } +} + +func TestProjectPathEncoding(t *testing.T) { + var gotPath string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // EscapedPath returns RawPath when set, preserving %2F encoding. + // r.URL.Path is always decoded so we must not use it here. + gotPath = r.URL.EscapedPath() + w.WriteHeader(http.StatusCreated) + w.Write([]byte(`{}`)) //nolint:errcheck + })) + defer srv.Close() + + client := New(srv.URL, "token", "group/my-app") + client.CreateRelease(context.Background(), "v1.0.0", "") //nolint:errcheck + + // "group/my-app" must be URL-encoded as "group%2Fmy-app" + if gotPath != "/api/v4/projects/group%2Fmy-app/releases" { + t.Errorf("path = %q, want /api/v4/projects/group%%2Fmy-app/releases", gotPath) + } +} + +func TestEndpointFallback(t *testing.T) { + // "://" is an invalid URL that url.Parse will reject, + // exercising the fallback branch in endpoint(). + c := New("://invalid", "token", "123") + got := c.endpoint("releases") + want := "://invalid/api/v4/projects/123/releases" + if got != want { + t.Errorf("endpoint fallback = %q, want %q", got, want) + } +} + +func TestCreateReleaseStatusNoMessage(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + w.Write([]byte(`not json`)) //nolint:errcheck + })) + defer srv.Close() + + err := New(srv.URL, "token", "42").CreateRelease(context.Background(), "v1.0.0", "notes") + if err == nil { + t.Fatal("expected error for 500 response") + } + // Should contain just the status code, no message field + if !strings.Contains(err.Error(), "500") { + t.Errorf("error should mention status 500: %v", err) + } +} + +func TestCreateReleaseInvalidURL(t *testing.T) { + // "://" is unparseable as a URL scheme, so endpoint() returns the raw fallback + // string and http.NewRequestWithContext fails when it tries to parse it. + c := New("://", "token", "42") + err := c.CreateRelease(context.Background(), "v1.0.0", "notes") + if err == nil { + t.Fatal("expected error for client with invalid base URL") + } +} + +func TestCreateReleaseNetworkError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})) + srv.Close() // close immediately so the request fails at TCP level + + err := New(srv.URL, "token", "42").CreateRelease(context.Background(), "v1.0.0", "notes") + if err == nil { + t.Fatal("expected network error after server is closed") + } +} + +// FuzzEncodeProjectPath verifies that the encoder never panics and never emits +// a raw slash when the input contains one. +func FuzzEncodeProjectPath(f *testing.F) { + f.Add("group/project") + f.Add("123") + f.Add("") + f.Add("group/subgroup/project") + f.Add("a/b/c/d") + f.Add("/leading/slash") + f.Add("trailing/slash/") + f.Add(strings.Repeat("a/", 50)) + + f.Fuzz(func(t *testing.T, project string) { + result := encodeProjectPath(project) + // A slash in the input must NOT appear as a literal slash in the result + if strings.Contains(project, "/") && strings.Contains(result, "/") { + t.Errorf("encodeProjectPath(%q) = %q: contains unencoded slash", project, result) + } + }) +} diff --git a/internal/maven/maven.go b/internal/maven/maven.go new file mode 100644 index 0000000..8b51511 --- /dev/null +++ b/internal/maven/maven.go @@ -0,0 +1,64 @@ +package maven + +import ( + "fmt" + "os" + "regexp" + "strings" +) + +var parentBlockRe = regexp.MustCompile(`(?s).*?`) +var versionTagRe = regexp.MustCompile(`([^<]+)`) + +// ReadVersion returns the project version from a pom.xml file. +// It ignores the block version and dependency versions. +func ReadVersion(pomPath string) (string, error) { + data, err := os.ReadFile(pomPath) + if err != nil { + return "", fmt.Errorf("read %s: %w", pomPath, err) + } + + // Strip before searching so we don't pick up the parent version. + stripped := parentBlockRe.ReplaceAllString(string(data), "") + + m := versionTagRe.FindStringSubmatch(stripped) + if m == nil { + return "", fmt.Errorf("no element found in %s", pomPath) + } + return strings.TrimSpace(m[1]), nil +} + +// WriteVersion replaces the project version in a pom.xml file in-place. +// oldVersion must match what ReadVersion returned. The version is never touched. +func WriteVersion(pomPath, oldVersion, newVersion string) error { + data, err := os.ReadFile(pomPath) + if err != nil { + return fmt.Errorf("read %s: %w", pomPath, err) + } + + updated := replaceProjectVersion(string(data), oldVersion, newVersion) + if updated == string(data) { + return fmt.Errorf("version %q not found in %s", oldVersion, pomPath) + } + + return os.WriteFile(pomPath, []byte(updated), 0644) +} + +// replaceProjectVersion replaces the first oldVersion that is not +// inside a block. This preserves the parent version and dependency versions. +func replaceProjectVersion(content, oldVersion, newVersion string) string { + target := "" + oldVersion + "" + replacement := "" + newVersion + "" + + // If there is a block, start searching after it. + if idx := strings.Index(content, ""); idx >= 0 { + after := content[idx:] + if pos := strings.Index(after, target); pos >= 0 { + abs := idx + pos + return content[:abs] + replacement + content[abs+len(target):] + } + return content + } + + return strings.Replace(content, target, replacement, 1) +} diff --git a/internal/maven/maven_test.go b/internal/maven/maven_test.go new file mode 100644 index 0000000..4783b09 --- /dev/null +++ b/internal/maven/maven_test.go @@ -0,0 +1,213 @@ +package maven + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +const simplePom = ` + + 4.0.0 + com.example + myapp + 1.2.3 + My App +` + +const pomWithParent = ` + + 4.0.0 + + org.springframework.boot + spring-boot-starter-parent + 3.2.0 + + com.example + myapp + 1.2.3 +` + +const pomWithSnapshot = ` + + myapp + 1.2.4-SNAPSHOT +` + +func writePom(t *testing.T, content string) string { + t.Helper() + path := filepath.Join(t.TempDir(), "pom.xml") + if err := os.WriteFile(path, []byte(content), 0644); err != nil { + t.Fatal(err) + } + return path +} + +func TestReadVersion(t *testing.T) { + cases := []struct { + name string + content string + want string + }{ + {"simple pom", simplePom, "1.2.3"}, + {"pom with parent", pomWithParent, "1.2.3"}, + {"snapshot version", pomWithSnapshot, "1.2.4-SNAPSHOT"}, + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + got, err := ReadVersion(writePom(t, c.content)) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != c.want { + t.Errorf("got %q, want %q", got, c.want) + } + }) + } +} + +func TestReadVersionParentNotMatched(t *testing.T) { + // ReadVersion must return the project version, NOT the parent version (3.2.0). + got, err := ReadVersion(writePom(t, pomWithParent)) + if err != nil { + t.Fatal(err) + } + if got != "1.2.3" { + t.Errorf("got %q, want 1.2.3 (parent version must be ignored)", got) + } +} + +func TestWriteVersion(t *testing.T) { + cases := []struct { + name string + content string + oldVersion string + newVersion string + wantInFile string + }{ + {"simple replace", simplePom, "1.2.3", "1.2.4", "1.2.4"}, + {"with parent", pomWithParent, "1.2.3", "1.2.4", "1.2.4"}, + {"snapshot to release", pomWithSnapshot, "1.2.4-SNAPSHOT", "1.2.4", "1.2.4"}, + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + path := writePom(t, c.content) + if err := WriteVersion(path, c.oldVersion, c.newVersion); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + data, _ := os.ReadFile(path) + content := string(data) + + if !contains(content, c.wantInFile) { + t.Errorf("expected %q in updated pom, got:\n%s", c.wantInFile, content) + } + + // Parent version must be unchanged in the parent block case. + if c.name == "with parent" && !contains(content, "3.2.0") { + t.Error("parent version was modified") + } + }) + } +} + +func TestWriteVersionParentPreserved(t *testing.T) { + path := writePom(t, pomWithParent) + if err := WriteVersion(path, "1.2.3", "1.2.4"); err != nil { + t.Fatal(err) + } + got, _ := ReadVersion(path) + if got != "1.2.4" { + t.Errorf("project version = %q, want 1.2.4", got) + } + + data, _ := os.ReadFile(path) + if !contains(string(data), "3.2.0") { + t.Error("parent version was modified") + } +} + +func TestReadVersionMissingFile(t *testing.T) { + _, err := ReadVersion(filepath.Join(t.TempDir(), "nonexistent.xml")) + if err == nil { + t.Error("expected error for missing pom file") + } +} + +func TestReadVersionNoVersionTag(t *testing.T) { + _, err := ReadVersion(writePom(t, "app")) + if err == nil { + t.Error("expected error when no tag present") + } +} + +func TestWriteVersionMissingFile(t *testing.T) { + err := WriteVersion(filepath.Join(t.TempDir(), "nosuchfile.xml"), "1.0", "1.1") + if err == nil { + t.Error("expected error for missing pom file") + } +} + +func TestWriteVersionNotFound(t *testing.T) { + err := WriteVersion(writePom(t, simplePom), "9.9.9", "9.9.10") + if err == nil { + t.Error("expected error when old version not found in pom") + } +} + +func TestReplaceVersionParentNoMatch(t *testing.T) { + // Has but the target version only appears inside the parent block, + // not after it. replaceProjectVersion should return content unchanged. + content := `3.0.0` + result := replaceProjectVersion(content, "3.0.0", "4.0.0") + if result != content { + t.Errorf("expected content unchanged; got:\n%s", result) + } +} + +func contains(s, sub string) bool { return strings.Contains(s, sub) } + +// FuzzReadVersion verifies that ReadVersion never panics on arbitrary file content. +func FuzzReadVersion(f *testing.F) { + f.Add(simplePom) + f.Add(pomWithParent) + f.Add(pomWithSnapshot) + f.Add("") + f.Add("") + f.Add("1.0") + f.Add("\x00\x01\xff") + + f.Fuzz(func(t *testing.T, content string) { + path := filepath.Join(t.TempDir(), "pom.xml") + os.WriteFile(path, []byte(content), 0644) //nolint:errcheck + // Must not panic regardless of content + ReadVersion(path) //nolint:errcheck + }) +} + +// FuzzReplaceProjectVersion verifies the internal replacer is robust against arbitrary inputs. +func FuzzReplaceProjectVersion(f *testing.F) { + f.Add(simplePom, "1.2.3", "1.2.4") + f.Add(pomWithParent, "1.2.3", "2.0.0") + f.Add("", "1.0", "2.0") + f.Add("1.0", "1.0", "1.1") + f.Add("\x00", "", "1.0") + + f.Fuzz(func(t *testing.T, content, oldVersion, newVersion string) { + result := replaceProjectVersion(content, oldVersion, newVersion) + // Result must always be a string (no panic) + // If a replacement happened, oldVersion must not appear where newVersion was inserted + if oldVersion == newVersion { + return + } + if result != content { + // Replacement occurred — newVersion must appear in result + if !strings.Contains(result, ""+newVersion+"") { + t.Errorf("replacement happened but newVersion not found in result") + } + } + }) +} diff --git a/internal/notes/notes.go b/internal/notes/notes.go new file mode 100644 index 0000000..659fbde --- /dev/null +++ b/internal/notes/notes.go @@ -0,0 +1,65 @@ +package notes + +import ( + "fmt" + "regexp" + "strings" + + "git.k3nny.fr/releaser/internal/commits" +) + +// headerSubjectRe captures the subject (description) part of a conventional commit header. +var headerSubjectRe = regexp.MustCompile(`(?i)^\w+(?:\([^)]*\))?!?\s*:\s*(.+)`) + +// Generate produces grouped markdown release notes from a list of commit messages. +// Commits are grouped into Breaking Changes, Features, and Bug Fixes. +// Commits with no releasable type are omitted. +func Generate(tagName string, messages []string) string { + var breaking, feats, fixes []string + + for _, msg := range messages { + t := commits.Parse(msg) + if t == commits.TypeNone { + continue + } + first := strings.SplitN(strings.TrimSpace(msg), "\n", 2)[0] + subject := extractSubject(first) + + switch t { + case commits.TypeBreaking: + breaking = append(breaking, subject) + case commits.TypeFeat: + feats = append(feats, subject) + case commits.TypeFix: + fixes = append(fixes, subject) + } + } + + var sb strings.Builder + fmt.Fprintf(&sb, "## %s\n", tagName) + writeSection(&sb, "Breaking Changes", breaking) + writeSection(&sb, "Features", feats) + writeSection(&sb, "Bug Fixes", fixes) + + return strings.TrimRight(sb.String(), "\n") +} + +func writeSection(sb *strings.Builder, title string, items []string) { + if len(items) == 0 { + return + } + fmt.Fprintf(sb, "\n### %s\n", title) + for _, item := range items { + fmt.Fprintf(sb, "- %s\n", item) + } +} + +// extractSubject returns the description part of a conventional commit header. +// Falls back to the raw header if the pattern does not match. +func extractSubject(header string) string { + m := headerSubjectRe.FindStringSubmatch(header) + if m != nil { + return strings.TrimSpace(m[1]) + } + return strings.TrimSpace(header) +} diff --git a/internal/notes/notes_test.go b/internal/notes/notes_test.go new file mode 100644 index 0000000..cc66be5 --- /dev/null +++ b/internal/notes/notes_test.go @@ -0,0 +1,109 @@ +package notes + +import ( + "strings" + "testing" +) + +func TestGenerate(t *testing.T) { + messages := []string{ + "feat: add OAuth2 login", + "fix: correct null pointer in user service", + "feat(search): improve performance", + "chore: update dependencies", // ignored + "Merge branch 'feature/x' into main", // ignored + "feat!: remove legacy API", + "fix: handle empty response", + "WIP something", // ignored + } + + got := Generate("v1.2.4", messages) + + must := []string{ + "## v1.2.4", + "### Breaking Changes", + "- remove legacy API", + "### Features", + "- add OAuth2 login", + "- improve performance", + "### Bug Fixes", + "- correct null pointer in user service", + "- handle empty response", + } + for _, want := range must { + if !strings.Contains(got, want) { + t.Errorf("missing %q in output:\n%s", want, got) + } + } + + absent := []string{"update dependencies", "Merge branch", "WIP"} + for _, s := range absent { + if strings.Contains(got, s) { + t.Errorf("unexpected %q in output:\n%s", s, got) + } + } +} + +func TestGenerateOnlyFixes(t *testing.T) { + messages := []string{ + "fix: patch SQL injection", + "fix: escape HTML output", + } + got := Generate("v1.2.1", messages) + + if strings.Contains(got, "### Features") { + t.Error("Features section should be absent when there are no feat commits") + } + if strings.Contains(got, "### Breaking Changes") { + t.Error("Breaking Changes section should be absent") + } + if !strings.Contains(got, "### Bug Fixes") { + t.Error("Bug Fixes section should be present") + } +} + +func TestGenerateEmpty(t *testing.T) { + got := Generate("v1.2.0", []string{"chore: bump deps", "docs: update readme"}) + if strings.Contains(got, "###") { + t.Errorf("no sections expected when no releasable commits, got:\n%s", got) + } +} + +func TestExtractSubject(t *testing.T) { + cases := []struct { + header string + want string + }{ + {"feat: add login", "add login"}, + {"feat(auth): add OAuth2", "add OAuth2"}, + {"feat!: remove API", "remove API"}, + {"FIX:typo", "typo"}, + {"plain message", "plain message"}, + } + for _, c := range cases { + got := extractSubject(c.header) + if got != c.want { + t.Errorf("extractSubject(%q) = %q, want %q", c.header, got, c.want) + } + } +} + +// FuzzGenerate verifies that Generate never panics on arbitrary inputs and +// always includes the tag name in the output. +func FuzzGenerate(f *testing.F) { + f.Add("v1.2.4", "feat: add thing") + f.Add("v1.0.0", "fix: patch") + f.Add("1.2.0", "feat!: breaking change") + f.Add("v0.0.1", "") + f.Add("", "feat: msg") + f.Add("v1.0.0", "BREAKING CHANGE: dropped") + f.Add("tag\nwith\nnewline", "feat: something") + f.Add("v1.0.0", strings.Repeat("feat: x\n", 1000)) + + f.Fuzz(func(t *testing.T, tag, msg string) { + result := Generate(tag, []string{msg}) + if !strings.Contains(result, tag) { + t.Errorf("output does not contain tag %q\noutput: %s", tag, result) + } + }) +} diff --git a/internal/version/version.go b/internal/version/version.go new file mode 100644 index 0000000..863c8b1 --- /dev/null +++ b/internal/version/version.go @@ -0,0 +1,19 @@ +package version + +import ( + "fmt" + + "git.k3nny.fr/releaser/internal/commits" +) + +// Next computes the next version string (without tag prefix, e.g. "1.2.4"). +// currentPatch is -1 when no tag exists yet (first release will be X.Y.0). +// Returns ("", false) when there are no releasable commits. +func Next(major, minor, currentPatch int, types []commits.Type) (string, bool) { + for _, t := range types { + if t != commits.TypeNone { + return fmt.Sprintf("%d.%d.%d", major, minor, currentPatch+1), true + } + } + return "", false +} diff --git a/internal/version/version_test.go b/internal/version/version_test.go new file mode 100644 index 0000000..e70ec35 --- /dev/null +++ b/internal/version/version_test.go @@ -0,0 +1,73 @@ +package version + +import ( + "testing" + + "git.k3nny.fr/releaser/internal/commits" +) + +func TestNext(t *testing.T) { + cases := []struct { + desc string + major, minor int + currentPatch int + types []commits.Type + want string + wantOk bool + }{ + { + desc: "first release with feat", + major: 1, minor: 2, currentPatch: -1, + types: []commits.Type{commits.TypeFeat}, + want: "1.2.0", + wantOk: true, + }, + { + desc: "bump from patch 3", + major: 1, minor: 2, currentPatch: 3, + types: []commits.Type{commits.TypeFix}, + want: "1.2.4", + wantOk: true, + }, + { + desc: "breaking change still bumps patch on release branch", + major: 2, minor: 0, currentPatch: 1, + types: []commits.Type{commits.TypeBreaking}, + want: "2.0.2", + wantOk: true, + }, + { + desc: "only chore commits — nothing to release", + major: 1, minor: 2, currentPatch: 5, + types: []commits.Type{commits.TypeNone, commits.TypeNone}, + want: "", + wantOk: false, + }, + { + desc: "no commits at all", + major: 1, minor: 2, currentPatch: 0, + types: nil, + want: "", + wantOk: false, + }, + { + desc: "one releasable commit among chores", + major: 1, minor: 3, currentPatch: 7, + types: []commits.Type{commits.TypeNone, commits.TypeFix, commits.TypeNone}, + want: "1.3.8", + wantOk: true, + }, + } + + for _, c := range cases { + t.Run(c.desc, func(t *testing.T) { + got, ok := Next(c.major, c.minor, c.currentPatch, c.types) + if ok != c.wantOk { + t.Errorf("ok=%v, want %v", ok, c.wantOk) + } + if got != c.want { + t.Errorf("got %q, want %q", got, c.want) + } + }) + } +}