Add sorting, user assignment, and UI improvements
Features: - Add work item sorting by ID, Type, State (keys 1, 2, 3) - Add user assignment modal with team member filtering (key a) - Add parent work item title display in details panel - Preserve sort order and selection on panel reload UI improvements: - Remove zebra striping from work items list - Remove priority column from list and details - Align metadata fields in details panel - Add markdown rendering for descriptions (using glamour) - Add state colors: To Do (orange), In Progress (purple), Done (green), Testing (yellow) 🤖 Generated with [Claude Code](https://claude.ai/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,51 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/samuelenocsson/devops-tui/internal/models"
|
||||
)
|
||||
|
||||
// teamMembersResponse represents the response from team members API
|
||||
type teamMembersResponse struct {
|
||||
Value []teamMemberItem `json:"value"`
|
||||
Count int `json:"count"`
|
||||
}
|
||||
|
||||
type teamMemberItem struct {
|
||||
Identity identityRef `json:"identity"`
|
||||
}
|
||||
|
||||
type identityRef struct {
|
||||
DisplayName string `json:"displayName"`
|
||||
UniqueName string `json:"uniqueName"`
|
||||
ID string `json:"id"`
|
||||
}
|
||||
|
||||
// GetTeamMembers fetches all members of the configured team
|
||||
func (c *Client) GetTeamMembers() ([]models.TeamMember, error) {
|
||||
// Azure DevOps API: GET https://dev.azure.com/{org}/_apis/projects/{project}/teams/{team}/members
|
||||
url := fmt.Sprintf("https://dev.azure.com/%s/_apis/projects/%s/teams/%s/members?api-version=%s",
|
||||
c.organization, c.project, c.team, apiVersion)
|
||||
|
||||
resp, err := c.doRequest("GET", url, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var apiResp teamMembersResponse
|
||||
if err := decode(resp, &apiResp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
members := make([]models.TeamMember, 0, len(apiResp.Value))
|
||||
for _, item := range apiResp.Value {
|
||||
members = append(members, models.TeamMember{
|
||||
ID: item.Identity.ID,
|
||||
DisplayName: item.Identity.DisplayName,
|
||||
UniqueName: item.Identity.UniqueName,
|
||||
})
|
||||
}
|
||||
|
||||
return members, nil
|
||||
}
|
||||
@@ -167,9 +167,60 @@ func (c *Client) GetWorkItems(ids []string) ([]models.WorkItem, error) {
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch parent titles
|
||||
c.populateParentTitles(allItems)
|
||||
|
||||
return allItems, nil
|
||||
}
|
||||
|
||||
// populateParentTitles fetches titles for all parent work items
|
||||
func (c *Client) populateParentTitles(items []models.WorkItem) {
|
||||
// Collect unique parent IDs
|
||||
parentIDs := make(map[int]bool)
|
||||
for _, item := range items {
|
||||
if item.ParentID > 0 {
|
||||
parentIDs[item.ParentID] = true
|
||||
}
|
||||
}
|
||||
|
||||
if len(parentIDs) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
// Convert to string slice
|
||||
ids := make([]string, 0, len(parentIDs))
|
||||
for id := range parentIDs {
|
||||
ids = append(ids, fmt.Sprintf("%d", id))
|
||||
}
|
||||
|
||||
// Fetch parent work items (only need ID and Title)
|
||||
endpoint := fmt.Sprintf("/wit/workitems?ids=%s&fields=System.Id,System.Title", strings.Join(ids, ","))
|
||||
resp, err := c.get(endpoint)
|
||||
if err != nil {
|
||||
return // Silently fail - parent titles are optional
|
||||
}
|
||||
|
||||
var apiResp workItemsResponse
|
||||
if err := decode(resp, &apiResp); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Build ID -> Title map
|
||||
titleMap := make(map[int]string)
|
||||
for _, item := range apiResp.Value {
|
||||
titleMap[item.ID] = item.Fields.Title
|
||||
}
|
||||
|
||||
// Update items with parent titles
|
||||
for i := range items {
|
||||
if items[i].ParentID > 0 {
|
||||
if title, ok := titleMap[items[i].ParentID]; ok {
|
||||
items[i].ParentTitle = title
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// GetWorkItem fetches a single work item by ID
|
||||
func (c *Client) GetWorkItem(id int) (*models.WorkItem, error) {
|
||||
fields := "System.Id,System.Title,System.State,System.WorkItemType,System.AssignedTo,System.IterationPath,System.AreaPath,System.Description,System.Tags,System.Parent,Microsoft.VSTS.Common.Priority,System.CreatedDate,System.ChangedDate"
|
||||
@@ -186,6 +237,19 @@ func (c *Client) GetWorkItem(id int) (*models.WorkItem, error) {
|
||||
}
|
||||
|
||||
wi := c.convertWorkItem(item)
|
||||
|
||||
// Fetch parent title if parent exists
|
||||
if wi.ParentID > 0 {
|
||||
parentEndpoint := fmt.Sprintf("/wit/workitems/%d?fields=System.Title", wi.ParentID)
|
||||
parentResp, err := c.get(parentEndpoint)
|
||||
if err == nil {
|
||||
var parentItem workItemAPIItem
|
||||
if decode(parentResp, &parentItem) == nil {
|
||||
wi.ParentTitle = parentItem.Fields.Title
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return &wi, nil
|
||||
}
|
||||
|
||||
@@ -252,6 +316,33 @@ func (c *Client) UpdateWorkItemState(id int, newState string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// AssignWorkItem assigns a work item to a user
|
||||
// Pass empty string to unassign
|
||||
func (c *Client) AssignWorkItem(id int, userEmail string) error {
|
||||
// Azure DevOps uses JSON Patch format
|
||||
patchDoc := []map[string]interface{}{
|
||||
{
|
||||
"op": "add",
|
||||
"path": "/fields/System.AssignedTo",
|
||||
"value": userEmail,
|
||||
},
|
||||
}
|
||||
|
||||
bodyBytes, err := json.Marshal(patchDoc)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshaling patch document: %w", err)
|
||||
}
|
||||
|
||||
endpoint := fmt.Sprintf("/wit/workitems/%d", id)
|
||||
resp, err := c.patch(endpoint, bytes.NewReader(bodyBytes))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// stripHTML removes HTML tags from a string
|
||||
func stripHTML(s string) string {
|
||||
// Simple HTML tag removal
|
||||
|
||||
Reference in New Issue
Block a user