diff --git a/bin/gstack-pr-title-rewrite.sh b/bin/gstack-pr-title-rewrite.sh new file mode 100755 index 00000000..4725ed72 --- /dev/null +++ b/bin/gstack-pr-title-rewrite.sh @@ -0,0 +1,44 @@ +#!/usr/bin/env bash +# Rewrite a PR/MR title to start with v. +# +# Usage: bin/gstack-pr-title-rewrite.sh +# Output: corrected title on stdout. +# +# Rule: PR titles MUST start with v. Three cases: +# 1. Already starts with "v " -> no change. +# 2. Starts with a different "v " prefix -> replace prefix. +# 3. No version prefix -> prepend "v ". +# +# The version-prefix regex matches two or more dot-separated digit segments +# (covers v1.2, v1.2.3, v1.2.3.4) so the rule is portable across repos that +# use 3-part or 4-part versions, but does NOT strip plain words like +# "version 5". + +set -euo pipefail + +if [ $# -lt 2 ]; then + echo "usage: $0 " >&2 + exit 2 +fi + +NEW_VERSION="$1" +TITLE="$2" + +# Reject malformed NEW_VERSION early. Real values are dot-separated digits; +# anything with shell pattern metacharacters or whitespace is a caller bug. +if ! printf '%s' "$NEW_VERSION" | grep -qE '^[0-9]+(\.[0-9]+)*$'; then + echo "error: NEW_VERSION must be dot-separated digits, got: $NEW_VERSION" >&2 + exit 2 +fi + +# Literal prefix match (case statement is glob-quoted by bash, but our +# regex-validated NEW_VERSION has no glob metacharacters so this is safe). +case "$TITLE" in + "v$NEW_VERSION "*) + printf '%s\n' "$TITLE" + exit 0 + ;; +esac + +REST=$(printf '%s' "$TITLE" | sed -E 's/^v[0-9]+(\.[0-9]+)+ //') +printf 'v%s %s\n' "$NEW_VERSION" "$REST" diff --git a/test/pr-title-rewrite.test.ts b/test/pr-title-rewrite.test.ts new file mode 100644 index 00000000..28a7b61a --- /dev/null +++ b/test/pr-title-rewrite.test.ts @@ -0,0 +1,54 @@ +import { describe, test, expect } from 'bun:test'; +import { spawnSync } from 'child_process'; +import * as path from 'path'; + +const HELPER = path.join(import.meta.dir, '..', 'bin', 'gstack-pr-title-rewrite.sh'); + +function rewrite(version: string, title: string): { stdout: string; status: number; stderr: string } { + const r = spawnSync(HELPER, [version, title], { encoding: 'utf-8' }); + return { stdout: (r.stdout ?? '').trimEnd(), status: r.status ?? -1, stderr: r.stderr ?? '' }; +} + +describe('gstack-pr-title-rewrite', () => { + test('already correct: no change', () => { + const r = rewrite('1.2.3.4', 'v1.2.3.4 feat: foo'); + expect(r.status).toBe(0); + expect(r.stdout).toBe('v1.2.3.4 feat: foo'); + }); + + test('different version prefix: replaces it', () => { + expect(rewrite('1.2.3.5', 'v1.2.3.4 feat: foo').stdout).toBe('v1.2.3.5 feat: foo'); + }); + + test('different prefix length (3-part vs 4-part): replaces it', () => { + expect(rewrite('1.2.3.4', 'v1.2.3 feat: foo').stdout).toBe('v1.2.3.4 feat: foo'); + }); + + test('no version prefix: prepends', () => { + expect(rewrite('1.2.3.4', 'feat: foo').stdout).toBe('v1.2.3.4 feat: foo'); + }); + + test('does not mistake plain words for a prefix', () => { + expect(rewrite('1.2.3.4', 'version 5 feature').stdout).toBe('v1.2.3.4 version 5 feature'); + }); + + test('does not strip a single-segment prefix like v1', () => { + expect(rewrite('1.2.3.4', 'v1 feat: foo').stdout).toBe('v1.2.3.4 v1 feat: foo'); + }); + + test('errors on missing args', () => { + const r = spawnSync(HELPER, ['1.2.3.4'], { encoding: 'utf-8' }); + expect(r.status).not.toBe(0); + }); + + test('rejects malformed VERSION with shell metacharacters', () => { + expect(rewrite('1.*.*.*', 'feat: foo').status).toBe(2); + expect(rewrite('1.2.3.4; rm -rf /', 'feat: foo').status).toBe(2); + }); + + test('idempotent: applying twice yields the same result', () => { + const once = rewrite('1.2.3.4', 'feat: foo').stdout; + const twice = rewrite('1.2.3.4', once).stdout; + expect(twice).toBe(once); + }); +});