package ghapi import ( "strings" "fleetdm/gm/pkg/logger" ) // ActionType represents the type of action to be performed on an issue. type ActionType string // newworkflow new actions added must have a new actiontype const ( ATAddLabel ActionType = "add_label" ATRemoveLabel ActionType = "remove_label" ATAddIssueToProject ActionType = "add_issue_to_project" ATRemoveIssueFromProject ActionType = "remove_issue_from_project" ATSetStatus ActionType = "set_status" ATSyncEstimate ActionType = "sync_estimate" ATSetSprint ActionType = "set_sprint" ATCloseIssue ActionType = "close_issue" ) // Action represents a single action to be performed on an issue. type Action struct { Type ActionType `json:"type"` Issue Issue `json:"issue"` Project int `json:"project,omitempty"` // Project ID for project-related actions Label string `json:"label,omitempty"` // Label for label-related actions Status string `json:"status,omitempty"` // Status for status-related actions Sprint string `json:"sprint,omitempty"` // Sprint for sprint-related actions SourceProject int `json:"source_project,omitempty"` // Source project for moving issues } // Status represents the status of an item in a project. type Status struct { Index int `json:"index"` State string `json:"state"` } // newworkflow new workflow 'action builder' functions are here // CreateBulkAddLableAction creates actions to add a label to multiple issues. func CreateBulkAddLableAction(issues []Issue, label string) []Action { var actions []Action for _, issue := range issues { actions = append(actions, Action{ Type: ATAddLabel, Issue: issue, Label: label, }) } return actions } // CreateBulkRemoveLabelAction creates actions to remove a label from multiple issues. func CreateBulkRemoveLabelAction(issues []Issue, label string) []Action { var actions []Action for _, issue := range issues { actions = append(actions, Action{ Type: ATRemoveLabel, Issue: issue, Label: label, }) } return actions } // CreateBulkAddIssueToProjectAction creates actions to add multiple issues to a project. func CreateBulkAddIssueToProjectAction(issues []Issue, projectID int) []Action { var actions []Action for _, issue := range issues { actions = append(actions, Action{ Type: ATAddIssueToProject, Issue: issue, Project: projectID, }) } return actions } // CreateBulkRemoveIssueFromProjectAction creates actions to remove multiple issues from a project. func CreateBulkRemoveIssueFromProjectAction(issues []Issue, projectID int) []Action { var actions []Action for _, issue := range issues { actions = append(actions, Action{ Type: ATRemoveIssueFromProject, Issue: issue, Project: projectID, }) } return actions } // CreateBulkSetStatusAction creates actions to set the status for multiple issues in a project. func CreateBulkSetStatusAction(issues []Issue, projectID int, status string) []Action { var actions []Action for _, issue := range issues { actions = append(actions, Action{ Type: ATSetStatus, Issue: issue, Project: projectID, Status: status, }) } return actions } // CreateBulkSyncEstimateAction creates actions to sync estimates from source to target projects for multiple issues. func CreateBulkSyncEstimateAction(issues []Issue, sourceProjectID, targetProjectID int) []Action { var actions []Action for _, issue := range issues { actions = append(actions, Action{ Type: ATSyncEstimate, Issue: issue, SourceProject: sourceProjectID, Project: targetProjectID, }) } return actions } // CreateBulkSetSprintAction creates actions to set the sprint for multiple issues in a project. func CreateBulkSetSprintAction(issues []Issue, projectID int) []Action { var actions []Action for _, issue := range issues { actions = append(actions, Action{ Type: ATSetSprint, Issue: issue, Project: projectID, }) } return actions } // CreateBulkMoveToCurrentSprintIfNotReadyQA creates actions to set current sprint for issues whose // status does NOT contain "ready" or "qa" (case-insensitive) in the given project. func CreateBulkMoveToCurrentSprintIfNotReadyQA(issues []Issue, projectID int) []Action { var filtered []Issue for _, is := range issues { s := strings.ToLower(strings.TrimSpace(is.Status)) if s == "" { // No status -> include (move to current sprint) filtered = append(filtered, is) continue } if strings.Contains(s, "ready") || strings.Contains(s, "qa") { // Skip statuses with ready or qa continue } filtered = append(filtered, is) } return CreateBulkSetSprintAction(filtered, projectID) } func CreateBulkSprintKickoffActions(issues []Issue, sourceProjectID, projectID int) []Action { logger.Infof("Creating sprint kickoff actions for %d issues (source project: %d, target project: %d)", len(issues), sourceProjectID, projectID) actions := CreateBulkAddIssueToProjectAction(issues, projectID) actions = append(actions, CreateBulkAddLableAction(issues, ":release")...) actions = append(actions, CreateBulkSyncEstimateAction(issues, sourceProjectID, projectID)...) actions = append(actions, CreateBulkSetSprintAction(issues, projectID)...) actions = append(actions, CreateBulkRemoveLabelAction(issues, ":product")...) actions = append(actions, CreateBulkRemoveIssueFromProjectAction(issues, Aliases["draft"])...) logger.Infof("Created %d sprint kickoff actions", len(actions)) return actions } func CreateBulkMilestoneCloseActions(issues []Issue) []Action { logger.Infof("Creating milestone close actions for %d issues", len(issues)) // Split issues by type var storyIssues []Issue var closeIssues []Issue for _, is := range issues { isStory := false isBug := false isSubTask := false for _, l := range is.Labels { name := strings.ToLower(strings.TrimSpace(l.Name)) if name == "story" { isStory = true } else if name == "bug" { isBug = true } else if name == "~sub-task" { isSubTask = true } } if isStory { storyIssues = append(storyIssues, is) } else if isBug || isSubTask { closeIssues = append(closeIssues, is) } else { // default: treat as story to keep previous behavior storyIssues = append(storyIssues, is) } } var actions []Action if len(storyIssues) > 0 { actions = append(actions, CreateBulkAddIssueToProjectAction(storyIssues, Aliases["draft"])...) actions = append(actions, CreateBulkAddLableAction(storyIssues, ":product")...) actions = append(actions, CreateBulkSetStatusAction(storyIssues, Aliases["draft"], "confirm and")...) actions = append(actions, CreateBulkRemoveLabelAction(storyIssues, ":release")...) } for _, is := range closeIssues { actions = append(actions, Action{Type: ATCloseIssue, Issue: is}) } logger.Infof("Created %d milestone close actions (stories=%d, close=%d)", len(actions), len(storyIssues), len(closeIssues)) return actions } func CreateBulkKickOutOfSprintActions(issues []Issue, sourceProjectID int) []Action { logger.Infof("Creating kick out of sprint actions for %d issues (source project: %d)", len(issues), sourceProjectID) actions := CreateBulkAddIssueToProjectAction(issues, Aliases["draft"]) actions = append(actions, CreateBulkSetStatusAction(issues, Aliases["draft"], "estimated")...) actions = append(actions, CreateBulkSyncEstimateAction(issues, sourceProjectID, Aliases["draft"])...) actions = append(actions, CreateBulkAddLableAction(issues, ":product")...) actions = append(actions, CreateBulkRemoveLabelAction(issues, ":release")...) actions = append(actions, CreateBulkRemoveIssueFromProjectAction(issues, sourceProjectID)...) logger.Infof("Created %d kick out of sprint actions", len(actions)) return actions } // AsyncManager takes a list of actions and a channel to process them assynchronously. // This will allow to send status back on the channel for live updates. the channel must return index of the action // and the status of the action. func AsyncManager(actions []Action, statusChan chan<- Status) { defer close(statusChan) logger.Infof("Starting AsyncManager with %d actions", len(actions)) for i, action := range actions { logger.Infof("Processing action %d/%d: %s for issue #%d", i+1, len(actions), action.Type, action.Issue.Number) switch action.Type { // newworkflow new actions must be supported in this switch case ATAddLabel: err := AddLabelToIssue(action.Issue.Number, action.Label) if err != nil { logger.Errorf("Failed to add label '%s' to issue #%d: %v", action.Label, action.Issue.Number, err) statusChan <- Status{Index: i, State: "error"} continue } logger.Infof("Successfully added label '%s' to issue #%d", action.Label, action.Issue.Number) statusChan <- Status{Index: i, State: "success"} case ATRemoveLabel: err := RemoveLabelFromIssue(action.Issue.Number, action.Label) if err != nil { logger.Errorf("Failed to remove label '%s' from issue #%d: %v", action.Label, action.Issue.Number, err) statusChan <- Status{Index: i, State: "error"} continue } logger.Infof("Successfully removed label '%s' from issue #%d", action.Label, action.Issue.Number) statusChan <- Status{Index: i, State: "success"} case ATAddIssueToProject: err := AddIssueToProject(action.Issue.Number, action.Project) if err != nil { logger.Errorf("Failed to add issue #%d to project %d: %v", action.Issue.Number, action.Project, err) statusChan <- Status{Index: i, State: "error"} continue } logger.Infof("Successfully added issue #%d to project %d", action.Issue.Number, action.Project) statusChan <- Status{Index: i, State: "success"} case ATRemoveIssueFromProject: err := RemoveIssueFromProject(action.Issue.Number, action.Project) if err != nil { logger.Errorf("Failed to remove issue #%d from project %d: %v", action.Issue.Number, action.Project, err) statusChan <- Status{Index: i, State: "error"} continue } logger.Infof("Successfully removed issue #%d from project %d", action.Issue.Number, action.Project) statusChan <- Status{Index: i, State: "success"} case ATSetStatus: err := SetIssueStatus(action.Issue.Number, action.Project, action.Status) if err != nil { logger.Errorf("Failed to set status '%s' for issue #%d in project %d: %v", action.Status, action.Issue.Number, action.Project, err) statusChan <- Status{Index: i, State: "error"} continue } logger.Infof("Successfully set status '%s' for issue #%d in project %d", action.Status, action.Issue.Number, action.Project) statusChan <- Status{Index: i, State: "success"} case ATSyncEstimate: err := SyncEstimateField(action.Issue.Number, action.SourceProject, action.Project) if err != nil { logger.Errorf("Failed to sync estimate for issue #%d from project %d to project %d: %v", action.Issue.Number, action.SourceProject, action.Project, err) statusChan <- Status{Index: i, State: "error"} continue } logger.Infof("Successfully synced estimate for issue #%d from project %d to project %d", action.Issue.Number, action.SourceProject, action.Project) statusChan <- Status{Index: i, State: "success"} case ATSetSprint: err := SetCurrentSprint(action.Issue.Number, action.Project) if err != nil { logger.Errorf("Failed to set current sprint for issue #%d in project %d: %v", action.Issue.Number, action.Project, err) statusChan <- Status{Index: i, State: "error"} continue } logger.Infof("Successfully set current sprint for issue #%d in project %d", action.Issue.Number, action.Project) statusChan <- Status{Index: i, State: "success"} case ATCloseIssue: err := CloseIssue(action.Issue.Number) if err != nil { logger.Errorf("Failed to close issue #%d: %v", action.Issue.Number, err) statusChan <- Status{Index: i, State: "error"} continue } logger.Infof("Successfully closed issue #%d", action.Issue.Number) statusChan <- Status{Index: i, State: "success"} default: logger.Errorf("Unknown action type: %s for issue #%d", action.Type, action.Issue.Number) statusChan <- Status{Index: i, State: "error"} } } logger.Info("AsyncManager completed all actions") } // // IMPORTANT: TUI workflows should use the Async methods above instead of the synchronous methods below // // These methods are more for testing individual steps w/o tui. // BulkAddLabel adds a label to multiple issues. func BulkAddLabel(issues []Issue, label string) error { logger.Infof("Adding label '%s' to %d issues", label, len(issues)) for _, issue := range issues { err := AddLabelToIssue(issue.Number, label) if err != nil { logger.Errorf("Failed to add label '%s' to issue #%d: %v", label, issue.Number, err) return err } logger.Debugf("Added label '%s' to issue #%d", label, issue.Number) } logger.Infof("Successfully added label '%s' to all %d issues", label, len(issues)) return nil } // BulkRemoveLabel removes a label from multiple issues. func BulkRemoveLabel(issues []Issue, label string) error { logger.Infof("Removing label '%s' from %d issues", label, len(issues)) for _, issue := range issues { err := RemoveLabelFromIssue(issue.Number, label) if err != nil { logger.Errorf("Failed to remove label '%s' from issue #%d: %v", label, issue.Number, err) return err } logger.Debugf("Removed label '%s' from issue #%d", label, issue.Number) } logger.Infof("Successfully removed label '%s' from all %d issues", label, len(issues)) return nil } // BulkSprintKickoff performs the sprint kickoff workflow for multiple issues. // This includes adding issues to the target project, adding release labels, syncing estimates, and setting sprints. func BulkSprintKickoff(issues []Issue, sourceProjectID, projectID int) error { logger.Infof("Starting sprint kickoff workflow for %d issues (source: %d, target: %d)", len(issues), sourceProjectID, projectID) // Add ticket to the target product group project logger.Info("Step 1/6: Adding issues to target project") for _, issue := range issues { err := AddIssueToProject(issue.Number, projectID) if err != nil { logger.Errorf("Failed to add issue #%d to project %d: %v", issue.Number, projectID, err) return err } logger.Debugf("Added issue #%d to project %d", issue.Number, projectID) } // Add the `:release` label to each issue logger.Info("Step 2/6: Adding release labels") err := BulkAddLabel(issues, ":release") if err != nil { return err } // Sync the Estimate field from drafting project to the target product group project logger.Info("Step 3/6: Syncing estimates") for _, issue := range issues { err := SyncEstimateField(issue.Number, sourceProjectID, projectID) if err != nil { logger.Errorf("Failed to sync estimate for issue #%d: %v", issue.Number, err) return err } logger.Debugf("Synced estimate for issue #%d", issue.Number) } // Set the sprint to the current sprint logger.Info("Step 4/6: Setting current sprint") for _, issue := range issues { err := SetCurrentSprint(issue.Number, projectID) if err != nil { logger.Errorf("Failed to set current sprint for issue #%d: %v", issue.Number, err) return err } logger.Debugf("Set current sprint for issue #%d", issue.Number) } // Remove the `:product` label from each issue logger.Info("Step 5/6: Removing product labels") err = BulkRemoveLabel(issues, ":product") if err != nil { return err } // Remove from the drafting project logger.Info("Step 6/6: Removing from drafting project") draftingProjectID := Aliases["draft"] for _, issue := range issues { err := RemoveIssueFromProject(issue.Number, draftingProjectID) if err != nil { logger.Errorf("Failed to remove issue #%d from drafting project: %v", issue.Number, err) return err } logger.Debugf("Removed issue #%d from drafting project", issue.Number) } logger.Info("Sprint kickoff workflow completed successfully") return nil } // BulkMilestoneClose performs the milestone close workflow for multiple issues. // This includes moving issues back to the drafting project and removing them from product group projects. func BulkMilestoneClose(issues []Issue) error { logger.Infof("Starting milestone close workflow for %d issues", len(issues)) draftingProjectID := Aliases["draft"] var storyIssues []Issue var closeIssues []Issue for _, is := range issues { isStory := false isBug := false isSubTask := false for _, l := range is.Labels { name := strings.ToLower(strings.TrimSpace(l.Name)) if name == "story" { isStory = true } else if name == "bug" { isBug = true } else if name == "~sub-task" { isSubTask = true } } if isStory { storyIssues = append(storyIssues, is) } else if isBug || isSubTask { closeIssues = append(closeIssues, is) } else { storyIssues = append(storyIssues, is) } } // Process story issues: move to drafting, add :product, set status, remove :release if len(storyIssues) > 0 { logger.Infof("Processing %d story issues for milestone close", len(storyIssues)) // Step 1: Add to drafting for _, issue := range storyIssues { err := AddIssueToProject(issue.Number, draftingProjectID) if err != nil { logger.Errorf("Failed to add issue #%d to drafting project: %v", issue.Number, err) return err } } // Step 2: Add :product if err := BulkAddLabel(storyIssues, ":product"); err != nil { return err } // Step 3: Set status to confirm and celebrate for _, issue := range storyIssues { err := SetIssueStatus(issue.Number, draftingProjectID, "confirm and celebrate") if err != nil { logger.Errorf("Failed to set status for issue #%d: %v", issue.Number, err) return err } } // Step 4: Remove :release if err := BulkRemoveLabel(storyIssues, ":release"); err != nil { return err } } // Process bug/sub-task issues: close them if len(closeIssues) > 0 { logger.Infof("Closing %d bug/sub-task issues for milestone close", len(closeIssues)) for _, issue := range closeIssues { if err := CloseIssue(issue.Number); err != nil { logger.Errorf("Failed to close issue #%d: %v", issue.Number, err) return err } } } logger.Info("Milestone close workflow completed successfully") return nil } // BulkKickOutOfSprint performs the kick out of sprint workflow for multiple issues. // This includes moving issues back to the drafting project, setting status to estimated, // syncing estimates from source project, and updating labels. func BulkKickOutOfSprint(issues []Issue, sourceProjectID int) error { logger.Infof("Starting kick out of sprint workflow for %d issues (source: %d)", len(issues), sourceProjectID) // Add issues to the drafting project logger.Info("Step 1/5: Adding issues to drafting project") draftingProjectID := Aliases["draft"] for _, issue := range issues { err := AddIssueToProject(issue.Number, draftingProjectID) if err != nil { logger.Errorf("Failed to add issue #%d to drafting project: %v", issue.Number, err) return err } logger.Debugf("Added issue #%d to drafting project", issue.Number) } // Set the status to "estimated" logger.Info("Step 2/5: Setting status to 'estimated'") for _, issue := range issues { err := SetIssueStatus(issue.Number, draftingProjectID, "estimated") if err != nil { logger.Errorf("Failed to set status for issue #%d: %v", issue.Number, err) return err } logger.Debugf("Set status for issue #%d", issue.Number) } // Sync the Estimate field from source project to the drafting project logger.Info("Step 3/5: Syncing estimates from source project") for _, issue := range issues { err := SyncEstimateField(issue.Number, sourceProjectID, draftingProjectID) if err != nil { logger.Errorf("Failed to sync estimate for issue #%d: %v", issue.Number, err) return err } logger.Debugf("Synced estimate for issue #%d", issue.Number) } // Add the `:product` label to each issue logger.Info("Step 4/5: Adding product labels") err := BulkAddLabel(issues, ":product") if err != nil { return err } // Remove the `:release` label from each issue logger.Info("Step 5/5: Removing release labels") err = BulkRemoveLabel(issues, ":release") if err != nil { return err } logger.Info("Kick out of sprint workflow completed successfully") return nil }