Gitfed
bastien-mrq/gitfed / cmd / gitfed-ctl / changelog.go
package main

import (
	"context"
	"fmt"
	"strconv"
	"strings"
)

// bullet is one CHANGELOG.md entry under a version heading.
type bullet struct {
	Text     string
	Breaking bool
}

// versionChangelog is everything gitfed-ctl found for one version in an
// update range. TagMissing means the release commit is on the upstream
// branch but no "vX.Y.Z" tag exists yet for it (a real gap: update.sh only
// tags after a manual `git push --tags`) — rather than silently skipping
// what might be a breaking change we can't actually read yet, this
// produces one synthetic, non-dismissible bullet forcing acknowledgment.
type versionChangelog struct {
	Version    string
	Bullets    []bullet
	TagMissing bool
}

// checkForUpdates fetches tags and compares what's actually deployed on the
// cluster right now against whatever VERSION says at the tip of the
// configured upstream branch, without touching the working tree (no pull
// yet — that only happens once the wizard is confirmed).
//
// "current" deliberately comes from the live deployment (kubectl), not the
// checkout's own VERSION file — found live: a previous run's git pull can
// succeed (advancing VERSION) while a later step (build/import/apply/
// rollout) fails, leaving the checkout ahead of what's actually running.
// Reading VERSION here would then report "up to date" even though
// production is still on the old image, and silently skip re-showing any
// breaking changes for a version that was never actually deployed.
func checkForUpdates(ctx context.Context, sourceDir string) (current, target string, upToDate bool, err error) {
	if _, err := runQuiet(ctx, sourceDir, "git", "fetch", "--tags"); err != nil {
		return "", "", false, fmt.Errorf("git fetch: %w", err)
	}

	ref, err := upstreamRef(ctx, sourceDir)
	if err != nil {
		return "", "", false, err
	}

	targetRaw, err := runQuiet(ctx, sourceDir, "git", "show", ref+":VERSION")
	if err != nil {
		return "", "", false, fmt.Errorf("read VERSION at %s: %w", ref, err)
	}
	target = strings.TrimSpace(targetRaw)

	current, err = deployedVersion(ctx)
	if err != nil {
		return "", "", false, fmt.Errorf("read deployed version: %w", err)
	}

	return current, target, current == target, nil
}

// deployedVersion reads the image tag actually configured on the live
// "gitfed" deployment (namespace gitfed, container "server" — see
// deploy/k8s/deployment.yaml) via kubectl, the source of truth for "what's
// really running" this check needs.
func deployedVersion(ctx context.Context) (string, error) {
	out, err := runQuiet(ctx, "", "kubectl", "-n", "gitfed", "get", "deployment", "gitfed",
		"-o", `jsonpath={.spec.template.spec.containers[?(@.name=="server")].image}`)
	if err != nil {
		return "", err
	}
	tag := strings.TrimPrefix(strings.TrimSpace(out), "gitfed:")
	if tag == "" {
		return "", fmt.Errorf("empty image tag reported by kubectl")
	}
	return tag, nil
}

// upstreamRef resolves the branch's configured tracking remote, falling
// back to origin/main if none is set (e.g. a checkout cloned in a way that
// never set one up).
func upstreamRef(ctx context.Context, sourceDir string) (string, error) {
	out, err := runQuiet(ctx, sourceDir, "git", "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}")
	if err != nil {
		return "origin/main", nil
	}
	ref := strings.TrimSpace(out)
	if ref == "" {
		return "origin/main", nil
	}
	return ref, nil
}

