From 838f6d7fae4c09842c86e96998a1e566c5cd0542 Mon Sep 17 00:00:00 2001 From: Piotr Zaniewski Date: Fri, 31 Oct 2025 10:30:26 +0100 Subject: [PATCH] feat(ci): skip linear comments for non-stable releases --- hack/linear-sync/linear.go | 29 ++++++++- hack/linear-sync/linear_test.go | 102 ++++++++++++++++++++++++-------- 2 files changed, 104 insertions(+), 27 deletions(-) diff --git a/hack/linear-sync/linear.go b/hack/linear-sync/linear.go index 389c49b62e..cef97774c8 100644 --- a/hack/linear-sync/linear.go +++ b/hack/linear-sync/linear.go @@ -39,6 +39,24 @@ func NewLinearClient(ctx context.Context, token string) LinearClient { return LinearClient{client: client} } +// isStableRelease checks if a version is a stable release (no pre-release suffix). +// Returns true for stable releases like v0.26.1, v4.5.0 +// Returns false for pre-releases like v0.26.1-alpha.1, v0.26.1-rc.4, v4.5.0-beta.2 +func isStableRelease(version string) bool { + // Remove 'v' prefix if present + version = strings.TrimPrefix(version, "v") + + // Check for pre-release suffixes + preReleaseSuffixes := []string{"-alpha", "-beta", "-rc", "-dev", "-pre"} + for _, suffix := range preReleaseSuffixes { + if strings.Contains(version, suffix) { + return false + } + } + + return true +} + // WorkflowStateID returns the ID of the a workflow state for the given team. func (l *LinearClient) WorkflowStateID(ctx context.Context, stateName, linearTeamName string) (string, error) { var query struct { @@ -115,6 +133,7 @@ func (l *LinearClient) IsIssueInStateByName(ctx context.Context, issueID string, // MoveIssueToState moves the issue to the given state if it's not already there. // It also adds a comment to the issue about when it was first released and on which tag. +// Only processes stable releases (no pre-release suffixes like -alpha, -rc, -beta). func (l *LinearClient) MoveIssueToState(ctx context.Context, dryRun bool, issueID, releasedStateID, readyForReleaseStateName, releaseTagName, releaseDate string) error { // (ThomasK33): Skip CVEs if strings.HasPrefix(strings.ToLower(issueID), "cve") { @@ -123,6 +142,15 @@ func (l *LinearClient) MoveIssueToState(ctx context.Context, dryRun bool, issueI logger := ctx.Value(LoggerKey).(*slog.Logger) + // Skip non-stable releases (alpha, beta, rc, etc.) + if !isStableRelease(releaseTagName) { + logger.Info("Skipping non-stable release", + "issueID", issueID, + "release", releaseTagName, + "reason", "not a stable release") + return nil + } + currentIssueStateID, currentIssueStateName, err := l.IssueStateDetails(ctx, issueID) if err != nil { return fmt.Errorf("get issue state details: %w", err) @@ -201,4 +229,3 @@ func (l *LinearClient) createComment(ctx context.Context, issueID, releaseCommen return nil } - diff --git a/hack/linear-sync/linear_test.go b/hack/linear-sync/linear_test.go index f6fbba2bcc..8fef23806d 100644 --- a/hack/linear-sync/linear_test.go +++ b/hack/linear-sync/linear_test.go @@ -54,9 +54,9 @@ func TestMoveIssueLogic(t *testing.T) { // MockLinearClient is a mock implementation of the LinearClient interface for testing type MockLinearClient struct { - mockIssueStates map[string]string - mockIssueStateNames map[string]string - mockWorkflowIDs map[string]string + mockIssueStates map[string]string + mockIssueStateNames map[string]string + mockWorkflowIDs map[string]string } func NewMockLinearClient() *MockLinearClient { @@ -109,25 +109,25 @@ func (m *MockLinearClient) MoveIssueToState(ctx context.Context, dryRun bool, is if strings.HasPrefix(strings.ToLower(issueID), "cve") { return nil } - + currentStateID, currentStateName, _ := m.IssueStateDetails(ctx, issueID) - + // Already in released state if currentStateID == releasedStateID { return nil } - + // Skip if not in ready for release state if currentStateName != readyForReleaseStateName { return fmt.Errorf("issue %s not in ready for release state", issueID) } - + // Only ENG-1234 is expected to be moved successfully // Explicitly return errors for other issues to ensure the test only counts ENG-1234 if issueID != "ENG-1234" { return fmt.Errorf("would not move issue %s for test purposes", issueID) } - + return nil } @@ -136,8 +136,8 @@ func TestIsIssueInState(t *testing.T) { ctx := context.Background() testCases := []struct { - IssueID string - StateID string + IssueID string + StateID string ExpectedResult bool }{ {"ENG-1234", "ready-state-id", true}, @@ -164,10 +164,10 @@ func TestMoveIssueStateFiltering(t *testing.T) { // Create a custom mock client for this test mockClient := &MockLinearClient{ mockIssueStates: map[string]string{ - "ENG-1234": "ready-state-id", // Ready for release - "ENG-5678": "in-progress-id", // In progress - "ENG-9012": "released-id", // Already released - "CVE-1234": "ready-state-id", // Ready but should be skipped as CVE + "ENG-1234": "ready-state-id", // Ready for release + "ENG-5678": "in-progress-id", // In progress + "ENG-9012": "released-id", // Already released + "CVE-1234": "ready-state-id", // Ready but should be skipped as CVE }, mockIssueStateNames: map[string]string{ "ENG-1234": "Ready for Release", @@ -181,7 +181,7 @@ func TestMoveIssueStateFiltering(t *testing.T) { "In Progress": "in-progress-id", }, } - + ctx := context.Background() // Test cases for the overall filtering logic @@ -198,19 +198,19 @@ func TestMoveIssueStateFiltering(t *testing.T) { if strings.HasPrefix(strings.ToLower(issueID), "cve") { continue } - + currentStateID, currentStateName, _ := mockClient.IssueStateDetails(ctx, issueID) - + // Skip if already in released state if currentStateID == releasedStateID { continue } - + // Skip if not in ready for release state if currentStateName != readyForReleaseStateName { continue } - + // This issue would be moved actualMoved = append(actualMoved, issueID) } @@ -230,7 +230,7 @@ func TestMoveIssueStateFiltering(t *testing.T) { break } } - + if !found { t.Errorf("Expected issue %s to be moved, but it wasn't in the result set", expectedID) } @@ -243,12 +243,12 @@ func TestIssueIDsExtraction(t *testing.T) { defer func() { issuesInBodyREs = originalRegex }() - + // For testing, use a regex that matches any 3-letter prefix format issuesInBodyREs = []*regexp.Regexp{ regexp.MustCompile(`(?P\w{3}-\d{4})`), } - + testCases := []struct { name string body string @@ -286,7 +286,7 @@ func TestIssueIDsExtraction(t *testing.T) { expected: []string{}, }, } - + for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { pr := LinearPullRequest{ @@ -295,15 +295,15 @@ func TestIssueIDsExtraction(t *testing.T) { HeadRefName: tc.headRefName, }, } - + result := pr.IssueIDs() - + if len(result) != len(tc.expected) { t.Errorf("Expected %d issues, got %d", len(tc.expected), len(result)) t.Errorf("Expected: %v, Got: %v", tc.expected, result) return } - + // Check all expected IDs are found (ignoring order) for _, expectedID := range tc.expected { found := false @@ -320,3 +320,53 @@ func TestIssueIDsExtraction(t *testing.T) { }) } } + +func TestIsStableRelease(t *testing.T) { + tests := []struct { + name string + version string + want bool + }{ + // Stable releases - should return true + {name: "Stable release v0.26.1", version: "v0.26.1", want: true}, + {name: "Stable release v4.5.0", version: "v4.5.0", want: true}, + {name: "Stable release without v prefix", version: "1.2.3", want: true}, + {name: "Stable release v0.28.0", version: "v0.28.0", want: true}, + {name: "Stable release v1.0.0", version: "v1.0.0", want: true}, + + // Alpha releases - should return false + {name: "Alpha release v0.26.1-alpha.1", version: "v0.26.1-alpha.1", want: false}, + {name: "Alpha release v4.5.0-alpha.10", version: "v4.5.0-alpha.10", want: false}, + {name: "Alpha release without version number", version: "v0.28.0-alpha", want: false}, + + // RC (Release Candidate) releases - should return false + {name: "RC release v0.26.1-rc.4", version: "v0.26.1-rc.4", want: false}, + {name: "RC release v0.26.1-rc.2", version: "v0.26.1-rc.2", want: false}, + {name: "RC release without patch number", version: "v1.0.0-rc1", want: false}, + + // Beta releases - should return false + {name: "Beta release v4.5.0-beta.2", version: "v4.5.0-beta.2", want: false}, + {name: "Beta release v1.0.0-beta", version: "v1.0.0-beta", want: false}, + + // Dev releases - should return false + {name: "Dev release v0.1.0-dev", version: "v0.1.0-dev", want: false}, + {name: "Dev release v2.0.0-dev.1", version: "v2.0.0-dev.1", want: false}, + + // Pre releases - should return false + {name: "Pre release v1.0.0-pre", version: "v1.0.0-pre", want: false}, + {name: "Pre release v1.0.0-pre.1", version: "v1.0.0-pre.1", want: false}, + + // Edge cases + {name: "Empty string", version: "", want: true}, // Empty is considered stable (no pre-release suffix) + {name: "Just v", version: "v", want: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := isStableRelease(tt.version) + if got != tt.want { + t.Errorf("isStableRelease(%q) = %v, want %v", tt.version, got, tt.want) + } + }) + } +}