Skip to content

Commit 51ce47a

Browse files
committed
fix: resolve conflict markers in linear-sync backport
1 parent e13646a commit 51ce47a

File tree

2 files changed

+168
-60
lines changed

2 files changed

+168
-60
lines changed

hack/linear-sync/linear_test.go

Lines changed: 112 additions & 30 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,17 +243,12 @@ func TestIssueIDsExtraction(t *testing.T) {
243243
defer func() {
244244
issuesInBodyREs = originalRegex
245245
}()
246-
<<<<<<< HEAD
247-
248-
// For testing, use a regex that matches any 3-letter prefix format
249-
=======
250246

251247
// For testing, use a regex that matches team keys of 2-10 chars and issue numbers 1-5 digits
252-
>>>>>>> 3aa6f7157 (fix(linear-sync): support variable-length team keys in issue regex (#3469))
253248
issuesInBodyREs = []*regexp.Regexp{
254249
regexp.MustCompile(`(?P<issue>\w{2,10}-\d{1,5})`),
255250
}
256-
251+
257252
testCases := []struct {
258253
name string
259254
body string
@@ -321,7 +316,7 @@ func TestIssueIDsExtraction(t *testing.T) {
321316
expected: []string{"eng-12345"},
322317
},
323318
}
324-
319+
325320
for _, tc := range testCases {
326321
t.Run(tc.name, func(t *testing.T) {
327322
pr := LinearPullRequest{
@@ -330,15 +325,15 @@ func TestIssueIDsExtraction(t *testing.T) {
330325
HeadRefName: tc.headRefName,
331326
},
332327
}
333-
328+
334329
result := pr.IssueIDs()
335-
330+
336331
if len(result) != len(tc.expected) {
337332
t.Errorf("Expected %d issues, got %d", len(tc.expected), len(result))
338333
t.Errorf("Expected: %v, Got: %v", tc.expected, result)
339334
return
340335
}
341-
336+
342337
// Check all expected IDs are found (ignoring order)
343338
for _, expectedID := range tc.expected {
344339
found := false
@@ -355,3 +350,90 @@ func TestIssueIDsExtraction(t *testing.T) {
355350
})
356351
}
357352
}
353+
354+
func TestIsStableRelease(t *testing.T) {
355+
testCases := []struct {
356+
version string
357+
expected bool
358+
}{
359+
// Stable releases
360+
{"v0.26.1", true},
361+
{"v4.5.0", true},
362+
{"v1.0.0", true},
363+
{"0.26.1", true}, // without v prefix
364+
{"v27.0.0", true},
365+
366+
// Pre-releases
367+
{"v0.26.1-alpha.1", false},
368+
{"v0.26.1-alpha.5", false},
369+
{"v0.26.1-beta.1", false},
370+
{"v0.26.1-rc.1", false},
371+
{"v0.26.1-rc.4", false},
372+
{"v0.26.1-dev.1", false},
373+
{"v0.26.1-pre.1", false},
374+
{"v0.26.1-next.1", false},
375+
{"v4.5.0-beta.2", false},
376+
{"0.27.0-alpha.1", false}, // without v prefix
377+
}
378+
379+
for _, tc := range testCases {
380+
t.Run(tc.version, func(t *testing.T) {
381+
result := isStableRelease(tc.version)
382+
if result != tc.expected {
383+
t.Errorf("isStableRelease(%q) = %v, want %v", tc.version, result, tc.expected)
384+
}
385+
})
386+
}
387+
}
388+
389+
func TestStableReleaseCommentText(t *testing.T) {
390+
// Test the comment text logic for different scenarios
391+
testCases := []struct {
392+
name string
393+
alreadyReleased bool
394+
isStable bool
395+
releaseTag string
396+
releaseDate string
397+
expectedContains string
398+
}{
399+
{
400+
name: "First release (pre-release)",
401+
alreadyReleased: false,
402+
isStable: false,
403+
releaseTag: "v0.27.0-alpha.1",
404+
releaseDate: "2025-01-15",
405+
expectedContains: "first released in",
406+
},
407+
{
408+
name: "First release (stable)",
409+
alreadyReleased: false,
410+
isStable: true,
411+
releaseTag: "v0.27.0",
412+
releaseDate: "2025-02-01",
413+
expectedContains: "first released in",
414+
},
415+
{
416+
name: "Stable release on already-released issue",
417+
alreadyReleased: true,
418+
isStable: true,
419+
releaseTag: "v0.27.0",
420+
releaseDate: "2025-02-01",
421+
expectedContains: "Now available in stable release",
422+
},
423+
}
424+
425+
for _, tc := range testCases {
426+
t.Run(tc.name, func(t *testing.T) {
427+
var releaseComment string
428+
if tc.alreadyReleased && tc.isStable {
429+
releaseComment = fmt.Sprintf("Now available in stable release %v (released %v)", tc.releaseTag, tc.releaseDate)
430+
} else {
431+
releaseComment = fmt.Sprintf("This issue was first released in %v on %v", tc.releaseTag, tc.releaseDate)
432+
}
433+
434+
if !strings.Contains(releaseComment, tc.expectedContains) {
435+
t.Errorf("Comment %q does not contain expected text %q", releaseComment, tc.expectedContains)
436+
}
437+
})
438+
}
439+
}

hack/linear-sync/main.go

Lines changed: 56 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ var (
2222
ErrMissingReleaseTag = errors.New("release tag must be set")
2323
)
2424

25-
var LoggerKey struct{}
25+
var LoggerKey = struct{ name string }{"logger"}
2626

2727
func main() {
2828
if err := run(context.Background(), io.Writer(os.Stderr), os.Args); err != nil {
@@ -38,17 +38,17 @@ func run(
3838
) error {
3939
flagset := flag.NewFlagSet(args[0], flag.ExitOnError)
4040
var (
41-
owner = flagset.String("owner", "loft-sh", "The GitHub owner of the repository")
42-
repo = flagset.String("repo", "vcluster", "The GitHub repository to generate the changelog for")
43-
githubToken = flagset.String("token", "", "The GitHub token to use for authentication")
44-
previousTag = flagset.String("previous-tag", "", "The previous tag to generate the changelog for (if not set, the last stable release will be used)")
45-
releaseTag = flagset.String("release-tag", "", "The tag of the new release")
46-
debug = flagset.Bool("debug", false, "Enable debug logging")
47-
linearToken = flagset.String("linear-token", "", "The Linear token to use for authentication")
48-
releasedStateName = flagset.String("released-state-name", "Released", "The name of the state to use for the released state")
41+
owner = flagset.String("owner", "loft-sh", "The GitHub owner of the repository")
42+
repo = flagset.String("repo", "vcluster", "The GitHub repository to generate the changelog for")
43+
githubToken = flagset.String("token", "", "The GitHub token to use for authentication")
44+
previousTag = flagset.String("previous-tag", "", "The previous tag to generate the changelog for (if not set, the last stable release will be used)")
45+
releaseTag = flagset.String("release-tag", "", "The tag of the new release")
46+
debug = flagset.Bool("debug", false, "Enable debug logging")
47+
linearToken = flagset.String("linear-token", "", "The Linear token to use for authentication")
48+
releasedStateName = flagset.String("released-state-name", "Released", "The name of the state to use for the released state")
4949
readyForReleaseStateName = flagset.String("ready-for-release-state-name", "Ready for Release", "The name of the state that indicates an issue is ready to be released")
50-
linearTeamName = flagset.String("linear-team-name", "vCluster / Platform", "The name of the team to use for the linear team")
51-
dryRun = flagset.Bool("dry-run", false, "Do not actually move issues to the released state")
50+
dryRun = flagset.Bool("dry-run", false, "Do not actually move issues to the released state")
51+
strictFiltering = flagset.Bool("strict-filtering", true, "Only include PRs that were actually merged before the release was published (recommended to avoid false positives)")
5252
)
5353
if err := flagset.Parse(args[1:]); err != nil {
5454
return fmt.Errorf("parse flags: %w", err)
@@ -139,9 +139,20 @@ func run(
139139
return fmt.Errorf("fetch all PRs until: %w", err)
140140
}
141141

142-
pullRequests := NewLinearPullRequests(prs)
143-
144-
logger.Info("Found merged pull requests between releases", "count", len(pullRequests), "previous", stableTag, "current", *releaseTag)
142+
var pullRequests []LinearPullRequest
143+
if *strictFiltering {
144+
// Filter PRs to only include those that were actually part of this release
145+
filteredPRs, err := pullrequests.FetchPRsForRelease(ctx, gqlClient, *owner, *repo, stableTag, *releaseTag, currentRelease.PublishedAt.Time)
146+
if err != nil {
147+
return fmt.Errorf("filter PRs for release: %w", err)
148+
}
149+
pullRequests = NewLinearPullRequests(filteredPRs)
150+
logger.Info("Found merged pull requests for release", "total", len(prs), "filtered", len(pullRequests), "previous", stableTag, "current", *releaseTag)
151+
} else {
152+
// Use all PRs between tags (original behavior)
153+
pullRequests = NewLinearPullRequests(prs)
154+
logger.Info("Found merged pull requests between releases", "count", len(pullRequests), "previous", stableTag, "current", *releaseTag)
155+
}
145156

146157
releasedIssues := []string{}
147158

@@ -162,27 +173,46 @@ func run(
162173

163174
linearClient := NewLinearClient(ctx, *linearToken)
164175

165-
releasedStateID, err := linearClient.WorkflowStateID(ctx, *releasedStateName, *linearTeamName)
166-
if err != nil {
167-
return fmt.Errorf("get released workflow ID: %w", err)
168-
}
169-
170-
logger.Debug("Found released workflow ID", "workflowID", releasedStateID)
176+
// Cache of team name -> released state ID (looked up on demand)
177+
releasedStateIDByTeam := make(map[string]string)
171178

172-
readyForReleaseStateID, err := linearClient.WorkflowStateID(ctx, *readyForReleaseStateName, *linearTeamName)
173-
if err != nil {
174-
return fmt.Errorf("get ready for release workflow ID: %w", err)
179+
// Helper to get released state ID for a team (with caching)
180+
getReleasedStateID := func(teamName string) (string, error) {
181+
if stateID, ok := releasedStateIDByTeam[teamName]; ok {
182+
return stateID, nil
183+
}
184+
stateID, err := linearClient.WorkflowStateID(ctx, *releasedStateName, teamName)
185+
if err != nil {
186+
return "", err
187+
}
188+
releasedStateIDByTeam[teamName] = stateID
189+
logger.Debug("Found released workflow ID for team", "team", teamName, "workflowID", stateID)
190+
return stateID, nil
175191
}
176192

177-
logger.Debug("Found ready for release workflow ID", "workflowID", readyForReleaseStateID)
178-
179193
currentReleaseDateStr := currentRelease.PublishedAt.Format("2006-01-02")
180194

181195
releasedCount := 0
182196
skippedCount := 0
183197

184198
for _, issueID := range releasedIssues {
185-
if err := linearClient.MoveIssueToState(ctx, *dryRun, issueID, releasedStateID, *readyForReleaseStateName, currentRelease.TagName, currentReleaseDateStr); err != nil {
199+
// Get issue details including team
200+
issueDetails, err := linearClient.GetIssueDetails(ctx, issueID)
201+
if err != nil {
202+
logger.Error("Failed to get issue details", "issueID", issueID, "error", err)
203+
skippedCount++
204+
continue
205+
}
206+
207+
// Get the released state ID for this issue's team
208+
releasedStateID, err := getReleasedStateID(issueDetails.TeamName)
209+
if err != nil {
210+
logger.Error("Failed to get released state for team", "issueID", issueID, "team", issueDetails.TeamName, "error", err)
211+
skippedCount++
212+
continue
213+
}
214+
215+
if err := linearClient.MoveIssueToState(ctx, *dryRun, issueID, issueDetails, releasedStateID, *readyForReleaseStateName, currentRelease.TagName, currentReleaseDateStr); err != nil {
186216
logger.Error("Failed to move issue to state", "issueID", issueID, "error", err)
187217
skippedCount++
188218
} else {
@@ -193,9 +223,6 @@ func run(
193223
logger.Info("Linear sync completed", "processed", len(releasedIssues), "released", releasedCount, "skipped", skippedCount)
194224

195225
return nil
196-
<<<<<<< HEAD
197-
}
198-
=======
199226
}
200227

201228
// deduplicateIssueIDs removes duplicate issue IDs from the slice while preserving order
@@ -210,4 +237,3 @@ func deduplicateIssueIDs(issueIDs []string) []string {
210237
}
211238
return result
212239
}
213-
>>>>>>> cfcf45a9d (fix(ci): duplicate comments prevented via issue id deduplication (#3449))

0 commit comments

Comments
 (0)