Skip to content

Commit be70d94

Browse files
authored
fix(linear-sync): look up team per issue instead of using global default (#3495)
"vCluster / Platform" team no longer exists after company reorg. Different teams have different workflow state IDs for "Released" state, so we must look up the released state ID per-team based on each issue's actual team. Key changes: - Remove hardcoded -linear-team-name flag - Add GetIssueDetails() to fetch issue state and team in single API call - Cache released state IDs by team name to avoid redundant lookups - Pass pre-fetched IssueDetails to MoveIssueToState() to eliminate double API call - Add debug info (available teams/states) when workflow lookup fails - Add deduplicateIssueIDs() to handle same issue in multiple PRs
1 parent ad7f975 commit be70d94

File tree

2 files changed

+151
-26
lines changed

2 files changed

+151
-26
lines changed

hack/linear-sync/linear.go

Lines changed: 120 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,18 @@ import (
1313

1414
var ErrNoWorkflowFound = errors.New("no workflow state found")
1515

16+
// AvailableWorkflowState lists all workflow states for a team (for debugging)
17+
type AvailableWorkflowState struct {
18+
Name string
19+
Team string
20+
}
21+
22+
// AvailableTeam lists a team with its key (for debugging)
23+
type AvailableTeam struct {
24+
Name string
25+
Key string
26+
}
27+
1628
type LinearClient struct {
1729
client *graphql.Client
1830
}
@@ -57,7 +69,58 @@ func isStableRelease(version string) bool {
5769
return true
5870
}
5971

72+
// ListTeams returns all available teams (for debugging workflow state lookup failures)
73+
func (l *LinearClient) ListTeams(ctx context.Context) ([]AvailableTeam, error) {
74+
var query struct {
75+
Teams struct {
76+
Nodes []struct {
77+
Name string
78+
Key string
79+
}
80+
} `graphql:"teams"`
81+
}
82+
83+
if err := l.client.Query(ctx, &query, nil); err != nil {
84+
return nil, fmt.Errorf("query failed: %w", err)
85+
}
86+
87+
teams := make([]AvailableTeam, len(query.Teams.Nodes))
88+
for i, t := range query.Teams.Nodes {
89+
teams[i] = AvailableTeam{Name: t.Name, Key: t.Key}
90+
}
91+
return teams, nil
92+
}
93+
94+
// ListWorkflowStates returns all workflow states for a team (for debugging workflow state lookup failures)
95+
func (l *LinearClient) ListWorkflowStates(ctx context.Context, teamName string) ([]AvailableWorkflowState, error) {
96+
var query struct {
97+
WorkflowStates struct {
98+
Nodes []struct {
99+
Name string
100+
Team struct {
101+
Name string
102+
}
103+
}
104+
} `graphql:"workflowStates(filter: { team: { name: { eq: $team } } })"`
105+
}
106+
107+
variables := map[string]any{
108+
"team": graphql.String(teamName),
109+
}
110+
111+
if err := l.client.Query(ctx, &query, variables); err != nil {
112+
return nil, fmt.Errorf("query failed: %w", err)
113+
}
114+
115+
states := make([]AvailableWorkflowState, len(query.WorkflowStates.Nodes))
116+
for i, s := range query.WorkflowStates.Nodes {
117+
states[i] = AvailableWorkflowState{Name: s.Name, Team: s.Team.Name}
118+
}
119+
return states, nil
120+
}
121+
60122
// WorkflowStateID returns the ID of the a workflow state for the given team.
123+
// If no matching state is found, it provides debugging information about available teams and states.
61124
func (l *LinearClient) WorkflowStateID(ctx context.Context, stateName, linearTeamName string) (string, error) {
62125
var query struct {
63126
WorkflowStates struct {
@@ -77,7 +140,32 @@ func (l *LinearClient) WorkflowStateID(ctx context.Context, stateName, linearTea
77140
}
78141

79142
if len(query.WorkflowStates.Nodes) == 0 {
80-
return "", ErrNoWorkflowFound
143+
// Provide debugging information about available teams and states
144+
debugInfo := fmt.Sprintf("searched for state %q in team %q", stateName, linearTeamName)
145+
146+
// Try to list available teams
147+
teams, err := l.ListTeams(ctx)
148+
if err == nil && len(teams) > 0 {
149+
teamNames := make([]string, len(teams))
150+
for i, t := range teams {
151+
teamNames[i] = fmt.Sprintf("%s (%s)", t.Name, t.Key)
152+
}
153+
debugInfo += fmt.Sprintf("; available teams: %s", strings.Join(teamNames, ", "))
154+
}
155+
156+
// Try to list available workflow states for the team
157+
states, err := l.ListWorkflowStates(ctx, linearTeamName)
158+
if err == nil && len(states) > 0 {
159+
stateNames := make([]string, len(states))
160+
for i, s := range states {
161+
stateNames[i] = s.Name
162+
}
163+
debugInfo += fmt.Sprintf("; available states for team: %s", strings.Join(stateNames, ", "))
164+
} else if err == nil {
165+
debugInfo += "; no states found for team (team may not exist or may have been renamed)"
166+
}
167+
168+
return "", fmt.Errorf("%w: %s", ErrNoWorkflowFound, debugInfo)
81169
}
82170

83171
return query.WorkflowStates.Nodes[0].Id, nil
@@ -91,12 +179,31 @@ func (l *LinearClient) IssueState(ctx context.Context, issueID string) (string,
91179

92180
// IssueStateDetails returns the current state ID and name of the issue.
93181
func (l *LinearClient) IssueStateDetails(ctx context.Context, issueID string) (string, string, error) {
182+
details, err := l.GetIssueDetails(ctx, issueID)
183+
if err != nil {
184+
return "", "", err
185+
}
186+
return details.StateID, details.StateName, nil
187+
}
188+
189+
// IssueDetails contains state and team information for an issue
190+
type IssueDetails struct {
191+
StateID string
192+
StateName string
193+
TeamName string
194+
}
195+
196+
// GetIssueDetails returns state and team information for an issue.
197+
func (l *LinearClient) GetIssueDetails(ctx context.Context, issueID string) (*IssueDetails, error) {
94198
var query struct {
95199
Issue struct {
96200
State struct {
97201
Id string
98202
Name string
99203
}
204+
Team struct {
205+
Name string
206+
}
100207
} `graphql:"issue(id: $id)"`
101208
}
102209

@@ -105,10 +212,14 @@ func (l *LinearClient) IssueStateDetails(ctx context.Context, issueID string) (s
105212
}
106213

107214
if err := l.client.Query(ctx, &query, variables); err != nil {
108-
return "", "", fmt.Errorf("query failed (issue ID: %v): %w", issueID, err)
215+
return nil, fmt.Errorf("query failed (issue ID: %v): %w", issueID, err)
109216
}
110217

111-
return query.Issue.State.Id, query.Issue.State.Name, nil
218+
return &IssueDetails{
219+
StateID: query.Issue.State.Id,
220+
StateName: query.Issue.State.Name,
221+
TeamName: query.Issue.Team.Name,
222+
}, nil
112223
}
113224

114225
// IsIssueInState checks if an issue is in a specific state.
@@ -131,10 +242,11 @@ func (l *LinearClient) IsIssueInStateByName(ctx context.Context, issueID string,
131242
return currentStateName == stateName, nil
132243
}
133244

134-
// MoveIssueToState moves the issue to the given state if it's not already there.
245+
// MoveIssueToState moves the issue to the given state if it's not already there and if it's in the ready for release state.
135246
// It also adds a comment to the issue about when it was first released and on which tag.
136247
// For stable releases on already-released issues, it adds a "now available in stable" comment.
137-
func (l *LinearClient) MoveIssueToState(ctx context.Context, dryRun bool, issueID, releasedStateID, readyForReleaseStateName, releaseTagName, releaseDate string) error {
248+
// issueDetails should be pre-fetched via GetIssueDetails to avoid redundant API calls.
249+
func (l *LinearClient) MoveIssueToState(ctx context.Context, dryRun bool, issueID string, issueDetails *IssueDetails, releasedStateID, readyForReleaseStateName, releaseTagName, releaseDate string) error {
138250
// (ThomasK33): Skip CVEs
139251
if strings.HasPrefix(strings.ToLower(issueID), "cve") {
140252
return nil
@@ -144,12 +256,7 @@ func (l *LinearClient) MoveIssueToState(ctx context.Context, dryRun bool, issueI
144256

145257
isStable := isStableRelease(releaseTagName)
146258

147-
currentIssueStateID, currentIssueStateName, err := l.IssueStateDetails(ctx, issueID)
148-
if err != nil {
149-
return fmt.Errorf("get issue state details: %w", err)
150-
}
151-
152-
alreadyReleased := currentIssueStateID == releasedStateID
259+
alreadyReleased := issueDetails.StateID == releasedStateID
153260

154261
// If already in released state:
155262
// - Pre-releases: skip entirely (already released in a previous pre-release)
@@ -162,8 +269,8 @@ func (l *LinearClient) MoveIssueToState(ctx context.Context, dryRun bool, issueI
162269
logger.Debug("Issue already released, adding stable release comment", "issueID", issueID)
163270
} else {
164271
// 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)
272+
if issueDetails.StateName != readyForReleaseStateName {
273+
logger.Debug("Skipping issue not in ready for release state", "issueID", issueID, "currentState", issueDetails.StateName, "requiredState", readyForReleaseStateName)
167274
return nil
168275
}
169276

hack/linear-sync/main.go

Lines changed: 31 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,6 @@ func run(
4747
linearToken = flagset.String("linear-token", "", "The Linear token to use for authentication")
4848
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")
5150
dryRun = flagset.Bool("dry-run", false, "Do not actually move issues to the released state")
5251
strictFiltering = flagset.Bool("strict-filtering", true, "Only include PRs that were actually merged before the release was published (recommended to avoid false positives)")
5352
)
@@ -174,27 +173,46 @@ func run(
174173

175174
linearClient := NewLinearClient(ctx, *linearToken)
176175

177-
releasedStateID, err := linearClient.WorkflowStateID(ctx, *releasedStateName, *linearTeamName)
178-
if err != nil {
179-
return fmt.Errorf("get released workflow ID: %w", err)
180-
}
176+
// Cache of team name -> released state ID (looked up on demand)
177+
releasedStateIDByTeam := make(map[string]string)
181178

182-
logger.Debug("Found released workflow ID", "workflowID", releasedStateID)
183-
184-
readyForReleaseStateID, err := linearClient.WorkflowStateID(ctx, *readyForReleaseStateName, *linearTeamName)
185-
if err != nil {
186-
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
187191
}
188192

189-
logger.Debug("Found ready for release workflow ID", "workflowID", readyForReleaseStateID)
190-
191193
currentReleaseDateStr := currentRelease.PublishedAt.Format("2006-01-02")
192194

193195
releasedCount := 0
194196
skippedCount := 0
195197

196198
for _, issueID := range releasedIssues {
197-
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 {
198216
logger.Error("Failed to move issue to state", "issueID", issueID, "error", err)
199217
skippedCount++
200218
} else {

0 commit comments

Comments
 (0)