Verified Commit 7088f6f2 authored by Florian Forster's avatar Florian Forster
Browse files

feat(workitems): Add more fields to WorkItem

Adds the following fields to the WorkItem struct, sourced from the
corresponding GraphQL widgets:

- Color
- Confidential
- DueDate / StartDate (from the startAndDueDate widget)
- HealthStatus
- IterationID
- Labels
- LinkedItems
- MilestoneID
- ParentID
- Status (changed from string to *string, since the widget may be absent)
- Weight

The `workItemFeaturesGQL` struct is refactored from a flat, ad-hoc
structure into individual typed widget structs (one per widget), each
with its own nil-safe unwrap() method. This matches the shape of the
GraphQL API, where each widget is an independent nullable object, and
makes it straightforward to add further widgets in the future.

Two smaller fixes are included:

- userCoreBasicGQL.unwrap() now has a pointer receiver and handles
  nil, preventing a panic when the author field is absent on a work
  item.

- The Locked field is removed from the BasicUser populated via
  userCoreBasicGQL, as the GraphQL type does not expose a locked
  field; the previous derivation from state != "active" was
  incorrect.
parent a4288809
Loading
Loading
Loading
Loading
+25 −0
Original line number Diff line number Diff line
@@ -19,6 +19,7 @@ package gitlab
import (
	"encoding/json"
	"net/http"
	"time"
)

type (
@@ -85,6 +86,30 @@ func (l Label) String() string {
	return Stringify(l)
}

type labelGQL struct {
	Archived        bool      `json:"archived"`
	Color           string    `json:"color"`
	CreatedAt       time.Time `json:"createdAt"`
	Description     *string   `json:"description"`
	DescriptionHTML *string   `json:"descriptionHtml"`
	ID              gidGQL    `json:"id"`
	LockOnMerge     bool      `json:"lockOnMerge"`
	TextColor       string    `json:"textColor"`
	Title           string    `json:"title"`
	UpdatedAt       time.Time `json:"updatedAt"`
}

func (l *labelGQL) unwrap() LabelDetails {
	return LabelDetails{
		ID:              l.ID.Int64,
		Name:            l.Title,
		Color:           l.Color,
		Description:     deref(l.Description),
		DescriptionHTML: deref(l.DescriptionHTML),
		TextColor:       l.TextColor,
	}
}

// ListLabelsOptions represents the available ListLabels() options.
//
// GitLab API docs: https://docs.gitlab.com/api/labels/#list-labels
+77 −1
Original line number Diff line number Diff line
@@ -10,6 +10,7 @@
                "state": "OPEN",
                "title": "Update Helm charts to use Argo Rollouts for progressive deployments",
                "description": "## Overview\n\nUpdate Runway Helm charts to generate Argo Rollout resources ...",
                "confidential": false,
                "author": {
                    "id": "gid://gitlab/User/5532616",
                    "username": "swainaina",
@@ -39,14 +40,89 @@
                            ]
                        }
                    },
                    "color": null,
                    "healthStatus": {
                        "healthStatus": "onTrack"
                    },
                    "hierarchy": {
                        "hasParent": true,
                        "parent": {
                            "iid": "673",
                            "namespace": {
                                "fullPath": "gitlab-com/gl-infra/platform/runway/team"
                            }
                        }
                    },
                    "iteration": {
                        "iteration": {
                            "id": "gid://gitlab/Iteration/2748074"
                        }
                    },
                    "labels": {
                        "labels": {
                            "nodes": [
                                {
                                    "id": "gid://gitlab/GroupLabel/32754251",
                                    "title": "Category:Runway",
                                    "color": "#6699cc",
                                    "description": "",
                                    "descriptionHtml": "",
                                    "textColor": "#FFFFFF"
                                },
                                {
                                    "id": "gid://gitlab/GroupLabel/32832335",
                                    "title": "Service::Runway",
                                    "color": "#d1d100",
                                    "description": "",
                                    "descriptionHtml": "",
                                    "textColor": "#1F1E24"
                                },
                                {
                                    "id": "gid://gitlab/GroupLabel/12970969",
                                    "title": "workflow-infra::Triage",
                                    "color": "#FEAF09",
                                    "description": "For @gitlab-com/gl-infra/managers to triage, prioritize, and assign.",
                                    "descriptionHtml": "For <a href=\"/gitlab-com/gl-infra/managers\" data-reference-type=\"user\" data-group=\"4684757\" data-container=\"body\" data-placement=\"top\" class=\"gfm gfm-project_member js-user-link\" title=\"GitLab.com / GitLab Infrastructure Team / Infrastructure Managers\">@gitlab-com/gl-infra/managers</a> to triage, prioritize, and assign.",
                                    "textColor": "#1F1E24"
                                }
                            ]
                        }
                    },
                    "linkedItems": {
                        "linkedItems": {
                            "nodes": [
                                {
                                    "workItem": {
                                        "iid": "774",
                                        "namespace": {
                                            "fullPath": "gitlab-com/gl-infra/platform/runway/team"
                                        }
                                    },
                                    "linkType": "relates_to"
                                }
                            ]
                        }
                    },
                    "milestone": {
                        "milestone": {
                            "id": "gid://gitlab/Milestone/6161376"
                        }
                    },
                    "startAndDueDate": {
                        "startDate": "2025-08-01",
                        "dueDate": "2026-07-31"
                    },
                    "status": {
                        "status": {
                            "name": "New"
                        }
                    },
                    "weight": {
                        "weight": 8
                    }
                }
            }
        }
    },
    "correlationId": "9c8b606d64ef170e-MUC"
    "correlationId": "9d48044d12265148-MUC"
}
+11 −0
Original line number Diff line number Diff line
@@ -33,6 +33,17 @@ func Ptr[T any](v T) *T {
	return &v
}

