mirror of
https://github.com/fleetdm/fleet
synced 2026-04-21 21:47:20 +00:00
- **Adding new command gm milestone report to veiw all statuses from all projects for all issues tied to a milestone** - **Add descriptions to workflow select and new workflows** - **Adding new commands**
932 lines
28 KiB
Go
932 lines
28 KiB
Go
// Package ghapi provides GitHub project management functionality including
|
|
// project item management, field operations, and GraphQL API interactions.
|
|
package ghapi
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"fleetdm/gm/pkg/logger"
|
|
)
|
|
|
|
var Aliases = map[string]int{
|
|
"mdm": 58,
|
|
"g-mdm": 58,
|
|
"draft": 67,
|
|
"drafting": 67,
|
|
"g-software": 70,
|
|
"soft": 70,
|
|
"g-orchestration": 71,
|
|
"orch": 71,
|
|
"sec": 97,
|
|
"g-security-compliance": 97,
|
|
"roadmap": 87,
|
|
}
|
|
|
|
// ProjectLabels maps project IDs to their corresponding label filters for the drafting project
|
|
var ProjectLabels = map[int]string{
|
|
58: "#g-mdm", // mdm project
|
|
70: "#g-software", // g-software project
|
|
71: "#g-orchestration", // g-orchestration project
|
|
97: "#g-security-compliance", // g-security-compliance project
|
|
}
|
|
|
|
// ResolveProjectID resolves a project identifier (alias or numeric string) to a project ID.
|
|
func ResolveProjectID(identifier string) (int, error) {
|
|
// First check if it's an alias
|
|
if projectID, exists := Aliases[identifier]; exists {
|
|
return projectID, nil
|
|
}
|
|
|
|
// Try to parse as a number
|
|
if projectID, err := strconv.Atoi(identifier); err == nil {
|
|
return projectID, nil
|
|
}
|
|
|
|
return 0, fmt.Errorf("invalid project identifier '%s'. Must be a numeric ID or one of these aliases: %v", identifier, getAliasKeys())
|
|
}
|
|
|
|
// getAliasKeys returns a slice of all available alias keys.
|
|
func getAliasKeys() []string {
|
|
keys := make([]string, 0, len(Aliases))
|
|
for k := range Aliases {
|
|
keys = append(keys, k)
|
|
}
|
|
return keys
|
|
}
|
|
|
|
// ParseJSONtoProjectItems converts JSON data to a slice of ProjectItem structs.
|
|
func ParseJSONtoProjectItems(jsonData []byte, limit int) ([]ProjectItem, int, error) {
|
|
var items ProjectItemsResponse
|
|
err := json.Unmarshal(jsonData, &items)
|
|
if err != nil {
|
|
return nil, 0, err
|
|
}
|
|
if items.TotalCount > limit {
|
|
logger.Infof("Project item limit less than total, could be missing items - l: %d, t: %d", limit, items.TotalCount)
|
|
}
|
|
return items.Items, items.TotalCount, nil
|
|
}
|
|
|
|
// GetProjectItemsWithTotal retrieves project items and returns the server reported total count.
|
|
func GetProjectItemsWithTotal(projectID, limit int) ([]ProjectItem, int, error) {
|
|
results, err := runCommandWithRetry(fmt.Sprintf("gh project item-list --owner fleetdm --format json --limit %d %d", limit, projectID), 5, 2*time.Second)
|
|
if err != nil {
|
|
return nil, 0, err
|
|
}
|
|
items, total, err := ParseJSONtoProjectItems(results, limit)
|
|
if err != nil {
|
|
return nil, 0, err
|
|
}
|
|
return items, total, nil
|
|
}
|
|
|
|
// GetProjectItems retrieves project items for a specific project with a limit.
|
|
func GetProjectItems(projectID, limit int) ([]ProjectItem, error) { // backward compatible helper
|
|
items, _, err := GetProjectItemsWithTotal(projectID, limit)
|
|
return items, err
|
|
}
|
|
|
|
// GetCurrentSprintItems returns only the items in the current sprint for a project.
|
|
// It first fetches all items (up to limit) and then filters to those that have a non-nil
|
|
// Sprint field whose Title matches the active iteration. The active iteration is determined
|
|
// by looking up the sprint field configuration and selecting the first iteration (same logic
|
|
// used by SetCurrentSprint when applying @current).
|
|
// GetCurrentSprintItemsWithTotal returns current sprint items plus total available items in project.
|
|
func GetCurrentSprintItemsWithTotal(projectID, limit int) ([]ProjectItem, int, error) {
|
|
items, total, err := GetProjectItemsWithTotal(projectID, limit)
|
|
if err != nil {
|
|
return nil, 0, err
|
|
}
|
|
projectNodeID, err := getProjectNodeID(projectID)
|
|
if err != nil {
|
|
return nil, total, fmt.Errorf("failed to get project node id: %v", err)
|
|
}
|
|
sprintField, err := LookupProjectFieldName(projectID, "sprint")
|
|
if err != nil {
|
|
return []ProjectItem{}, total, nil // no sprint field
|
|
}
|
|
currentIterationID, err := getCurrentIterationID(projectNodeID, sprintField.ID)
|
|
if err != nil {
|
|
return nil, total, fmt.Errorf("failed to get current iteration id: %v", err)
|
|
}
|
|
var filtered []ProjectItem
|
|
for _, it := range items {
|
|
if it.Sprint != nil && it.Sprint.IterationId == currentIterationID {
|
|
filtered = append(filtered, it)
|
|
}
|
|
}
|
|
return filtered, total, nil
|
|
}
|
|
|
|
func GetCurrentSprintItems(projectID, limit int) ([]ProjectItem, error) { // backward compatible
|
|
items, _, err := GetCurrentSprintItemsWithTotal(projectID, limit)
|
|
return items, err
|
|
}
|
|
|
|
// GetPreviousSprintItemsWithTotal returns only the items in the previous sprint for a project.
|
|
// It mirrors GetCurrentSprintItemsWithTotal but selects the iteration immediately before current
|
|
// based on the iteration field configuration ordering.
|
|
func GetPreviousSprintItemsWithTotal(projectID, limit int) ([]ProjectItem, int, error) {
|
|
items, total, err := GetProjectItemsWithTotal(projectID, limit)
|
|
if err != nil {
|
|
return nil, 0, err
|
|
}
|
|
projectNodeID, err := getProjectNodeID(projectID)
|
|
if err != nil {
|
|
return nil, total, fmt.Errorf("failed to get project node id: %v", err)
|
|
}
|
|
sprintField, err := LookupProjectFieldName(projectID, "sprint")
|
|
if err != nil {
|
|
return []ProjectItem{}, total, nil // no sprint field
|
|
}
|
|
// Get current iteration details (ID, start date, duration)
|
|
curID, curStart, curDuration, err := getCurrentIterationDetails(projectNodeID, sprintField.ID)
|
|
if err != nil || curID == "" || curStart == "" || curDuration <= 0 {
|
|
return []ProjectItem{}, total, fmt.Errorf("failed to get current iteration details: %v", err)
|
|
}
|
|
// Compute previous iteration start date as ISO date string
|
|
prevStart, err := computePrevStartDate(curStart, curDuration)
|
|
if err != nil {
|
|
return []ProjectItem{}, total, fmt.Errorf("failed to compute previous iteration start date: %v", err)
|
|
}
|
|
var filtered []ProjectItem
|
|
for _, it := range items {
|
|
if it.Sprint != nil && strings.TrimSpace(it.Sprint.StartDate) == prevStart {
|
|
filtered = append(filtered, it)
|
|
}
|
|
}
|
|
return filtered, total, nil
|
|
}
|
|
|
|
// Backward compatible helper
|
|
func GetPreviousSprintItems(projectID, limit int) ([]ProjectItem, error) {
|
|
items, _, err := GetPreviousSprintItemsWithTotal(projectID, limit)
|
|
return items, err
|
|
}
|
|
|
|
// GetProjectFields retrieves all fields for a specific project.
|
|
func GetProjectFields(projectID int) (map[string]ProjectField, error) {
|
|
// Run the command to get project fields
|
|
results, err := RunCommandAndReturnOutput(fmt.Sprintf("gh project field-list --owner fleetdm --format json %d", projectID))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var fields ProjectFieldsResponse
|
|
err = json.Unmarshal(results, &fields)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
fieldMap := make(map[string]ProjectField)
|
|
for _, field := range fields.Fields {
|
|
fieldMap[field.Name] = field
|
|
}
|
|
return fieldMap, nil
|
|
}
|
|
|
|
// LoadProjectFields loads project fields from cache or API.
|
|
func LoadProjectFields(projectID int) (map[string]ProjectField, error) {
|
|
if fields, exists := MapProjectFieldNameToField[projectID]; exists {
|
|
return fields, nil
|
|
}
|
|
fields, err := GetProjectFields(projectID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
MapProjectFieldNameToField[projectID] = fields
|
|
return fields, nil
|
|
}
|
|
|
|
// LookupProjectFieldName looks up a project field by name (case-insensitive).
|
|
func LookupProjectFieldName(projectID int, fieldName string) (ProjectField, error) {
|
|
fields, err := LoadProjectFields(projectID)
|
|
if err != nil {
|
|
return ProjectField{}, err
|
|
}
|
|
|
|
// First try exact match
|
|
if field, exists := fields[fieldName]; exists {
|
|
return field, nil
|
|
}
|
|
|
|
// Then try case-insensitive match
|
|
lowercaseSearch := strings.ToLower(fieldName)
|
|
for name, field := range fields {
|
|
if strings.ToLower(name) == lowercaseSearch {
|
|
return field, nil
|
|
}
|
|
}
|
|
|
|
return ProjectField{}, fmt.Errorf("field '%s' not found in project %d", fieldName, projectID)
|
|
}
|
|
|
|
// FindFieldValueByName finds a field option by partial name match (case-insensitive).
|
|
func FindFieldValueByName(projectID int, fieldName, search string) (string, error) {
|
|
field, err := LookupProjectFieldName(projectID, fieldName)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
for _, option := range field.Options {
|
|
if strings.Contains(strings.ToLower(option.Name), strings.ToLower(search)) {
|
|
return option.Name, nil
|
|
}
|
|
}
|
|
return "", fmt.Errorf("field '%s' not found in project %d", fieldName, projectID)
|
|
}
|
|
|
|
// SetProjectItemFieldValue sets a field value for a project item.
|
|
// Uses GraphQL node IDs for proper API compatibility.
|
|
func SetProjectItemFieldValue(itemID string, projectID int, fieldName, value string) error {
|
|
// Get the field information
|
|
field, err := LookupProjectFieldName(projectID, fieldName)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to lookup field '%s': %v", fieldName, err)
|
|
}
|
|
|
|
// Get the project's GraphQL node ID
|
|
projectNodeID, err := getProjectNodeID(projectID)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to get project node ID: %v", err)
|
|
}
|
|
|
|
// If itemID is provided, use it directly
|
|
if itemID != "" {
|
|
// For number fields (like Estimate) - try different possible type names
|
|
if field.Type == "NUMBER" || field.Type == "ProjectV2Field" || strings.Contains(strings.ToLower(field.Type), "number") {
|
|
command := fmt.Sprintf(`gh api graphql -f query='mutation { updateProjectV2ItemFieldValue(input: { projectId: "%s", itemId: "%s", fieldId: "%s", value: { number: %s } }) { projectV2Item { id } } }'`,
|
|
projectNodeID, itemID, field.ID, value)
|
|
|
|
_, err := RunCommandAndReturnOutput(command)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to set number field value: %v", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// For single select fields (like Status)
|
|
if field.Type == "SINGLE_SELECT" || field.Type == "ProjectV2SingleSelectField" || strings.Contains(strings.ToLower(field.Type), "select") {
|
|
// Use FindFieldValueByName to get the actual option name (which may include emojis)
|
|
actualOptionName, err := FindFieldValueByName(projectID, fieldName, value)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to find option '%s' for field '%s': %v", value, fieldName, err)
|
|
}
|
|
|
|
// Find the option ID for the actual option name
|
|
var optionID string
|
|
for _, option := range field.Options {
|
|
if option.Name == actualOptionName {
|
|
optionID = option.ID
|
|
break
|
|
}
|
|
}
|
|
|
|
if optionID == "" {
|
|
return fmt.Errorf("option ID not found for '%s' in field '%s'", actualOptionName, fieldName)
|
|
}
|
|
|
|
command := fmt.Sprintf(`gh api graphql -f query='mutation { updateProjectV2ItemFieldValue(input: { projectId: "%s", itemId: "%s", fieldId: "%s", value: { singleSelectOptionId: "%s" } }) { projectV2Item { id } } }'`,
|
|
projectNodeID, itemID, field.ID, optionID)
|
|
|
|
_, err = RunCommandAndReturnOutput(command)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to set single select field value: %v", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// For iteration fields (Sprint)
|
|
if field.Type == "ITERATION" || field.Type == "ProjectV2IterationField" || strings.Contains(strings.ToLower(field.Type), "iteration") {
|
|
// For iteration fields, if value is "@current", we need to find the current iteration ID
|
|
if value == "@current" {
|
|
// First, get the current iteration ID for this field
|
|
currentIterationID, err := getCurrentIterationID(projectNodeID, field.ID)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to get current iteration ID: %v", err)
|
|
}
|
|
|
|
if currentIterationID == "" {
|
|
return fmt.Errorf("no current iteration found for field '%s'", fieldName)
|
|
}
|
|
|
|
// Use GraphQL mutation with the actual current iteration ID
|
|
command := fmt.Sprintf(`gh api graphql -f query='mutation { updateProjectV2ItemFieldValue(input: { projectId: "%s", itemId: "%s", fieldId: "%s", value: { iterationId: "%s" } }) { projectV2Item { id } } }'`,
|
|
projectNodeID, itemID, field.ID, currentIterationID)
|
|
|
|
out, err := RunCommandAndReturnOutput(command)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to set current iteration: %v", err)
|
|
}
|
|
logger.Debugf("GraphQL command input: %s", command)
|
|
logger.Debugf("GraphQL command output: %s", string(out))
|
|
return nil
|
|
}
|
|
|
|
// For other iteration values, we would need to look up the iteration ID
|
|
// This is more complex and would require additional API calls
|
|
return fmt.Errorf("setting specific iteration values (other than @current) is not yet implemented")
|
|
}
|
|
|
|
// For text fields
|
|
if field.Type == "TEXT" || strings.Contains(strings.ToLower(field.Type), "text") {
|
|
command := fmt.Sprintf(`gh api graphql -f query='mutation { updateProjectV2ItemFieldValue(input: { projectId: "%s", itemId: "%s", fieldId: "%s", value: { text: "%s" } }) { projectV2Item { id } } }'`,
|
|
projectNodeID, itemID, field.ID, value)
|
|
|
|
_, err := RunCommandAndReturnOutput(command)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to set text field value: %v", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// If we can't determine the type, try to infer from field name or context
|
|
if strings.EqualFold(fieldName, "Estimate") || strings.Contains(strings.ToLower(fieldName), "estimate") {
|
|
command := fmt.Sprintf(`gh api graphql -f query='mutation { updateProjectV2ItemFieldValue(input: { projectId: "%s", itemId: "%s", fieldId: "%s", value: { number: %s } }) { projectV2Item { id } } }'`,
|
|
projectNodeID, itemID, field.ID, value)
|
|
|
|
_, err := RunCommandAndReturnOutput(command)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to set number field value (inferred): %v", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
return fmt.Errorf("unsupported field type: %s for field: %s", field.Type, fieldName)
|
|
}
|
|
|
|
return fmt.Errorf("itemID is required for SetProjectItemFieldValue")
|
|
}
|
|
|
|
// GetProjectItemID finds the project item ID for a given issue number in a project with caching.
|
|
// Uses GitHub API directly for better performance and reliability.
|
|
func GetProjectItemID(issueNumber int, projectID int) (string, error) {
|
|
// Check cache first
|
|
cacheKey := generateProjectItemCacheKey(issueNumber, projectID)
|
|
projectItemIDMutex.RLock()
|
|
if itemID, exists := projectItemIDCache[cacheKey]; exists {
|
|
projectItemIDMutex.RUnlock()
|
|
return itemID, nil
|
|
}
|
|
projectItemIDMutex.RUnlock()
|
|
|
|
// Not in cache, fetch from API
|
|
|
|
// First, we need to get the project's node ID
|
|
projectNodeID, err := getProjectNodeID(projectID)
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to get project node ID: %v", err)
|
|
}
|
|
|
|
// GraphQL query to find the project item for the specific issue
|
|
query := fmt.Sprintf(`{
|
|
node(id: "%s") {
|
|
... on ProjectV2 {
|
|
items(first: 100) {
|
|
nodes {
|
|
id
|
|
content {
|
|
... on Issue {
|
|
number
|
|
}
|
|
}
|
|
}
|
|
pageInfo {
|
|
hasNextPage
|
|
endCursor
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}`, projectNodeID)
|
|
|
|
// Use gh api to execute the GraphQL query
|
|
command := fmt.Sprintf(`gh api graphql -f query='%s'`, query)
|
|
output, err := RunCommandAndReturnOutput(command)
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to query project items via API: %v", err)
|
|
}
|
|
|
|
// Parse the GraphQL response
|
|
var response struct {
|
|
Data struct {
|
|
Node struct {
|
|
Items struct {
|
|
Nodes []struct {
|
|
ID string `json:"id"`
|
|
Content struct {
|
|
Number int `json:"number"`
|
|
} `json:"content"`
|
|
} `json:"nodes"`
|
|
PageInfo struct {
|
|
HasNextPage bool `json:"hasNextPage"`
|
|
EndCursor string `json:"endCursor"`
|
|
} `json:"pageInfo"`
|
|
} `json:"items"`
|
|
} `json:"node"`
|
|
} `json:"data"`
|
|
}
|
|
|
|
err = json.Unmarshal(output, &response)
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to parse GraphQL response: %v", err)
|
|
}
|
|
|
|
// Search through the items to find the matching issue number
|
|
for _, item := range response.Data.Node.Items.Nodes {
|
|
if item.Content.Number == issueNumber {
|
|
// Cache the result
|
|
projectItemIDMutex.Lock()
|
|
projectItemIDCache[cacheKey] = item.ID
|
|
projectItemIDMutex.Unlock()
|
|
|
|
return item.ID, nil
|
|
}
|
|
}
|
|
|
|
// If we have more pages, we should search them too
|
|
if response.Data.Node.Items.PageInfo.HasNextPage {
|
|
return getProjectItemIDWithPagination(issueNumber, projectNodeID, response.Data.Node.Items.PageInfo.EndCursor, cacheKey)
|
|
}
|
|
|
|
return "", fmt.Errorf("issue #%d not found in project %d", issueNumber, projectID)
|
|
}
|
|
|
|
// getProjectNodeID gets the GraphQL node ID for a project with caching.
|
|
func getProjectNodeID(projectID int) (string, error) {
|
|
// Check cache first
|
|
projectNodeIDMutex.RLock()
|
|
if nodeID, exists := projectNodeIDCache[projectID]; exists {
|
|
projectNodeIDMutex.RUnlock()
|
|
return nodeID, nil
|
|
}
|
|
projectNodeIDMutex.RUnlock()
|
|
|
|
// Not in cache, fetch from API
|
|
command := fmt.Sprintf("gh project view --owner fleetdm --format json %d", projectID)
|
|
output, err := RunCommandAndReturnOutput(command)
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to get project details: %v", err)
|
|
}
|
|
|
|
var response struct {
|
|
ID string `json:"id"`
|
|
}
|
|
|
|
err = json.Unmarshal(output, &response)
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to parse project details response: %v", err)
|
|
}
|
|
|
|
if response.ID == "" {
|
|
return "", fmt.Errorf("project ID not found in response for project %d", projectID)
|
|
}
|
|
|
|
// Cache the result
|
|
projectNodeIDMutex.Lock()
|
|
projectNodeIDCache[projectID] = response.ID
|
|
projectNodeIDMutex.Unlock()
|
|
|
|
return response.ID, nil
|
|
}
|
|
|
|
// getProjectItemIDWithPagination handles pagination when searching for project items with caching.
|
|
func getProjectItemIDWithPagination(issueNumber int, projectNodeID, cursor, cacheKey string) (string, error) {
|
|
query := fmt.Sprintf(`{
|
|
node(id: "%s") {
|
|
... on ProjectV2 {
|
|
items(first: 100, after: "%s") {
|
|
nodes {
|
|
id
|
|
content {
|
|
... on Issue {
|
|
number
|
|
}
|
|
}
|
|
}
|
|
pageInfo {
|
|
hasNextPage
|
|
endCursor
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}`, projectNodeID, cursor)
|
|
|
|
command := fmt.Sprintf(`gh api graphql -f query='%s'`, query)
|
|
output, err := RunCommandAndReturnOutput(command)
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to query project items via API (pagination): %v", err)
|
|
}
|
|
|
|
var response struct {
|
|
Data struct {
|
|
Node struct {
|
|
Items struct {
|
|
Nodes []struct {
|
|
ID string `json:"id"`
|
|
Content struct {
|
|
Number int `json:"number"`
|
|
} `json:"content"`
|
|
} `json:"nodes"`
|
|
PageInfo struct {
|
|
HasNextPage bool `json:"hasNextPage"`
|
|
EndCursor string `json:"endCursor"`
|
|
} `json:"pageInfo"`
|
|
} `json:"items"`
|
|
} `json:"node"`
|
|
} `json:"data"`
|
|
}
|
|
|
|
err = json.Unmarshal(output, &response)
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to parse GraphQL response (pagination): %v", err)
|
|
}
|
|
|
|
// Search through this page of items
|
|
for _, item := range response.Data.Node.Items.Nodes {
|
|
if item.Content.Number == issueNumber {
|
|
// Cache the result
|
|
projectItemIDMutex.Lock()
|
|
projectItemIDCache[cacheKey] = item.ID
|
|
projectItemIDMutex.Unlock()
|
|
|
|
return item.ID, nil
|
|
}
|
|
}
|
|
|
|
// If there are more pages, continue searching
|
|
if response.Data.Node.Items.PageInfo.HasNextPage {
|
|
return getProjectItemIDWithPagination(issueNumber, projectNodeID, response.Data.Node.Items.PageInfo.EndCursor, cacheKey)
|
|
}
|
|
|
|
return "", fmt.Errorf("issue #%d not found in project after searching all pages", issueNumber)
|
|
}
|
|
|
|
// getProjectItemFieldValue retrieves the value of a specific field for a project item.
|
|
func getProjectItemFieldValue(itemID string, projectID int, fieldName string) (string, error) {
|
|
// Get the field information
|
|
field, err := LookupProjectFieldName(projectID, fieldName)
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to lookup field '%s': %v", fieldName, err)
|
|
}
|
|
|
|
// GraphQL query to get the specific project item with field values
|
|
query := fmt.Sprintf(`{
|
|
node(id: "%s") {
|
|
... on ProjectV2Item {
|
|
fieldValues(first: 10) {
|
|
nodes {
|
|
... on ProjectV2ItemFieldNumberValue {
|
|
field {
|
|
... on ProjectV2FieldCommon {
|
|
id
|
|
}
|
|
}
|
|
number
|
|
}
|
|
... on ProjectV2ItemFieldTextValue {
|
|
field {
|
|
... on ProjectV2FieldCommon {
|
|
id
|
|
}
|
|
}
|
|
text
|
|
}
|
|
... on ProjectV2ItemFieldSingleSelectValue {
|
|
field {
|
|
... on ProjectV2FieldCommon {
|
|
id
|
|
}
|
|
}
|
|
name
|
|
}
|
|
... on ProjectV2ItemFieldIterationValue {
|
|
field { ... on ProjectV2FieldCommon { id } }
|
|
title
|
|
startDate
|
|
duration
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}`, itemID)
|
|
|
|
command := fmt.Sprintf(`gh api graphql -f query='%s'`, query)
|
|
output, err := RunCommandAndReturnOutput(command)
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to query project item field values: %v", err)
|
|
}
|
|
|
|
// Parse the GraphQL response
|
|
var response struct {
|
|
Data struct {
|
|
Node struct {
|
|
FieldValues struct {
|
|
Nodes []struct {
|
|
Field struct {
|
|
ID string `json:"id"`
|
|
} `json:"field"`
|
|
Number *float64 `json:"number,omitempty"`
|
|
Text *string `json:"text,omitempty"`
|
|
Name *string `json:"name,omitempty"`
|
|
Title *string `json:"title,omitempty"`
|
|
} `json:"nodes"`
|
|
} `json:"fieldValues"`
|
|
} `json:"node"`
|
|
} `json:"data"`
|
|
}
|
|
|
|
err = json.Unmarshal(output, &response)
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to parse field values response: %v", err)
|
|
}
|
|
|
|
// Find the field value that matches our field ID
|
|
for _, fieldValue := range response.Data.Node.FieldValues.Nodes {
|
|
if fieldValue.Field.ID == field.ID {
|
|
if fieldValue.Number != nil {
|
|
return fmt.Sprintf("%.0f", *fieldValue.Number), nil
|
|
}
|
|
if fieldValue.Text != nil {
|
|
return *fieldValue.Text, nil
|
|
}
|
|
if fieldValue.Name != nil {
|
|
return *fieldValue.Name, nil
|
|
}
|
|
if fieldValue.Title != nil {
|
|
return *fieldValue.Title, nil
|
|
}
|
|
}
|
|
}
|
|
|
|
return "", nil // Field not set or empty value
|
|
}
|
|
|
|
// getCurrentIterationID finds the current iteration ID for a project's iteration field.
|
|
func getCurrentIterationID(projectNodeID, fieldID string) (string, error) {
|
|
// GraphQL query to get the iteration field configuration and find current iteration
|
|
query := fmt.Sprintf(`{
|
|
node(id: "%s") {
|
|
... on ProjectV2 {
|
|
fields(first: 20) {
|
|
nodes {
|
|
... on ProjectV2IterationField {
|
|
id
|
|
name
|
|
configuration {
|
|
iterations {
|
|
id
|
|
title
|
|
startDate
|
|
duration
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}`, projectNodeID)
|
|
|
|
command := fmt.Sprintf(`gh api graphql -f query='%s'`, query)
|
|
output, err := RunCommandAndReturnOutput(command)
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to query iterations: %v", err)
|
|
}
|
|
|
|
// Debug: Let's see what the actual response looks like
|
|
logger.Debugf("Iterations query response: %s", string(output))
|
|
|
|
// Parse the GraphQL response
|
|
var response struct {
|
|
Data struct {
|
|
Node struct {
|
|
Fields struct {
|
|
Nodes []struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
Configuration struct {
|
|
Iterations []struct {
|
|
ID string `json:"id"`
|
|
Title string `json:"title"`
|
|
StartDate string `json:"startDate"`
|
|
Duration int `json:"duration"`
|
|
} `json:"iterations"`
|
|
} `json:"configuration"`
|
|
} `json:"nodes"`
|
|
} `json:"fields"`
|
|
} `json:"node"`
|
|
} `json:"data"`
|
|
}
|
|
|
|
err = json.Unmarshal(output, &response)
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to parse iterations response: %v", err)
|
|
}
|
|
|
|
// Find the field that matches our fieldID
|
|
var targetField *struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
Configuration struct {
|
|
Iterations []struct {
|
|
ID string `json:"id"`
|
|
Title string `json:"title"`
|
|
StartDate string `json:"startDate"`
|
|
Duration int `json:"duration"`
|
|
} `json:"iterations"`
|
|
} `json:"configuration"`
|
|
}
|
|
|
|
for _, field := range response.Data.Node.Fields.Nodes {
|
|
if field.ID == fieldID {
|
|
targetField = &field
|
|
break
|
|
}
|
|
}
|
|
|
|
if targetField == nil {
|
|
return "", fmt.Errorf("iteration field with ID %s not found", fieldID)
|
|
}
|
|
|
|
// Find the current iteration (the first one in the list is the current active one)
|
|
// GitHub appears to return iterations in chronological order with past iterations removed
|
|
iterations := targetField.Configuration.Iterations
|
|
if len(iterations) > 0 {
|
|
// Return the first iteration (current active one)
|
|
logger.Infof("Selected current iteration: %s (ID: %s)", iterations[0].Title, iterations[0].ID)
|
|
return iterations[0].ID, nil
|
|
}
|
|
|
|
return "", fmt.Errorf("no iterations found for field")
|
|
}
|
|
|
|
// getCurrentIterationDetails returns ID, startDate (YYYY-MM-DD), and duration (days) for the current iteration.
|
|
func getCurrentIterationDetails(projectNodeID, fieldID string) (string, string, int, error) {
|
|
// Same query shape as getCurrentIterationID
|
|
query := fmt.Sprintf(`{
|
|
node(id: "%s") {
|
|
... on ProjectV2 {
|
|
fields(first: 20) {
|
|
nodes {
|
|
... on ProjectV2IterationField {
|
|
id
|
|
name
|
|
configuration {
|
|
iterations {
|
|
id
|
|
title
|
|
startDate
|
|
duration
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}`, projectNodeID)
|
|
|
|
command := fmt.Sprintf(`gh api graphql -f query='%s'`, query)
|
|
output, err := RunCommandAndReturnOutput(command)
|
|
if err != nil {
|
|
return "", "", 0, fmt.Errorf("failed to query iterations: %v", err)
|
|
}
|
|
|
|
var response struct {
|
|
Data struct {
|
|
Node struct {
|
|
Fields struct {
|
|
Nodes []struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
Configuration struct {
|
|
Iterations []struct {
|
|
ID string `json:"id"`
|
|
Title string `json:"title"`
|
|
StartDate string `json:"startDate"`
|
|
Duration int `json:"duration"`
|
|
} `json:"iterations"`
|
|
} `json:"configuration"`
|
|
} `json:"nodes"`
|
|
} `json:"fields"`
|
|
} `json:"node"`
|
|
} `json:"data"`
|
|
}
|
|
if err := json.Unmarshal(output, &response); err != nil {
|
|
return "", "", 0, fmt.Errorf("failed to parse iterations response: %v", err)
|
|
}
|
|
for _, field := range response.Data.Node.Fields.Nodes {
|
|
if field.ID == fieldID {
|
|
if len(field.Configuration.Iterations) > 0 {
|
|
it := field.Configuration.Iterations[0]
|
|
return it.ID, it.StartDate, it.Duration, nil
|
|
}
|
|
}
|
|
}
|
|
return "", "", 0, fmt.Errorf("no iterations found for field")
|
|
}
|
|
|
|
// computePrevStartDate takes an ISO date string (YYYY-MM-DD) and a duration in days and returns the previous
|
|
// iteration start date in the same format.
|
|
func computePrevStartDate(curStart string, duration int) (string, error) {
|
|
t, err := time.Parse("2006-01-02", strings.TrimSpace(curStart))
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
prev := t.AddDate(0, 0, -duration)
|
|
return prev.Format("2006-01-02"), nil
|
|
}
|
|
|
|
// getPreviousIterationID attempts to find the iteration ID immediately prior to the current one.
|
|
// It relies on the ordering returned by the iteration field configuration. If a previous iteration
|
|
// is not present in the returned list, this will return an error.
|
|
func getPreviousIterationID(projectNodeID, fieldID string) (string, error) {
|
|
// Reuse the same query as getCurrentIterationID to retrieve iterations
|
|
query := fmt.Sprintf(`{
|
|
node(id: "%s") {
|
|
... on ProjectV2 {
|
|
fields(first: 20) {
|
|
nodes {
|
|
... on ProjectV2IterationField {
|
|
id
|
|
name
|
|
configuration {
|
|
iterations {
|
|
id
|
|
title
|
|
startDate
|
|
duration
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}`, projectNodeID)
|
|
|
|
command := fmt.Sprintf(`gh api graphql -f query='%s'`, query)
|
|
output, err := RunCommandAndReturnOutput(command)
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to query iterations: %v", err)
|
|
}
|
|
logger.Debugf("Iterations query response (previous): %s", string(output))
|
|
|
|
var response struct {
|
|
Data struct {
|
|
Node struct {
|
|
Fields struct {
|
|
Nodes []struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
Configuration struct {
|
|
Iterations []struct {
|
|
ID string `json:"id"`
|
|
Title string `json:"title"`
|
|
StartDate string `json:"startDate"`
|
|
Duration int `json:"duration"`
|
|
} `json:"iterations"`
|
|
} `json:"configuration"`
|
|
} `json:"nodes"`
|
|
} `json:"fields"`
|
|
} `json:"node"`
|
|
} `json:"data"`
|
|
}
|
|
|
|
if err := json.Unmarshal(output, &response); err != nil {
|
|
return "", fmt.Errorf("failed to parse iterations response: %v", err)
|
|
}
|
|
|
|
var targetField *struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
Configuration struct {
|
|
Iterations []struct {
|
|
ID string `json:"id"`
|
|
Title string `json:"title"`
|
|
StartDate string `json:"startDate"`
|
|
Duration int `json:"duration"`
|
|
} `json:"iterations"`
|
|
} `json:"configuration"`
|
|
}
|
|
for _, field := range response.Data.Node.Fields.Nodes {
|
|
if field.ID == fieldID {
|
|
targetField = &field
|
|
break
|
|
}
|
|
}
|
|
if targetField == nil {
|
|
return "", fmt.Errorf("iteration field with ID %s not found", fieldID)
|
|
}
|
|
|
|
iters := targetField.Configuration.Iterations
|
|
if len(iters) >= 2 {
|
|
logger.Infof("Selected previous iteration: %s (ID: %s)", iters[1].Title, iters[1].ID)
|
|
return iters[1].ID, nil
|
|
}
|
|
return "", fmt.Errorf("no previous iteration available")
|
|
}
|