kanban

Kanban client in Gio
Log | Files | Refs | README | LICENSE

kanban.go (7612B)


      1 // Package kanban implements Kanban logic.
      2 //
      3 // Kanban is Project oriented, where a Project holds the context for given set
      4 // of Stages and Tickets.
      5 //
      6 // Projects are independent of each other.
      7 //
      8 // Notes:
      9 //
     10 // Project
     11 // - represents some project that can be broken down into to discrete tasks, described by a name
     12 // - each project has it's own arbitrary pipeline of stages with which tickets move through left-to-right
     13 // - contains an ordered list of stages
     14 // - stages are re-orderable
     15 // - can be renamed
     16 // - can be deleted
     17 //
     18 // Stage
     19 // - represents an important part in the lifecycle of a task, described by a name
     20 // - contains an ordered list of tickets
     21 // - tickets are re-orderable
     22 // - tickets can advance back and forth between stages, typically linearly
     23 // - can be renamed
     24 // - can be deleted
     25 //
     26 // Ticket
     27 // - contains information about a task for a project
     28 // - is unique to a Project and sits within one of it's stages
     29 // - cannot occupy more than one stage
     30 // - can be edited
     31 // - can be deleted
     32 package kanban
     33 
     34 import (
     35 	"fmt"
     36 	"time"
     37 
     38 	"github.com/google/uuid"
     39 )
     40 
     41 // Project is a context for a given set of tickets.
     42 type Project struct {
     43 	ID uuid.UUID
     44 	// Name of the project.
     45 	Name string
     46 	// Stages is the list of stages owned by the project.
     47 	Stages Stages
     48 	// Finalized is a psuedo stage that contains all finalized tickets.
     49 	Finalized []Ticket
     50 }
     51 
     52 // MakeStage assigns a ticket to the given stage.
     53 func (p *Project) MakeStage(name string) {
     54 	p.Stages = append(p.Stages, Stage{
     55 		Name: name,
     56 	})
     57 }
     58 
     59 func (p *Project) ListStages() []Stage {
     60 	return p.Stages
     61 }
     62 
     63 func (p *Project) MoveStage(name string, dir Direction) bool {
     64 	return p.Stages.Swap(name, dir)
     65 }
     66 
     67 // AssignTicket assigns a ticket to the given stage.
     68 func (p *Project) AssignTicket(stage string, ticket Ticket) error {
     69 	return p.Stages.Find(stage).Assign(ticket)
     70 }
     71 
     72 // Update an existing ticket.
     73 // It is an error to attempt to update a ticket that does not exist.
     74 func (p *Project) UpdateTicket(ticket Ticket) error {
     75 	for _, s := range p.Stages {
     76 		if s.Update(ticket) {
     77 			return nil
     78 		}
     79 	}
     80 	return fmt.Errorf("ticket does not exist: %v", ticket)
     81 }
     82 
     83 // ProgressTicket moves a ticket to the "next" stage.
     84 func (p *Project) ProgressTicket(ticket Ticket) {
     85 	for ii, s := range p.Stages {
     86 		if s.Contains(ticket) {
     87 			if ii < len(p.Stages)-1 {
     88 				_ = p.Stages[ii+1].Assign(p.Stages[ii].Take(ticket))
     89 			}
     90 			break
     91 		}
     92 	}
     93 }
     94 
     95 // RegressTicket moves a ticket to the "previous" stage.
     96 func (p *Project) RegressTicket(ticket Ticket) {
     97 	for ii, s := range p.Stages {
     98 		if s.Contains(ticket) {
     99 			if ii > 0 {
    100 				_ = p.Stages[ii-1].Assign(p.Stages[ii].Take(ticket))
    101 			}
    102 			break
    103 		}
    104 	}
    105 }
    106 
    107 // MoveTicket within a stage.
    108 func (p *Project) MoveTicket(ticket Ticket, dir Direction) bool {
    109 	// @implement
    110 	return false
    111 }
    112 
    113 func (p *Project) ListTickets(stage string) []Ticket {
    114 	return p.Stages.Find(stage).Tickets
    115 }
    116 
    117 // StageForTicket returns the stage containing the specified ticket.
    118 func (p *Project) StageForTicket(ticket Ticket) *Stage {
    119 	for ii, s := range p.Stages {
    120 		if s.Contains(ticket) {
    121 			return &p.Stages[ii]
    122 		}
    123 	}
    124 	return &Stage{}
    125 }
    126 
    127 // FinalizeTicket renders the ticket "complete" and moves it into an archive.
    128 func (p *Project) FinalizeTicket(t Ticket) {
    129 	for ii, s := range p.Stages {
    130 		if s.Contains(t) {
    131 			p.Stages[ii].UnAssign(t)
    132 			p.Finalized = append(p.Finalized, t)
    133 			break
    134 		}
    135 	}
    136 }
    137 
    138 // Stage in the kanban pipeline, can hold a number of tickets.
    139 type Stage struct {
    140 	Name    string
    141 	Tickets []Ticket
    142 }
    143 
    144 // Assign appends a ticket to the stage with a unique ID.
    145 // Existing tickets will be duplicated, but with different IDs.
    146 func (s *Stage) Assign(ticket Ticket) error {
    147 	if ticket.ID == uuid.Nil {
    148 		id, err := uuid.NewUUID()
    149 		if err != nil {
    150 			return fmt.Errorf("generating ID: %v", err)
    151 		}
    152 		ticket.ID = id
    153 		ticket.Created = time.Now()
    154 	}
    155 	s.Tickets = append(s.Tickets, ticket)
    156 	return nil
    157 }
    158 
    159 // UnAssign removes a ticket from the stage.
    160 func (s *Stage) UnAssign(ticket Ticket) {
    161 	for ii, t := range s.Tickets {
    162 		if t == ticket {
    163 			if len(s.Tickets) == 1 {
    164 				s.Tickets = []Ticket{}
    165 			} else {
    166 				s.Tickets = append(s.Tickets[:ii], s.Tickets[ii+1:]...)
    167 			}
    168 		}
    169 	}
    170 }
    171 
    172 // Stages is a list of Stage.
    173 type Stages []Stage
    174 
    175 // Swap the specified stage in the given direction.
    176 // Returns false when at a boundary, and therefore no swap can occur.
    177 func (stages *Stages) Swap(stage string, dir Direction) bool {
    178 	ii, ok := stages.Index(stage)
    179 	if !ok {
    180 		return false
    181 	}
    182 	if bounds := ii + dir.Next(); bounds < 0 || bounds > len(*stages)-1 {
    183 		return false
    184 	}
    185 	(*stages)[ii], (*stages)[ii+dir.Next()] = (*stages)[ii+dir.Next()], (*stages)[ii]
    186 	return true
    187 }
    188 
    189 // Find stage by name.
    190 func (stages *Stages) Find(name string) *Stage {
    191 	for ii, s := range *stages {
    192 		if s.Name == name {
    193 			return &(*stages)[ii]
    194 		}
    195 	}
    196 	return &Stage{}
    197 }
    198 
    199 // Index returns the index postition for the stage, false if no stage exists.
    200 func (stages *Stages) Index(name string) (int, bool) {
    201 	for ii, s := range *stages {
    202 		if s.Name == name {
    203 			return ii, true
    204 		}
    205 	}
    206 	return 0, false
    207 }
    208 
    209 // Take the specified ticket, if it exists.
    210 // Removes it from the stage.
    211 func (s *Stage) Take(ticket Ticket) Ticket {
    212 	s.UnAssign(ticket)
    213 	return ticket
    214 }
    215 
    216 // Contains returns true if the specified ticket exists in the stage.
    217 func (s *Stage) Contains(ticket Ticket) bool {
    218 	for _, t := range s.Tickets {
    219 		if t == ticket {
    220 			return true
    221 		}
    222 	}
    223 	return false
    224 }
    225 
    226 // Update a ticket, returning a bool to indicate success.
    227 // False means ticket does not exist and therefore nothing was updated.
    228 func (s *Stage) Update(ticket Ticket) bool {
    229 	for ii, t := range s.Tickets {
    230 		if t.ID == ticket.ID {
    231 			s.Tickets[ii] = ticket
    232 			return true
    233 		}
    234 	}
    235 	return false
    236 }
    237 
    238 // Ticket in a stage.
    239 type Ticket struct {
    240 	ID uuid.UUID
    241 	// Title of the ticket.
    242 	Title string
    243 	// Summary contains short and concise overview of the ticket.
    244 	Summary string
    245 	// Details contains the full details of the ticket.
    246 	Details string
    247 	// Created when the ticket was created.
    248 	Created time.Time
    249 }
    250 
    251 // Direction encodes mutually exclusive directions.
    252 type Direction int8
    253 
    254 const (
    255 	Forward Direction = iota
    256 	Backward
    257 )
    258 
    259 // Next returns the direction as a signed integer, where positive is forward.
    260 func (dir Direction) Next() int {
    261 	switch dir {
    262 	case Forward:
    263 		return 1
    264 	case Backward:
    265 		return -1
    266 	}
    267 	return 0
    268 }
    269 
    270 // Invert returns the inverse of dir.
    271 func (dir Direction) Invert() Direction {
    272 	switch dir {
    273 	case Forward:
    274 		return Backward
    275 	case Backward:
    276 		return Forward
    277 	}
    278 	return dir
    279 }
    280 
    281 func (p *Project) String() string {
    282 	if p == nil {
    283 		return "<nil>"
    284 	}
    285 	return fmt.Sprintf("%v", *p)
    286 }
    287 
    288 // Clone a project ensuring all data is copied.
    289 func (p Project) Clone() Project {
    290 	var (
    291 		stages    = make([]Stage, len(p.Stages))
    292 		finalized = make([]Ticket, len(p.Finalized))
    293 	)
    294 	copy(finalized, p.Finalized)
    295 	for ii, s := range p.Stages {
    296 		tickets := make([]Ticket, len(s.Tickets))
    297 		copy(tickets, s.Tickets)
    298 		stages[ii] = Stage{
    299 			Name:    s.Name,
    300 			Tickets: tickets,
    301 		}
    302 	}
    303 	return Project{
    304 		ID:        p.ID,
    305 		Name:      p.Name,
    306 		Stages:    stages,
    307 		Finalized: finalized,
    308 	}
    309 }
    310 
    311 func (p *Project) Eq(other *Project) bool {
    312 	return p.ID == other.ID &&
    313 		p.Name == other.Name &&
    314 		p.Stages.Eq(other.Stages)
    315 }
    316 
    317 func (s Stages) Eq(other Stages) bool {
    318 	for ii := range s {
    319 		if !s[ii].Eq(other[ii]) {
    320 			return false
    321 		}
    322 	}
    323 	return true
    324 }
    325 
    326 func (s Stage) Eq(other Stage) bool {
    327 	if len(s.Tickets) != len(other.Tickets) {
    328 		return false
    329 	}
    330 	for ii, t := range s.Tickets {
    331 		if t != other.Tickets[ii] {
    332 			return false
    333 		}
    334 	}
    335 	return s.Name == other.Name
    336 }