Compare commits

..

5 Commits

7 changed files with 558 additions and 79 deletions

View File

@@ -1,2 +1,2 @@
[tools] [tools]
nim = "2.2.4" nim = "2.2.6"

View File

@@ -1,6 +1,6 @@
# Package # Package
version = "4.29.2" version = "4.30.1"
author = "Jonathan Bernard" author = "Jonathan Bernard"
description = "Personal issue tracker." description = "Personal issue tracker."
license = "MIT" license = "MIT"
@@ -19,7 +19,7 @@ requires @[
# Dependencies from git.jdb-software.com/jdb/nim-packages # Dependencies from git.jdb-software.com/jdb/nim-packages
requires @[ requires @[
"cliutils >= 0.9.1", "cliutils >= 0.10.2",
"langutils >= 0.4.0", "langutils >= 0.4.0",
"timeutils >= 0.5.4", "timeutils >= 0.5.4",
"data_uri > 1.0.0", "data_uri > 1.0.0",

View File

@@ -6,7 +6,7 @@ import data_uri, docopt, json, timeutils, uuids
from nre import re from nre import re
import strutils except alignLeft, capitalize, strip, toUpper, toLower import strutils except alignLeft, capitalize, strip, toUpper, toLower
import pit/[cliconstants, formatting, libpit, sync_pbm_vsb] import pit/[cliconstants, formatting, libpit, projects, sync_pbm_vsb]
export formatting, libpit export formatting, libpit
@@ -31,6 +31,7 @@ proc parseExclPropertiesOption(propsOpt: string): TableRef[string, seq[string]]
if result.hasKey(pair[0]): result[pair[0]].add(val) if result.hasKey(pair[0]): result[pair[0]].add(val)
else: result[pair[0]] = @[val] else: result[pair[0]] = @[val]
proc reorder(ctx: CliContext, state: IssueState) = proc reorder(ctx: CliContext, state: IssueState) =
# load the issues to make sure the order file contains all issues in the state. # load the issues to make sure the order file contains all issues in the state.
@@ -87,6 +88,10 @@ when isMainModule:
var tagsOption = none(seq[string]) var tagsOption = none(seq[string])
var exclTagsOption = none(seq[string]) var exclTagsOption = none(seq[string])
let filter = initFilter()
var filterOption = none(IssueFilter)
if args["--properties"] or args["--context"]: if args["--properties"] or args["--context"]:
var props = var props =
@@ -116,6 +121,54 @@ when isMainModule:
if args["--excl-tags"]: exclTagsOption = if args["--excl-tags"]: exclTagsOption =
some(($args["--excl-tags"]).split(",").mapIt(it.strip)) some(($args["--excl-tags"]).split(",").mapIt(it.strip))
# Initialize filter with properties (if given)
if propertiesOption.isSome:
filter.properties = propertiesOption.get
filterOption = some(filter)
# Add property exclusions (if given)
if exclPropsOption.isSome:
filter.exclProperties = exclPropsOption.get
filterOption = some(filter)
# If they supplied text matches, add that to the filter.
if args["--match"]:
filter.summaryMatch = some(re("(?i)" & $args["--match"]))
filterOption = some(filter)
if args["--match-all"]:
filter.fullMatch = some(re("(?i)" & $args["--match-all"]))
filterOption = some(filter)
# If no "context" property is given, use the default (if we have one)
if ctx.defaultContext.isSome and not filter.properties.hasKey("context"):
stderr.writeLine("Limiting to default context: " & ctx.defaultContext.get)
filter.properties["context"] = ctx.defaultContext.get
filterOption = some(filter)
if tagsOption.isSome:
filter.hasTags = tagsOption.get
filterOption = some(filter)
if exclTagsOption.isSome:
filter.exclTags = exclTagsOption.get
filterOption = some(filter)
if args["--today"]:
filter.inclStates.add(@[Current, TodoToday, Pending])
filterOption = some(filter)
if args["--future"]:
filter.inclStates.add(@[Pending, Todo])
filterOption = some(filter)
# Finally, if the "context" is "all", don't filter on context
if filter.properties.hasKey("context") and
filter.properties["context"] == "all":
filter.properties.del("context")
filter.exclProperties.del("context")
## Actual command runners ## Actual command runners
if args["new"] or args["add"]: if args["new"] or args["add"]:
let state = let state =
@@ -224,7 +277,6 @@ when isMainModule:
updatedIssues.add(nextIssue) updatedIssues.add(nextIssue)
stdout.writeLine formatIssue(nextIssue) stdout.writeLine formatIssue(nextIssue)
issue.changeState(ctx.cfg.tasksDir, targetState) issue.changeState(ctx.cfg.tasksDir, targetState)
updatedIssues.add(issue) updatedIssues.add(issue)
@@ -276,55 +328,16 @@ when isMainModule:
elif args["list"]: elif args["list"]:
let filter = initFilter()
var filterOption = none(IssueFilter)
# Initialize filter with properties (if given)
if propertiesOption.isSome:
filter.properties = propertiesOption.get
filterOption = some(filter)
# Add property exclusions (if given)
if exclPropsOption.isSome:
filter.exclProperties = exclPropsOption.get
filterOption = some(filter)
# If they supplied text matches, add that to the filter.
if args["--match"]:
filter.summaryMatch = some(re("(?i)" & $args["--match"]))
filterOption = some(filter)
if args["--match-all"]:
filter.fullMatch = some(re("(?i)" & $args["--match-all"]))
filterOption = some(filter)
# If no "context" property is given, use the default (if we have one)
if ctx.defaultContext.isSome and not filter.properties.hasKey("context"):
stderr.writeLine("Limiting to default context: " & ctx.defaultContext.get)
filter.properties["context"] = ctx.defaultContext.get
filterOption = some(filter)
if tagsOption.isSome:
filter.hasTags = tagsOption.get
filterOption = some(filter)
if exclTagsOption.isSome:
filter.exclTags = exclTagsOption.get
filterOption = some(filter)
# Finally, if the "context" is "all", don't filter on context
if filter.properties.hasKey("context") and
filter.properties["context"] == "all":
filter.properties.del("context")
filter.exclProperties.del("context")
var listContexts = false var listContexts = false
var listTags = false var listTags = false
var listProjects = false
var listMilestones = false
var statesOption = none(seq[IssueState]) var statesOption = none(seq[IssueState])
var issueIdsOption = none(seq[string]) var issueIdsOption = none(seq[string])
if args["contexts"]: listContexts = true if args["contexts"]: listContexts = true
elif args["projects"]: listProjects = true
elif args["milestones"]: listMilestones = true
elif args["tags"]: listTags = true elif args["tags"]: listTags = true
elif args["<stateOrId>"]: elif args["<stateOrId>"]:
try: try:
@@ -372,6 +385,12 @@ when isMainModule:
let issue = ctx.cfg.tasksDir.loadIssueById(issueId) let issue = ctx.cfg.tasksDir.loadIssueById(issueId)
stdout.writeLine formatIssue(issue) stdout.writeLine formatIssue(issue)
# List projects
elif listProjects: ctx.listProjects(filterOption)
# List milestones
elif listMilestones: ctx.listMilestones(filterOption)
# List all issues # List all issues
else: else:
trace "listing all issues" trace "listing all issues"
@@ -384,6 +403,32 @@ when isMainModule:
showHidden = args["--show-hidden"], showHidden = args["--show-hidden"],
verbose = ctx.verbose) verbose = ctx.verbose)
elif args["show"]:
if args["project-board"]:
ctx.showProjectBoard(filterOption)
discard
elif args["dupes"]:
ctx.loadAllIssues()
var idsToPaths = newTable[string, var seq[string]]()
for (state, issues) in pairs(ctx.issues):
for issue in issues:
let issueId = $issue.id
if idsToPaths.hasKey(issueId): idsToPaths[issueId].add(issue.filepath)
else: idsToPaths[issueId] = @[issue.filepath]
for (issueId, issuePaths) in pairs(idsToPaths):
if issuePaths.len < 2: continue
stdout.writeLine(issueId & ":\p " & issuePaths.join("\p ") & "\p\p")
else: # list specific Issues
for issueId in args["<id>"].mapIt($it):
let issue = ctx.cfg.tasksDir.loadIssueById(issueId)
stdout.writeLine formatIssue(issue)
elif args["add-binary-property"]: elif args["add-binary-property"]:
let issue = ctx.cfg.tasksDir.loadIssueById($(args["<id>"])) let issue = ctx.cfg.tasksDir.loadIssueById($(args["<id>"]))
@@ -412,21 +457,6 @@ when isMainModule:
try: write(propOut, decodeDataUri(issue[$(args["<propName>"])])) try: write(propOut, decodeDataUri(issue[$(args["<propName>"])]))
finally: close(propOut) finally: close(propOut)
elif args["show-dupes"]:
ctx.loadAllIssues()
var idsToPaths = newTable[string, var seq[string]]()
for (state, issues) in pairs(ctx.issues):
for issue in issues:
let issueId = $issue.id
if idsToPaths.hasKey(issueId): idsToPaths[issueId].add(issue.filepath)
else: idsToPaths[issueId] = @[issue.filepath]
for (issueId, issuePaths) in pairs(idsToPaths):
if issuePaths.len < 2: continue
stdout.writeLine(issueId & ":\p " & issuePaths.join("\p ") & "\p\p")
elif args["sync"]: elif args["sync"]:
if ctx.cfg.syncTargets.len == 0: if ctx.cfg.syncTargets.len == 0:
info "No sync targets configured" info "No sync targets configured"

View File

@@ -1,8 +1,10 @@
const PIT_VERSION* = "4.29.2" const PIT_VERSION* = "4.30.1"
const USAGE* = """Usage: const USAGE* = """Usage:
pit ( new | add) <summary> [<state>] [options] pit ( new | add) <summary> [<state>] [options]
pit list contexts [options] pit list contexts [options]
pit list projects [options]
pit list milestones [options]
pit list tags [options] pit list tags [options]
pit list [<stateOrId>...] [options] pit list [<stateOrId>...] [options]
pit ( start | done | pending | todo-today | todo | suspend ) <id>... [options] pit ( start | done | pending | todo-today | todo | suspend ) <id>... [options]
@@ -15,10 +17,13 @@ const USAGE* = """Usage:
pit ( delete | rm ) <id>... [options] pit ( delete | rm ) <id>... [options]
pit add-binary-property <id> <propName> <propSource> [options] pit add-binary-property <id> <propName> <propSource> [options]
pit get-binary-property <id> <propName> <propDest> [options] pit get-binary-property <id> <propName> <propDest> [options]
pit show-dupes pit show dupes [options]
pit show project-board [options]
pit show <id> [options]
pit sync [<syncTarget>...] [options] pit sync [<syncTarget>...] [options]
pit help [options] pit help [options]
Options: Options:
-h, --help Print this usage and help information. -h, --help Print this usage and help information.
@@ -144,6 +149,38 @@ Issue Properties:
hide-until: 2024-01-01T13:45:00-05:00 hide-until: 2024-01-01T13:45:00-05:00
priority
Allows setting a priority/priority level. This is used in the project
management view to automatically order issues being displayed. Valid
values, in order from most to least important, are:
- essential Intended for issues that must be done. Failure to
complete these issues would result in failure of the
project.
- vital Intended for issues that are vital to the success of the
project, but not absolutely essential. Failure to complete
these issues may not result in failure of the project but
would seriously impact it's value or viability.
- important Intended for issues that are important to the project,
but not vital. These should be completed, but delay is
acceptable.
- optional Intended for issues that are worth doing but can be deferred
or skipped if necessary.
priority: essential
milestone
Allows grouping issues according to a milestone name. Milestones are
available as subsets of projects. The 'list milestones' command will show
all values of 'milestone' set in existing issues for a given project.
milestone: Phase 1
pending pending
When an issue is blocked by a third party, this property can be used to When an issue is blocked by a third party, this property can be used to
@@ -152,6 +189,13 @@ Issue Properties:
pending: Results of WCAG analysis. pending: Results of WCAG analysis.
project
Allows grouping issues according to a project name. The 'list projects'
command will show all values of 'project' set in existing issues.
project: Website Redesign
recurrence recurrence
When an issue is moved to the "done" state, if the issue has a valid When an issue is moved to the "done" state, if the issue has a valid

View File

@@ -1,17 +1,10 @@
import std/[options, sequtils, wordwrap, tables, terminal, times, unicode, wordwrap] import std/[options, sequtils, tables, terminal, times, unicode, wordwrap]
import cliutils, uuids import cliutils, uuids
import std/strutils except alignLeft, capitalize, strip, toLower, toUpper import std/strutils except alignLeft, capitalize, strip, toLower, toUpper
import ./libpit import ./libpit
proc adjustedTerminalWidth(): int = min(terminalWidth(), 80) proc adjustedTerminalWidth(): int = min(terminalWidth(), 80)
proc getIssueContextDisplayName*(ctx: CliContext, context: string): string =
if not ctx.contexts.hasKey(context):
if context.isEmptyOrWhitespace: return "<default>"
else: return context.capitalize()
return ctx.contexts[context]
proc formatIssue*(issue: Issue): string = proc formatIssue*(issue: Issue): string =
result = ($issue.id).withColor(fgBlack, true) & "\n"& result = ($issue.id).withColor(fgBlack, true) & "\n"&
issue.summary.withColor(fgWhite) & "\n" issue.summary.withColor(fgWhite) & "\n"
@@ -21,9 +14,22 @@ proc formatIssue*(issue: Issue): string =
issue.tags.join(",").withColor(fgGreen, true) & "\n" issue.tags.join(",").withColor(fgGreen, true) & "\n"
if issue.properties.len > 0: if issue.properties.len > 0:
result &= termColor(fgMagenta) for k, v in issue.properties:
for k, v in issue.properties: result &= k & ": " & v & "\n"
if k == "project":
result &= "project: ".withColor(fgMagenta) &
v.withColor(fgBlue, bright = true) & "\n"
elif k == "milestone":
result &= "milestone: ".withColor(fgMagenta) &
v.withColor(fgBlue, bright = true) & "\n"
elif k == "priority":
result &= "priority: ".withColor(fgMagenta) &
v.withColor(fgRed, bright = true) & "\n"
else:
result &= termColor(fgMagenta) & k & ": " & v & "\n"
result &= "--------".withColor(fgBlack, true) & "\n" result &= "--------".withColor(fgBlack, true) & "\n"
if not issue.details.isEmptyOrWhitespace: if not issue.details.isEmptyOrWhitespace:
@@ -55,7 +61,8 @@ proc formatSectionIssue*(
issue: Issue, issue: Issue,
width: int = 80, width: int = 80,
indent = "", indent = "",
verbose = false): string = verbose = false,
bold = false): string =
result = (indent & ($issue.id)[0..<6]).withColor(fgBlack, true) & " " result = (indent & ($issue.id)[0..<6]).withColor(fgBlack, true) & " "
@@ -68,7 +75,7 @@ proc formatSectionIssue*(
.wrapWords(summaryWidth) .wrapWords(summaryWidth)
.splitLines .splitLines
result &= summaryLines[0].withColor(fgWhite) result &= summaryLines[0].termFmt(fgWhite, bold=bold, underline=bold)
for line in summaryLines[1..^1]: for line in summaryLines[1..^1]:
result &= "\p" & line.indent(summaryIndentLen) result &= "\p" & line.indent(summaryIndentLen)

View File

@@ -1,4 +1,5 @@
import std/[json, logging, options, os, strformat, strutils, tables, times] import std/[json, logging, options, os, strformat, strutils, tables, times,
unicode]
import cliutils, docopt, langutils, uuids, zero_functional import cliutils, docopt, langutils, uuids, zero_functional
import nre except toSeq import nre except toSeq
@@ -18,18 +19,22 @@ type
Current = "current", Current = "current",
TodoToday = "todo-today", TodoToday = "todo-today",
Pending = "pending", Pending = "pending",
Done = "done",
Todo = "todo" Todo = "todo"
Dormant = "dormant" Dormant = "dormant"
Done = "done",
IssueFilter* = ref object IssueFilter* = ref object
completedRange*: Option[tuple[b, e: DateTime]] completedRange*: Option[tuple[b, e: DateTime]]
fullMatch*, summaryMatch*: Option[Regex] fullMatch*, summaryMatch*: Option[Regex]
inclStates*: seq[IssueState]
exclStates*: seq[IssueState]
hasTags*: seq[string] hasTags*: seq[string]
exclTags*: seq[string] exclTags*: seq[string]
properties*: TableRef[string, string] properties*: TableRef[string, string]
exclProperties*: TableRef[string, seq[string]] exclProperties*: TableRef[string, seq[string]]
IssuePriority* {.pure.} = enum essential, vital, important, optional
PitConfig* = ref object PitConfig* = ref object
tasksDir*: string tasksDir*: string
contexts*: TableRef[string, string] contexts*: TableRef[string, string]
@@ -129,6 +134,13 @@ proc getRecurrence*(issue: Issue): Option[Recurrence] =
else: weeks(1), else: weeks(1),
cloneId: c[6])) cloneId: c[6]))
proc setPriority*(issue: Issue, priority: IssuePriority) =
issue["priority"] = $priority
proc getPriority*(issue: Issue): IssuePriority =
try: result = parseEnum[IssuePriority](issue["priority"].toLowerAscii())
except CatchableError: result = IssuePriority.optional
## Issue filtering ## Issue filtering
proc initFilter*(): IssueFilter = proc initFilter*(): IssueFilter =
@@ -136,6 +148,8 @@ proc initFilter*(): IssueFilter =
completedRange: none(tuple[b, e: DateTime]), completedRange: none(tuple[b, e: DateTime]),
fullMatch: none(Regex), fullMatch: none(Regex),
summaryMatch: none(Regex), summaryMatch: none(Regex),
inclStates: @[],
exclStates: @[],
hasTags: @[], hasTags: @[],
exclTags: @[], exclTags: @[],
properties: newTable[string, string](), properties: newTable[string, string](),
@@ -165,6 +179,10 @@ proc hasTagsFilter*(tags: seq[string]): IssueFilter =
result = initFilter() result = initFilter()
result.hasTags = tags result.hasTags = tags
proc stateFilter*(states: seq[IssueState]): IssueFilter =
result = initFilter()
result.inclStates = states
proc groupBy*(issues: seq[Issue], propertyKey: string): TableRef[string, seq[Issue]] = proc groupBy*(issues: seq[Issue], propertyKey: string): TableRef[string, seq[Issue]] =
result = newTable[string, seq[Issue]]() result = newTable[string, seq[Issue]]()
for i in issues: for i in issues:
@@ -426,6 +444,12 @@ proc filter*(issues: seq[Issue], filter: IssueFilter): seq[Issue] =
let exclTag = exclTagLent let exclTag = exclTagLent
f = f --> filter(it.tags.find(exclTag) < 0) f = f --> filter(it.tags.find(exclTag) < 0)
if filter.inclStates.len > 0:
f = f --> filter(filter.inclStates.contains(it.state))
if filter.exclStates.len > 0:
f = f --> filter(not filter.exclStates.contains(it.state))
return f # not using result because zero_functional doesn't play nice with it return f # not using result because zero_functional doesn't play nice with it
proc find*( proc find*(
@@ -513,3 +537,9 @@ proc loadAllIssues*(ctx: CliContext) =
proc filterIssues*(ctx: CliContext, filter: IssueFilter) = proc filterIssues*(ctx: CliContext, filter: IssueFilter) =
for state, issueList in ctx.issues: for state, issueList in ctx.issues:
ctx.issues[state] = issueList.filter(filter) ctx.issues[state] = issueList.filter(filter)
proc getIssueContextDisplayName*(ctx: CliContext, context: string): string =
if not ctx.contexts.hasKey(context):
if context.isEmptyOrWhitespace: return "<default>"
else: return context.capitalize()
return ctx.contexts[context]

368
src/pit/projects.nim Normal file
View File

@@ -0,0 +1,368 @@
import std/[algorithm, json, jsonutils, options, os, sets, strutils, tables,
terminal, times, unicode, wordwrap]
from std/sequtils import repeat, toSeq
import cliutils, uuids, zero_functional
import ./[formatting, libpit]
type
ProjectCfg* = ref object of RootObj
name: string
milestoneOrder*: seq[string]
Project* = ref object of ProjectCfg
milestones*: TableRef[string, seq[Issue]]
ProjectsConfiguration* = TableRef[string, seq[ProjectCfg]]
## ProjectCfgs by context
ProjectsDatabase* = TableRef[string, seq[Project]]
## Projects by context
converter extractConfig(pdb: ProjectsDatabase): ProjectsConfiguration =
result = newTable[string, seq[ProjectCfg]]()
for (context, projects) in pairs(pdb):
result[context] = @[]
for project in projects:
result[context].add(ProjectCfg(
name: project.name,
milestoneOrder: project.milestoneOrder))
proc loadProjectsConfiguration*(ctx: CliContext): ProjectsConfiguration =
let projectsCfgFile = ctx.cfg.tasksDir & "/projects.json"
if not fileExists(projectsCfgFile):
return newTable[string, seq[ProjectCfg]]()
else:
fromJson[ProjectsConfiguration](result, parseFile(projectsCfgFile))
proc saveProjectsConfiguration*(ctx: CliContext, cfg: ProjectsConfiguration) =
let projectsCfgFile = ctx.cfg.tasksDir / "projects.json"
writeFile(projectsCfgFile, toJson(cfg).pretty)
proc buildDb*(ctx: CliContext, cfg: ProjectsConfiguration): ProjectsDatabase =
result = newTable[string, seq[Project]]()
# Expand the configuration into the database structure
for (context, projectCfgs) in pairs(cfg):
result[context] = @[]
for projectCfg in projectCfgs:
let pcfg = projectCfg
let project = Project(
name: projectCfg.name,
milestoneOrder: projectCfg.milestoneOrder,
milestones: newTable[string, seq[Issue]](
pcfg.milestoneOrder --> map((it, newSeq[Issue]()))))
result[context].add(project)
# Now populate the database with issues
for (state, issues) in pairs(ctx.issues):
for issue in issues:
if not issue.hasProp("project") or
not issue.hasProp("milestone"):
continue
let projectName = issue["project"]
let milestone = issue["milestone"]
let context =
if issue.hasProp("context"): issue["context"]
else: "<no-context>"
# Make sure we have entries for this context and project
if not result.hasKey(context): result[context] = @[]
var projectsInContext = result[context]
if not (projectsInContext --> exists(it.name == projectName)):
projectsInContext.add(Project(
name: projectName,
milestoneOrder: @[],
milestones: newTable[string, seq[Issue]]()))
let projectIdx = projectsInContext --> index(it.name == projectName)
var project = projectsInContext[projectIdx]
# Make sure we have entries for this milestone
if not project.milestones.hasKey(milestone):
project.milestones[milestone] = @[]
if not project.milestoneOrder.contains(milestone):
project.milestoneOrder.add(milestone)
project.milestones[milestone].add(issue)
result[context] = projectsInContext
ctx.saveProjectsConfiguration(result)
proc cmp*(a, b: Issue): int =
if a.state != b.state:
return cmp(ord(a.state), ord(b.state))
if a.hasProp("priority") or b.hasProp("priority"):
if a.getPriority != b.getPriority:
return cmp(a.getPriority, b.getPriority) # higher priority first
if a.hasProp("last-updated") or b.hasProp("last-updated"):
var aUpdated = a.getDateTime("last-updated", local(fromUnix(0)))
var bUpdated = b.getDateTime("last-updated", local(fromUnix(0)))
if aUpdated != bUpdated:
return cmp(bUpdated, aUpdated) # newer first
return cmp(a.summary, b.summary)
proc listProjects*(ctx: CliContext, filter = none[IssueFilter]()) =
ctx.loadAllIssues()
let projectsCfg = ctx.loadProjectsConfiguration()
var projectsCfgChanged = false
if filter.isSome: ctx.filterIssues(filter.get)
let projectsByContext = newTable[string, CountTableRef[string]]()
for (state, issues) in pairs(ctx.issues):
for issue in issues:
if issue.hasProp("project"):
let context =
if issue.hasProp("context"): issue["context"]
else: "<no-context>"
if not projectsByContext.hasKey(context):
projectsByContext[context] = newCountTable[string]()
projectsByContext[context].inc(issue["project"])
for (context, projects) in pairs(projectsByContext):
stdout.writeLine(withColor(
ctx.getIssueContextDisplayName(context) & ":",
fgYellow) & termReset)
stdout.writeLine("")
var toList = toHashSet(toSeq(keys(projects)))
# Loop through the projects in the configured order first
if not projectsCfg.hasKey(context):
projectsCfg[context] = @[]
for project in projectsCfg[context]:
if project.name in toList:
toList.excl(project.name)
stdout.writeLine(" " & project.name &
" (" & $projects[project.name] & " issues)")
# Then list any remaining projects not in the configuration, and add them
# to the configuration
for (projectName, count) in pairs(projects):
if projectName in toList:
stdout.writeLine(" " & projectName & " (" & $count & " issues)")
projectsCfg[context].add(ProjectCfg(name: projectName, milestoneOrder: @[]))
projectsCfgChanged = true
stdout.writeLine("")
if projectsCfgChanged: ctx.saveProjectsConfiguration(projectsCfg)
proc listMilestones*(ctx: CliContext, filter = none[IssueFilter]()) =
ctx.loadAllIssues()
if filter.isSome: ctx.filterIssues(filter.get)
let projectsCfg = ctx.loadProjectsConfiguration()
let projectsDb = ctx.buildDb(projectsCfg)
var milestones = newCountTable[string]()
for (context, projects) in pairs(projectsDb):
for p in projects:
let project = p
if values(project.milestones) --> all(it.len == 0):
continue
stdout.writeLine(withColor(project.name, fgBlue, bold = true, bright=true))
stdout.writeLine(withColor(
"".repeat(runeLen(stripAnsi(project.name))),
fgBlue, bold = true))
for milestone in project.milestoneOrder:
if project.milestones.hasKey(milestone) and
project.milestones[milestone].len > 0:
let issueCount = project.milestones[milestone].len
stdout.writeLine(" " & milestone & " (" & $issueCount & " issues)")
stdout.writeLine("")
proc formatProjectIssue(
ctx: CliContext,
issue: Issue,
width: int): seq[string] =
var firstLine = ""
if issue.state == IssueState.Done:
firstLine &= withColor("", fgBlack, bold=true, bright=true)
else:
case issue.getPriority
of IssuePriority.essential:
firstLine &= withColor("", fgRed, bold=true, bright=true)
of IssuePriority.vital:
firstLine &= withColor("", fgYellow, bold=true, bright=true)
of IssuePriority.important:
firstLine &= withColor("", fgBlue, bold=true, bright=true)
of IssuePriority.optional:
firstLine &= withColor("", fgBlack, bold=false, bright=true)
let summaryText = formatSectionIssue(issue, width - 3,
bold = [Current, TodoToday].contains(issue.state)).splitLines
firstLine &= summaryText[0]
if issue.state == IssueState.Done:
firstLine = withColor(stripAnsi(firstLine), fgBlack, bright=true)
result.add(firstLine)
result.add(summaryText[1 .. ^1] --> map(" " & it))
if issue.state == IssueState.Done:
let origLines = result
result = origLines --> map(withColor(stripAnsi(it), fgBlack, bright=true))
proc formatParentIssue*(
ctx: CliContext,
parentIssue: Issue,
children: seq[Issue],
width: int): seq[string] =
result.add(ctx.formatProjectIssue(parentIssue, width))
for child in sorted(children, cmp):
let childLines = ctx.formatProjectIssue(child, width - 3)
result.add(childLines --> map(withColor("", fgBlack, bright=true) & it))
result.add("")
proc formatMilestone*(
ctx: CliContext,
milestone: string,
issues: seq[Issue],
availWidth: int): seq[string] =
result = @[""]
result.add(withColor(milestone, fgWhite, bold=true))
result.add(withColor("".repeat(availWidth), fgWhite))
var parentsToChildren = issues -->
filter(it.hasProp("parent")).group(it["parent"])
var issuesToFormat = sorted(issues, cmp) -->
filter(not it.hasProp("parent"))
for issue in issuesToFormat:
if parentsToChildren.hasKey($issue.id):
result.add(
ctx.formatParentIssue(issue, parentsToChildren[$issue.id], availWidth))
else:
result.add(ctx.formatProjectIssue(issue, availWidth))
proc findShortestColumn(columns: seq[seq[string]]): int =
var shortestIdx = 0
var shortestLen = columns[0].len
for i in 1 ..< columns.len:
if columns[i].len < shortestLen:
shortestLen = columns[i].len
shortestIdx = i
return shortestIdx
proc joinColumns(columns: seq[seq[string]], columnWidth: int): seq[string] =
let maxLines = columns --> map(it.len).max()
for lineNo in 0 ..< maxLines:
var newLine = ""
for col in columns:
if lineNo < col.len:
let lineLen = runeLen(stripAnsi(col[lineNo]))
newLine &= col[lineNo] & " ".repeat(max(0, columnWidth - lineLen) + 2)
else:
newLine &= " ".repeat(columnWidth + 2)
result.add(newLine)
proc showProject*(ctx: CliContext, project: Project) =
let fullWidth = terminalWidth() - 1
let columnWidth = 80
let numColumns = (fullWidth - 4) div (columnWidth + 2)
stdout.writeLine("")
stdout.writeLine(withColor(
"" & "".repeat(project.name.len + 2) &
"" & "".repeat(fullWidth - project.name.len - 4) & "",
fgBlue, bold=true))
stdout.writeLine(
withColor("", fgBlue, bold=true) &
withColor(project.name, fgBlue, bold=true, bright=true) &
withColor("" & " ".repeat(fullWidth - project.name.len - 4) & "", fgBlue, bold=true))
stdout.writeLine(withColor(
"" & "".repeat(project.name.len + 2) &
"" & " ".repeat(fullWidth - project.name.len - 4) & "",
fgBlue, bold=true))
let milestoneTexts: seq[seq[string]] = project.milestoneOrder -->
filter(project.milestones.hasKey(it) and project.milestones[it].len > 0).
map(ctx.formatMilestone(it, project.milestones[it], columnWidth))
var columns: seq[seq[string]] = repeat(newSeq[string](), numColumns)
for milestoneText in milestoneTexts:
let shortestColumnIdx = findShortestColumn(columns)
columns[shortestColumnIdx].add(milestoneText)
let joinedLines = joinColumns(columns, columnWidth)
for line in joinedLines:
let padLen = fullWidth - runeLen(stripAnsi(line)) - 3
stdout.writeLine(
withColor("", fgBlue) &
line &
" ".repeat(padLen) &
withColor("", fgBlue))
stdout.writeLine(withColor(
"" & "".repeat(terminalWidth() - 2) & "",
fgBlue, bold=true))
proc showProjectBoard*(ctx: CliContext, filter = none[IssueFilter]()) =
ctx.loadAllIssues()
if filter.isSome: ctx.filterIssues(filter.get)
let projectsCfg = ctx.loadProjectsConfiguration()
let projectsDb = ctx.buildDb(projectsCfg)
var contextsAndProjects: seq[(string, seq[Project])] = @[]
for (context, pjs) in pairs(projectsDb):
let projects = pjs
let issues: seq[Issue] = projects --> map(toSeq(values(it.milestones))).flatten().flatten()
if issues.len > 0:
contextsAndProjects.add((context, projects))
for (context, projects) in contextsAndProjects:
if contextsAndProjects.len > 1:
stdout.writeLine("")
stdout.writeLine(withColor(
ctx.getIssueContextDisplayName(context) & ":",
fgYellow, bold=true))
stdout.writeLine("")
for p in projects:
let project = p
if (values(project.milestones) --> exists(it.len > 0)):
ctx.showProject(project)