From 92b6512cd0c6387d66bcb42f3c7bb31803077cb7 Mon Sep 17 00:00:00 2001 From: daniel Date: Fri, 27 Feb 2026 19:29:55 +0000 Subject: [PATCH 1/3] feat: add ci create-or-update-pr command and per-target report export MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add support for parallel SDK generation workflows where each target generates independently and a finalize step accumulates reports and creates a single PR. - New `TargetGenerationReport` struct captures CI-agnostic per-target generation data (version report, URLs, metadata) - `ci generate` now writes per-target report to .speakeasy/reports/ when INPUT_TARGET is set (matrix mode) - New `ci create-or-update-pr` command reads accumulated reports JSON, merges version reports, generates PR description, and creates/updates the GitHub PR - Export `SetPRLabels` and add `GetClient` accessor on Git ✻ Clauded... --- cmd/ci/ci.go | 1 + cmd/ci/create_or_update_pr.go | 51 +++++++ internal/ci/actions/createOrUpdatePR.go | 175 ++++++++++++++++++++++++ internal/ci/actions/report.go | 57 ++++++++ internal/ci/actions/runWorkflow.go | 15 ++ internal/ci/git/git.go | 8 +- internal/ci/git/labels.go | 2 +- 7 files changed, 306 insertions(+), 3 deletions(-) create mode 100644 cmd/ci/create_or_update_pr.go create mode 100644 internal/ci/actions/createOrUpdatePR.go create mode 100644 internal/ci/actions/report.go diff --git a/cmd/ci/ci.go b/cmd/ci/ci.go index 4337bc1f2..536767b12 100644 --- a/cmd/ci/ci.go +++ b/cmd/ci/ci.go @@ -18,6 +18,7 @@ var CICmd = &model.CommandGroup{ suggestCmd, finalizeCmd, prDescriptionCmd, + createOrUpdatePRCmd, publishEventCmd, tagCmd, diff --git a/cmd/ci/create_or_update_pr.go b/cmd/ci/create_or_update_pr.go new file mode 100644 index 000000000..79e138a99 --- /dev/null +++ b/cmd/ci/create_or_update_pr.go @@ -0,0 +1,51 @@ +package ci + +import ( + "context" + + "github.com/speakeasy-api/speakeasy/internal/ci/actions" + "github.com/speakeasy-api/speakeasy/internal/model" + "github.com/speakeasy-api/speakeasy/internal/model/flag" +) + +type CreateOrUpdatePRFlags struct { + Input string `json:"input"` + Branch string `json:"branch"` +} + +var createOrUpdatePRCmd = &model.ExecutableCommand[CreateOrUpdatePRFlags]{ + Usage: "create-or-update-pr", + Short: "Create or update a PR from accumulated generation reports", + Long: `Create or update a GitHub PR from accumulated per-target generation reports. + +Reads an accumulated reports JSON file (keyed by target name), merges all +version reports, generates a PR title and body, and creates or updates the PR. + +The input file format is a JSON object where keys are target names and values +are per-target generation report objects containing version_report, URLs, etc. + +Environment variables: + - INPUT_GITHUB_ACCESS_TOKEN: GitHub token for API access + - GITHUB_REPOSITORY_OWNER: Repository owner + - GITHUB_REPOSITORY: Full repo path (owner/repo) + - GITHUB_WORKFLOW: Workflow name (used in PR title)`, + Run: runCreateOrUpdatePR, + Flags: []flag.Flag{ + flag.StringFlag{ + Name: "input", + Shorthand: "i", + Description: "Path to accumulated reports JSON file", + Required: true, + }, + flag.StringFlag{ + Name: "branch", + Shorthand: "b", + Description: "Branch name for the PR head", + Required: true, + }, + }, +} + +func runCreateOrUpdatePR(ctx context.Context, flags CreateOrUpdatePRFlags) error { + return actions.CreateOrUpdatePR(ctx, flags.Input, flags.Branch) +} diff --git a/internal/ci/actions/createOrUpdatePR.go b/internal/ci/actions/createOrUpdatePR.go new file mode 100644 index 000000000..9e93c7a0b --- /dev/null +++ b/internal/ci/actions/createOrUpdatePR.go @@ -0,0 +1,175 @@ +package actions + +import ( + "context" + "encoding/json" + "fmt" + "os" + "sort" + "strings" + + "github.com/google/go-github/v63/github" + "github.com/speakeasy-api/speakeasy/internal/ci/environment" + "github.com/speakeasy-api/speakeasy/internal/ci/git" + "github.com/speakeasy-api/speakeasy/internal/ci/logging" + "github.com/speakeasy-api/speakeasy/internal/prdescription" + "github.com/speakeasy-api/versioning-reports/versioning" + "golang.org/x/oauth2" +) + +// CreateOrUpdatePR reads an accumulated reports JSON file, builds a merged PR +// description, and creates or updates a GitHub PR on the given branch. +func CreateOrUpdatePR(ctx context.Context, inputFile, branchName string) error { + data, err := os.ReadFile(inputFile) + if err != nil { + return fmt.Errorf("failed to read accumulated reports: %w", err) + } + + var accumulated map[string]TargetGenerationReport + if err := json.Unmarshal(data, &accumulated); err != nil { + return fmt.Errorf("failed to parse accumulated reports: %w", err) + } + + if len(accumulated) == 0 { + logging.Info("No reports in accumulated file, skipping PR creation") + return nil + } + + // Sort target names alphabetically for stable ordering + targets := make([]string, 0, len(accumulated)) + for k := range accumulated { + targets = append(targets, k) + } + sort.Strings(targets) + + // Merge all per-target version reports into a single MergedVersionReport + var allReports []versioning.VersionReport + var speakeasyVersion string + var lintingReportURL, changesReportURL string + var manualBump bool + + for _, target := range targets { + report := accumulated[target] + if report.VersionReport != nil { + allReports = append(allReports, report.VersionReport.Reports...) + } + if speakeasyVersion == "" && report.SpeakeasyVersion != "" { + speakeasyVersion = report.SpeakeasyVersion + } + if lintingReportURL == "" && report.LintingReportURL != "" { + lintingReportURL = report.LintingReportURL + } + if changesReportURL == "" && report.ChangesReportURL != "" { + changesReportURL = report.ChangesReportURL + } + if report.ManualBump { + manualBump = true + } + } + + mergedReport := &versioning.MergedVersionReport{Reports: allReports} + + // Build PR description + input := prdescription.Input{ + VersionReport: mergedReport, + WorkflowName: environment.GetWorkflowName(), + SourceBranch: environment.GetSourceBranch(), + SpeakeasyVersion: speakeasyVersion, + LintingReportURL: lintingReportURL, + ChangesReportURL: changesReportURL, + ManualBump: manualBump, + } + + output, err := prdescription.Generate(input) + if err != nil { + return fmt.Errorf("failed to generate PR description: %w", err) + } + + // Initialize Git client + g, err := initAction() + if err != nil { + return fmt.Errorf("failed to initialize git: %w", err) + } + + // Find existing PR for this branch + owner := os.Getenv("GITHUB_REPOSITORY_OWNER") + repo := git.GetRepo() + + prClient := g.GetClient() + if providedPat := os.Getenv("PR_CREATION_PAT"); providedPat != "" { + ts := oauth2.StaticTokenSource( + &oauth2.Token{AccessToken: providedPat}, + ) + tc := oauth2.NewClient(ctx, ts) + prClient = github.NewClient(tc) + } + + // Search for existing PR from this branch + existingPR := findPRForBranch(ctx, prClient, owner, repo, branchName) + + // Compute labels from version report + labelTypes := g.UpsertLabelTypes(ctx) + _, _, labels := git.PRVersionMetadata(mergedReport, labelTypes) + + title := output.Title + body := output.Body + + const maxBodyLength = 65536 + if len(body) > maxBodyLength { + body = body[:maxBodyLength-3] + "..." + } + + if existingPR != nil { + logging.Info("Updating PR #%d", existingPR.GetNumber()) + existingPR.Title = &title + existingPR.Body = &body + _, _, err = prClient.PullRequests.Edit(ctx, owner, repo, existingPR.GetNumber(), existingPR) + if err != nil { + return fmt.Errorf("failed to update PR: %w", err) + } + g.SetPRLabels(ctx, owner, repo, existingPR.GetNumber(), labelTypes, existingPR.Labels, labels) + logging.Info("PR updated: %s", existingPR.GetHTMLURL()) + } else { + logging.Info("Creating PR") + targetBaseBranch := environment.GetTargetBaseBranch() + if strings.HasPrefix(targetBaseBranch, "refs/") { + targetBaseBranch = strings.TrimPrefix(targetBaseBranch, "refs/heads/") + } + + pr, _, err := prClient.PullRequests.Create(ctx, owner, repo, &github.NewPullRequest{ + Title: github.String(title), + Body: github.String(body), + Head: github.String(branchName), + Base: github.String(targetBaseBranch), + MaintainerCanModify: github.Bool(true), + }) + if err != nil { + messageSuffix := "" + if strings.Contains(err.Error(), "GitHub Actions is not permitted to create or approve pull requests") { + messageSuffix += "\nNavigate to Settings > Actions > Workflow permissions and ensure that allow GitHub Actions to create and approve pull requests is checked." + } + return fmt.Errorf("failed to create PR: %w%s", err, messageSuffix) + } + if pr != nil && len(labels) > 0 { + g.SetPRLabels(ctx, owner, repo, pr.GetNumber(), labelTypes, pr.Labels, labels) + } + logging.Info("PR created: %s", pr.GetHTMLURL()) + } + + return nil +} + +func findPRForBranch(ctx context.Context, client *github.Client, owner, repo, branch string) *github.PullRequest { + prs, _, err := client.PullRequests.List(ctx, owner, repo, &github.PullRequestListOptions{ + Head: owner + ":" + branch, + State: "open", + }) + if err != nil { + logging.Debug("failed to list PRs: %v", err) + return nil + } + if len(prs) > 0 { + return prs[0] + } + return nil +} diff --git a/internal/ci/actions/report.go b/internal/ci/actions/report.go new file mode 100644 index 000000000..372cb8788 --- /dev/null +++ b/internal/ci/actions/report.go @@ -0,0 +1,57 @@ +package actions + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + + "github.com/speakeasy-api/speakeasy/internal/ci/environment" + "github.com/speakeasy-api/speakeasy/internal/ci/logging" + "github.com/speakeasy-api/versioning-reports/versioning" +) + +// TargetGenerationReport captures all CI-agnostic data from a single target's +// generation run that is needed to build a PR description later. +type TargetGenerationReport struct { + Target string `json:"target"` + VersionReport *versioning.MergedVersionReport `json:"version_report,omitempty"` + LintingReportURL string `json:"linting_report_url,omitempty"` + ChangesReportURL string `json:"changes_report_url,omitempty"` + OpenAPIChangeSummary string `json:"openapi_change_summary,omitempty"` + SpeakeasyVersion string `json:"speakeasy_version,omitempty"` + ManualBump bool `json:"manual_bump,omitempty"` +} + +const reportsDir = ".speakeasy/reports" + +// writeGenerationReport serializes a TargetGenerationReport to +// .speakeasy/reports/.json and returns the file path. +// Only writes when a specific target is set (matrix mode). +func writeGenerationReport(report TargetGenerationReport) (string, error) { + target := environment.SpecifiedTarget() + if target == "" { + return "", nil + } + + report.Target = target + + if err := os.MkdirAll(reportsDir, 0o755); err != nil { + return "", fmt.Errorf("failed to create reports directory: %w", err) + } + + reportPath := filepath.Join(reportsDir, target+".json") + + data, err := json.MarshalIndent(report, "", " ") + if err != nil { + return "", fmt.Errorf("failed to marshal generation report: %w", err) + } + + if err := os.WriteFile(reportPath, data, 0o644); err != nil { + return "", fmt.Errorf("failed to write generation report: %w", err) + } + + logging.Info("Wrote generation report to %s", reportPath) + + return reportPath, nil +} diff --git a/internal/ci/actions/runWorkflow.go b/internal/ci/actions/runWorkflow.go index 82f94696e..be91d4634 100644 --- a/internal/ci/actions/runWorkflow.go +++ b/internal/ci/actions/runWorkflow.go @@ -197,6 +197,21 @@ func RunWorkflow(ctx context.Context) error { } } + // Write per-target generation report for matrix mode accumulation. + // This file is CI-agnostic and will be uploaded as an artifact by the workflow. + if reportPath, err := writeGenerationReport(TargetGenerationReport{ + VersionReport: runRes.VersioningInfo.VersionReport, + LintingReportURL: runRes.LintingReportURL, + ChangesReportURL: runRes.ChangesReportURL, + OpenAPIChangeSummary: runRes.OpenAPIChangeSummary, + SpeakeasyVersion: resolvedVersion, + ManualBump: runRes.VersioningInfo.ManualBump, + }); err != nil { + logging.Debug("failed to write generation report: %v", err) + } else if reportPath != "" { + outputs["generation_report_file"] = reportPath + } + // If test mode is successful to this point, exit here if environment.IsTestMode() { success = true diff --git a/internal/ci/git/git.go b/internal/ci/git/git.go index bfb17d972..ed90acacb 100644 --- a/internal/ci/git/git.go +++ b/internal/ci/git/git.go @@ -45,6 +45,10 @@ func (g *Git) GetRepoRoot() string { return g.repoRoot } +func (g *Git) GetClient() *github.Client { + return g.client +} + const ( speakeasyBotName = "speakeasybot" speakeasyBotAlias = "speakeasy-bot" @@ -912,7 +916,7 @@ func (g *Git) CreateOrUpdatePR(info PRInfo) (*github.PullRequest, error) { info.PR.Title = &title info.PR, _, err = prClient.PullRequests.Edit(context.Background(), os.Getenv("GITHUB_REPOSITORY_OWNER"), GetRepo(), info.PR.GetNumber(), info.PR) // Set labels MUST always follow updating the PR - g.setPRLabels(context.Background(), os.Getenv("GITHUB_REPOSITORY_OWNER"), GetRepo(), info.PR.GetNumber(), labelTypes, info.PR.Labels, labels) + g.SetPRLabels(context.Background(), os.Getenv("GITHUB_REPOSITORY_OWNER"), GetRepo(), info.PR.GetNumber(), labelTypes, info.PR.Labels, labels) if err != nil { return nil, fmt.Errorf("failed to update PR: %w", err) } @@ -940,7 +944,7 @@ func (g *Git) CreateOrUpdatePR(info PRInfo) (*github.PullRequest, error) { } return nil, fmt.Errorf("failed to create PR: %w%s", err, messageSuffix) } else if info.PR != nil && len(labels) > 0 { - g.setPRLabels(context.Background(), os.Getenv("GITHUB_REPOSITORY_OWNER"), GetRepo(), info.PR.GetNumber(), labelTypes, info.PR.Labels, labels) + g.SetPRLabels(context.Background(), os.Getenv("GITHUB_REPOSITORY_OWNER"), GetRepo(), info.PR.GetNumber(), labelTypes, info.PR.Labels, labels) } } diff --git a/internal/ci/git/labels.go b/internal/ci/git/labels.go index a4a2925bb..37fa52ee1 100644 --- a/internal/ci/git/labels.go +++ b/internal/ci/git/labels.go @@ -55,7 +55,7 @@ func (g *Git) UpsertLabelTypes(ctx context.Context) map[string]github.Label { return actualLabels } -func (g *Git) setPRLabels(background context.Context, owner string, repo string, issueNumber int, labelTypes map[string]github.Label, actualLabels, desiredLabels []*github.Label) { +func (g *Git) SetPRLabels(background context.Context, owner string, repo string, issueNumber int, labelTypes map[string]github.Label, actualLabels, desiredLabels []*github.Label) { shouldRemove := []string{} shouldAdd := []string{} for _, label := range actualLabels { From 62f3cccec5dce0e8d83d9805f4b61c9e36c125d6 Mon Sep 17 00:00:00 2001 From: daniel Date: Fri, 27 Feb 2026 19:33:07 +0000 Subject: [PATCH 2/3] feat: add --dry-run flag to ci create-or-update-pr MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Allows testing the report accumulation and PR description generation locally without requiring GitHub API access. ✻ Clauded... --- cmd/ci/create_or_update_pr.go | 10 ++++- internal/ci/actions/createOrUpdatePR.go | 57 ++++++++++++++++--------- 2 files changed, 45 insertions(+), 22 deletions(-) diff --git a/cmd/ci/create_or_update_pr.go b/cmd/ci/create_or_update_pr.go index 79e138a99..94334489b 100644 --- a/cmd/ci/create_or_update_pr.go +++ b/cmd/ci/create_or_update_pr.go @@ -11,6 +11,7 @@ import ( type CreateOrUpdatePRFlags struct { Input string `json:"input"` Branch string `json:"branch"` + DryRun bool `json:"dry-run"` } var createOrUpdatePRCmd = &model.ExecutableCommand[CreateOrUpdatePRFlags]{ @@ -24,6 +25,8 @@ version reports, generates a PR title and body, and creates or updates the PR. The input file format is a JSON object where keys are target names and values are per-target generation report objects containing version_report, URLs, etc. +Use --dry-run to print the generated title and body without creating a PR. + Environment variables: - INPUT_GITHUB_ACCESS_TOKEN: GitHub token for API access - GITHUB_REPOSITORY_OWNER: Repository owner @@ -41,11 +44,14 @@ Environment variables: Name: "branch", Shorthand: "b", Description: "Branch name for the PR head", - Required: true, + }, + flag.BooleanFlag{ + Name: "dry-run", + Description: "Print generated PR title and body without creating a PR", }, }, } func runCreateOrUpdatePR(ctx context.Context, flags CreateOrUpdatePRFlags) error { - return actions.CreateOrUpdatePR(ctx, flags.Input, flags.Branch) + return actions.CreateOrUpdatePR(ctx, flags.Input, flags.Branch, flags.DryRun) } diff --git a/internal/ci/actions/createOrUpdatePR.go b/internal/ci/actions/createOrUpdatePR.go index 9e93c7a0b..f9a22692f 100644 --- a/internal/ci/actions/createOrUpdatePR.go +++ b/internal/ci/actions/createOrUpdatePR.go @@ -17,22 +17,22 @@ import ( "golang.org/x/oauth2" ) -// CreateOrUpdatePR reads an accumulated reports JSON file, builds a merged PR -// description, and creates or updates a GitHub PR on the given branch. -func CreateOrUpdatePR(ctx context.Context, inputFile, branchName string) error { +// GeneratePRFromReports reads an accumulated reports JSON file, merges all +// per-target version reports, and generates a PR title and body. +// This is the pure logic with no side effects. +func GeneratePRFromReports(inputFile string) (*prdescription.Output, *versioning.MergedVersionReport, error) { data, err := os.ReadFile(inputFile) if err != nil { - return fmt.Errorf("failed to read accumulated reports: %w", err) + return nil, nil, fmt.Errorf("failed to read accumulated reports: %w", err) } var accumulated map[string]TargetGenerationReport if err := json.Unmarshal(data, &accumulated); err != nil { - return fmt.Errorf("failed to parse accumulated reports: %w", err) + return nil, nil, fmt.Errorf("failed to parse accumulated reports: %w", err) } if len(accumulated) == 0 { - logging.Info("No reports in accumulated file, skipping PR creation") - return nil + return nil, nil, fmt.Errorf("no reports in accumulated file") } // Sort target names alphabetically for stable ordering @@ -69,7 +69,6 @@ func CreateOrUpdatePR(ctx context.Context, inputFile, branchName string) error { mergedReport := &versioning.MergedVersionReport{Reports: allReports} - // Build PR description input := prdescription.Input{ VersionReport: mergedReport, WorkflowName: environment.GetWorkflowName(), @@ -82,7 +81,36 @@ func CreateOrUpdatePR(ctx context.Context, inputFile, branchName string) error { output, err := prdescription.Generate(input) if err != nil { - return fmt.Errorf("failed to generate PR description: %w", err) + return nil, nil, fmt.Errorf("failed to generate PR description: %w", err) + } + + return output, mergedReport, nil +} + +// CreateOrUpdatePR reads an accumulated reports JSON file, builds a merged PR +// description, and creates or updates a GitHub PR on the given branch. +func CreateOrUpdatePR(ctx context.Context, inputFile, branchName string, dryRun bool) error { + output, mergedReport, err := GeneratePRFromReports(inputFile) + if err != nil { + return err + } + + title := output.Title + body := output.Body + + const maxBodyLength = 65536 + if len(body) > maxBodyLength { + body = body[:maxBodyLength-3] + "..." + } + + if dryRun { + result := map[string]string{ + "title": title, + "body": body, + } + out, _ := json.MarshalIndent(result, "", " ") + fmt.Println(string(out)) + return nil } // Initialize Git client @@ -91,7 +119,6 @@ func CreateOrUpdatePR(ctx context.Context, inputFile, branchName string) error { return fmt.Errorf("failed to initialize git: %w", err) } - // Find existing PR for this branch owner := os.Getenv("GITHUB_REPOSITORY_OWNER") repo := git.GetRepo() @@ -104,21 +131,11 @@ func CreateOrUpdatePR(ctx context.Context, inputFile, branchName string) error { prClient = github.NewClient(tc) } - // Search for existing PR from this branch existingPR := findPRForBranch(ctx, prClient, owner, repo, branchName) - // Compute labels from version report labelTypes := g.UpsertLabelTypes(ctx) _, _, labels := git.PRVersionMetadata(mergedReport, labelTypes) - title := output.Title - body := output.Body - - const maxBodyLength = 65536 - if len(body) > maxBodyLength { - body = body[:maxBodyLength-3] + "..." - } - if existingPR != nil { logging.Info("Updating PR #%d", existingPR.GetNumber()) existingPR.Title = &title From 80b185065ec87e605de32d1648ef4aebf262cd43 Mon Sep 17 00:00:00 2001 From: daniel Date: Fri, 27 Feb 2026 21:29:11 +0000 Subject: [PATCH 3/3] fix: gofumpt struct alignment and switch to directory-based report input The create-or-update-pr command now takes a directory of per-target JSON files instead of a single accumulated file. This simplifies the CI accumulation step to just file-level overwrites with no jq merging. Also skips unparseable files gracefully instead of failing. Co-Authored-By: Claude Opus 4.6 --- cmd/ci/create_or_update_pr.go | 12 +++---- internal/ci/actions/createOrUpdatePR.go | 44 +++++++++++++++++-------- internal/ci/actions/report.go | 12 +++---- 3 files changed, 42 insertions(+), 26 deletions(-) diff --git a/cmd/ci/create_or_update_pr.go b/cmd/ci/create_or_update_pr.go index 94334489b..705a5f30f 100644 --- a/cmd/ci/create_or_update_pr.go +++ b/cmd/ci/create_or_update_pr.go @@ -17,13 +17,13 @@ type CreateOrUpdatePRFlags struct { var createOrUpdatePRCmd = &model.ExecutableCommand[CreateOrUpdatePRFlags]{ Usage: "create-or-update-pr", Short: "Create or update a PR from accumulated generation reports", - Long: `Create or update a GitHub PR from accumulated per-target generation reports. + Long: `Create or update a GitHub PR from per-target generation reports. -Reads an accumulated reports JSON file (keyed by target name), merges all -version reports, generates a PR title and body, and creates or updates the PR. +Reads a directory of per-target report JSON files, merges all version reports, +generates a PR title and body, and creates or updates the PR. -The input file format is a JSON object where keys are target names and values -are per-target generation report objects containing version_report, URLs, etc. +Each file in the directory should be a JSON object with target, version_report, +speakeasy_version, etc. Files are keyed by filename (target name + .json). Use --dry-run to print the generated title and body without creating a PR. @@ -37,7 +37,7 @@ Environment variables: flag.StringFlag{ Name: "input", Shorthand: "i", - Description: "Path to accumulated reports JSON file", + Description: "Path to directory containing per-target report JSON files", Required: true, }, flag.StringFlag{ diff --git a/internal/ci/actions/createOrUpdatePR.go b/internal/ci/actions/createOrUpdatePR.go index f9a22692f..ce1a02f09 100644 --- a/internal/ci/actions/createOrUpdatePR.go +++ b/internal/ci/actions/createOrUpdatePR.go @@ -5,6 +5,7 @@ import ( "encoding/json" "fmt" "os" + "path/filepath" "sort" "strings" @@ -17,22 +18,37 @@ import ( "golang.org/x/oauth2" ) -// GeneratePRFromReports reads an accumulated reports JSON file, merges all -// per-target version reports, and generates a PR title and body. -// This is the pure logic with no side effects. -func GeneratePRFromReports(inputFile string) (*prdescription.Output, *versioning.MergedVersionReport, error) { - data, err := os.ReadFile(inputFile) +// GeneratePRFromReports reads a directory of per-target JSON report files, +// merges all version reports, and generates a PR title and body. +// Each file in the directory should be a TargetGenerationReport JSON. +func GeneratePRFromReports(inputDir string) (*prdescription.Output, *versioning.MergedVersionReport, error) { + entries, err := os.ReadDir(inputDir) if err != nil { - return nil, nil, fmt.Errorf("failed to read accumulated reports: %w", err) + return nil, nil, fmt.Errorf("failed to read reports directory: %w", err) } - var accumulated map[string]TargetGenerationReport - if err := json.Unmarshal(data, &accumulated); err != nil { - return nil, nil, fmt.Errorf("failed to parse accumulated reports: %w", err) + accumulated := make(map[string]TargetGenerationReport) + for _, entry := range entries { + if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".json") { + continue + } + data, err := os.ReadFile(filepath.Join(inputDir, entry.Name())) + if err != nil { + return nil, nil, fmt.Errorf("failed to read report %s: %w", entry.Name(), err) + } + var report TargetGenerationReport + if err := json.Unmarshal(data, &report); err != nil { + logging.Debug("skipping %s: %v", entry.Name(), err) + continue + } + if report.Target == "" { + report.Target = strings.TrimSuffix(entry.Name(), ".json") + } + accumulated[report.Target] = report } if len(accumulated) == 0 { - return nil, nil, fmt.Errorf("no reports in accumulated file") + return nil, nil, fmt.Errorf("no reports found in %s", inputDir) } // Sort target names alphabetically for stable ordering @@ -87,10 +103,10 @@ func GeneratePRFromReports(inputFile string) (*prdescription.Output, *versioning return output, mergedReport, nil } -// CreateOrUpdatePR reads an accumulated reports JSON file, builds a merged PR -// description, and creates or updates a GitHub PR on the given branch. -func CreateOrUpdatePR(ctx context.Context, inputFile, branchName string, dryRun bool) error { - output, mergedReport, err := GeneratePRFromReports(inputFile) +// CreateOrUpdatePR reads a directory of per-target report files, builds a merged +// PR description, and creates or updates a GitHub PR on the given branch. +func CreateOrUpdatePR(ctx context.Context, inputDir, branchName string, dryRun bool) error { + output, mergedReport, err := GeneratePRFromReports(inputDir) if err != nil { return err } diff --git a/internal/ci/actions/report.go b/internal/ci/actions/report.go index 372cb8788..f16e2dfbb 100644 --- a/internal/ci/actions/report.go +++ b/internal/ci/actions/report.go @@ -14,13 +14,13 @@ import ( // TargetGenerationReport captures all CI-agnostic data from a single target's // generation run that is needed to build a PR description later. type TargetGenerationReport struct { - Target string `json:"target"` + Target string `json:"target"` VersionReport *versioning.MergedVersionReport `json:"version_report,omitempty"` - LintingReportURL string `json:"linting_report_url,omitempty"` - ChangesReportURL string `json:"changes_report_url,omitempty"` - OpenAPIChangeSummary string `json:"openapi_change_summary,omitempty"` - SpeakeasyVersion string `json:"speakeasy_version,omitempty"` - ManualBump bool `json:"manual_bump,omitempty"` + LintingReportURL string `json:"linting_report_url,omitempty"` + ChangesReportURL string `json:"changes_report_url,omitempty"` + OpenAPIChangeSummary string `json:"openapi_change_summary,omitempty"` + SpeakeasyVersion string `json:"speakeasy_version,omitempty"` + ManualBump bool `json:"manual_bump,omitempty"` } const reportsDir = ".speakeasy/reports"