Skip to content
Merged
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
1 change: 1 addition & 0 deletions cmd/ci/ci.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ var CICmd = &model.CommandGroup{
suggestCmd,
finalizeCmd,
prDescriptionCmd,
createOrUpdatePRCmd,
publishEventCmd,

tagCmd,
Expand Down
57 changes: 57 additions & 0 deletions cmd/ci/create_or_update_pr.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
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"`
DryRun bool `json:"dry-run"`
}

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 per-target generation reports.

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.

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.

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 directory containing per-target report JSON files",
Required: true,
},
flag.StringFlag{
Name: "branch",
Shorthand: "b",
Description: "Branch name for the PR head",
},
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, flags.DryRun)
}
208 changes: 208 additions & 0 deletions internal/ci/actions/createOrUpdatePR.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,208 @@
package actions

import (
"context"
"encoding/json"
"fmt"
"os"
"path/filepath"
"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"
)

// 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 reports directory: %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 found in %s", inputDir)
}

// 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}

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 nil, nil, fmt.Errorf("failed to generate PR description: %w", err)
}

return output, mergedReport, nil
}

// 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
}

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
g, err := initAction()
if err != nil {
return fmt.Errorf("failed to initialize git: %w", err)
}

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)
}

existingPR := findPRForBranch(ctx, prClient, owner, repo, branchName)

labelTypes := g.UpsertLabelTypes(ctx)
_, _, labels := git.PRVersionMetadata(mergedReport, labelTypes)

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
}
57 changes: 57 additions & 0 deletions internal/ci/actions/report.go
Original file line number Diff line number Diff line change
@@ -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/<target>.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
}
15 changes: 15 additions & 0 deletions internal/ci/actions/runWorkflow.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 6 additions & 2 deletions internal/ci/git/git.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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)
}
}

Expand Down
2 changes: 1 addition & 1 deletion internal/ci/git/labels.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading