diff --git a/.github/workflows/workflow-executor.yaml b/.github/workflows/workflow-executor.yaml index 433fc8b2..faa05ee0 100644 --- a/.github/workflows/workflow-executor.yaml +++ b/.github/workflows/workflow-executor.yaml @@ -165,11 +165,32 @@ jobs: branch_name: ${{ steps.run-workflow.outputs.branch_name }} resolved_speakeasy_version: ${{ steps.run-workflow.outputs.resolved_speakeasy_version }} use_sonatype_legacy: ${{ steps.run-workflow.outputs.use_sonatype_legacy }} + short_circuit_label_trigger: ${{ steps.check-label.outputs.short_circuit_label_trigger }} steps: + - name: Check Pull Request Label + if: ${{ github.event_name == 'pull_request' && github.event.action == 'labeled' }} + id: check-label + continue-on-error: true + run: | + label="${{ github.event.label.name }}" + tmpfile=$(mktemp) + echo "${{ github.event.pull_request.body }}" > "$tmpfile" + + # Check if the label is a valid version bump and ensure the current PR isn't already on that version bump + # If either are true we short circuit the rest of the action + if [[ "$label" != "patch" && "$label" != "minor" && "$label" != "major" && "$label" != "graduate" ]] || grep -Fq "Version Bump Type: [$label]" "$tmpfile"; then + echo "No version bump label found in PR body. Short-circuiting." + echo "short_circuit_label_trigger=true" >> "$GITHUB_OUTPUT" + exit 0 + else + echo "short_circuit_label_trigger=false" >> "$GITHUB_OUTPUT" + fi - name: Tune GitHub-hosted runner network + if: ${{ steps.check-label.outputs.short_circuit_label_trigger != 'true' }} uses: smorimoto/tune-github-hosted-runner-network@v1 - id: run-workflow name: Run Generation Workflow + if: ${{ steps.check-label.outputs.short_circuit_label_trigger != 'true' }} uses: speakeasy-api/sdk-generation-action@v15 with: speakeasy_version: ${{ inputs.speakeasy_version }} @@ -188,7 +209,7 @@ jobs: cli_environment_variables: ${{ inputs.environment }} pnpm_version: ${{ inputs.pnpm_version }} - uses: ravsamhq/notify-slack-action@v2 - if: always() && env.SLACK_WEBHOOK_URL != '' + if: ${{ steps.check-label.outputs.short_circuit_label_trigger != 'true' && env.SLACK_WEBHOOK_URL != '' }} with: status: ${{ job.status }} token: ${{ secrets.github_access_token }} @@ -201,7 +222,7 @@ jobs: - id: log-result name: Log Generation Output uses: speakeasy-api/sdk-generation-action@v15 - if: always() + if: ${{ steps.check-label.outputs.short_circuit_label_trigger != 'true'}} with: speakeasy_version: ${{ inputs.speakeasy_version }} github_access_token: ${{ secrets.github_access_token }} diff --git a/internal/actions/runWorkflow.go b/internal/actions/runWorkflow.go index 87ffade3..d618dddc 100644 --- a/internal/actions/runWorkflow.go +++ b/internal/actions/runWorkflow.go @@ -6,6 +6,7 @@ import ( "strings" "github.com/google/go-github/v63/github" + "github.com/speakeasy-api/sdk-generation-action/internal/versionbumps" "github.com/speakeasy-api/versioning-reports/versioning" "github.com/speakeasy-api/sdk-generation-action/internal/configuration" @@ -173,7 +174,7 @@ func RunWorkflow() error { AnythingRegenerated: anythingRegenerated, SourcesOnly: sourcesOnly, Git: g, - VersioningReport: runRes.VersioningReport, + VersioningInfo: runRes.VersioningInfo, LintingReportURL: runRes.LintingReportURL, ChangesReportURL: runRes.ChangesReportURL, OpenAPIChangeSummary: runRes.OpenAPIChangeSummary, @@ -202,6 +203,7 @@ type finalizeInputs struct { ChangesReportURL string OpenAPIChangeSummary string VersioningReport *versioning.MergedVersionReport + VersioningInfo versionbumps.VersioningInfo currentRelease *releases.ReleasesInfo } @@ -240,7 +242,7 @@ func finalize(inputs finalizeInputs) error { SourceGeneration: inputs.SourcesOnly, LintingReportURL: inputs.LintingReportURL, ChangesReportURL: inputs.ChangesReportURL, - VersioningReport: inputs.VersioningReport, + VersioningInfo: inputs.VersioningInfo, OpenAPIChangeSummary: inputs.OpenAPIChangeSummary, }) diff --git a/internal/cli/cli.go b/internal/cli/cli.go index a03faaa8..630f76a5 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -53,7 +53,7 @@ func GetSupportedLanguages() []string { return supportedTargets } } - + return defaultSupportedTargets } diff --git a/internal/cli/run.go b/internal/cli/run.go index c947f520..d7e6acaf 100644 --- a/internal/cli/run.go +++ b/internal/cli/run.go @@ -7,18 +7,20 @@ import ( "regexp" "strings" - "github.com/speakeasy-api/sdk-generation-action/internal/registry" - "github.com/speakeasy-api/sdk-generation-action/internal/environment" + "github.com/speakeasy-api/sdk-generation-action/internal/registry" + "github.com/speakeasy-api/versioning-reports/versioning" ) +const BumpOverrideEnvVar = "SPEAKEASY_BUMP_OVERRIDE" + type RunResults struct { LintingReportURL string ChangesReportURL string OpenAPIChangeSummary string } -func Run(sourcesOnly bool, installationURLs map[string]string, repoURL string, repoSubdirectories map[string]string) (*RunResults, error) { +func Run(sourcesOnly bool, installationURLs map[string]string, repoURL string, repoSubdirectories map[string]string, manualVersionBump *versioning.BumpType) (*RunResults, error) { args := []string{ "run", } @@ -64,6 +66,10 @@ func Run(sourcesOnly bool, installationURLs map[string]string, repoURL string, r os.Setenv("SPEAKEASY_FORCE_GENERATION", "true") } + if manualVersionBump != nil { + os.Setenv(BumpOverrideEnvVar, string(*manualVersionBump)) + } + //if environment.ShouldOutputTests() { // TODO: Add CLI flag for outputting tests //} diff --git a/internal/environment/environment.go b/internal/environment/environment.go index 068a9155..4f72b6e1 100644 --- a/internal/environment/environment.go +++ b/internal/environment/environment.go @@ -225,6 +225,10 @@ func GetWorkflowEventPayloadPath() string { return os.Getenv("GITHUB_EVENT_PATH") } +func GetWorkflowEventLabelName() string { + return os.Getenv("GITHUB_EVENT_LABEL_NAME") +} + func GetBranchName() string { return os.Getenv("INPUT_BRANCH_NAME") } @@ -234,6 +238,10 @@ func GetCliOutput() string { } func GetRef() string { + // handle pr based action triggers + if strings.Contains(os.Getenv("GITHUB_REF"), "refs/pull") || strings.Contains(os.Getenv("GITHUB_REF"), "refs/pulls") { + return os.Getenv("GITHUB_BASE_REF") + } return os.Getenv("GITHUB_REF") } diff --git a/internal/git/git.go b/internal/git/git.go index 55689c27..8997fa43 100644 --- a/internal/git/git.go +++ b/internal/git/git.go @@ -16,8 +16,6 @@ import ( "strings" "time" - "github.com/speakeasy-api/versioning-reports/versioning" - "github.com/go-git/go-git/v5" "github.com/go-git/go-git/v5/config" "github.com/go-git/go-git/v5/plumbing" @@ -28,6 +26,7 @@ import ( "github.com/speakeasy-api/sdk-generation-action/internal/cli" "github.com/speakeasy-api/sdk-generation-action/internal/environment" "github.com/speakeasy-api/sdk-generation-action/internal/logging" + "github.com/speakeasy-api/sdk-generation-action/internal/versionbumps" "github.com/speakeasy-api/sdk-generation-action/pkg/releases" "github.com/google/go-github/v63/github" @@ -62,7 +61,7 @@ func (g *Git) CloneRepo() error { return fmt.Errorf("failed to construct repo url: %w", err) } - ref := os.Getenv("GITHUB_REF") + ref := environment.GetRef() logging.Info("Cloning repo: %s from ref: %s", repoPath, ref) @@ -426,7 +425,7 @@ type PRInfo struct { LintingReportURL string ChangesReportURL string OpenAPIChangeSummary string - VersioningReport *versioning.MergedVersionReport + VersioningInfo versionbumps.VersioningInfo } func (g *Git) CreateOrUpdatePR(info PRInfo) (*github.PullRequest, error) { @@ -442,7 +441,7 @@ func (g *Git) CreateOrUpdatePR(info PRInfo) (*github.PullRequest, error) { } // Deprecated -- kept around for old CLI versions. VersioningReport is newer pathway - if info.ReleaseInfo != nil && info.VersioningReport == nil { + if info.ReleaseInfo != nil && info.VersioningInfo.VersionReport == nil { for language, genInfo := range info.ReleaseInfo.LanguagesGenerated { genPath := path.Join(environment.GetWorkspace(), "repo", genInfo.Path) @@ -500,6 +499,16 @@ func (g *Git) CreateOrUpdatePR(info PRInfo) (*github.PullRequest, error) { } } + title := getGenPRTitlePrefix() + if environment.IsDocsGeneration() { + title = getDocsPRTitlePrefix() + } else if info.SourceGeneration { + title = getGenSourcesTitlePrefix() + } + + suffix, labelBumpType, labels := PRVersionMetadata(info.VersioningInfo.VersionReport, labelTypes) + title += suffix + body := "" if info.LintingReportURL != "" || info.ChangesReportURL != "" { @@ -527,8 +536,24 @@ Based on: `, info.ReleaseInfo.DocVersion, info.ReleaseInfo.DocLocation, info.ReleaseInfo.SpeakeasyVersion, info.ReleaseInfo.GenerationVersion) } - if info.VersioningReport != nil { - body += stripCodes(info.VersioningReport.GetMarkdownSection()) + if info.VersioningInfo.VersionReport != nil { + body += stripCodes(info.VersioningInfo.VersionReport.GetMarkdownSection()) + + // We keep track of explicit bump types and whether that bump type is manual or automated in the PR body + if labelBumpType != nil { + // be very careful if changing this it critically aligns with a regex in parseBumpFromPRBody + versionBumpMsg := "Version Bump Type: " + fmt.Sprintf("[%s]", string(*labelBumpType)) + " - " + if info.VersioningInfo.ManualBump { + versionBumpMsg += string(versionbumps.BumpMethodManual) + " (manual)" + // if manual we bold the message + versionBumpMsg = "**" + versionBumpMsg + "**" + versionBumpMsg += fmt.Sprintf("\n\nThis PR will stay on the current version until the %s label is removed and/or modified.", string(*labelBumpType)) + } else { + versionBumpMsg += string(versionbumps.BumpMethodAutomated) + " (automated)" + } + body += "\n\n" + versionBumpMsg + } + } else { if len(info.OpenAPIChangeSummary) > 0 { body += fmt.Sprintf(`## OpenAPI Change Summary @@ -545,14 +570,6 @@ Based on: if len(body) > maxBodyLength { body = body[:maxBodyLength-3] + "..." } - title := getGenPRTitlePrefix() - if environment.IsDocsGeneration() { - title = getDocsPRTitlePrefix() - } else if info.SourceGeneration { - title = getGenSourcesTitlePrefix() - } - suffix, labels := PRMetadata(info.VersioningReport, labelTypes) - title += suffix if info.PR != nil { logging.Info("Updating PR") @@ -560,6 +577,7 @@ Based on: info.PR.Body = github.String(body) info.PR.Title = &title info.PR, _, err = g.client.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) if err != nil { return nil, fmt.Errorf("failed to update PR: %w", err) @@ -595,54 +613,6 @@ Based on: return info.PR, nil } -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 { - foundInDesired := false - for _, desired := range desiredLabels { - if label.GetName() == desired.GetName() { - foundInDesired = true - break - } - if _, ok := labelTypes[label.GetName()]; !ok { - foundInDesired = true - continue - } - break - } - if !foundInDesired { - shouldRemove = append(shouldRemove, label.GetName()) - } - } - for _, desired := range desiredLabels { - foundInActual := false - for _, label := range actualLabels { - if label.GetName() == desired.GetName() { - foundInActual = true - break - } - } - if !foundInActual { - shouldAdd = append(shouldAdd, desired.GetName()) - } - } - if len(shouldAdd) > 0 { - _, _, err := g.client.Issues.AddLabelsToIssue(background, owner, repo, issueNumber, shouldAdd) - if err != nil { - logging.Info("failed to add labels %v: %s", shouldAdd, err.Error()) - } - } - if len(shouldRemove) > 0 { - for _, label := range shouldRemove { - _, err := g.client.Issues.RemoveLabelForIssue(background, owner, repo, issueNumber, label) - if err != nil { - logging.Info("failed to remove labels %s: %s", label, err.Error()) - } - } - } -} - func notEquivalent(desired []*github.Label, actual []*github.Label) bool { desiredByName := make(map[string]bool) for _, label := range desired { @@ -726,45 +696,6 @@ Based on: return nil } -func PRMetadata(m *versioning.MergedVersionReport, labelTypes map[string]github.Label) (string, []*github.Label) { - if m == nil { - return "", []*github.Label{} - } - labels := []*github.Label{} - skipBumpType := false - skipVersionNumber := false - singleBumpType := "" - singleNewVersion := "" - for _, report := range m.Reports { - if len(report.BumpType) > 0 && report.BumpType != versioning.BumpNone && report.BumpType != versioning.BumpCustom { - if len(singleBumpType) > 0 { - skipBumpType = true - } - singleBumpType = string(report.BumpType) - } - if len(report.NewVersion) > 0 { - if len(singleNewVersion) > 0 { - skipVersionNumber = true - } - singleNewVersion = report.NewVersion - } - } - var builder []string - if !skipVersionNumber { - builder = append(builder, singleNewVersion) - } - if !skipBumpType { - if matched, ok := labelTypes[singleBumpType]; ok { - labels = append(labels, &matched) - } - } - // Add an extra " " at front - if len(builder) > 0 { - builder = append([]string{""}, builder...) - } - return strings.Join(builder, " "), labels -} - func (g *Git) CreateSuggestionPR(branchName, output string) (*int, string, error) { body := fmt.Sprintf(`Generated OpenAPI Suggestions by Speakeasy CLI. Outputs changes to *%s*.`, output) @@ -1054,51 +985,6 @@ func (g *Git) CreateTag(tag string, hash string) error { return nil } -func (g *Git) UpsertLabelTypes(ctx context.Context) map[string]github.Label { - desiredLabels := map[string]github.Label{} - addGitHubLabel := func(name, description string) { - desiredLabels[name] = github.Label{ - Name: &name, - Description: &description, - } - } - addGitHubLabel(string(versioning.BumpMajor), "Major version bump") - addGitHubLabel(string(versioning.BumpMinor), "Minor version bump") - addGitHubLabel(string(versioning.BumpPatch), "Patch version bump") - addGitHubLabel(string(versioning.BumpGraduate), "Graduate prerelease to stable") - addGitHubLabel(string(versioning.BumpPrerelease), "Bump by a prerelease version") - actualLabels := make(map[string]github.Label) - allLabels, _, err := g.client.Issues.ListLabels(ctx, os.Getenv("GITHUB_REPOSITORY_OWNER"), getRepo(), nil) - if err != nil { - return actualLabels - } - for _, label := range allLabels { - actualLabels[*label.Name] = *label - } - - for _, label := range desiredLabels { - foundLabel, ok := actualLabels[*label.Name] - if ok { - if *foundLabel.Description != *label.Description { - _, _, err = g.client.Issues.EditLabel(ctx, os.Getenv("GITHUB_REPOSITORY_OWNER"), getRepo(), *label.Name, &github.Label{ - Name: label.Name, - Description: label.Description, - }) - if err != nil { - return actualLabels - } - } - } else { - _, _, err = g.client.Issues.CreateLabel(ctx, os.Getenv("GITHUB_REPOSITORY_OWNER"), getRepo(), &label) - if err != nil { - return actualLabels - } - } - actualLabels[*label.Name] = label - } - return actualLabels -} - func getGithubAuth(accessToken string) *gitHttp.BasicAuth { return &gitHttp.BasicAuth{ Username: "gen", diff --git a/internal/git/git_test.go b/internal/git/git_test.go index 66211ee8..2463e737 100644 --- a/internal/git/git_test.go +++ b/internal/git/git_test.go @@ -247,7 +247,6 @@ func TestArtifactMatchesRelease(t *testing.T) { goarch: "amd", want: false, }, - } for _, tt := range tests { diff --git a/internal/git/labels.go b/internal/git/labels.go new file mode 100644 index 00000000..a73a3d91 --- /dev/null +++ b/internal/git/labels.go @@ -0,0 +1,148 @@ +package git + +import ( + "context" + "os" + "strings" + + "github.com/google/go-github/v63/github" + "github.com/speakeasy-api/sdk-generation-action/internal/logging" + "github.com/speakeasy-api/sdk-generation-action/internal/versionbumps" + "github.com/speakeasy-api/versioning-reports/versioning" +) + +func (g *Git) UpsertLabelTypes(ctx context.Context) map[string]github.Label { + desiredLabels := map[string]github.Label{} + addGitHubLabel := func(name, description string) { + desiredLabels[name] = github.Label{ + Name: &name, + Description: &description, + } + } + for bumpType, description := range versionbumps.GetBumpTypeLabels() { + addGitHubLabel(string(bumpType), description) + } + + actualLabels := make(map[string]github.Label) + allLabels, _, err := g.client.Issues.ListLabels(ctx, os.Getenv("GITHUB_REPOSITORY_OWNER"), getRepo(), nil) + if err != nil { + return actualLabels + } + for _, label := range allLabels { + actualLabels[*label.Name] = *label + } + + for _, label := range desiredLabels { + foundLabel, ok := actualLabels[*label.Name] + if ok { + if *foundLabel.Description != *label.Description { + _, _, err = g.client.Issues.EditLabel(ctx, os.Getenv("GITHUB_REPOSITORY_OWNER"), getRepo(), *label.Name, &github.Label{ + Name: label.Name, + Description: label.Description, + }) + if err != nil { + return actualLabels + } + } + } else { + _, _, err = g.client.Issues.CreateLabel(ctx, os.Getenv("GITHUB_REPOSITORY_OWNER"), getRepo(), &label) + if err != nil { + return actualLabels + } + } + actualLabels[*label.Name] = 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) { + shouldRemove := []string{} + shouldAdd := []string{} + for _, label := range actualLabels { + foundInDesired := false + for _, desired := range desiredLabels { + if label.GetName() == desired.GetName() { + foundInDesired = true + break + } + if _, ok := labelTypes[label.GetName()]; !ok { + foundInDesired = true + continue + } + break + } + + // We shouldn't delete labels that aren't managed by us + if _, ok := versionbumps.GetBumpTypeLabels()[versioning.BumpType(label.GetName())]; ok && !foundInDesired { + shouldRemove = append(shouldRemove, label.GetName()) + } + } + for _, desired := range desiredLabels { + foundInActual := false + for _, label := range actualLabels { + if label.GetName() == desired.GetName() { + foundInActual = true + break + } + } + if !foundInActual { + shouldAdd = append(shouldAdd, desired.GetName()) + } + } + if len(shouldAdd) > 0 { + _, _, err := g.client.Issues.AddLabelsToIssue(background, owner, repo, issueNumber, shouldAdd) + if err != nil { + logging.Info("failed to add labels %v: %s", shouldAdd, err.Error()) + } + } + if len(shouldRemove) > 0 { + for _, label := range shouldRemove { + _, err := g.client.Issues.RemoveLabelForIssue(background, owner, repo, issueNumber, label) + if err != nil { + logging.Info("failed to remove labels %s: %s", label, err.Error()) + } + } + } +} + +func PRVersionMetadata(m *versioning.MergedVersionReport, labelTypes map[string]github.Label) (string, *versioning.BumpType, []*github.Label) { + var labelBumpTypeAdded *versioning.BumpType + if m == nil { + return "", labelBumpTypeAdded, []*github.Label{} + } + labels := []*github.Label{} + skipBumpType := false + skipVersionNumber := false + singleBumpType := "" + singleNewVersion := "" + for _, report := range m.Reports { + if len(report.BumpType) > 0 && report.BumpType != versioning.BumpNone && report.BumpType != versioning.BumpCustom { + if len(singleBumpType) > 0 { + skipBumpType = true + } + singleBumpType = string(report.BumpType) + } + if len(report.NewVersion) > 0 { + if len(singleNewVersion) > 0 { + skipVersionNumber = true + } + singleNewVersion = report.NewVersion + } + } + var builder []string + if !skipVersionNumber { + builder = append(builder, singleNewVersion) + } + if !skipBumpType { + if matched, ok := labelTypes[singleBumpType]; ok { + labels = append(labels, &matched) + bumpType := versioning.BumpType(singleBumpType) + labelBumpTypeAdded = &bumpType + } + } + // Add an extra " " at front + if len(builder) > 0 { + builder = append([]string{""}, builder...) + } + return strings.Join(builder, " "), labelBumpTypeAdded, labels +} diff --git a/internal/run/run.go b/internal/run/run.go index 0da75acb..23c9c30f 100644 --- a/internal/run/run.go +++ b/internal/run/run.go @@ -10,6 +10,7 @@ import ( "github.com/google/go-github/v63/github" "github.com/speakeasy-api/sdk-generation-action/internal/utils" + "github.com/speakeasy-api/sdk-generation-action/internal/versionbumps" "github.com/speakeasy-api/versioning-reports/versioning" "github.com/speakeasy-api/sdk-gen-config/workflow" @@ -37,6 +38,7 @@ type RunResult struct { LintingReportURL string ChangesReportURL string VersioningReport *versioning.MergedVersionReport + VersioningInfo versionbumps.VersioningInfo } type Git interface { @@ -69,6 +71,12 @@ func Run(g Git, pr *github.PullRequest, wf *workflow.Workflow) (*RunResult, map[ repoSubdirectories := map[string]string{} previousManagementInfos := map[string]config.Management{} + var manualVersioningBump *versioning.BumpType + if versionBump := versionbumps.GetLabelBasedVersionBump(pr); versionBump != "" && versionBump != versioning.BumpNone { + fmt.Println("Using label based version bump: ", versionBump) + manualVersioningBump = &versionBump + } + getDirAndOutputDir := func(target workflow.Target) (string, string) { dir := "." if target.Output != nil { @@ -126,7 +134,7 @@ func Run(g Git, pr *github.PullRequest, wf *workflow.Workflow) (*RunResult, map[ var changereport *versioning.MergedVersionReport changereport, runRes, err = versioning.WithVersionReportCapture[*cli.RunResults](context.Background(), func(ctx context.Context) (*cli.RunResults, error) { - return cli.Run(wf.Targets == nil || len(wf.Targets) == 0, installationURLs, repoURL, repoSubdirectories) + return cli.Run(wf.Targets == nil || len(wf.Targets) == 0, installationURLs, repoURL, repoSubdirectories, manualVersioningBump) }) if err != nil { return nil, outputs, err @@ -139,8 +147,11 @@ func Run(g Git, pr *github.PullRequest, wf *workflow.Workflow) (*RunResult, map[ // no further steps fmt.Printf("No changes that imply the need for us to automatically regenerate the SDK.\n Use \"Force Generation\" if you want to force a new generation.\n Changes would include:\n-----\n%s", changereport.GetMarkdownSection()) return &RunResult{ - GenInfo: nil, - VersioningReport: changereport, + GenInfo: nil, + VersioningInfo: versionbumps.VersioningInfo{ + VersionReport: changereport, + ManualBump: versionbumps.ManualBumpWasUsed(manualVersioningBump, changereport), + }, OpenAPIChangeSummary: runRes.OpenAPIChangeSummary, LintingReportURL: runRes.LintingReportURL, ChangesReportURL: runRes.ChangesReportURL, @@ -233,8 +244,11 @@ func Run(g Git, pr *github.PullRequest, wf *workflow.Workflow) (*RunResult, map[ } return &RunResult{ - GenInfo: genInfo, - VersioningReport: changereport, + GenInfo: genInfo, + VersioningInfo: versionbumps.VersioningInfo{ + VersionReport: changereport, + ManualBump: versionbumps.ManualBumpWasUsed(manualVersioningBump, changereport), + }, OpenAPIChangeSummary: runRes.OpenAPIChangeSummary, LintingReportURL: runRes.LintingReportURL, ChangesReportURL: runRes.ChangesReportURL, diff --git a/internal/versionbumps/versionBumps.go b/internal/versionbumps/versionBumps.go new file mode 100644 index 00000000..67f08f26 --- /dev/null +++ b/internal/versionbumps/versionBumps.go @@ -0,0 +1,119 @@ +package versionbumps + +import ( + "fmt" + "regexp" + + "github.com/google/go-github/v63/github" + "github.com/speakeasy-api/versioning-reports/versioning" + "golang.org/x/exp/slices" +) + +type BumpMethod string + +// Enum values for BumpMethod +const ( + BumpMethodManual BumpMethod = "👤" + BumpMethodAutomated BumpMethod = "🤖" +) + +var bumpTypeLabels = map[versioning.BumpType]string{ + versioning.BumpMajor: "Major version bump", + versioning.BumpMinor: "Minor version bump", + versioning.BumpPatch: "Patch version bump", + versioning.BumpGraduate: "Graduate prerelease to stable", + versioning.BumpPrerelease: "Bump by a prerelease version", +} + +type VersioningInfo struct { + ManualBump bool + VersionReport *versioning.MergedVersionReport +} + +func GetBumpTypeLabels() map[versioning.BumpType]string { + return bumpTypeLabels +} + +func GetLabelBasedVersionBump(pr *github.PullRequest) versioning.BumpType { + if pr == nil { + return versioning.BumpNone + } + + var bumpLabels []versioning.BumpType + for _, label := range pr.Labels { + if _, ok := bumpTypeLabels[versioning.BumpType(label.GetName())]; ok { + bumpLabels = append(bumpLabels, versioning.BumpType(label.GetName())) + } + } + + if bumpType := stackRankBumpLabels(bumpLabels); bumpType != versioning.BumpNone { + currentPRBumpType, currentPRBumpMethod, err := parseBumpFromPRBody(pr.GetBody()) + if err != nil { + fmt.Errorf("failed to parse bump type and mode from PR body: %w", err) + return versioning.BumpNone + } + + // rules for explicit label versioning + // if the current Bump Type != label based versioning Bump we will use the label based versioning Bump + // if the current Bump Type == label based versioning Bump and that was manually set we will stick to it + if currentPRBumpType != bumpType || currentPRBumpMethod == BumpMethodManual { + return bumpType + } + } + + return versioning.BumpNone +} + +func ManualBumpWasUsed(bumpType *versioning.BumpType, versionReport *versioning.MergedVersionReport) bool { + if bumpType == nil || versionReport == nil { + return false + } + + for _, report := range versionReport.Reports { + if report.BumpType == *bumpType { + return true + } + } + + return false +} + +// We get the recorded BumpType and BumpMethod out of the PR body +func parseBumpFromPRBody(prBody string) (versioning.BumpType, BumpMethod, error) { + // be very careful if changing this regex, it is critical + re := regexp.MustCompile(`Version Bump Type:\s*\[(\w+)]\s*-\s*(👤|🤖)`) + matches := re.FindStringSubmatch(prBody) + + // Check if the expected parts were found + if len(matches) != 3 { + return "", "", fmt.Errorf("failed to parse bump type and mode from PR body") + } + + // Extract bump type and mode + bumpType := matches[1] + mode := matches[2] + if _, ok := bumpTypeLabels[versioning.BumpType(bumpType)]; !ok { + return "", "", fmt.Errorf("invalid bump type: %s", bumpType) + } + + return versioning.BumpType(bumpType), BumpMethod(mode), nil +} + +// If someone happens to have multiple version labels applied we have a specific priority rankings for determining bump type +func stackRankBumpLabels(bumpLabels []versioning.BumpType) versioning.BumpType { + // Priority order from highest to lowest + priorityOrder := []versioning.BumpType{ + versioning.BumpGraduate, + versioning.BumpMajor, + versioning.BumpMinor, + versioning.BumpPatch, + } + + for _, priority := range priorityOrder { + if slices.Contains(bumpLabels, priority) { + return priority + } + } + + return versioning.BumpNone +}