// deref is a helper function that safely dereferences a pointer and returns the
// zero value if the pointer is nil.
func deref[T any](v *T) T {
	if v == nil {
		var zero T
		return zero
	}

	return *v
}

// AccessControlValue represents an access control value within GitLab,
// used for managing access to certain project features.
//
+2 −2
Original line number Diff line number Diff line
@@ -1396,8 +1396,8 @@ type userCoreBasicGQL struct {
}

// unwrap converts the GraphQL data structure to a *BasicUser.
func (u userCoreBasicGQL) unwrap() *BasicUser {
	if u.Username == "" {
func (u *userCoreBasicGQL) unwrap() *BasicUser {
	if u == nil || u.Username == "" {
		return nil
	}

+366 −37
Original line number Diff line number Diff line
@@ -46,6 +46,18 @@ type WorkItem struct {
	WebURL      string
	Author      *BasicUser
	Assignees   []*BasicUser

	Color        *string
	Confidential bool
	DueDate      *ISOTime
	HealthStatus *string
	IterationID  *int64
	Labels       []LabelDetails
	LinkedItems  []*WorkItemLinkedItem
	MilestoneID  *int64
	ParentID     *WorkItemIID
	StartDate    *ISOTime
	Weight       *int64
}

func (wi WorkItem) GID() string {
@@ -55,6 +67,21 @@ func (wi WorkItem) GID() string {
	}.String()
}

// WorkItemIID identifies a work item by its namespace path and internal ID.
type WorkItemIID struct {
	NamespacePath string
	IID           int64
}

// WorkItemLinkedItem represents a linked work item with its relationship type.
type WorkItemLinkedItem struct {
	WorkItemIID

	// LinkType is the type of relationship between the work items.
	// Possible values: blocks, is_blocked_by, relates_to
	LinkType string
}

// workItemTemplate defines the common fields for a work item in GraphQL queries.
// It's chained from userCoreBasicTemplate so nested templates work.
var workItemTemplate = template.Must(template.Must(userCoreBasicTemplate.Clone()).New("WorkItem").Parse(`
@@ -66,6 +93,7 @@ var workItemTemplate = template.Must(template.Must(userCoreBasicTemplate.Clone()
	state
	title
	description
	confidential
	author {
		{{ template "UserCoreBasic" }}
	}
@@ -81,11 +109,69 @@ var workItemTemplate = template.Must(template.Must(userCoreBasicTemplate.Clone()
				}
			}
		}
		color {
			color
			textColor
		}
		healthStatus {
			healthStatus
		}
		hierarchy {
			hasParent
			parent {
				iid
				namespace {
					fullPath
				}
			}
		}
		iteration {
			iteration {
				id
			}
		}
		labels {
			labels {
				nodes {
					id
					title
					color
					description
					descriptionHtml
					textColor
				}
			}
		}
		linkedItems {
			linkedItems {
				nodes {
					workItem {
						iid
						namespace {
							fullPath
						}
					}
					linkType
				}
			}
		}
		milestone {
			milestone {
				id
			}
		}
		startAndDueDate {
			startDate
			dueDate
		}
		status {
			status {
				name
			}
		}
		weight {
			weight
		}
	}
`))

@@ -422,30 +508,20 @@ type workItemGQL struct {
	CreatedAt    *time.Time          `json:"createdAt"`
	UpdatedAt    *time.Time          `json:"updatedAt"`
	ClosedAt     *time.Time          `json:"closedAt"`
	Author      userCoreBasicGQL    `json:"author"`
	Author       *userCoreBasicGQL   `json:"author"`
	Features     workItemFeaturesGQL `json:"features"`
	WebURL       string              `json:"webUrl"`
	Confidential bool                `json:"confidential"`
}

func (w workItemGQL) unwrap() *WorkItem {
	var assignees []*BasicUser

	for _, a := range w.Features.Assignees.Assignees.Nodes {
		assignees = append(assignees, a.unwrap())
	}

	var status *string

	if w.Features.Status != nil && w.Features.Status.Status != nil {
		status = w.Features.Status.Status.Name
	}

	return &WorkItem{
	wi := &WorkItem{
		ID:           w.ID.Int64,
		IID:          int64(w.IID),
		Type:         w.WorkItemType.Name,
		State:        w.State,
		Status:      status,
		Title:        w.Title,
		Description:  w.Description,
		CreatedAt:    w.CreatedAt,
@@ -454,18 +530,271 @@ func (w workItemGQL) unwrap() *WorkItem {
		WebURL:       w.WebURL,
		Author:       w.Author.unwrap(),
		Assignees:    assignees,
		Confidential: w.Confidential,
	}

	w.Features.unwrap(wi)

	return wi
}

// workItemFeaturesGQL represents the optional features of the work item.
//
// While the "features" field in the "WorkItem" type is not nullable, each
// feature inside the struct is.
//
// API docs: https://docs.gitlab.com/api/graphql/reference/#workitemfeatures
type workItemFeaturesGQL struct {
	Assignees struct {
		Assignees struct {
			Nodes []userCoreBasicGQL `json:"nodes"`
		} `json:"assignees"`
	} `json:"assignees"`
	Status *struct {
	Assignees       *workItemWidgetAssigneesGQL       `json:"assignees"`
	Color           *workItemWidgetColorGQL           `json:"color"`
	HealthStatus    *workItemWidgetHealthStatusGQL    `json:"healthStatus"`
	Hierarchy       *workItemWidgetHierarchyGQL       `json:"hierarchy"`
	Iteration       *workItemWidgetIterationGQL       `json:"iteration"`
	Labels          *workItemWidgetLabelsGQL          `json:"labels"`
	LinkedItems     *workItemWidgetLinkedItemsGQL     `json:"linkedItems"`
	Milestone       *workItemWidgetMilestoneGQL       `json:"milestone"`
	StartAndDueDate *workItemWidgetStartAndDueDateGQL `json:"startAndDueDate"`
	Status          *workItemWidgetStatusGQL          `json:"status"`
	Weight          *workItemWidgetWeightGQL          `json:"weight"`
}

func (f workItemFeaturesGQL) unwrap(wi *WorkItem) {
	wi.Assignees = f.Assignees.unwrap()
	wi.Color = f.Color.unwrap()
	wi.HealthStatus = f.HealthStatus.unwrap()
	wi.ParentID = f.Hierarchy.unwrap()
	wi.IterationID = f.Iteration.unwrap()
	wi.Labels = f.Labels.unwrap()
	wi.LinkedItems = f.LinkedItems.unwrap()
	wi.MilestoneID = f.Milestone.unwrap()
	wi.StartDate, wi.DueDate = f.StartAndDueDate.unwrap()
	wi.Status = f.Status.unwrap()
	wi.Weight = f.Weight.unwrap()
}

// workItemWidgetAssigneesGQL represents the assignees widget.
//
// API docs: https://docs.gitlab.com/api/graphql/reference/#workitemwidgetassignees
type workItemWidgetAssigneesGQL struct {
	Assignees connectionGQL[userCoreBasicGQL] `json:"assignees"`
}

func (a *workItemWidgetAssigneesGQL) unwrap() []*BasicUser {
	if a == nil {
		return nil
	}

	var ret []*BasicUser

	for _, assignee := range a.Assignees.Nodes {
		ret = append(ret, assignee.unwrap())
	}

	return ret
}

// workItemWidgetColorGQL represents a color widget.
//
// API docs: https://docs.gitlab.com/api/graphql/reference/#workitemwidgetcolor
type workItemWidgetColorGQL struct {
	Color     *string `json:"color"`
	TextColor *string `json:"textColor"`
}

func (c *workItemWidgetColorGQL) unwrap() *string {
	if c == nil {
		return nil
	}

	return c.Color
}

// workItemWidgetHealthStatusGQL represents a health status widget.
//
// API docs: https://docs.gitlab.com/api/graphql/reference/#workitemwidgethealthstatus
type workItemWidgetHealthStatusGQL struct {
	HealthStatus *string `json:"healthStatus"`
}

func (h *workItemWidgetHealthStatusGQL) unwrap() *string {
	if h == nil {
		return nil
	}

	return h.HealthStatus
}

// API docs: https://docs.gitlab.com/api/graphql/reference/#workitemwidgethierarchy
type workItemWidgetHierarchyGQL struct {
	HasParent bool `json:"hasParent"`
	Parent    *struct {
		IID       string `json:"iid"`
		Namespace struct {
			FullPath string `json:"fullPath"`
		} `json:"namespace"`
	} `json:"parent"`
}

func (h *workItemWidgetHierarchyGQL) unwrap() *WorkItemIID {
	if h == nil || !h.HasParent || h.Parent == nil {
		return nil
	}

	iid, err := strconv.ParseInt(h.Parent.IID, 10, 64)
	if err != nil {
		return nil
	}

	return &WorkItemIID{
		NamespacePath: h.Parent.Namespace.FullPath,
		IID:           iid,
	}
}

// workItemWidgetIterationGQL represents a iteration widget.
//
// API docs: https://docs.gitlab.com/api/graphql/reference/#workitemwidgetiteration
type workItemWidgetIterationGQL struct {
	Iteration *struct {
		ID gidGQL `json:"id"`
	} `json:"iteration"`
}

func (c *workItemWidgetIterationGQL) unwrap() *int64 {
	if c == nil || c.Iteration == nil {
		return nil
	}

	return Ptr(c.Iteration.ID.Int64)
}

// workItemWidgetLabelsGQL represents the labels widget.
//
// API docs: https://docs.gitlab.com/api/graphql/reference/#workitemwidgetlabels
type workItemWidgetLabelsGQL struct {
	Labels *connectionGQL[labelGQL] `json:"labels"`
}

func (l *workItemWidgetLabelsGQL) unwrap() []LabelDetails {
	if l == nil || l.Labels == nil {
		return nil
	}

	var ret []LabelDetails

	for _, label := range l.Labels.Nodes {
		ret = append(ret, label.unwrap())
	}

	return ret
}

// workItemWidgetLinkedItemsGQL represents the linked items widget.
//
// API docs: https://docs.gitlab.com/api/graphql/reference/#workitemwidgetlinkeditems
type workItemWidgetLinkedItemsGQL struct {
	LinkedItems *connectionGQL[struct {
		WorkItem *struct {
			IID       string `json:"iid"`
			Namespace struct {
				FullPath string `json:"fullPath"`
			} `json:"namespace"`
		} `json:"workItem"`
		LinkType string `json:"linkType"`
	}] `json:"linkedItems"`
}

func (li *workItemWidgetLinkedItemsGQL) unwrap() []*WorkItemLinkedItem {
	if li == nil || li.LinkedItems == nil {
		return nil
	}

	var ret []*WorkItemLinkedItem

	for _, item := range li.LinkedItems.Nodes {
		if item.WorkItem == nil {
			continue
		}

		iid, err := strconv.ParseInt(item.WorkItem.IID, 10, 64)
		if err != nil {
			continue
		}

		ret = append(ret, &WorkItemLinkedItem{
			WorkItemIID: WorkItemIID{
				NamespacePath: item.WorkItem.Namespace.FullPath,
				IID:           iid,
			},
			LinkType: item.LinkType,
		})
	}

	return ret
}

// workItemWidgetMilestoneGQL represents the milestone widget.
//
// API docs: https://docs.gitlab.com/api/graphql/reference/#workitemwidgetmilestone
type workItemWidgetMilestoneGQL struct {
	Milestone *struct {
		ID gidGQL `json:"id"`
	} `json:"milestone"`
}

func (m *workItemWidgetMilestoneGQL) unwrap() *int64 {
	if m == nil || m.Milestone == nil {
		return nil
	}

	return Ptr(m.Milestone.ID.Int64)
}

// workItemWidgetStartAndDueDateGQL represents a start and due date widget.
//
// API docs: https://docs.gitlab.com/api/graphql/reference/#workitemwidgetstartandduedate
type workItemWidgetStartAndDueDateGQL struct {
	DueDate   *ISOTime `json:"dueDate"`
	IsFixed   bool     `json:"isFixed"`
	StartDate *ISOTime `json:"startDate"`
}

func (du *workItemWidgetStartAndDueDateGQL) unwrap() (start, due *ISOTime) {
	if du == nil {
		return nil, nil
	}

	return du.StartDate, du.DueDate
}

// workItemWidgetStatusGQL represents the status widget.
//
// API docs: https://docs.gitlab.com/api/graphql/reference/#workitemwidgetstatus
type workItemWidgetStatusGQL struct {
	Status *struct {
			Name *string
		Name *string `json:"name"`
	} `json:"status"`
}

func (s *workItemWidgetStatusGQL) unwrap() *string {
	if s == nil || s.Status == nil {
		return nil
	}

	return s.Status.Name
}

// workItemWidgetWeightGQL represents the weight widget.
//
// API docs: https://docs.gitlab.com/api/graphql/reference/#workitemwidgetweight
type workItemWidgetWeightGQL struct {
	Weight *int64 `json:"weight"`
}

func (w *workItemWidgetWeightGQL) unwrap() *int64 {
	if w == nil {
		return nil
	}

	return w.Weight
}
Loading