2555afce19
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>
180 lines
4.5 KiB
Go
180 lines
4.5 KiB
Go
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
|
|
}
|