diff --git a/src/pit.nim b/src/pit.nim index 00862da..2d6de1c 100644 --- a/src/pit.nim +++ b/src/pit.nim @@ -6,7 +6,7 @@ import data_uri, docopt, json, timeutils, uuids from nre import re 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 @@ -330,10 +330,14 @@ when isMainModule: var listContexts = false var listTags = false + var listProjects = false + var listMilestones = false var statesOption = none(seq[IssueState]) var issueIdsOption = none(seq[string]) if args["contexts"]: listContexts = true + elif args["projects"]: listProjects = true + elif args["milestones"]: listMilestones = true elif args["tags"]: listTags = true elif args[""]: try: @@ -381,6 +385,12 @@ when isMainModule: let issue = ctx.cfg.tasksDir.loadIssueById(issueId) stdout.writeLine formatIssue(issue) + # List projects + elif listProjects: ctx.listProjects(filterOption) + + # List milestones + elif listMilestones: ctx.listMilestones(filterOption) + # List all issues else: trace "listing all issues" @@ -393,6 +403,32 @@ when isMainModule: showHidden = args["--show-hidden"], 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[""].mapIt($it): + let issue = ctx.cfg.tasksDir.loadIssueById(issueId) + stdout.writeLine formatIssue(issue) + elif args["add-binary-property"]: let issue = ctx.cfg.tasksDir.loadIssueById($(args[""])) @@ -421,21 +457,6 @@ when isMainModule: try: write(propOut, decodeDataUri(issue[$(args[""])])) 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"]: if ctx.cfg.syncTargets.len == 0: info "No sync targets configured" diff --git a/src/pit/cliconstants.nim b/src/pit/cliconstants.nim index 7d49e14..852910d 100644 --- a/src/pit/cliconstants.nim +++ b/src/pit/cliconstants.nim @@ -1,8 +1,10 @@ -const PIT_VERSION* = "4.29.2" +const PIT_VERSION* = "4.30.0" const USAGE* = """Usage: pit ( new | add) [] [options] pit list contexts [options] + pit list projects [options] + pit list milestones [options] pit list tags [options] pit list [...] [options] pit ( start | done | pending | todo-today | todo | suspend ) ... [options] @@ -15,10 +17,13 @@ const USAGE* = """Usage: pit ( delete | rm ) ... [options] pit add-binary-property [options] pit get-binary-property [options] - pit show-dupes + pit show dupes [options] + pit show project-board [options] + pit show [options] pit sync [...] [options] pit help [options] + Options: -h, --help Print this usage and help information. @@ -144,6 +149,38 @@ Issue Properties: 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 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. + 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 When an issue is moved to the "done" state, if the issue has a valid @@ -191,4 +235,4 @@ Issue Properties: If present, expected to be a comma-delimited list of text tags. The -g option is a short-hand for '-p tags:'. -""" \ No newline at end of file +""" diff --git a/src/pit/formatting.nim b/src/pit/formatting.nim index a0399ca..0f440b5 100644 --- a/src/pit/formatting.nim +++ b/src/pit/formatting.nim @@ -14,9 +14,22 @@ proc formatIssue*(issue: Issue): string = issue.tags.join(",").withColor(fgGreen, true) & "\n" if issue.properties.len > 0: - result &= termColor(fgMagenta) - for k, v in issue.properties: result &= k & ": " & v & "\n" + for k, v in issue.properties: + 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" if not issue.details.isEmptyOrWhitespace: @@ -48,7 +61,8 @@ proc formatSectionIssue*( issue: Issue, width: int = 80, indent = "", - verbose = false): string = + verbose = false, + bold = false): string = result = (indent & ($issue.id)[0..<6]).withColor(fgBlack, true) & " " @@ -61,7 +75,7 @@ proc formatSectionIssue*( .wrapWords(summaryWidth) .splitLines - result &= summaryLines[0].withColor(fgWhite) + result &= summaryLines[0].termFmt(fgWhite, bold=bold, underline=bold) for line in summaryLines[1..^1]: result &= "\p" & line.indent(summaryIndentLen) diff --git a/src/pit/libpit.nim b/src/pit/libpit.nim index ba8b8ed..aea4b4d 100644 --- a/src/pit/libpit.nim +++ b/src/pit/libpit.nim @@ -33,6 +33,8 @@ type properties*: TableRef[string, string] exclProperties*: TableRef[string, seq[string]] + IssuePriority* {.pure.} = enum essential, vital, important, optional + PitConfig* = ref object tasksDir*: string contexts*: TableRef[string, string] @@ -132,6 +134,13 @@ proc getRecurrence*(issue: Issue): Option[Recurrence] = else: weeks(1), 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 proc initFilter*(): IssueFilter = diff --git a/src/pit/projects.nim b/src/pit/projects.nim new file mode 100644 index 0000000..8f05771 --- /dev/null +++ b/src/pit/projects.nim @@ -0,0 +1,361 @@ +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: "" + + # 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: "" + + 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) + + for (context, projects) in pairs(projectsDb): + if projectsDb.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)