diff --git a/cli/internal/automation/release_pr_checks.go b/cli/internal/automation/release_pr_checks.go index 22cfc7ef4..3e86f4973 100644 --- a/cli/internal/automation/release_pr_checks.go +++ b/cli/internal/automation/release_pr_checks.go @@ -8,6 +8,7 @@ import ( "io" "os" "os/exec" + "regexp" "strconv" "strings" "time" @@ -23,6 +24,8 @@ const ( var ( releasePRCheckNow = time.Now releasePRCheckSleep = time.Sleep + + releasePRCheckPlainUnityPackageSummary = regexp.MustCompile(`
((?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-[A-Za-z0-9][A-Za-z0-9.-]*)?)`) ) type releasePRCheckConfig struct { @@ -42,6 +45,10 @@ type releasePullRequest struct { URL string `json:"url"` } +type releasePullRequestBody struct { + Body string `json:"body"` +} + type releaseWorkflowRun struct { DatabaseID int64 `json:"databaseId"` HeadSHA string `json:"headSha"` @@ -65,6 +72,15 @@ func RunReleasePleasePRChecks(ctx context.Context, stdout io.Writer, stderr io.W return 0 } + bodyChanged, err := clarifyReleasePRCheckBody(ctx, config, releasePR) + if err != nil { + writeReleasePRCheckLine(stderr, err) + return 1 + } + if bodyChanged { + writeReleasePRCheckLine(stdout, fmt.Sprintf("Updated release PR #%d body to label the Unity package summary.", releasePR.Number)) + } + err = markReleasePRCheckDraft(ctx, config, releasePR) if err != nil { writeReleasePRCheckLine(stderr, err) @@ -242,6 +258,71 @@ func dispatchReleasePRCheckWorkflow(ctx context.Context, config releasePRCheckCo return err } +func clarifyReleasePRCheckBody(ctx context.Context, config releasePRCheckConfig, releasePR releasePullRequest) (bool, error) { + output, err := runReleasePRCheckOutput(ctx, "gh", "pr", "view", strconv.Itoa(releasePR.Number), "--repo", config.repository, "--json", "body") + if err != nil { + return false, err + } + + prBody := releasePullRequestBody{} + err = json.Unmarshal([]byte(output), &prBody) + if err != nil { + return false, fmt.Errorf("failed to parse release PR body: %w", err) + } + + clarifiedBody, changed := clarifyReleasePRCheckUnityPackageSummary(prBody.Body) + if !changed { + return false, nil + } + + bodyFile, cleanup, err := writeReleasePRCheckBodyFile(clarifiedBody) + if err != nil { + return false, err + } + defer cleanup() + + _, err = runReleasePRCheckOutput(ctx, "gh", "pr", "edit", strconv.Itoa(releasePR.Number), "--repo", config.repository, "--body-file", bodyFile) + if err != nil { + return false, err + } + return true, nil +} + +func clarifyReleasePRCheckUnityPackageSummary(body string) (string, bool) { + if strings.Contains(body, "
unity-package: ") { + return body, false + } + + matches := releasePRCheckPlainUnityPackageSummary.FindStringSubmatchIndex(body) + if matches == nil { + return body, false + } + + version := body[matches[2]:matches[3]] + replacement := "
unity-package: " + version + "" + return body[:matches[0]] + replacement + body[matches[1]:], true +} + +func writeReleasePRCheckBodyFile(body string) (string, func(), error) { + bodyFile, err := os.CreateTemp("", "uloop-release-pr-body-*.md") + if err != nil { + return "", func() {}, fmt.Errorf("failed to create release PR body file: %w", err) + } + + cleanup := func() { _ = os.Remove(bodyFile.Name()) } + _, writeErr := bodyFile.WriteString(body) + closeErr := bodyFile.Close() + if writeErr != nil { + cleanup() + return "", func() {}, fmt.Errorf("failed to write release PR body file: %w", writeErr) + } + if closeErr != nil { + cleanup() + return "", func() {}, fmt.Errorf("failed to close release PR body file: %w", closeErr) + } + return bodyFile.Name(), cleanup, nil +} + func findDispatchedReleasePRCheckRun( ctx context.Context, config releasePRCheckConfig, diff --git a/cli/internal/automation/release_pr_checks_test.go b/cli/internal/automation/release_pr_checks_test.go index 939f4e494..79200fd63 100644 --- a/cli/internal/automation/release_pr_checks_test.go +++ b/cli/internal/automation/release_pr_checks_test.go @@ -5,6 +5,7 @@ import ( "context" "os" "path/filepath" + "runtime" "strings" "testing" "time" @@ -19,6 +20,44 @@ type releasePRCheckRunResult struct { sleeps []time.Duration } +// Verifies that a plain root package release summary is labeled as the Unity package. +func TestReleasePRCheckUnityPackageSummaryIsClarified(t *testing.T) { + body := "## Releases\n" + + "
3.0.0-beta.46\n" + + "\n" + + "* Unity package notes\n" + + "
\n" + + "
uloop-project-runner: 3.0.0-beta.44\n" + + "\n" + + "* Project runner notes\n" + + "
\n" + + clarifiedBody, changed := clarifyReleasePRCheckUnityPackageSummary(body) + + if !changed { + t.Fatal("expected plain Unity package summary to change") + } + assertReleasePRCheckLogContains(t, clarifiedBody, "
unity-package: 3.0.0-beta.46") + assertReleasePRCheckLogContains(t, clarifiedBody, "
uloop-project-runner: 3.0.0-beta.44") +} + +// Verifies that already labeled release summaries are left unchanged. +func TestReleasePRCheckUnityPackageSummaryAlreadyClarified(t *testing.T) { + body := "
unity-package: 3.0.0-beta.46\n
\n" + + "
1.2.3\n" + + "* Release note content that should not be relabeled.\n" + + "
\n" + + clarifiedBody, changed := clarifyReleasePRCheckUnityPackageSummary(body) + + if changed { + t.Fatal("expected labeled Unity package summary to stay unchanged") + } + if clarifiedBody != body { + t.Fatalf("expected body to stay unchanged, got:\n%s", clarifiedBody) + } +} + // Verifies that missing release PRs skip without dispatching checks. func TestReleasePRChecksSkipWhenNoReleasePRExists(t *testing.T) { result := runReleasePRCheckCase(t, releasePRCheckCase{ @@ -59,6 +98,24 @@ func TestReleasePRChecksDispatchAndMarkReady(t *testing.T) { assertReleasePRCheckLogContainsLine(t, result.ghLog, "pr ready 1043 --repo owner/repository") } +// Verifies that matching release PRs are updated when the body has a plain Unity package summary. +func TestReleasePRChecksClarifyUnityPackageSummaryBeforeDispatch(t *testing.T) { + result := runReleasePRCheckCase(t, releasePRCheckCase{ + prListJSON: `[{"number":1043,"headRefName":"release-please--branches--v3-beta","headRefOid":"abc123","title":"chore(v3-beta): release 3.0.0-beta.46","url":"https://example.test/pr/1043"}]`, + prViewJSON: `{"body":"
3.0.0-beta.46\n
\n
uloop-project-runner: 3.0.0-beta.44\n
\n"}`, + runListJSON: `[{"databaseId":4242,"headSha":"abc123","createdAt":"2026-05-30T01:00:01Z","status":"queued","conclusion":"","url":"https://example.test/run/4242"}]`, + runWatchStatus: "0", + }) + + if result.exitCode != 0 { + t.Fatalf("expected exit code 0, got %d\nstderr: %s", result.exitCode, result.stderr) + } + assertReleasePRCheckLogContains(t, result.stdout, "Updated release PR #1043 body to label the Unity package summary.") + assertReleasePRCheckLogContains(t, result.ghLog, "pr view 1043 --repo owner/repository --json body") + assertReleasePRCheckLogContains(t, result.ghLog, "pr edit 1043 --repo owner/repository --body-file") + assertReleasePRCheckLogContains(t, result.ghLog, "workflow run build-and-test.yml --repo owner/repository --ref release-please--branches--v3-beta") +} + // Verifies that same-second workflow runs are accepted when GitHub rounds createdAt timestamps. func TestReleasePRChecksAcceptSameSecondRunAfterDispatch(t *testing.T) { result := runReleasePRCheckCase(t, releasePRCheckCase{ @@ -161,6 +218,7 @@ func TestReleasePRChecksFailWhenHeadChangesBeforeReady(t *testing.T) { type releasePRCheckCase struct { prListJSON string prListJSONAfterWatch string + prViewJSON string runListJSON string runWatchStatus string now time.Time @@ -182,6 +240,15 @@ func runReleasePRCheckCase(t *testing.T, testCase releasePRCheckCase) releasePRC writeReleasePRCheckMockGH(t, filepath.Join(mockBin, "gh")) writeReleasePRCheckMockGit(t, filepath.Join(mockBin, "git")) + prViewJSON := testCase.prViewJSON + if prViewJSON == "" { + prViewJSON = `{"body":""}` + } + prListJSONAfterWatch := testCase.prListJSONAfterWatch + if prListJSONAfterWatch == "" { + prListJSONAfterWatch = testCase.prListJSON + } + t.Setenv("PATH", mockBin+string(os.PathListSeparator)+os.Getenv("PATH")) t.Setenv("GITHUB_REPOSITORY", "owner/repository") t.Setenv("TARGET_BRANCH", "v3-beta") @@ -190,8 +257,9 @@ func runReleasePRCheckCase(t *testing.T, testCase releasePRCheckCase) releasePRC t.Setenv("RELEASE_PR_CHECK_LOOKUP_INTERVAL_SECONDS", "1") t.Setenv("RELEASE_PR_CHECK_WATCH_INTERVAL_SECONDS", "1") t.Setenv("GH_PR_LIST_JSON", testCase.prListJSON) - t.Setenv("GH_PR_LIST_JSON_AFTER_WATCH", testCase.prListJSONAfterWatch) + t.Setenv("GH_PR_LIST_JSON_AFTER_WATCH", prListJSONAfterWatch) t.Setenv("GH_PR_LIST_COUNT_PATH", prListCountPath) + t.Setenv("GH_PR_VIEW_JSON", prViewJSON) t.Setenv("GH_RUN_LIST_JSON", testCase.runListJSON) t.Setenv("GH_RUN_WATCH_STATUS", testCase.runWatchStatus) t.Setenv("GH_LOG", ghLogPath) @@ -237,6 +305,11 @@ func runReleasePRCheckCase(t *testing.T, testCase releasePRCheckCase) releasePRC func writeReleasePRCheckMockGH(t *testing.T, path string) { t.Helper() + if runtime.GOOS == "windows" { + writeReleasePRCheckMockGHBatch(t, path+".cmd") + return + } + content := `#!/bin/sh set -eu @@ -263,6 +336,15 @@ if [ "$1" = "pr" ] && [ "$2" = "ready" ]; then exit 0 fi +if [ "$1" = "pr" ] && [ "$2" = "view" ]; then + printf '%s\n' "$GH_PR_VIEW_JSON" + exit 0 +fi + +if [ "$1" = "pr" ] && [ "$2" = "edit" ]; then + exit 0 +fi + if [ "$1" = "workflow" ] && [ "$2" = "run" ]; then exit 0 fi @@ -288,6 +370,11 @@ exit 1 func writeReleasePRCheckMockGit(t *testing.T, path string) { t.Helper() + if runtime.GOOS == "windows" { + writeReleasePRCheckMockGitBatch(t, path+".cmd") + return + } + content := `#!/bin/sh set -eu @@ -351,6 +438,93 @@ exit 1 } } +func writeReleasePRCheckMockGHBatch(t *testing.T, path string) { + t.Helper() + content := `@echo off +setlocal EnableExtensions EnableDelayedExpansion +>>"%GH_LOG%" echo %* + +if "%~1"=="pr" if "%~2"=="list" ( + set "count=0" + if exist "%GH_PR_LIST_COUNT_PATH%" set /p count=<"%GH_PR_LIST_COUNT_PATH%" + set /a count+=1 + >"%GH_PR_LIST_COUNT_PATH%" echo !count! + if !count! GTR 1 ( + powershell -NoProfile -ExecutionPolicy Bypass -Command "[Console]::Out.WriteLine($env:GH_PR_LIST_JSON_AFTER_WATCH)" + exit /b 0 + ) + powershell -NoProfile -ExecutionPolicy Bypass -Command "[Console]::Out.WriteLine($env:GH_PR_LIST_JSON)" + exit /b 0 +) + +if "%~1"=="pr" if "%~2"=="ready" exit /b 0 + +if "%~1"=="pr" if "%~2"=="view" ( + powershell -NoProfile -ExecutionPolicy Bypass -Command "[Console]::Out.WriteLine($env:GH_PR_VIEW_JSON)" + exit /b 0 +) + +if "%~1"=="pr" if "%~2"=="edit" exit /b 0 + +if "%~1"=="workflow" if "%~2"=="run" exit /b 0 + +if "%~1"=="run" if "%~2"=="list" ( + powershell -NoProfile -ExecutionPolicy Bypass -Command "[Console]::Out.WriteLine($env:GH_RUN_LIST_JSON)" + exit /b 0 +) + +if "%~1"=="run" if "%~2"=="watch" exit /b %GH_RUN_WATCH_STATUS% + +echo unexpected gh command: %* 1>&2 +exit /b 1 +` + writeFile(t, path, content) +} + +func writeReleasePRCheckMockGitBatch(t *testing.T, path string) { + t.Helper() + content := `@echo off +setlocal EnableExtensions EnableDelayedExpansion +>>"%GIT_LOG%" echo %* + +if "%~1"=="rev-parse" if "%~2"=="--show-toplevel" ( + powershell -NoProfile -ExecutionPolicy Bypass -Command "[Console]::Out.WriteLine($env:ULOOP_REPOSITORY_ROOT)" + exit /b 0 +) + +if "%~1"=="-C" ( + shift + shift +) + +if "%~1"=="fetch" if "%~2"=="origin" exit /b 0 +if "%~1"=="switch" if "%~2"=="--detach" if "%~3"=="FETCH_HEAD" exit /b 0 + +if "%~1"=="show" ( + echo %~2| findstr /r "^uloop-project-runner-v.*:cli/contract.json$" >nul + if not errorlevel 1 ( + if not "%GIT_RELEASE_CONTRACT%"=="" ( + type "%GIT_RELEASE_CONTRACT%" + exit /b 0 + ) + echo release not found 1>&2 + exit /b 1 + ) + echo unexpected git show ref: %~2 1>&2 + exit /b 1 +) + +if "%~1"=="config" exit /b 0 +if "%~1"=="add" exit /b 0 +if "%~1"=="commit" exit /b 0 +if "%~1"=="push" exit /b 0 + +echo unexpected git command: %* 1>&2 +exit /b 1 +` + writeFile(t, path, content) +} + func readOptionalFile(t *testing.T, path string) string { t.Helper() content, err := os.ReadFile(path) @@ -380,7 +554,7 @@ func assertReleasePRCheckLogDoesNotContain(t *testing.T, actual string, unexpect func assertReleasePRCheckLogContainsLine(t *testing.T, actual string, expected string) { t.Helper() for _, line := range strings.Split(actual, "\n") { - if line == expected { + if strings.TrimSuffix(line, "\r") == expected { return } } @@ -390,7 +564,7 @@ func assertReleasePRCheckLogContainsLine(t *testing.T, actual string, expected s func assertReleasePRCheckLogDoesNotContainLine(t *testing.T, actual string, unexpected string) { t.Helper() for _, line := range strings.Split(actual, "\n") { - if line == unexpected { + if strings.TrimSuffix(line, "\r") == unexpected { t.Fatalf("expected log not to contain line %q, got:\n%s", unexpected, actual) } }