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,25 @@
|
||||
package models
|
||||
|
||||
import "strings"
|
||||
|
||||
// Area represents an Azure DevOps area
|
||||
type Area struct {
|
||||
ID int `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Path string `json:"path"`
|
||||
}
|
||||
|
||||
// DisplayName returns a shortened display name
|
||||
func (a Area) DisplayName() string {
|
||||
parts := strings.Split(a.Path, "\\")
|
||||
if len(parts) > 1 {
|
||||
// Return the last part for brevity
|
||||
return parts[len(parts)-1]
|
||||
}
|
||||
return a.Name
|
||||
}
|
||||
|
||||
// FullPath returns the full path
|
||||
func (a Area) FullPath() string {
|
||||
return a.Path
|
||||
}
|
||||
@@ -0,0 +1,317 @@
|
||||
package models
|
||||
|
||||
// FilterType represents the type of filter
|
||||
type FilterType int
|
||||
|
||||
const (
|
||||
FilterTypeSprint FilterType = iota
|
||||
FilterTypeState
|
||||
FilterTypeAssigned
|
||||
FilterTypeArea
|
||||
)
|
||||
|
||||
// FilterOption represents a selectable filter option
|
||||
type FilterOption struct {
|
||||
Label string
|
||||
Value string
|
||||
Selected bool
|
||||
}
|
||||
|
||||
// FilterGroup represents a group of filter options
|
||||
type FilterGroup struct {
|
||||
Type FilterType
|
||||
Title string
|
||||
Options []FilterOption
|
||||
Cursor int
|
||||
Offset int // Scroll offset for viewing
|
||||
}
|
||||
|
||||
// SelectedOption returns the currently selected option
|
||||
func (f *FilterGroup) SelectedOption() *FilterOption {
|
||||
for i := range f.Options {
|
||||
if f.Options[i].Selected {
|
||||
return &f.Options[i]
|
||||
}
|
||||
}
|
||||
if len(f.Options) > 0 {
|
||||
return &f.Options[0]
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Select marks the option at the given index as selected
|
||||
func (f *FilterGroup) Select(index int) {
|
||||
if index < 0 || index >= len(f.Options) {
|
||||
return
|
||||
}
|
||||
for i := range f.Options {
|
||||
f.Options[i].Selected = i == index
|
||||
}
|
||||
}
|
||||
|
||||
// SelectCurrent marks the option at the current cursor as selected
|
||||
func (f *FilterGroup) SelectCurrent() {
|
||||
f.Select(f.Cursor)
|
||||
}
|
||||
|
||||
// MoveUp moves the cursor up
|
||||
func (f *FilterGroup) MoveUp() {
|
||||
if f.Cursor > 0 {
|
||||
f.Cursor--
|
||||
}
|
||||
}
|
||||
|
||||
// MoveDown moves the cursor down
|
||||
func (f *FilterGroup) MoveDown() {
|
||||
if f.Cursor < len(f.Options)-1 {
|
||||
f.Cursor++
|
||||
}
|
||||
}
|
||||
|
||||
// MoveToTop moves cursor to the first option
|
||||
func (f *FilterGroup) MoveToTop() {
|
||||
f.Cursor = 0
|
||||
}
|
||||
|
||||
// MoveToBottom moves cursor to the last option
|
||||
func (f *FilterGroup) MoveToBottom() {
|
||||
if len(f.Options) > 0 {
|
||||
f.Cursor = len(f.Options) - 1
|
||||
}
|
||||
}
|
||||
|
||||
// FilterState holds the complete filter state
|
||||
type FilterState struct {
|
||||
Groups []*FilterGroup
|
||||
ActiveGroup int
|
||||
SearchQuery string
|
||||
}
|
||||
|
||||
// NewFilterState creates a new filter state with default groups
|
||||
func NewFilterState(iterations []Iteration, areas []Area, statesByType map[string][]WorkItemStateInfo) *FilterState {
|
||||
// Build sprint options from iterations
|
||||
sprintOptions := []FilterOption{
|
||||
{Label: "All", Value: "all", Selected: false},
|
||||
}
|
||||
|
||||
for _, iter := range iterations {
|
||||
selected := iter.IsCurrent()
|
||||
sprintOptions = append(sprintOptions, FilterOption{
|
||||
Label: iter.DisplayName(),
|
||||
Value: iter.Path,
|
||||
Selected: selected,
|
||||
})
|
||||
}
|
||||
|
||||
// If no current sprint found, select "All"
|
||||
hasSelected := false
|
||||
for _, opt := range sprintOptions {
|
||||
if opt.Selected {
|
||||
hasSelected = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hasSelected && len(sprintOptions) > 0 {
|
||||
sprintOptions[0].Selected = true
|
||||
}
|
||||
|
||||
// Build area options from areas
|
||||
areaOptions := []FilterOption{
|
||||
{Label: "All", Value: "all", Selected: true},
|
||||
}
|
||||
for _, area := range areas {
|
||||
areaOptions = append(areaOptions, FilterOption{
|
||||
Label: area.DisplayName(),
|
||||
Value: area.Path,
|
||||
Selected: false,
|
||||
})
|
||||
}
|
||||
|
||||
// Build state options from all work item types (unique states)
|
||||
stateOptions := []FilterOption{
|
||||
{Label: "All", Value: "all", Selected: true},
|
||||
}
|
||||
if len(statesByType) > 0 {
|
||||
// Collect unique states preserving order by category
|
||||
seenStates := make(map[string]bool)
|
||||
// Process in category order: Proposed, InProgress, Resolved, Completed
|
||||
categoryOrder := []string{"Proposed", "InProgress", "Resolved", "Completed", "Removed"}
|
||||
for _, category := range categoryOrder {
|
||||
for _, states := range statesByType {
|
||||
for _, state := range states {
|
||||
if state.Category == category && !seenStates[state.Name] {
|
||||
seenStates[state.Name] = true
|
||||
stateOptions = append(stateOptions, FilterOption{
|
||||
Label: state.Name,
|
||||
Value: state.Name,
|
||||
Selected: false,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Also add any states that didn't match known categories
|
||||
for _, states := range statesByType {
|
||||
for _, state := range states {
|
||||
if !seenStates[state.Name] {
|
||||
seenStates[state.Name] = true
|
||||
stateOptions = append(stateOptions, FilterOption{
|
||||
Label: state.Name,
|
||||
Value: state.Name,
|
||||
Selected: false,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Fallback to default states if we only have "All"
|
||||
if len(stateOptions) <= 1 {
|
||||
defaultStates := []string{"New", "Active", "Resolved", "Closed"}
|
||||
for _, state := range defaultStates {
|
||||
stateOptions = append(stateOptions, FilterOption{
|
||||
Label: state,
|
||||
Value: state,
|
||||
Selected: false,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return &FilterState{
|
||||
Groups: []*FilterGroup{
|
||||
{
|
||||
Type: FilterTypeSprint,
|
||||
Title: "Sprint",
|
||||
Options: sprintOptions,
|
||||
Cursor: 0,
|
||||
},
|
||||
{
|
||||
Type: FilterTypeState,
|
||||
Title: "State",
|
||||
Options: stateOptions,
|
||||
Cursor: 0,
|
||||
},
|
||||
{
|
||||
Type: FilterTypeAssigned,
|
||||
Title: "Assigned",
|
||||
Options: []FilterOption{
|
||||
{Label: "All", Value: "all", Selected: false},
|
||||
{Label: "Me", Value: "me", Selected: true},
|
||||
},
|
||||
Cursor: 0,
|
||||
},
|
||||
{
|
||||
Type: FilterTypeArea,
|
||||
Title: "Area",
|
||||
Options: areaOptions,
|
||||
Cursor: 0,
|
||||
},
|
||||
},
|
||||
ActiveGroup: 0,
|
||||
}
|
||||
}
|
||||
|
||||
// ActiveFilterGroup returns the currently active filter group
|
||||
func (f *FilterState) ActiveFilterGroup() *FilterGroup {
|
||||
if f.ActiveGroup >= 0 && f.ActiveGroup < len(f.Groups) {
|
||||
return f.Groups[f.ActiveGroup]
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// NextGroup moves to the next filter group
|
||||
func (f *FilterState) NextGroup() {
|
||||
if f.ActiveGroup < len(f.Groups)-1 {
|
||||
f.ActiveGroup++
|
||||
}
|
||||
}
|
||||
|
||||
// PrevGroup moves to the previous filter group
|
||||
func (f *FilterState) PrevGroup() {
|
||||
if f.ActiveGroup > 0 {
|
||||
f.ActiveGroup--
|
||||
}
|
||||
}
|
||||
|
||||
// GetSelectedSprint returns the selected sprint path
|
||||
func (f *FilterState) GetSelectedSprint() string {
|
||||
for _, g := range f.Groups {
|
||||
if g.Type == FilterTypeSprint {
|
||||
if opt := g.SelectedOption(); opt != nil {
|
||||
return opt.Value
|
||||
}
|
||||
}
|
||||
}
|
||||
return "all"
|
||||
}
|
||||
|
||||
// GetSelectedState returns the selected state filter
|
||||
func (f *FilterState) GetSelectedState() string {
|
||||
for _, g := range f.Groups {
|
||||
if g.Type == FilterTypeState {
|
||||
if opt := g.SelectedOption(); opt != nil {
|
||||
return opt.Value
|
||||
}
|
||||
}
|
||||
}
|
||||
return "all"
|
||||
}
|
||||
|
||||
// GetSelectedAssigned returns the selected assigned filter
|
||||
func (f *FilterState) GetSelectedAssigned() string {
|
||||
for _, g := range f.Groups {
|
||||
if g.Type == FilterTypeAssigned {
|
||||
if opt := g.SelectedOption(); opt != nil {
|
||||
return opt.Value
|
||||
}
|
||||
}
|
||||
}
|
||||
return "all"
|
||||
}
|
||||
|
||||
// GetSelectedArea returns the selected area path
|
||||
func (f *FilterState) GetSelectedArea() string {
|
||||
for _, g := range f.Groups {
|
||||
if g.Type == FilterTypeArea {
|
||||
if opt := g.SelectedOption(); opt != nil {
|
||||
return opt.Value
|
||||
}
|
||||
}
|
||||
}
|
||||
return "all"
|
||||
}
|
||||
|
||||
// ApplySavedSelections applies saved filter selections
|
||||
func (f *FilterState) ApplySavedSelections(sprint, state, assigned, area string) {
|
||||
for _, g := range f.Groups {
|
||||
var targetValue string
|
||||
switch g.Type {
|
||||
case FilterTypeSprint:
|
||||
targetValue = sprint
|
||||
case FilterTypeState:
|
||||
targetValue = state
|
||||
case FilterTypeAssigned:
|
||||
targetValue = assigned
|
||||
case FilterTypeArea:
|
||||
targetValue = area
|
||||
}
|
||||
|
||||
if targetValue == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
// Find and select the matching option
|
||||
found := false
|
||||
for i, opt := range g.Options {
|
||||
if opt.Value == targetValue {
|
||||
g.Select(i)
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// If not found and it's sprint with "current", keep the current selection
|
||||
if !found && g.Type == FilterTypeSprint && targetValue == "current" {
|
||||
// Already handled in NewFilterState
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package models
|
||||
|
||||
import "time"
|
||||
|
||||
// Iteration represents an Azure DevOps iteration (sprint)
|
||||
type Iteration struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Path string `json:"path"`
|
||||
StartDate time.Time `json:"startDate"`
|
||||
FinishDate time.Time `json:"finishDate"`
|
||||
TimeFrame string `json:"timeFrame"` // "past", "current", "future"
|
||||
URL string `json:"url"`
|
||||
}
|
||||
|
||||
// IsCurrent returns true if this is the current iteration
|
||||
func (i *Iteration) IsCurrent() bool {
|
||||
return i.TimeFrame == "current"
|
||||
}
|
||||
|
||||
// IsPast returns true if this iteration is in the past
|
||||
func (i *Iteration) IsPast() bool {
|
||||
return i.TimeFrame == "past"
|
||||
}
|
||||
|
||||
// IsFuture returns true if this iteration is in the future
|
||||
func (i *Iteration) IsFuture() bool {
|
||||
return i.TimeFrame == "future"
|
||||
}
|
||||
|
||||
// DisplayName returns a formatted name for display
|
||||
func (i *Iteration) DisplayName() string {
|
||||
if i.IsCurrent() {
|
||||
return i.Name + " (current)"
|
||||
}
|
||||
return i.Name
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
package models
|
||||
|
||||
import "time"
|
||||
|
||||
// WorkItemType represents the type of work item
|
||||
type WorkItemType string
|
||||
|
||||
const (
|
||||
WorkItemTypeStory WorkItemType = "User Story"
|
||||
WorkItemTypeTask WorkItemType = "Task"
|
||||
WorkItemTypeBug WorkItemType = "Bug"
|
||||
WorkItemTypeFeature WorkItemType = "Feature"
|
||||
WorkItemTypeEpic WorkItemType = "Epic"
|
||||
)
|
||||
|
||||
// WorkItemState represents the state of a work item
|
||||
type WorkItemState string
|
||||
|
||||
const (
|
||||
WorkItemStateNew WorkItemState = "New"
|
||||
WorkItemStateActive WorkItemState = "Active"
|
||||
WorkItemStateResolved WorkItemState = "Resolved"
|
||||
WorkItemStateClosed WorkItemState = "Closed"
|
||||
)
|
||||
|
||||
// WorkItem represents an Azure DevOps work item
|
||||
type WorkItem struct {
|
||||
ID int `json:"id"`
|
||||
Rev int `json:"rev"`
|
||||
Title string `json:"title"`
|
||||
State WorkItemState `json:"state"`
|
||||
Type WorkItemType `json:"type"`
|
||||
AssignedTo string `json:"assignedTo"`
|
||||
IterationPath string `json:"iterationPath"`
|
||||
AreaPath string `json:"areaPath"`
|
||||
Description string `json:"description"`
|
||||
Tags []string `json:"tags"`
|
||||
ParentID int `json:"parentId"`
|
||||
ParentTitle string `json:"parentTitle"`
|
||||
Priority int `json:"priority"`
|
||||
CreatedDate time.Time `json:"createdDate"`
|
||||
ChangedDate time.Time `json:"changedDate"`
|
||||
URL string `json:"url"`
|
||||
WebURL string `json:"webUrl"`
|
||||
}
|
||||
|
||||
// ShortType returns a short version of the work item type
|
||||
func (w *WorkItem) ShortType() string {
|
||||
switch w.Type {
|
||||
case WorkItemTypeStory:
|
||||
return "Story"
|
||||
case WorkItemTypeTask:
|
||||
return "Task"
|
||||
case WorkItemTypeBug:
|
||||
return "Bug"
|
||||
case WorkItemTypeFeature:
|
||||
return "Feature"
|
||||
case WorkItemTypeEpic:
|
||||
return "Epic"
|
||||
default:
|
||||
return string(w.Type)
|
||||
}
|
||||
}
|
||||
|
||||
// SprintName extracts the sprint name from the iteration path
|
||||
func (w *WorkItem) SprintName() string {
|
||||
// IterationPath is like "MyProject\\Sprint 42"
|
||||
// Return just "Sprint 42"
|
||||
for i := len(w.IterationPath) - 1; i >= 0; i-- {
|
||||
if w.IterationPath[i] == '\\' {
|
||||
return w.IterationPath[i+1:]
|
||||
}
|
||||
}
|
||||
return w.IterationPath
|
||||
}
|
||||
|
||||
// AreaName extracts the area name from the area path
|
||||
func (w *WorkItem) AreaName() string {
|
||||
for i := len(w.AreaPath) - 1; i >= 0; i-- {
|
||||
if w.AreaPath[i] == '\\' {
|
||||
return w.AreaPath[i+1:]
|
||||
}
|
||||
}
|
||||
return w.AreaPath
|
||||
}
|
||||
|
||||
// WorkItemStateInfo represents state metadata from Azure DevOps
|
||||
type WorkItemStateInfo struct {
|
||||
Name string `json:"name"`
|
||||
Color string `json:"color"`
|
||||
Category string `json:"category"` // Proposed, InProgress, Resolved, Completed, Removed
|
||||
}
|
||||
Reference in New Issue
Block a user