Skip to content

Commit 30e67c4

Browse files
committed
feat(ci): allow stable releases to comment on already released issues
1 parent f1c56cb commit 30e67c4

File tree

2 files changed

+167
-44
lines changed

2 files changed

+167
-44
lines changed

hack/linear-sync/linear.go

Lines changed: 54 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,24 @@ func NewLinearClient(ctx context.Context, token string) LinearClient {
3939
return LinearClient{client: client}
4040
}
4141

42+
// isStableRelease checks if a version is a stable release (no pre-release suffix).
43+
// Returns true for stable releases like v0.26.1, v4.5.0
44+
// Returns false for pre-releases like v0.26.1-alpha.1, v0.26.1-rc.4, v4.5.0-beta.2
45+
func isStableRelease(version string) bool {
46+
// Remove 'v' prefix if present
47+
version = strings.TrimPrefix(version, "v")
48+
49+
// Check for pre-release suffixes
50+
preReleaseSuffixes := []string{"-alpha", "-beta", "-rc", "-dev", "-pre", "-next"}
51+
for _, suffix := range preReleaseSuffixes {
52+
if strings.Contains(version, suffix) {
53+
return false
54+
}
55+
}
56+
57+
return true
58+
}
59+
4260
// WorkflowStateID returns the ID of the a workflow state for the given team.
4361
func (l *LinearClient) WorkflowStateID(ctx context.Context, stateName, linearTeamName string) (string, error) {
4462
var query struct {
@@ -115,6 +133,7 @@ func (l *LinearClient) IsIssueInStateByName(ctx context.Context, issueID string,
115133

116134
// MoveIssueToState moves the issue to the given state if it's not already there.
117135
// It also adds a comment to the issue about when it was first released and on which tag.
136+
// For stable releases on already-released issues, it adds a "now available in stable" comment.
118137
func (l *LinearClient) MoveIssueToState(ctx context.Context, dryRun bool, issueID, releasedStateID, readyForReleaseStateName, releaseTagName, releaseDate string) error {
119138
// (ThomasK33): Skip CVEs
120139
if strings.HasPrefix(strings.ToLower(issueID), "cve") {
@@ -123,31 +142,51 @@ func (l *LinearClient) MoveIssueToState(ctx context.Context, dryRun bool, issueI
123142

124143
logger := ctx.Value(LoggerKey).(*slog.Logger)
125144

145+
isStable := isStableRelease(releaseTagName)
146+
126147
currentIssueStateID, currentIssueStateName, err := l.IssueStateDetails(ctx, issueID)
127148
if err != nil {
128149
return fmt.Errorf("get issue state details: %w", err)
129150
}
130151

131-
if currentIssueStateID == releasedStateID {
132-
logger.Debug("Issue already has desired state", "issueID", issueID, "stateID", releasedStateID)
133-
return nil
134-
}
135-
136-
// Skip issues not in ready for release state
137-
if currentIssueStateName != readyForReleaseStateName {
138-
logger.Debug("Skipping issue not in ready for release state", "issueID", issueID, "currentState", currentIssueStateName, "requiredState", readyForReleaseStateName)
139-
return nil
140-
}
152+
alreadyReleased := currentIssueStateID == releasedStateID
141153

142-
if !dryRun {
143-
if err := l.updateIssueState(ctx, issueID, releasedStateID); err != nil {
144-
return fmt.Errorf("update issue state: %w", err)
154+
// If already in released state:
155+
// - Pre-releases: skip entirely (already released in a previous pre-release)
156+
// - Stable releases: skip state update but add "now available in stable" comment
157+
if alreadyReleased {
158+
if !isStable {
159+
logger.Debug("Issue already has desired state", "issueID", issueID, "stateID", releasedStateID)
160+
return nil
145161
}
162+
logger.Debug("Issue already released, adding stable release comment", "issueID", issueID)
146163
} else {
147-
logger.Info("Would update issue state", "issueID", issueID, "releasedStateID", releasedStateID)
164+
// Skip issues not in ready for release state
165+
if currentIssueStateName != readyForReleaseStateName {
166+
logger.Debug("Skipping issue not in ready for release state", "issueID", issueID, "currentState", currentIssueStateName, "requiredState", readyForReleaseStateName)
167+
return nil
168+
}
169+
170+
// Update issue state to Released
171+
if !dryRun {
172+
if err := l.updateIssueState(ctx, issueID, releasedStateID); err != nil {
173+
return fmt.Errorf("update issue state: %w", err)
174+
}
175+
} else {
176+
logger.Info("Would update issue state", "issueID", issueID, "releasedStateID", releasedStateID)
177+
}
178+
logger.Info("Moved issue to desired state", "issueID", issueID, "stateID", releasedStateID)
148179
}
149180

150-
releaseComment := fmt.Sprintf("This issue was first released in %v on %v", releaseTagName, releaseDate)
181+
// Add release comment
182+
// Use different text for stable releases on already-released issues to avoid
183+
// confusion with the "first released in" pattern used by linear-webhook-service
184+
var releaseComment string
185+
if alreadyReleased && isStable {
186+
releaseComment = fmt.Sprintf("Now available in stable release %v (released %v)", releaseTagName, releaseDate)
187+
} else {
188+
releaseComment = fmt.Sprintf("This issue was first released in %v on %v", releaseTagName, releaseDate)
189+
}
151190

152191
if !dryRun {
153192
if err := l.createComment(ctx, issueID, releaseComment); err != nil {
@@ -157,8 +196,6 @@ func (l *LinearClient) MoveIssueToState(ctx context.Context, dryRun bool, issueI
157196
logger.Info("Would create comment on issue", "issueID", issueID, "comment", releaseComment)
158197
}
159198

160-
logger.Info("Moved issue to desired state", "issueID", issueID, "stateID", releasedStateID)
161-
162199
return nil
163200
}
164201

@@ -201,4 +238,3 @@ func (l *LinearClient) createComment(ctx context.Context, issueID, releaseCommen
201238

202239
return nil
203240
}
204-

hack/linear-sync/linear_test.go

Lines changed: 113 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -54,9 +54,9 @@ func TestMoveIssueLogic(t *testing.T) {
5454

5555
// MockLinearClient is a mock implementation of the LinearClient interface for testing
5656
type MockLinearClient struct {
57-
mockIssueStates map[string]string
58-
mockIssueStateNames map[string]string
59-
mockWorkflowIDs map[string]string
57+
mockIssueStates map[string]string
58+
mockIssueStateNames map[string]string
59+
mockWorkflowIDs map[string]string
6060
}
6161

6262
func NewMockLinearClient() *MockLinearClient {
@@ -109,25 +109,25 @@ func (m *MockLinearClient) MoveIssueToState(ctx context.Context, dryRun bool, is
109109
if strings.HasPrefix(strings.ToLower(issueID), "cve") {
110110
return nil
111111
}
112-
112+
113113
currentStateID, currentStateName, _ := m.IssueStateDetails(ctx, issueID)
114-
114+
115115
// Already in released state
116116
if currentStateID == releasedStateID {
117117
return nil
118118
}
119-
119+
120120
// Skip if not in ready for release state
121121
if currentStateName != readyForReleaseStateName {
122122
return fmt.Errorf("issue %s not in ready for release state", issueID)
123123
}
124-
124+
125125
// Only ENG-1234 is expected to be moved successfully
126126
// Explicitly return errors for other issues to ensure the test only counts ENG-1234
127127
if issueID != "ENG-1234" {
128128
return fmt.Errorf("would not move issue %s for test purposes", issueID)
129129
}
130-
130+
131131
return nil
132132
}
133133

@@ -136,8 +136,8 @@ func TestIsIssueInState(t *testing.T) {
136136
ctx := context.Background()
137137

138138
testCases := []struct {
139-
IssueID string
140-
StateID string
139+
IssueID string
140+
StateID string
141141
ExpectedResult bool
142142
}{
143143
{"ENG-1234", "ready-state-id", true},
@@ -164,10 +164,10 @@ func TestMoveIssueStateFiltering(t *testing.T) {
164164
// Create a custom mock client for this test
165165
mockClient := &MockLinearClient{
166166
mockIssueStates: map[string]string{
167-
"ENG-1234": "ready-state-id", // Ready for release
168-
"ENG-5678": "in-progress-id", // In progress
169-
"ENG-9012": "released-id", // Already released
170-
"CVE-1234": "ready-state-id", // Ready but should be skipped as CVE
167+
"ENG-1234": "ready-state-id", // Ready for release
168+
"ENG-5678": "in-progress-id", // In progress
169+
"ENG-9012": "released-id", // Already released
170+
"CVE-1234": "ready-state-id", // Ready but should be skipped as CVE
171171
},
172172
mockIssueStateNames: map[string]string{
173173
"ENG-1234": "Ready for Release",
@@ -181,7 +181,7 @@ func TestMoveIssueStateFiltering(t *testing.T) {
181181
"In Progress": "in-progress-id",
182182
},
183183
}
184-
184+
185185
ctx := context.Background()
186186

187187
// Test cases for the overall filtering logic
@@ -198,19 +198,19 @@ func TestMoveIssueStateFiltering(t *testing.T) {
198198
if strings.HasPrefix(strings.ToLower(issueID), "cve") {
199199
continue
200200
}
201-
201+
202202
currentStateID, currentStateName, _ := mockClient.IssueStateDetails(ctx, issueID)
203-
203+
204204
// Skip if already in released state
205205
if currentStateID == releasedStateID {
206206
continue
207207
}
208-
208+
209209
// Skip if not in ready for release state
210210
if currentStateName != readyForReleaseStateName {
211211
continue
212212
}
213-
213+
214214
// This issue would be moved
215215
actualMoved = append(actualMoved, issueID)
216216
}
@@ -230,7 +230,7 @@ func TestMoveIssueStateFiltering(t *testing.T) {
230230
break
231231
}
232232
}
233-
233+
234234
if !found {
235235
t.Errorf("Expected issue %s to be moved, but it wasn't in the result set", expectedID)
236236
}
@@ -243,12 +243,12 @@ func TestIssueIDsExtraction(t *testing.T) {
243243
defer func() {
244244
issuesInBodyREs = originalRegex
245245
}()
246-
246+
247247
// For testing, use a regex that matches any 3-letter prefix format
248248
issuesInBodyREs = []*regexp.Regexp{
249249
regexp.MustCompile(`(?P<issue>\w{3}-\d{4})`),
250250
}
251-
251+
252252
testCases := []struct {
253253
name string
254254
body string
@@ -286,7 +286,7 @@ func TestIssueIDsExtraction(t *testing.T) {
286286
expected: []string{},
287287
},
288288
}
289-
289+
290290
for _, tc := range testCases {
291291
t.Run(tc.name, func(t *testing.T) {
292292
pr := LinearPullRequest{
@@ -295,15 +295,15 @@ func TestIssueIDsExtraction(t *testing.T) {
295295
HeadRefName: tc.headRefName,
296296
},
297297
}
298-
298+
299299
result := pr.IssueIDs()
300-
300+
301301
if len(result) != len(tc.expected) {
302302
t.Errorf("Expected %d issues, got %d", len(tc.expected), len(result))
303303
t.Errorf("Expected: %v, Got: %v", tc.expected, result)
304304
return
305305
}
306-
306+
307307
// Check all expected IDs are found (ignoring order)
308308
for _, expectedID := range tc.expected {
309309
found := false
@@ -320,3 +320,90 @@ func TestIssueIDsExtraction(t *testing.T) {
320320
})
321321
}
322322
}
323+
324+
func TestIsStableRelease(t *testing.T) {
325+
testCases := []struct {
326+
version string
327+
expected bool
328+
}{
329+
// Stable releases
330+
{"v0.26.1", true},
331+
{"v4.5.0", true},
332+
{"v1.0.0", true},
333+
{"0.26.1", true}, // without v prefix
334+
{"v27.0.0", true},
335+
336+
// Pre-releases
337+
{"v0.26.1-alpha.1", false},
338+
{"v0.26.1-alpha.5", false},
339+
{"v0.26.1-beta.1", false},
340+
{"v0.26.1-rc.1", false},
341+
{"v0.26.1-rc.4", false},
342+
{"v0.26.1-dev.1", false},
343+
{"v0.26.1-pre.1", false},
344+
{"v0.26.1-next.1", false},
345+
{"v4.5.0-beta.2", false},
346+
{"0.27.0-alpha.1", false}, // without v prefix
347+
}
348+
349+
for _, tc := range testCases {
350+
t.Run(tc.version, func(t *testing.T) {
351+
result := isStableRelease(tc.version)
352+
if result != tc.expected {
353+
t.Errorf("isStableRelease(%q) = %v, want %v", tc.version, result, tc.expected)
354+
}
355+
})
356+
}
357+
}
358+
359+
func TestStableReleaseCommentText(t *testing.T) {
360+
// Test the comment text logic for different scenarios
361+
testCases := []struct {
362+
name string
363+
alreadyReleased bool
364+
isStable bool
365+
releaseTag string
366+
releaseDate string
367+
expectedContains string
368+
}{
369+
{
370+
name: "First release (pre-release)",
371+
alreadyReleased: false,
372+
isStable: false,
373+
releaseTag: "v0.27.0-alpha.1",
374+
releaseDate: "2025-01-15",
375+
expectedContains: "first released in",
376+
},
377+
{
378+
name: "First release (stable)",
379+
alreadyReleased: false,
380+
isStable: true,
381+
releaseTag: "v0.27.0",
382+
releaseDate: "2025-02-01",
383+
expectedContains: "first released in",
384+
},
385+
{
386+
name: "Stable release on already-released issue",
387+
alreadyReleased: true,
388+
isStable: true,
389+
releaseTag: "v0.27.0",
390+
releaseDate: "2025-02-01",
391+
expectedContains: "Now available in stable release",
392+
},
393+
}
394+
395+
for _, tc := range testCases {
396+
t.Run(tc.name, func(t *testing.T) {
397+
var releaseComment string
398+
if tc.alreadyReleased && tc.isStable {
399+
releaseComment = fmt.Sprintf("Now available in stable release %v (released %v)", tc.releaseTag, tc.releaseDate)
400+
} else {
401+
releaseComment = fmt.Sprintf("This issue was first released in %v on %v", tc.releaseTag, tc.releaseDate)
402+
}
403+
404+
if !strings.Contains(releaseComment, tc.expectedContains) {
405+
t.Errorf("Comment %q does not contain expected text %q", releaseComment, tc.expectedContains)
406+
}
407+
})
408+
}
409+
}

0 commit comments

Comments
 (0)