// versionsInRange returns every version strictly after currentVersion and
// up to and including targetVersion, in ascending order, each with its
// CHANGELOG.md bullets read straight from its tag (git show, never
// checked out) so nothing in the working tree changes before the wizard is
// confirmed.
func versionsInRange(ctx context.Context, sourceDir, currentVersion, targetVersion string) ([]versionChangelog, error) {
	tagsOut, err := runQuiet(ctx, sourceDir, "git", "tag", "--list", "v*", "--sort=v:refname")
	if err != nil {
		return nil, fmt.Errorf("git tag --list: %w", err)
	}

	var versions []versionChangelog
	seen := map[string]bool{}
	for _, line := range strings.Split(tagsOut, "\n") {
		tag := strings.TrimSpace(line)
		if tag == "" || !strings.HasPrefix(tag, "v") {
			continue
		}
		ver := strings.TrimPrefix(tag, "v")
		if compareVersions(ver, currentVersion) <= 0 || compareVersions(ver, targetVersion) > 0 {
			continue
		}
		seen[ver] = true

		out, err := runQuiet(ctx, sourceDir, "git", "show", tag+":CHANGELOG.md")
		if err != nil {
			versions = append(versions, versionChangelog{
				Version:    ver,
				TagMissing: true,
				Bullets: []bullet{{
					Text:     "impossible de lire CHANGELOG.md pour " + tag + " — le tag existe mais son contenu n'a pas pu être lu",
					Breaking: true,
				}},
			})
			continue
		}
		versions = append(versions, versionChangelog{Version: ver, Bullets: parseSection(out, ver)})
	}

	// The release commit for targetVersion may be on the upstream branch
	// without a tag yet (update.sh only tags after a manual git push) —
	// fail safe rather than silently skip it.
	if !seen[targetVersion] {
		versions = append(versions, versionChangelog{
			Version:    targetVersion,
			TagMissing: true,
			Bullets: []bullet{{
				Text:     "aucun tag trouvé pour " + targetVersion + " — impossible de vérifier s'il contient un changement cassant",
				Breaking: true,
			}},
		})
	}

	sortVersions(versions)
	return versions, nil
}

func sortVersions(versions []versionChangelog) {
	for i := 1; i < len(versions); i++ {
		for j := i; j > 0 && compareVersions(versions[j].Version, versions[j-1].Version) < 0; j-- {
			versions[j], versions[j-1] = versions[j-1], versions[j]
		}
	}
}

// parseSection extracts the bullets under "## version" in changelogMD, up
// to the next "## " heading or EOF, and flags any bullet whose text starts
// with "**BREAKING:**" (see the convention note at the top of
// CHANGELOG.md).
func parseSection(changelogMD, version string) []bullet {
	heading := "## " + version
	lines := strings.Split(changelogMD, "\n")

	start := -1
	for i, l := range lines {
		if strings.TrimSpace(l) == heading {
			start = i + 1
			break
		}
	}
	if start == -1 {
		return nil
	}

	var bullets []bullet
	for _, l := range lines[start:] {
		trimmed := strings.TrimSpace(l)
		if strings.HasPrefix(trimmed, "## ") {
			break
		}
		if !strings.HasPrefix(trimmed, "- ") {
			continue
		}
		text := strings.TrimPrefix(trimmed, "- ")
		bullets = append(bullets, bullet{
			Text:     text,
			Breaking: strings.HasPrefix(text, "**BREAKING:**"),
		})
	}
	return bullets
}

// compareVersions compares two bare "X.Y.Z" semver strings, returning -1,
// 0 or 1. Malformed components parse as 0, matching update.sh's own
// unchecked assumption that VERSION/tags are always well-formed.
func compareVersions(a, b string) int {
	pa, pb := versionParts(a), versionParts(b)
	for i := 0; i < 3; i++ {
		if pa[i] != pb[i] {
			if pa[i] < pb[i] {
				return -1
			}
			return 1
		}
	}
	return 0
}

func versionParts(v string) [3]int {
	var parts [3]int
	fields := strings.SplitN(v, ".", 3)
	for i := 0; i < len(fields) && i < 3; i++ {
		n, _ := strconv.Atoi(fields[i])
		parts[i] = n
	}
	return parts
}

// flattenBreaking collects every breaking (or tag-missing) bullet across a
// version range, in order — the update wizard's acknowledgment screen
// works off this flat list.
func flattenBreaking(versions []versionChangelog) []bullet {
	var out []bullet
	for _, v := range versions {
		for _, b := range v.Bullets {
			if b.Breaking {
				out = append(out, b)
			}
		}
	}
	return out
}