Initial commit: Azure DevOps TUI client
A terminal-based user interface for browsing and managing Azure DevOps work items. Features include: - Browse work items with filtering by area, iteration, state, and type - View work item details with markdown rendering - Open work items in browser - Create git branches from work items - Update work item state - Keyboard-driven navigation 🤖 Generated with [Claude Code](https://claude.ai/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,95 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/samuelenocsson/devops-tui/internal/models"
|
||||
)
|
||||
|
||||
// classificationNode represents an area node from the API
|
||||
type classificationNode struct {
|
||||
ID int `json:"id"`
|
||||
Identifier string `json:"identifier"`
|
||||
Name string `json:"name"`
|
||||
Path string `json:"path"`
|
||||
HasChildren bool `json:"hasChildren"`
|
||||
Children []classificationNode `json:"children"`
|
||||
}
|
||||
|
||||
// GetAreas fetches all areas for the project
|
||||
func (c *Client) GetAreas() ([]models.Area, error) {
|
||||
// Use the classification nodes API with depth to get area hierarchy
|
||||
resp, err := c.get("/wit/classificationnodes/areas?$depth=10")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var rootNode classificationNode
|
||||
if err := decode(resp, &rootNode); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Flatten the tree into a list
|
||||
areas := flattenAreas(rootNode, "")
|
||||
|
||||
// Sort areas by path for consistent ordering
|
||||
sort.Slice(areas, func(i, j int) bool {
|
||||
return areas[i].Path < areas[j].Path
|
||||
})
|
||||
|
||||
return areas, nil
|
||||
}
|
||||
|
||||
// flattenAreas recursively flattens the area tree
|
||||
func flattenAreas(node classificationNode, parentPath string) []models.Area {
|
||||
var areas []models.Area
|
||||
|
||||
// Build the path
|
||||
path := node.Path
|
||||
if path == "" {
|
||||
path = node.Name
|
||||
}
|
||||
|
||||
// Clean up the path:
|
||||
// 1. Remove leading backslash
|
||||
// 2. Remove "\Area" from path (API returns \Project\Area\Team but work items use Project\Team)
|
||||
path = strings.TrimPrefix(path, "\\")
|
||||
path = strings.TrimSuffix(path, "\\")
|
||||
|
||||
// Remove the "Area" segment from the path (e.g., "Project\Area\Team" -> "Project\Team")
|
||||
parts := strings.Split(path, "\\")
|
||||
if len(parts) >= 2 && parts[1] == "Area" {
|
||||
// Remove the "Area" part
|
||||
newParts := []string{parts[0]}
|
||||
if len(parts) > 2 {
|
||||
newParts = append(newParts, parts[2:]...)
|
||||
}
|
||||
path = strings.Join(newParts, "\\")
|
||||
}
|
||||
|
||||
// Add this node
|
||||
areas = append(areas, models.Area{
|
||||
ID: node.ID,
|
||||
Name: node.Name,
|
||||
Path: path,
|
||||
})
|
||||
|
||||
// Recursively add children
|
||||
for _, child := range node.Children {
|
||||
childAreas := flattenAreas(child, path)
|
||||
areas = append(areas, childAreas...)
|
||||
}
|
||||
|
||||
return areas
|
||||
}
|
||||
|
||||
// GetAreaDisplayName returns a shortened display name for an area
|
||||
func GetAreaDisplayName(path string) string {
|
||||
parts := strings.Split(path, "\\")
|
||||
if len(parts) > 1 {
|
||||
// Return the last part for brevity
|
||||
return parts[len(parts)-1]
|
||||
}
|
||||
return path
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/samuelenocsson/devops-tui/internal/config"
|
||||
)
|
||||
|
||||
const apiVersion = "7.1"
|
||||
|
||||
// Client is the Azure DevOps API client
|
||||
type Client struct {
|
||||
httpClient *http.Client
|
||||
baseURL string
|
||||
teamURL string
|
||||
webURL string
|
||||
authHeader string
|
||||
organization string
|
||||
project string
|
||||
team string
|
||||
}
|
||||
|
||||
// NewClient creates a new Azure DevOps API client
|
||||
func NewClient(cfg *config.Config) *Client {
|
||||
// Azure DevOps uses Basic auth with empty username and PAT as password
|
||||
auth := base64.StdEncoding.EncodeToString([]byte(":" + cfg.PAT))
|
||||
|
||||
return &Client{
|
||||
httpClient: &http.Client{
|
||||
Timeout: 30 * time.Second,
|
||||
},
|
||||
baseURL: cfg.BaseURL(),
|
||||
teamURL: cfg.TeamURL(),
|
||||
webURL: cfg.WebURL(),
|
||||
authHeader: "Basic " + auth,
|
||||
organization: cfg.Organization,
|
||||
project: cfg.Project,
|
||||
team: cfg.Team,
|
||||
}
|
||||
}
|
||||
|
||||
// doRequest performs an HTTP request with authentication
|
||||
func (c *Client) doRequest(method, url string, body io.Reader) (*http.Response, error) {
|
||||
return c.doRequestWithContentType(method, url, body, "application/json")
|
||||
}
|
||||
|
||||
// doRequestWithContentType performs an HTTP request with authentication and custom content type
|
||||
func (c *Client) doRequestWithContentType(method, url string, body io.Reader, contentType string) (*http.Response, error) {
|
||||
req, err := http.NewRequest(method, url, body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("creating request: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Authorization", c.authHeader)
|
||||
req.Header.Set("Content-Type", contentType)
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("executing request: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
return nil, fmt.Errorf("API error %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// get performs a GET request to base URL
|
||||
func (c *Client) get(endpoint string) (*http.Response, error) {
|
||||
return c.getWithBase(c.baseURL, endpoint)
|
||||
}
|
||||
|
||||
// getTeam performs a GET request to team-specific URL
|
||||
func (c *Client) getTeam(endpoint string) (*http.Response, error) {
|
||||
return c.getWithBase(c.teamURL, endpoint)
|
||||
}
|
||||
|
||||
// getWithBase performs a GET request with a specific base URL
|
||||
func (c *Client) getWithBase(baseURL, endpoint string) (*http.Response, error) {
|
||||
url := fmt.Sprintf("%s%s", baseURL, endpoint)
|
||||
if endpoint[0] != '/' {
|
||||
url = fmt.Sprintf("%s/%s", baseURL, endpoint)
|
||||
}
|
||||
|
||||
// Add API version
|
||||
if len(url) > 0 {
|
||||
separator := "?"
|
||||
if len(url) > 0 && url[len(url)-1] != '?' {
|
||||
for _, c := range url {
|
||||
if c == '?' {
|
||||
separator = "&"
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
url = fmt.Sprintf("%s%sapi-version=%s", url, separator, apiVersion)
|
||||
}
|
||||
|
||||
return c.doRequest("GET", url, nil)
|
||||
}
|
||||
|
||||
// post performs a POST request
|
||||
func (c *Client) post(endpoint string, body io.Reader) (*http.Response, error) {
|
||||
url := fmt.Sprintf("%s%s", c.baseURL, endpoint)
|
||||
if endpoint[0] != '/' {
|
||||
url = fmt.Sprintf("%s/%s", c.baseURL, endpoint)
|
||||
}
|
||||
|
||||
// Add API version
|
||||
separator := "?"
|
||||
for _, ch := range url {
|
||||
if ch == '?' {
|
||||
separator = "&"
|
||||
break
|
||||
}
|
||||
}
|
||||
url = fmt.Sprintf("%s%sapi-version=%s", url, separator, apiVersion)
|
||||
|
||||
return c.doRequest("POST", url, body)
|
||||
}
|
||||
|
||||
// patch performs a PATCH request (for work item updates)
|
||||
func (c *Client) patch(endpoint string, body io.Reader) (*http.Response, error) {
|
||||
url := fmt.Sprintf("%s%s", c.baseURL, endpoint)
|
||||
if endpoint[0] != '/' {
|
||||
url = fmt.Sprintf("%s/%s", c.baseURL, endpoint)
|
||||
}
|
||||
|
||||
// Add API version
|
||||
separator := "?"
|
||||
for _, ch := range url {
|
||||
if ch == '?' {
|
||||
separator = "&"
|
||||
break
|
||||
}
|
||||
}
|
||||
url = fmt.Sprintf("%s%sapi-version=%s", url, separator, apiVersion)
|
||||
|
||||
return c.doRequestWithContentType("PATCH", url, body, "application/json-patch+json")
|
||||
}
|
||||
|
||||
// decode decodes a JSON response into the given target
|
||||
func decode(resp *http.Response, target interface{}) error {
|
||||
defer resp.Body.Close()
|
||||
|
||||
if err := json.NewDecoder(resp.Body).Decode(target); err != nil {
|
||||
return fmt.Errorf("decoding response: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// WorkItemWebURL returns the web URL for a work item
|
||||
func (c *Client) WorkItemWebURL(id int) string {
|
||||
return fmt.Sprintf("%s/_workitems/edit/%d", c.webURL, id)
|
||||
}
|
||||
|
||||
// Organization returns the organization name
|
||||
func (c *Client) Organization() string {
|
||||
return c.organization
|
||||
}
|
||||
|
||||
// Project returns the project name
|
||||
func (c *Client) Project() string {
|
||||
return c.project
|
||||
}
|
||||
|
||||
// Team returns the team name
|
||||
func (c *Client) Team() string {
|
||||
return c.team
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/samuelenocsson/devops-tui/internal/models"
|
||||
)
|
||||
|
||||
// iterationsResponse represents the API response for iterations
|
||||
type iterationsResponse struct {
|
||||
Count int `json:"count"`
|
||||
Value []iterationAPIItem `json:"value"`
|
||||
}
|
||||
|
||||
type iterationAPIItem struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Path string `json:"path"`
|
||||
Attributes iterationAttrs `json:"attributes"`
|
||||
URL string `json:"url"`
|
||||
}
|
||||
|
||||
type iterationAttrs struct {
|
||||
StartDate *time.Time `json:"startDate"`
|
||||
FinishDate *time.Time `json:"finishDate"`
|
||||
TimeFrame string `json:"timeFrame"`
|
||||
}
|
||||
|
||||
// GetIterations fetches all iterations (sprints) for the team
|
||||
func (c *Client) GetIterations() ([]models.Iteration, error) {
|
||||
resp, err := c.getTeam("/work/teamsettings/iterations")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var apiResp iterationsResponse
|
||||
if err := decode(resp, &apiResp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
iterations := make([]models.Iteration, 0, apiResp.Count)
|
||||
for _, item := range apiResp.Value {
|
||||
iter := models.Iteration{
|
||||
ID: item.ID,
|
||||
Name: item.Name,
|
||||
Path: item.Path,
|
||||
TimeFrame: item.Attributes.TimeFrame,
|
||||
URL: item.URL,
|
||||
}
|
||||
|
||||
if item.Attributes.StartDate != nil {
|
||||
iter.StartDate = *item.Attributes.StartDate
|
||||
}
|
||||
if item.Attributes.FinishDate != nil {
|
||||
iter.FinishDate = *item.Attributes.FinishDate
|
||||
}
|
||||
|
||||
iterations = append(iterations, iter)
|
||||
}
|
||||
|
||||
return iterations, nil
|
||||
}
|
||||
|
||||
// GetCurrentIteration returns the current iteration
|
||||
func (c *Client) GetCurrentIteration() (*models.Iteration, error) {
|
||||
iterations, err := c.GetIterations()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, iter := range iterations {
|
||||
if iter.IsCurrent() {
|
||||
return &iter, nil
|
||||
}
|
||||
}
|
||||
|
||||
// Return the first one if no current found
|
||||
if len(iterations) > 0 {
|
||||
return &iterations[0], nil
|
||||
}
|
||||
|
||||
return nil, nil
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/samuelenocsson/devops-tui/internal/models"
|
||||
)
|
||||
|
||||
// wiqlRequest represents a WIQL query request
|
||||
type wiqlRequest struct {
|
||||
Query string `json:"query"`
|
||||
}
|
||||
|
||||
// wiqlResponse represents the response from a WIQL query
|
||||
type wiqlResponse struct {
|
||||
WorkItems []struct {
|
||||
ID int `json:"id"`
|
||||
URL string `json:"url"`
|
||||
} `json:"workItems"`
|
||||
}
|
||||
|
||||
// workItemsResponse represents the response for batch work item fetch
|
||||
type workItemsResponse struct {
|
||||
Count int `json:"count"`
|
||||
Value []workItemAPIItem `json:"value"`
|
||||
}
|
||||
|
||||
// workItemAPIItem represents a work item from the API
|
||||
type workItemAPIItem struct {
|
||||
ID int `json:"id"`
|
||||
Rev int `json:"rev"`
|
||||
Fields workItemFields `json:"fields"`
|
||||
URL string `json:"url"`
|
||||
}
|
||||
|
||||
type workItemFields struct {
|
||||
ID int `json:"System.Id"`
|
||||
Title string `json:"System.Title"`
|
||||
State string `json:"System.State"`
|
||||
WorkItemType string `json:"System.WorkItemType"`
|
||||
AssignedTo *struct {
|
||||
DisplayName string `json:"displayName"`
|
||||
UniqueName string `json:"uniqueName"`
|
||||
} `json:"System.AssignedTo"`
|
||||
IterationPath string `json:"System.IterationPath"`
|
||||
AreaPath string `json:"System.AreaPath"`
|
||||
Description string `json:"System.Description"`
|
||||
Tags string `json:"System.Tags"`
|
||||
Parent int `json:"System.Parent"`
|
||||
Priority int `json:"Microsoft.VSTS.Common.Priority"`
|
||||
CreatedDate time.Time `json:"System.CreatedDate"`
|
||||
ChangedDate time.Time `json:"System.ChangedDate"`
|
||||
}
|
||||
|
||||
// escapeWIQL escapes a string value for use in WIQL queries
|
||||
func escapeWIQL(s string) string {
|
||||
// Escape single quotes by doubling them
|
||||
return strings.ReplaceAll(s, "'", "''")
|
||||
}
|
||||
|
||||
// QueryWorkItems queries work items using WIQL
|
||||
func (c *Client) QueryWorkItems(sprintPath, state, assigned, areaPath string) ([]models.WorkItem, error) {
|
||||
// Build WIQL query
|
||||
query := `SELECT [System.Id], [System.Title], [System.State], [System.WorkItemType]
|
||||
FROM WorkItems
|
||||
WHERE [System.TeamProject] = @project`
|
||||
|
||||
// Add sprint filter
|
||||
if sprintPath != "" && sprintPath != "all" {
|
||||
query += fmt.Sprintf(`
|
||||
AND [System.IterationPath] = '%s'`, escapeWIQL(sprintPath))
|
||||
}
|
||||
|
||||
// Add state filter
|
||||
if state != "" && state != "all" {
|
||||
query += fmt.Sprintf(`
|
||||
AND [System.State] = '%s'`, escapeWIQL(state))
|
||||
}
|
||||
|
||||
// Add assigned filter
|
||||
if assigned == "me" {
|
||||
query += `
|
||||
AND [System.AssignedTo] = @me`
|
||||
}
|
||||
|
||||
// Add area filter
|
||||
if areaPath != "" && areaPath != "all" {
|
||||
// Clean up the path
|
||||
areaPath = strings.TrimPrefix(areaPath, "\\")
|
||||
areaPath = strings.TrimSuffix(areaPath, "\\")
|
||||
query += fmt.Sprintf(`
|
||||
AND [System.AreaPath] UNDER '%s'`, escapeWIQL(areaPath))
|
||||
}
|
||||
|
||||
query += `
|
||||
ORDER BY [System.ChangedDate] DESC`
|
||||
|
||||
// Execute WIQL query
|
||||
reqBody := wiqlRequest{Query: query}
|
||||
bodyBytes, err := json.Marshal(reqBody)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("marshaling WIQL request: %w", err)
|
||||
}
|
||||
|
||||
resp, err := c.post("/wit/wiql", bytes.NewReader(bodyBytes))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var wiqlResp wiqlResponse
|
||||
if err := decode(resp, &wiqlResp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(wiqlResp.WorkItems) == 0 {
|
||||
return []models.WorkItem{}, nil
|
||||
}
|
||||
|
||||
// Get the IDs
|
||||
ids := make([]string, 0, len(wiqlResp.WorkItems))
|
||||
for _, wi := range wiqlResp.WorkItems {
|
||||
ids = append(ids, fmt.Sprintf("%d", wi.ID))
|
||||
}
|
||||
|
||||
// Fetch the full work items
|
||||
return c.GetWorkItems(ids)
|
||||
}
|
||||
|
||||
// GetWorkItems fetches multiple work items by ID
|
||||
func (c *Client) GetWorkItems(ids []string) ([]models.WorkItem, error) {
|
||||
if len(ids) == 0 {
|
||||
return []models.WorkItem{}, nil
|
||||
}
|
||||
|
||||
// API has a limit of 200 items per request
|
||||
const batchSize = 200
|
||||
var allItems []models.WorkItem
|
||||
|
||||
for i := 0; i < len(ids); i += batchSize {
|
||||
end := i + batchSize
|
||||
if end > len(ids) {
|
||||
end = len(ids)
|
||||
}
|
||||
|
||||
batch := ids[i:end]
|
||||
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"
|
||||
|
||||
endpoint := fmt.Sprintf("/wit/workitems?ids=%s&fields=%s", strings.Join(batch, ","), fields)
|
||||
resp, err := c.get(endpoint)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var apiResp workItemsResponse
|
||||
if err := decode(resp, &apiResp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, item := range apiResp.Value {
|
||||
wi := c.convertWorkItem(item)
|
||||
allItems = append(allItems, wi)
|
||||
}
|
||||
}
|
||||
|
||||
return allItems, nil
|
||||
}
|
||||
|
||||
// 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"
|
||||
|
||||
endpoint := fmt.Sprintf("/wit/workitems/%d?fields=%s", id, fields)
|
||||
resp, err := c.get(endpoint)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var item workItemAPIItem
|
||||
if err := decode(resp, &item); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
wi := c.convertWorkItem(item)
|
||||
return &wi, nil
|
||||
}
|
||||
|
||||
// convertWorkItem converts an API work item to our model
|
||||
func (c *Client) convertWorkItem(item workItemAPIItem) models.WorkItem {
|
||||
wi := models.WorkItem{
|
||||
ID: item.ID,
|
||||
Rev: item.Rev,
|
||||
Title: item.Fields.Title,
|
||||
State: models.WorkItemState(item.Fields.State),
|
||||
Type: models.WorkItemType(item.Fields.WorkItemType),
|
||||
IterationPath: item.Fields.IterationPath,
|
||||
AreaPath: item.Fields.AreaPath,
|
||||
Description: stripHTML(item.Fields.Description),
|
||||
ParentID: item.Fields.Parent,
|
||||
Priority: item.Fields.Priority,
|
||||
CreatedDate: item.Fields.CreatedDate,
|
||||
ChangedDate: item.Fields.ChangedDate,
|
||||
URL: item.URL,
|
||||
WebURL: c.WorkItemWebURL(item.ID),
|
||||
}
|
||||
|
||||
if item.Fields.AssignedTo != nil {
|
||||
wi.AssignedTo = item.Fields.AssignedTo.DisplayName
|
||||
}
|
||||
|
||||
// Parse tags
|
||||
if item.Fields.Tags != "" {
|
||||
tags := strings.Split(item.Fields.Tags, ";")
|
||||
for _, tag := range tags {
|
||||
tag = strings.TrimSpace(tag)
|
||||
if tag != "" {
|
||||
wi.Tags = append(wi.Tags, tag)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return wi
|
||||
}
|
||||
|
||||
// UpdateWorkItemState updates a work item's state
|
||||
func (c *Client) UpdateWorkItemState(id int, newState string) error {
|
||||
// Azure DevOps uses JSON Patch format
|
||||
patchDoc := []map[string]interface{}{
|
||||
{
|
||||
"op": "add",
|
||||
"path": "/fields/System.State",
|
||||
"value": newState,
|
||||
},
|
||||
}
|
||||
|
||||
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
|
||||
re := regexp.MustCompile(`<[^>]*>`)
|
||||
s = re.ReplaceAllString(s, "")
|
||||
|
||||
// Replace common HTML entities
|
||||
s = strings.ReplaceAll(s, " ", " ")
|
||||
s = strings.ReplaceAll(s, "&", "&")
|
||||
s = strings.ReplaceAll(s, "<", "<")
|
||||
s = strings.ReplaceAll(s, ">", ">")
|
||||
s = strings.ReplaceAll(s, """, "\"")
|
||||
s = strings.ReplaceAll(s, "'", "'")
|
||||
|
||||
// Trim whitespace
|
||||
s = strings.TrimSpace(s)
|
||||
|
||||
return s
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/samuelenocsson/devops-tui/internal/models"
|
||||
)
|
||||
|
||||
// workItemTypesResponse represents the API response for work item types
|
||||
type workItemTypesResponse struct {
|
||||
Count int `json:"count"`
|
||||
Value []workItemTypeAPIItem `json:"value"`
|
||||
}
|
||||
|
||||
type workItemTypeAPIItem struct {
|
||||
Name string `json:"name"`
|
||||
ReferenceName string `json:"referenceName"`
|
||||
Description string `json:"description"`
|
||||
Color string `json:"color"`
|
||||
Icon struct {
|
||||
ID string `json:"id"`
|
||||
URL string `json:"url"`
|
||||
} `json:"icon"`
|
||||
}
|
||||
|
||||
// statesResponse represents the API response for work item states
|
||||
type statesResponse struct {
|
||||
Count int `json:"count"`
|
||||
Value []stateAPIItem `json:"value"`
|
||||
}
|
||||
|
||||
type stateAPIItem struct {
|
||||
Name string `json:"name"`
|
||||
Color string `json:"color"`
|
||||
Category string `json:"stateCategory"`
|
||||
}
|
||||
|
||||
// GetWorkItemTypes fetches all work item types for the project
|
||||
func (c *Client) GetWorkItemTypes() ([]string, error) {
|
||||
resp, err := c.get("/wit/workitemtypes")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var apiResp workItemTypesResponse
|
||||
if err := decode(resp, &apiResp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
types := make([]string, 0, apiResp.Count)
|
||||
for _, item := range apiResp.Value {
|
||||
types = append(types, item.Name)
|
||||
}
|
||||
|
||||
return types, nil
|
||||
}
|
||||
|
||||
// GetWorkItemTypeStates fetches all states for a specific work item type
|
||||
func (c *Client) GetWorkItemTypeStates(workItemType string) ([]models.WorkItemStateInfo, error) {
|
||||
endpoint := fmt.Sprintf("/wit/workitemtypes/%s/states", workItemType)
|
||||
resp, err := c.get(endpoint)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var apiResp statesResponse
|
||||
if err := decode(resp, &apiResp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
states := make([]models.WorkItemStateInfo, 0, apiResp.Count)
|
||||
for _, item := range apiResp.Value {
|
||||
states = append(states, models.WorkItemStateInfo{
|
||||
Name: item.Name,
|
||||
Color: item.Color,
|
||||
Category: item.Category,
|
||||
})
|
||||
}
|
||||
|
||||
return states, nil
|
||||
}
|
||||
|
||||
// GetAllWorkItemTypeStates fetches states for all work item types
|
||||
func (c *Client) GetAllWorkItemTypeStates() (map[string][]models.WorkItemStateInfo, error) {
|
||||
types, err := c.GetWorkItemTypes()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
statesByType := make(map[string][]models.WorkItemStateInfo)
|
||||
for _, t := range types {
|
||||
states, err := c.GetWorkItemTypeStates(t)
|
||||
if err != nil {
|
||||
// Skip types that fail (some system types may not have states)
|
||||
continue
|
||||
}
|
||||
statesByType[t] = states
|
||||
}
|
||||
|
||||
return statesByType, nil
|
||||
}
|
||||
Reference in New Issue
Block a user