Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 9 additions & 2 deletions cmd/plugin/main.go
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
// SPDX-License-Identifier: Apache-2.0
// SPDX-FileCopyrightText: 2026 The semrel Authors
// This code has been partially generated by an AI.

package main

Expand Down Expand Up @@ -35,8 +36,14 @@ func run(stdout, stderr io.Writer, getenv func(string) string) int {
file = "pyproject.toml"
}

if getenv("SEMREL_DRY_RUN") == "true" {
_, _ = fmt.Fprintf(stdout, "updater-python: [dry-run] would update %s to version %s\n", file, version)
if strings.EqualFold(getenv("SEMREL_DRY_RUN"), "true") || strings.EqualFold(getenv("SEMREL_PLUGIN_DRY_RUN"), "true") {
diff, err := plugin.NewUpdater().Diff(file, version)
if err != nil {
_, _ = fmt.Fprintln(stderr, "updater-python:", err)
return 1
}
_, _ = fmt.Fprintf(stderr, "[dry-run] updater-python: would update %s to version %s\n", file, version)
_, _ = io.WriteString(stdout, diff)
return 0
}

Expand Down
32 changes: 29 additions & 3 deletions cmd/plugin/main_test.go
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
// SPDX-License-Identifier: Apache-2.0
// SPDX-FileCopyrightText: 2026 The semrel Authors
// This code has been partially generated by an AI.

package main

import (
Expand Down Expand Up @@ -44,14 +48,36 @@ func TestRunUpdatesPyproject(t *testing.T) {
func TestRunDryRun(t *testing.T) {
t.Parallel()

env := map[string]string{"SEMREL_VERSION": "1.1.0", "SEMREL_DRY_RUN": "true"}
file := filepath.Join(t.TempDir(), "pyproject.toml")
original := "[project]\nname = \"demo\"\nversion = \"1.0.0\"\n"
if err := os.WriteFile(file, []byte(original), 0o644); err != nil {
t.Fatal(err)
}

env := map[string]string{"SEMREL_VERSION": "v1.1.0", "SEMREL_PLUGIN_FILE": file, "SEMREL_DRY_RUN": "true"}
var stdout, stderr bytes.Buffer
if code := run(&stdout, &stderr, func(key string) string { return env[key] }); code != 0 {
t.Fatalf("run() code = %d", code)
t.Fatalf("run() code = %d stderr = %s", code, stderr.String())
}
if !strings.HasPrefix(stderr.String(), "plugin_schema_version=1\n") {
t.Fatalf("stderr = %q", stderr.String())
}
if !strings.Contains(stdout.String(), "[dry-run]") {
if !strings.Contains(stderr.String(), "[dry-run] updater-python: would update "+file+" to version 1.1.0") {
t.Fatalf("stderr = %q", stderr.String())
}
if !strings.Contains(stdout.String(), "--- a/"+file+"\n+++ b/"+file+"\n") {
t.Fatalf("stdout = %q", stdout.String())
}
if !strings.Contains(stdout.String(), "-version = \"1.0.0\"\n+version = \"1.1.0\"\n") {
t.Fatalf("stdout = %q", stdout.String())
}
got, err := os.ReadFile(file)
if err != nil {
t.Fatal(err)
}
if string(got) != original {
t.Fatalf("dry-run modified file: %s", got)
}
}

func TestRunRequiresVersion(t *testing.T) {
Expand Down
207 changes: 207 additions & 0 deletions internal/plugin/diff.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,207 @@
// SPDX-License-Identifier: Apache-2.0
// SPDX-FileCopyrightText: 2026 The semrel Authors
// This code has been fully generated by an AI.

package plugin

import (
"encoding/json"
"fmt"
"strings"
)

const diffContextLines = 3

// DryRunSummaryPrefix is the optional stderr line prefix plugins can emit to
// provide a structured machine-readable summary for `semrel release --dry-run --output json`.
const DryRunSummaryPrefix = "plugin_dry_run_summary="

// UnifiedDiff computes a unified diff between before and after for the given
// file path entirely in memory. When before and after are identical, it returns
// an empty string.
func UnifiedDiff(path, before, after string) string {
if before == after {
return ""
}
cleanPath := strings.TrimPrefix(strings.ReplaceAll(path, "\\", "/"), "./")
if cleanPath == "" {
cleanPath = "file"
}

aLines := splitLines(before)
bLines := splitLines(after)
ops := diffLines(aLines, bLines)
hunks := buildHunks(ops, diffContextLines)
if len(hunks) == 0 {
return ""
}

var b strings.Builder
fmt.Fprintf(&b, "--- a/%s\n+++ b/%s\n", cleanPath, cleanPath)
for _, h := range hunks {
fmt.Fprintf(&b, "@@ -%d,%d +%d,%d @@\n", h.aStart, h.aCount, h.bStart, h.bCount)
for _, line := range h.lines {
b.WriteString(line)
b.WriteByte('\n')
}
}
return b.String()
}

// UnifiedDiffBytes computes a unified diff between two byte slices in memory.
func UnifiedDiffBytes(path string, before, after []byte) string {
return UnifiedDiff(path, string(before), string(after))
}

// MaskSecret masks a secret string or webhook token for safe dry-run output.
func MaskSecret(value string) string {
trimmed := strings.TrimSpace(value)
if trimmed == "" {
return ""
}
if len(trimmed) <= 8 {
return "***"
}
return trimmed[:4] + "***"
}

// FormatDryRunSummary encodes a structured dry-run summary line suitable for
// writing to stderr.
func FormatDryRunSummary(summary map[string]interface{}) string {
if len(summary) == 0 {
return ""
}
data, err := json.Marshal(summary)
if err != nil {
return ""
}
return DryRunSummaryPrefix + string(data)
}

type editOp struct {
kind byte // ' ', '-', '+'
text string
aIdx int
bIdx int
}

type diffHunk struct {
aStart int
aCount int
bStart int
bCount int
lines []string
}

func splitLines(s string) []string {
if s == "" {
return nil
}
normalized := strings.ReplaceAll(s, "\r\n", "\n")
normalized = strings.TrimSuffix(normalized, "\n")
if normalized == "" {
return []string{""}
}
return strings.Split(normalized, "\n")
}

func diffLines(a, b []string) []editOp {
n, m := len(a), len(b)
dp := make([][]int, n+1)
for i := range dp {
dp[i] = make([]int, m+1)
}
for i := n - 1; i >= 0; i-- {
for j := m - 1; j >= 0; j-- {
if a[i] == b[j] {
dp[i][j] = dp[i+1][j+1] + 1
} else if dp[i+1][j] >= dp[i][j+1] {
dp[i][j] = dp[i+1][j]
} else {
dp[i][j] = dp[i][j+1]
}
}
}

ops := make([]editOp, 0, n+m)
i, j := 0, 0
for i < n && j < m {
if a[i] == b[j] {
ops = append(ops, editOp{kind: ' ', text: a[i], aIdx: i + 1, bIdx: j + 1})
i++
j++
} else if dp[i+1][j] >= dp[i][j+1] {
ops = append(ops, editOp{kind: '-', text: a[i], aIdx: i + 1, bIdx: j + 1})
i++
} else {
ops = append(ops, editOp{kind: '+', text: b[j], aIdx: i + 1, bIdx: j + 1})
j++
}
}
for i < n {
ops = append(ops, editOp{kind: '-', text: a[i], aIdx: i + 1, bIdx: j + 1})
i++
}
for j < m {
ops = append(ops, editOp{kind: '+', text: b[j], aIdx: i + 1, bIdx: j + 1})
j++
}
return ops
}

func buildHunks(ops []editOp, contextLines int) []diffHunk {
type span struct{ start, end int }
var changes []span
for idx, op := range ops {
if op.kind == ' ' {
continue
}
s := idx - contextLines
if s < 0 {
s = 0
}
e := idx + contextLines + 1
if e > len(ops) {
e = len(ops)
}
if len(changes) > 0 && s <= changes[len(changes)-1].end {
changes[len(changes)-1].end = e
} else {
changes = append(changes, span{start: s, end: e})
}
}

hunks := make([]diffHunk, 0, len(changes))
for _, ch := range changes {
slice := ops[ch.start:ch.end]
h := diffHunk{
aStart: 1,
bStart: 1,
lines: make([]string, 0, len(slice)),
}
if len(slice) > 0 {
h.aStart = slice[0].aIdx
h.bStart = slice[0].bIdx
}
for _, op := range slice {
switch op.kind {
case ' ':
h.aCount++
h.bCount++
case '-':
h.aCount++
case '+':
h.bCount++
}
h.lines = append(h.lines, string(op.kind)+op.text)
}
if h.aCount == 0 {
h.aStart = 0
}
if h.bCount == 0 {
h.bStart = 0
}
hunks = append(hunks, h)
}
return hunks
}
29 changes: 24 additions & 5 deletions internal/plugin/updater.go
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
// SPDX-License-Identifier: Apache-2.0
// SPDX-FileCopyrightText: 2026 The semrel Authors
// This code has been partially generated by an AI.

// Package plugin updates pyproject.toml files in-place.
package plugin
Expand All @@ -22,14 +23,18 @@ func NewUpdater() *Updater {
return &Updater{}
}

// Update rewrites the version line in [project] or [tool.poetry].
func (u *Updater) Update(path, version string) error {
data, err := os.ReadFile(path)
// Diff computes a unified diff for updating the version line in [project] or [tool.poetry] without writing to disk.
func (u *Updater) Diff(path, version string) (string, error) {
before, after, err := u.prepare(path, version)
if err != nil {
return fmt.Errorf("read %s: %w", path, err)
return "", err
}
return UnifiedDiff(path, before, after), nil
}

updated, err := updateContent(string(data), version)
// Update rewrites the version line in [project] or [tool.poetry].
func (u *Updater) Update(path, version string) error {
_, updated, err := u.prepare(path, version)
if err != nil {
return err
}
Expand All @@ -40,6 +45,20 @@ func (u *Updater) Update(path, version string) error {
return nil
}

func (u *Updater) prepare(path, version string) (string, string, error) {
data, err := os.ReadFile(path)
if err != nil {
return "", "", fmt.Errorf("read %s: %w", path, err)
}

before := string(data)
updated, err := updateContent(before, version)
if err != nil {
return "", "", err
}
return before, updated, nil
}

func updateContent(content, version string) (string, error) {
scanner := bufio.NewScanner(strings.NewReader(content))
lines := make([]string, 0)
Expand Down
32 changes: 32 additions & 0 deletions internal/plugin/updater_test.go
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
// SPDX-License-Identifier: Apache-2.0
// SPDX-FileCopyrightText: 2026 The semrel Authors
// This code has been partially generated by an AI.

package plugin

import (
Expand Down Expand Up @@ -39,6 +43,34 @@ func TestUpdaterUpdateProjectVersion(t *testing.T) {
}
}

func TestUpdaterDiff(t *testing.T) {
t.Parallel()

file := filepath.Join(t.TempDir(), "pyproject.toml")
original := "[project]\nname = \"demo\"\nversion = \"1.2.3\"\n"
if err := os.WriteFile(file, []byte(original), 0o644); err != nil {
t.Fatal(err)
}

diff, err := NewUpdater().Diff(file, "1.3.0")
if err != nil {
t.Fatalf("Diff() error = %v", err)
}
if !strings.Contains(diff, "--- a/"+file+"\n+++ b/"+file+"\n") {
t.Fatalf("diff = %s", diff)
}
if !strings.Contains(diff, "-version = \"1.2.3\"\n+version = \"1.3.0\"\n") {
t.Fatalf("diff = %s", diff)
}
got, err := os.ReadFile(file)
if err != nil {
t.Fatal(err)
}
if string(got) != original {
t.Fatalf("Diff() modified file: %s", got)
}
}

func TestUpdaterUpdatePoetryVersion(t *testing.T) {
t.Parallel()

Expand Down
1 change: 1 addition & 0 deletions schema/v1.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
"$id": "https://registry.semrel.io/schemas/plugins/python/v1.json",
"title": "updater-python plugin configuration",
"description": "Schema v1 for the updater-python semrel plugin. Configure via SEMREL_* environment variables.",
"x-semrel-dry-run": true,
"type": "object",
"properties": {
"SEMREL_PLUGIN_FILE": {
Expand Down
Loading