Compare commits

..

11 Commits

Author SHA1 Message Date
jdb ec1198dcf0 Migrate to the regex library away from std/nre, as the libpcre version that std/nre depends on is being deprecated in many distros. 2026-07-07 22:06:21 -05:00
jdb 2ed12e7487 Update nimble dependencies for Nimble 0.22+ 2026-07-07 08:23:33 -05:00
jdb a273091dc7 Update to nim 2.2.8, cliutils 0.11.1 2026-03-09 13:30:55 -05:00
jdb e35bd9f85a Don't ignore '#' in issue details. 2026-02-24 08:57:24 -06:00
jdb 3d8fafd7b2 The edit command can now be called non-interactively to set properties/tags. 2026-02-08 05:50:30 -06:00
jdb 7d5d55d24a Add update-details command to allow setting an issue details via CLI non-interactively. 2026-02-08 05:10:55 -06:00
jdb 89c924bb72 Only emit ANSI escape codes when stdout is a TTY. 2025-12-01 18:07:03 -06:00
jdb 3d1dc7512a Allow including or excluding properties by name only. 2025-12-01 14:04:19 -06:00
jdb 1d18be9d1b Include issues without project or milestone on boards.
In order to help organize issues, show issues on boards even if they
don't have an assigned project or milestone.

Refactor the issue hiding feature (using the `hide-until` property) to
be an option to IssueFilter rather than a separate, special-case. This
means that the CLI always filters by default.

Hide issues in the Done state on project boards unless the new
`--show-done` arg is passed.
2025-12-01 13:50:13 -06:00
jdb 6ceca9b009 Make add interactive and allow defining default properties that should be prompted. 2025-11-24 10:47:22 -06:00
jdb de07665a8b Project boards: only show contexts with selected issues. 2025-11-19 19:00:01 -06:00
11 changed files with 497 additions and 212 deletions
+1 -1
View File
@@ -1,2 +1,2 @@
[tools]
nim = "2.2.6"
nim = "2.2.8"
+11 -14
View File
@@ -1,6 +1,6 @@
# Package
version = "4.30.0"
version = "4.33.3"
author = "Jonathan Bernard"
description = "Personal issue tracker."
license = "MIT"
@@ -10,21 +10,18 @@ bin = @["pit"]
# Dependencies
requires @[
"nim >= 1.4.0",
"docopt >= 0.7.1",
"uuids >= 0.1.10",
"zero_functional"
]
requires "nim >= 1.6.0"
requires "docopt >= 0.7.1"
requires "regex >= 0.26.3"
requires "uuids >= 0.1.10"
requires "zero_functional"
# Dependencies from git.jdb-software.com/jdb/nim-packages
requires @[
"cliutils >= 0.10.2",
"langutils >= 0.4.0",
"timeutils >= 0.5.4",
"data_uri > 1.0.0",
"update_nim_package_version >= 0.2.0"
]
requires "cliutils >= 0.11.1"
requires "langutils >= 0.4.0"
requires "timeutils >= 0.5.4"
requires "filetype >= 0.9.0"
requires "update_nim_package_version >= 0.2.0"
task updateVersion, "Update the version of this package.":
exec "update_nim_package_version pit 'src/pit/cliconstants.nim'"
+133 -60
View File
@@ -1,12 +1,14 @@
## Personal Issue Tracker CLI interface
## ====================================
import std/[algorithm, logging, options, os, sequtils, tables, times, unicode]
import data_uri, docopt, json, timeutils, uuids
import std/[algorithm, logging, options, os, sequtils, sets, tables, times,
unicode]
import docopt, json, timeutils, uuids, zero_functional
from nre import re
from regex import match, re2
import strutils except alignLeft, capitalize, strip, toUpper, toLower
import pit/[cliconstants, formatting, libpit, projects, sync_pbm_vsb]
import pit/[ansiterm, cliconstants, data_uri, formatting, libpit, projects,
sync_pbm_vsb]
export formatting, libpit
@@ -18,18 +20,16 @@ proc parsePropertiesOption(propsOpt: string): TableRef[string, string] =
result = newTable[string, string]()
for propText in propsOpt.split(";"):
let pair = propText.split(":", 1)
if pair.len == 1: result[pair[0]] = "true"
if pair.len == 1: result[pair[0]] = MATCH_ANY
else: result[pair[0]] = pair[1]
proc parseExclPropertiesOption(propsOpt: string): TableRef[string, seq[string]] =
result = newTable[string, seq[string]]()
for propText in propsOpt.split(";"):
let pair = propText.split(":", 1)
let val =
if pair.len == 1: "true"
else: pair[1]
if result.hasKey(pair[0]): result[pair[0]].add(val)
else: result[pair[0]] = @[val]
if not result.hasKey(pair[0]): result[pair[0]] = @[]
if pair.len == 2: result[pair[0]].add(pair[1])
proc reorder(ctx: CliContext, state: IssueState) =
@@ -38,6 +38,89 @@ proc reorder(ctx: CliContext, state: IssueState) =
ctx.loadIssues(state)
discard os.execShellCmd(EDITOR & " '" & (ctx.cfg.tasksDir / $state / "order.txt") & "' </dev/tty >/dev/tty")
proc addIssue(
ctx: CliContext,
args: Table[string, Value],
propertiesOption = none[TableRef[string, string]](),
tagsOption = none[seq[string]]()): Issue =
let state =
if args["<state>"]: parseEnum[IssueState]($args["<state>"])
else: TodoToday
var issueProps = propertiesOption.get(newTable[string,string]())
if not issueProps.hasKey("created"): issueProps["created"] = getTime().local.formatIso8601
if not issueProps.hasKey("context") and ctx.defaultContext.isSome():
stderr.writeLine("Using default context: " & ctx.defaultContext.get)
issueProps["context"] = ctx.defaultContext.get
if not args["--non-interactive"]:
# look for default properties for this context
let globalDefaultProps =
if ctx.cfg.defaultPropertiesByContext.hasKey("<all>"):
ctx.cfg.defaultPropertiesByContext["<all>"]
else: newSeq[string]()
let contextDefaultProps =
if issueProps.hasKey("context") and
ctx.cfg.defaultPropertiesByContext.hasKey(issueProps["context"]):
ctx.cfg.defaultPropertiesByContext[issueProps["context"]]
else: newSeq[string]()
let defaultProps = toOrderedSet(globalDefaultProps & contextDefaultProps)
if defaultProps.len > 0:
ctx.loadAllIssues()
if issueProps.hasKey("context"):
ctx.filterIssues(propsFilter(newTable({"context": issueProps["context"]})))
let numberRegex = re2("^[0-9]+$")
for propName in defaultProps:
if not issueProps.hasKey(propName):
let allIssues: seq[seq[Issue]] = toSeq(values(ctx.issues))
let previousValues = toSeq(toHashSet(allIssues -->
flatten()
.filter(it.hasProp(propName))
.map(it[propName])))
let idxValPairs: seq[tuple[key: int, val: string]] = toSeq(pairs(previousValues))
let previousValuesDisplay: seq[string] = idxValPairs -->
map(" " & $it[0] & " - " & it[1])
stdout.write(
"Previous values for property '" & propName & "':\p" &
previousValuesDisplay.join("\p") & "\p" &
"Do you want to set a value for '" & propName & "'? " &
"You can use the numbers above to use an existing value, enter " &
"something new, or leave blank to indicate no value.\p" &
color(propName, fg=cMagenta) & ":" &
ansiEscSeq(fg=cBrightBlue) & " ")
let resp = stdin.readLine.strip
let numberResp = resp.match(numberRegex)
if numberResp:
let idx = parseInt(resp)
if idx >= 0 and idx < previousValues.len:
issueProps[propName] = previousValues[idx]
elif resp.len > 0:
issueProps[propName] = resp
stdout.writeLine(RESET_FORMATTING)
result = Issue(
id: genUUID(),
summary: $args["<summary>"],
properties: issueProps,
tags:
if tagsOption.isSome: tagsOption.get
else: newSeq[string]())
ctx.cfg.tasksDir.store(result, state)
stdout.writeLine "\p" & formatIssue(result)
proc edit(issue: Issue) =
# Write format comments (to help when editing)
@@ -89,8 +172,6 @@ when isMainModule:
var exclTagsOption = none(seq[string])
let filter = initFilter()
var filterOption = none(IssueFilter)
if args["--properties"] or args["--context"]:
@@ -124,43 +205,37 @@ when isMainModule:
# 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)
filter.summaryMatch = some(re2("(?i)" & $args["--match"]))
if args["--match-all"]:
filter.fullMatch = some(re("(?i)" & $args["--match-all"]))
filterOption = some(filter)
filter.fullMatch = some(re2("(?i)" & $args["--match-all"]))
# 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)
if args["--show-hidden"]:
filter.exclHidden = false
# Finally, if the "context" is "all", don't filter on context
if filter.properties.hasKey("context") and
@@ -171,27 +246,7 @@ when isMainModule:
## Actual command runners
if args["new"] or args["add"]:
let state =
if args["<state>"]: parseEnum[IssueState]($args["<state>"])
else: TodoToday
var issueProps = propertiesOption.get(newTable[string,string]())
if not issueProps.hasKey("created"): issueProps["created"] = getTime().local.formatIso8601
if not issueProps.hasKey("context") and ctx.defaultContext.isSome():
stderr.writeLine("Using default context: " & ctx.defaultContext.get)
issueProps["context"] = ctx.defaultContext.get
var issue = Issue(
id: genUUID(),
summary: $args["<summary>"],
properties: issueProps,
tags:
if tagsOption.isSome: tagsOption.get
else: newSeq[string]())
ctx.cfg.tasksDir.store(issue, state)
updatedIssues.add(issue)
stdout.writeLine formatIssue(issue)
updatedIssues.add(ctx.addIssue(args, propertiesOption, tagsOption))
elif args["reorder"]:
ctx.reorder(parseEnum[IssueState]($args["<state>"]))
@@ -199,11 +254,6 @@ when isMainModule:
elif args["edit"]:
for editRef in @(args["<ref>"]):
let propsOption =
if args["--properties"]:
some(parsePropertiesOption($args["--properties"]))
else: none(TableRef[string, string])
var stateOption = none(IssueState)
try: stateOption = some(parseEnum[IssueState](editRef))
@@ -213,10 +263,16 @@ when isMainModule:
let state = stateOption.get
ctx.loadIssues(state)
for issue in ctx.issues[state]:
if propsOption.isSome:
for k,v in propsOption.get:
if propertiesOption.isSome:
for k,v in propertiesOption.get:
issue[k] = v
edit(issue)
if tagsOption.isSome:
issue.tags = deduplicate(issue.tags & tagsOption.get)
if exclTagsOption.isSome:
issue.tags = issue.tags.filter(
proc (tag: string): bool = not exclTagsOption.get.anyIt(it == tag))
if args["--non-interactive"]: issue.store()
else: edit(issue)
updatedIssues.add(issue)
else:
@@ -224,7 +280,13 @@ when isMainModule:
if propertiesOption.isSome:
for k,v in propertiesOption.get:
issue[k] = v
edit(issue)
if tagsOption.isSome:
issue.tags = deduplicate(issue.tags & tagsOption.get)
if exclTagsOption.isSome:
issue.tags = issue.tags.filter(
proc (tag: string): bool = not exclTagsOption.get.anyIt(it == tag))
if args["--non-interactive"]: issue.store()
else: edit(issue)
updatedIssues.add(issue)
elif args["tag"]:
@@ -252,6 +314,17 @@ when isMainModule:
issue.store()
updatedIssues.add(issue)
elif args["update-details"]:
let details =
if not args["--file"] or $args["--file"] == "-": readAll(stdin)
else: readFile($args["--file"])
for id in @(args["<id>"]):
var issue = ctx.cfg.tasksDir.loadIssueById(id)
issue.details = details
issue.store()
updatedIssues.add(issue)
elif args["start"] or args["todo-today"] or args["done"] or
args["pending"] or args["todo"] or args["suspend"]:
@@ -318,7 +391,7 @@ when isMainModule:
let issue = ctx.cfg.tasksDir.loadIssueById(id)
if not args["--yes"]:
if not args["--non-interactive"]:
stderr.write("Delete '" & issue.summary & "' (y/n)? ")
if not "yes".startsWith(stdin.readLine.toLower):
continue
@@ -370,7 +443,7 @@ when isMainModule:
for state in statesOption.get: ctx.loadIssues(state)
else: ctx.loadAllIssues()
if filterOption.isSome: ctx.filterIssues(filterOption.get)
ctx.filterIssues(filter)
for state, issueList in ctx.issues:
for issue in issueList:
@@ -386,27 +459,27 @@ when isMainModule:
stdout.writeLine formatIssue(issue)
# List projects
elif listProjects: ctx.listProjects(filterOption)
elif listProjects: ctx.listProjects(some(filter))
# List milestones
elif listMilestones: ctx.listMilestones(filterOption)
elif listMilestones: ctx.listMilestones(some(filter))
# List all issues
else:
trace "listing all issues"
let showBoth = args["--today"] == args["--future"]
ctx.list(
filter = filterOption,
filter = some(filter),
states = statesOption,
showToday = showBoth or args["--today"],
showFuture = showBoth or args["--future"],
showHidden = args["--show-hidden"],
verbose = ctx.verbose)
elif args["show"]:
if args["project-board"]:
ctx.showProjectBoard(filterOption)
if not args["--show-done"]: filter.exclStates.add(Done)
ctx.showProjectBoard(some(filter))
discard
elif args["dupes"]:
+75
View File
@@ -0,0 +1,75 @@
import std/[sequtils, strutils]
const CSI = "\x1b["
const RESET_FORMATTING* = "\x1b[0m"
type
TrueColor* = tuple[r, g, b: int]
TerminalColors* = enum
cBlack = 0, cRed, cGreen, cYellow, cBlue, cMagenta, cCyan, cWhite,
cDefault = 9
cBrightBlack = 60, cBrightRed, cBrightGreen, cBrightYellow, cBrightBlue,
cBrightMagenta, cBrightCyan, cBrightWhite
TerminalStyle* = enum
tsBold = 1, tsDim, tsItalic, tsUnderline, tsBlink, tsInverse, tsHidden,
tsStrikethrough
func isAnsiTerminator(c: char): bool =
c in {'A'..'Z'} or c in {'a'..'z'}
proc stripAnsi*(text: string): string =
var i = 0
while i < text.len:
if i + 1 < text.len and text[i] == '\x1b' and text[i + 1] == '[':
i += 2
while i < text.len and not text[i].isAnsiTerminator:
inc i
if i < text.len:
inc i
else:
result.add(text[i])
inc i
func ansiEscSeq*(style: set[TerminalStyle]): string =
CSI & toSeq(style).mapIt($int(it)).join(";") & "m"
func ansiEscSeq*(fg: int): string = CSI & "38;5;" & $fg & "m"
func ansiEscSeq*(bg: int): string = CSI & "48;5;" & $bg & "m"
func ansiEscSeq*(fg: TrueColor): string =
"$#38;2;$#;$#;$#m" % [CSI, $fg.r, $fg.g, $fg.b]
func ansiEscSeq*(bg: TrueColor): string =
"$#48;2;$#;$#;$#m" % [CSI, $bg.r, $bg.g, $bg.b]
func ansiEscSeq*(fg: TerminalColors): string = CSI & $(int(fg) + 30) & "m"
func ansiEscSeq*(bg: TerminalColors): string = CSI & $(int(bg) + 40) & "m"
func ansiEscSeq*(
fg: TerminalColors,
bg: TerminalColors,
style: set[TerminalStyle]): string =
result = CSI
if style != {}:
result &= toSeq(style).mapIt($int(it)).join(";") & ";"
result &= $(int(fg) + 30) & ";" & $(int(bg) + 40) & "m"
func termFmt*(
text: string,
fg = cDefault,
bg = cDefault,
style: set[TerminalStyle] = {}): string =
ansiEscSeq(fg, bg, style) & text & RESET_FORMATTING
func termFmt*[C: int | TrueColor](
text: string,
fg, bg: C,
style: set[TerminalStyle] = {}): string =
ansiEscSeq(style) & ansiEscSeq(fg = fg) & ansiEscSeq(bg = bg) &
text & RESET_FORMATTING
func color*(text: string, fg = cDefault, bg = cDefault): string =
termFmt(text, fg, bg, {})
+20 -7
View File
@@ -1,4 +1,4 @@
const PIT_VERSION* = "4.30.0"
const PIT_VERSION* = "4.33.3"
const USAGE* = """Usage:
pit ( new | add) <summary> [<state>] [options]
@@ -10,6 +10,7 @@ const USAGE* = """Usage:
pit ( start | done | pending | todo-today | todo | suspend ) <id>... [options]
pit edit <ref>... [options]
pit tag <id>... [options]
pit update-details <id> [--file=<path>] [options]
pit untag <id>... [options]
pit reorder <state> [options]
pit delegate <id> <delegated-to> [options]
@@ -18,7 +19,7 @@ const USAGE* = """Usage:
pit add-binary-property <id> <propName> <propSource> [options]
pit get-binary-property <id> <propName> <propDest> [options]
pit show dupes [options]
pit show project-board [options]
pit show project-board [--show-done] [options]
pit show <id> [options]
pit sync [<syncTarget>...] [options]
pit help [options]
@@ -33,12 +34,20 @@ Options:
a filter to the issues listed, only allowing those
which have all of the given properties.
If a propert name is provided without a value, this
will allow all issues which have any value defined
for the named property.
-P, --excl-properties <props>
When used with the list command, exclude issues
that contain properties with the given value. This
parameter is formatted the same as the --properties
parameter: "key:val;key:val"
If no value is provided for a property, this will
filter out all issues with *any* value for that
property.
-c, --context <ctx> Shorthand for '-p context:<ctx>'
-C, --excl-context <ctx> Don't show issues from the given context(s).
@@ -62,17 +71,15 @@ Options:
properties set.
-m, --match <pattern> Limit to issues whose summaries match the given
pattern (PCRE regex supported).
pattern (regex syntax supported).
-M, --match-all <pat> Limit to the issues whose summaries or details
match the given pattern (PCRE regex supported).
match the given pattern (regex syntax supported).
-v, --verbose Show issue details when listing issues.
-q, --quiet Suppress verbose output.
-y, --yes Automatically answer "yes" to any prompts.
--config <cfgFile> Location of the config file (defaults to $HOME/.pitrc)
-E, --echo-args Echo arguments (for debug purposes).
@@ -88,7 +95,13 @@ Options:
only print the changes that would be made, but do
not actually make them.
-s, --silent Suppress all logging and status output.
-I, --non-interactive Run in non-interactive mode. Commands that would
normally prompt for user input will instead use
default values or fail if required input is not
provided via command-line options.
-s, --silent Suppress all logging and status output and run in
non-interactive mode (implies --non-interactive).
"""
const ONLINE_HELP* = """Issue States:
+20
View File
@@ -0,0 +1,20 @@
import std/[base64, strutils]
import filetype
proc encodeAsDataUri*(value: string, mimeType: string = ""): string =
let actualMimeType =
if mimeType.isEmptyOrWhitespace: filetype.match(cast[seq[byte]](value)).mime.value
else: mimeType
"data:" & actualMimeType & ";base64," & encode(value)
proc decodeDataUri*(dataUri: string): string =
if not dataUri.startsWith("data:"):
raise newException(Exception, "input does not look like a valid data URI")
let commaIdx = dataUri.find(',')
if commaIdx < 0:
raise newException(Exception, "input does not look like a valid data URI")
decode(dataUri[commaIdx + 1..^1])
+34 -39
View File
@@ -1,41 +1,42 @@
import std/[options, sequtils, tables, terminal, times, unicode, wordwrap]
import cliutils, uuids
import uuids
import std/strutils except alignLeft, capitalize, strip, toLower, toUpper
import ./ansiterm
import ./libpit
proc adjustedTerminalWidth(): int = min(terminalWidth(), 80)
proc formatIssue*(issue: Issue): string =
result = ($issue.id).withColor(fgBlack, true) & "\n"&
issue.summary.withColor(fgWhite) & "\n"
result = ($issue.id).color(cBrightBlack) & "\n"&
issue.summary.color(cWhite) & "\n"
if issue.tags.len > 0:
result &= "tags: ".withColor(fgMagenta) &
issue.tags.join(",").withColor(fgGreen, true) & "\n"
result &= "tags: ".color(cMagenta) &
issue.tags.join(",").color(cBrightGreen) & "\n"
if issue.properties.len > 0:
for k, v in issue.properties:
if k == "project":
result &= "project: ".withColor(fgMagenta) &
v.withColor(fgBlue, bright = true) & "\n"
result &= "project: ".color(cMagenta) &
v.color(cBrightBlue) & "\n"
elif k == "milestone":
result &= "milestone: ".withColor(fgMagenta) &
v.withColor(fgBlue, bright = true) & "\n"
result &= "milestone: ".color(cMagenta) &
v.color(cBrightBlue) & "\n"
elif k == "priority":
result &= "priority: ".withColor(fgMagenta) &
v.withColor(fgRed, bright = true) & "\n"
result &= "priority: ".color(cMagenta) &
v.color(cBrightRed) & "\n"
else:
result &= termColor(fgMagenta) & k & ": " & v & "\n"
result &= ansiEscSeq(fg = cMagenta) & k & ": " & v & "\n"
result &= "--------".withColor(fgBlack, true) & "\n"
result &= "--------".color(cBrightBlack) & "\n"
if not issue.details.isEmptyOrWhitespace:
result &= issue.details.strip.withColor(fgCyan) & "\n"
result &= issue.details.strip.color(cCyan) & "\n"
result &= termReset
result &= RESET_FORMATTING
proc formatPlainIssueSummary*(issue: Issue): string =
@@ -64,7 +65,7 @@ proc formatSectionIssue*(
verbose = false,
bold = false): string =
result = (indent & ($issue.id)[0..<6]).withColor(fgBlack, true) & " "
result = (indent & ($issue.id)[0..<6]).color(cBrightBlack) & " "
let showDetails = not issue.details.isEmptyOrWhitespace and verbose
@@ -75,7 +76,8 @@ proc formatSectionIssue*(
.wrapWords(summaryWidth)
.splitLines
result &= summaryLines[0].termFmt(fgWhite, bold=bold, underline=bold)
result &= summaryLines[0].termFmt(fg=cWhite, bg=cDefault,
style = if bold: { tsBold, tsUnderline } else: {})
for line in summaryLines[1..^1]:
result &= "\p" & line.indent(summaryIndentLen)
@@ -84,11 +86,11 @@ proc formatSectionIssue*(
if issue.hasProp("delegated-to"):
if lastLineLen + issue["delegated-to"].len + 1 < summaryWidth:
result &= " " & issue["delegated-to"].withColor(fgMagenta)
result &= " " & issue["delegated-to"].color(cMagenta)
lastLineLen += issue["delegated-to"].len + 1
else:
result &= "\p" & issue["delegated-to"]
.withColor(fgMagenta)
.color(cMagenta)
.indent(summaryIndentLen)
lastLineLen = issue["delegated-to"].len
@@ -99,28 +101,28 @@ proc formatSectionIssue*(
if tagsStrLines.len == 1 and
(lastLineLen + tagsStrLines[0].len + 1) < summaryWidth:
result &= " " & tagsStrLines[0].withColor(fgGreen)
result &= " " & tagsStrLines[0].color(cGreen)
lastLineLen += tagsStrLines[0].len + 1
else:
result &= "\p" & tagsStrLines
.mapIt(it.indent(summaryIndentLen))
.join("\p")
.withColor(fgGreen)
.color(cGreen)
lastLineLen = tagsStrLines[^1].len
if issue.hasProp("pending"):
result &= "\p" & ("Pending: " & issue["pending"])
.wrapwords(summaryWidth)
.withColor(fgCyan)
.color(cCyan)
.indent(summaryIndentLen)
if showDetails:
result &= "\p" & issue.details
.strip
.withColor(fgBlack, bright = true)
.color(cBrightBlack)
.indent(summaryIndentLen)
result &= termReset
result &= RESET_FORMATTING
proc formatSectionIssueList*(
@@ -139,19 +141,19 @@ proc formatSection(ctx: CliContext, issues: seq[Issue], state: IssueState,
indent = "", verbose = false): string =
let innerWidth = adjustedTerminalWidth() - (indent.len * 2)
result = termColor(fgBlue) &
result = ansiEscSeq(fg = cBlue) &
(indent & ".".repeat(innerWidth)) & "\n" &
state.displayName.center(adjustedTerminalWidth()) & "\n\n" &
termReset
RESET_FORMATTING
let issuesByContext = issues.groupBy("context")
if issues.len > 5 and issuesByContext.len > 1:
for context, ctxIssues in issuesByContext:
result &= termColor(fgYellow) &
result &= ansiEscSeq(fg = cYellow) &
indent & ctx.getIssueContextDisplayName(context) & ":" &
termReset & "\n\n"
RESET_FORMATTING & "\n\n"
result &= formatSectionIssueList(ctxIssues, innerWidth - 2, indent & " ", verbose)
result &= "\n"
@@ -160,11 +162,11 @@ proc formatSection(ctx: CliContext, issues: seq[Issue], state: IssueState,
proc writeHeader*(ctx: CliContext, header: string) =
stdout.setForegroundColor(fgRed, true)
stdout.write(ansiEscSeq(fg = cBrightRed))
stdout.writeLine('_'.repeat(adjustedTerminalWidth()))
stdout.writeLine(header.center(adjustedTerminalWidth()))
stdout.writeLine('~'.repeat(adjustedTerminalWidth()))
stdout.resetAttributes
stdout.write(RESET_FORMATTING)
proc list*(
@@ -173,7 +175,6 @@ proc list*(
states: Option[seq[IssueState]],
showToday = false,
showFuture = false,
showHidden = false,
verbose: bool) =
if states.isSome:
@@ -219,10 +220,7 @@ proc list*(
for s in [Current, TodoToday, Pending]:
if ctx.issues.hasKey(s) and ctx.issues[s].len > 0:
let visibleIssues = ctx.issues[s].filterIt(
showHidden or
not (it.hasProp("hide-until") and
it.getDateTime("hide-until") > getTime().local))
let visibleIssues = ctx.issues[s]
if isatty(stdout):
stdout.write ctx.formatSection(visibleIssues, s, indent, verbose)
@@ -242,10 +240,7 @@ proc list*(
for s in futureCategories:
if ctx.issues.hasKey(s) and ctx.issues[s].len > 0:
let visibleIssues = ctx.issues[s].filterIt(
showHidden or
not (it.hasProp("hide-until") and
it.getDateTime("hide-until") > getTime().local))
let visibleIssues = ctx.issues[s]
if isatty(stdout):
stdout.write ctx.formatSection(visibleIssues, s, indent, verbose)
+55 -22
View File
@@ -1,8 +1,8 @@
import std/[json, logging, options, os, strformat, strutils, tables, times,
unicode]
import cliutils, docopt, langutils, uuids, zero_functional
import cliutils/config, docopt, langutils, uuids, zero_functional
import nre except toSeq
import regex
import timeutils except `>`
from sequtils import deduplicate, toSeq
@@ -25,11 +25,12 @@ type
IssueFilter* = ref object
completedRange*: Option[tuple[b, e: DateTime]]
fullMatch*, summaryMatch*: Option[Regex]
fullMatch*, summaryMatch*: Option[Regex2]
inclStates*: seq[IssueState]
exclStates*: seq[IssueState]
hasTags*: seq[string]
exclTags*: seq[string]
exclHidden*: bool
properties*: TableRef[string, string]
exclProperties*: TableRef[string, seq[string]]
@@ -38,6 +39,7 @@ type
PitConfig* = ref object
tasksDir*: string
contexts*: TableRef[string, string]
defaultPropertiesByContext*: TableRef[string, seq[string]]
autoSync*: bool
syncTargets*: seq[JsonNode]
cfg*: CombinedConfig
@@ -55,11 +57,12 @@ type
isFromCompletion*: bool
const MATCH_ANY* = "<match-any>"
const DONE_FOLDER_FORMAT* = "yyyy-MM"
const ISO8601_MS = "yyyy-MM-dd'T'HH:mm:ss'.'fffzzz"
let ISSUE_FILE_PATTERN = re"[A-Fa-f0-9]{8}-[A-Fa-f0-9]{4}-[A-Fa-f0-9]{4}-[A-Fa-f0-9]{4}-[A-Fa-f0-9]{12}\.txt"
let RECURRENCE_PATTERN = re"(every|after) ((\d+) )?((hour|day|week|month|year)s?)(, ([0-9a-fA-F]+))?"
let ISSUE_FILE_PATTERN = re2"[A-Fa-f0-9]{8}-[A-Fa-f0-9]{4}-[A-Fa-f0-9]{4}-[A-Fa-f0-9]{4}-[A-Fa-f0-9]{12}\.txt"
let RECURRENCE_PATTERN = re2"(every|after) ((\d+) )?((hour|day|week|month|year)s?)(, ([0-9a-fA-F]+))?"
let traceStartTime = cpuTime()
var lastTraceTime = traceStartTime
@@ -113,26 +116,31 @@ proc setDateTime*(issue: Issue, key: string, dt: DateTime) =
proc getRecurrence*(issue: Issue): Option[Recurrence] =
if not issue.hasProp("recurrence"): return none[Recurrence]()
let m = issue["recurrence"].match(RECURRENCE_PATTERN)
let recurrence = issue["recurrence"]
var m = RegexMatch2()
if not m.isSome:
if not recurrence.match(RECURRENCE_PATTERN, m):
warn "not a valid recurrence value: '" & issue["recurrence"] & "'"
return none[Recurrence]()
let c = nre.toSeq(m.get.captures)
let timeVal = if c[2].isSome: c[2].get.parseInt
proc captured(i: int): Option[string] =
let bounds = m.group(i)
if bounds == reNonCapture: none(string)
else: some(recurrence[bounds])
let timeVal = if captured(2).isSome: captured(2).get.parseInt
else: 1
return some(Recurrence(
isFromCompletion: c[0].get == "after",
isFromCompletion: captured(0).get == "after",
interval:
case c[4].get:
case captured(4).get:
of "hour": hours(timeVal)
of "day": days(timeVal)
of "week": weeks(timeVal)
of "month": months(timeVal)
of "year": years(timeVal)
else: weeks(1),
cloneId: c[6]))
cloneId: captured(6)))
proc setPriority*(issue: Issue, priority: IssuePriority) =
issue["priority"] = $priority
@@ -146,10 +154,11 @@ proc getPriority*(issue: Issue): IssuePriority =
proc initFilter*(): IssueFilter =
result = IssueFilter(
completedRange: none(tuple[b, e: DateTime]),
fullMatch: none(Regex),
summaryMatch: none(Regex),
fullMatch: none(Regex2),
summaryMatch: none(Regex2),
inclStates: @[],
exclStates: @[],
exclHidden: true,
hasTags: @[],
exclTags: @[],
properties: newTable[string, string](),
@@ -169,11 +178,11 @@ proc dateFilter*(range: tuple[b, e: DateTime]): IssueFilter =
proc summaryMatchFilter*(pattern: string): IssueFilter =
result = initFilter()
result.summaryMatch = some(re("(?i)" & pattern))
result.summaryMatch = some(re2("(?i)" & pattern))
proc fullMatchFilter*(pattern: string): IssueFilter =
result = initFilter()
result.fullMatch = some(re("(?i)" & pattern))
result.fullMatch = some(re2("(?i)" & pattern))
proc hasTagsFilter*(tags: seq[string]): IssueFilter =
result = initFilter()
@@ -183,6 +192,10 @@ proc stateFilter*(states: seq[IssueState]): IssueFilter =
result = initFilter()
result.inclStates = states
proc showHiddenFilter*(): IssueFilter =
result = initFilter()
result.exclHidden = false
proc groupBy*(issues: seq[Issue], propertyKey: string): TableRef[string, seq[Issue]] =
result = newTable[string, seq[Issue]]()
for i in issues:
@@ -222,7 +235,9 @@ proc fromStorageFormat*(id: string, issueTxt: string): Issue =
var detailLines: seq[string] = @[]
for line in issueTxt.splitLines():
if line.startsWith("#"): continue # ignore lines starting with '#'
if line.startsWith("#") and parseState != ReadingDetails:
# ignore lines starting with '#', unless we're in the details section.
continue
case parseState
@@ -334,7 +349,7 @@ proc loadIssues*(path: string): seq[Issue] =
var unorderedIssues: seq[TaggedIssue] = @[]
for path in walkDirRec(path):
if extractFilename(path).match(ISSUE_FILE_PATTERN).isSome():
if extractFilename(path).match(ISSUE_FILE_PATTERN):
unorderedIssues.add((loadIssue(path), false))
trace "loaded " & $unorderedIssues.len & " issues", true
@@ -415,11 +430,23 @@ proc nextRecurrence*(tasksDir: string, rec: Recurrence, defaultIssue: Issue): Is
proc filter*(issues: seq[Issue], filter: IssueFilter): seq[Issue] =
var f: seq[Issue] = issues
if filter.exclHidden:
let now = getTime().local
f = f --> filter(
not it.hasProp("hide-until") or
it.getDateTime("hide-until") <= now)
for k,v in filter.properties:
f = f --> filter(it.hasProp(k) and it[k] == v)
if v == MATCH_ANY:
f = f --> filter(it.hasProp(k))
else:
f = f --> filter(it.hasProp(k) and it[k] == v)
for k,v in filter.exclProperties:
f = f --> filter(not (it.hasProp(k) and v.contains(it[k])))
if v.len == 0:
f = f --> filter(not (it.hasProp(k)))
else:
f = f --> filter(not (it.hasProp(k) and v.contains(it[k])))
if filter.completedRange.isSome:
let range = filter.completedRange.get
@@ -429,12 +456,12 @@ proc filter*(issues: seq[Issue], filter: IssueFilter): seq[Issue] =
if filter.summaryMatch.isSome:
let p = filter.summaryMatch.get
f = f --> filter(it.summary.find(p).isSome)
f = f --> filter(it.summary.contains(p))
if filter.fullMatch.isSome:
let p = filter.fullMatch.get
f = f -->
filter(it.summary.find(p).isSome or it.details.find(p).isSome)
filter(it.summary.contains(p) or it.details.contains(p))
for tagLent in filter.hasTags:
let tag = tagLent
@@ -486,12 +513,18 @@ proc loadConfig*(args: Table[string, Value] = initTable[string, Value]()): PitCo
cfg: cfg,
autoSync: parseBool(cfg.getVal("auto-sync", "false")),
contexts: newTable[string,string](),
defaultPropertiesByContext: newTable[string, seq[string]](),
tasksDir: cfg.getVal("tasks-dir", ""),
syncTargets: cfg.getJson("sync-targets", newJArray()).getElems)
for k, v in cfg.getJson("contexts", newJObject()):
result.contexts[k] = v.getStr()
for k, v in cfg.getJson("defaultPropertiesByContext", newJObject()):
result.defaultPropertiesByContext[k] = v.getElems() -->
map(it.getStr("").strip())
.filter(not it.isEmptyOrWhitespace)
if isEmptyOrWhitespace(result.tasksDir):
raise newException(Exception, "no tasks directory configured")
+104 -64
View File
@@ -1,8 +1,12 @@
import std/[algorithm, json, jsonutils, options, os, sets, strutils, tables, terminal,
times, unicode, wordwrap]
import std/[algorithm, json, jsonutils, options, os, sets, strutils, tables,
terminal, times, unicode]
from std/sequtils import repeat, toSeq
import cliutils, uuids, zero_functional
import ./[formatting, libpit]
import uuids, zero_functional
import ./[ansiterm, formatting, libpit]
const NO_PROJECT* = "∅ No Project"
const NO_MILESTONE* = "∅ No Milestone"
const NO_CONTEXT* = "<no-context>"
type
ProjectCfg* = ref object of RootObj
@@ -62,15 +66,18 @@ proc buildDb*(ctx: CliContext, cfg: ProjectsConfiguration): ProjectsDatabase =
# 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 projectName =
if issue.hasProp("project"): issue["project"]
else: NO_PROJECT
let milestone =
if issue.hasProp("milestone"): issue["milestone"]
else: NO_MILESTONE
let context =
if issue.hasProp("context"): issue["context"]
else: "<no-context>"
else: NO_CONTEXT
# Make sure we have entries for this context and project
if not result.hasKey(context): result[context] = @[]
@@ -122,28 +129,33 @@ proc listProjects*(ctx: CliContext, filter = none[IssueFilter]()) =
let projectsCfg = ctx.loadProjectsConfiguration()
var projectsCfgChanged = false
var linesToPrint = newSeq[string]()
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>"
let context =
if issue.hasProp("context"): issue["context"]
else: NO_CONTEXT
if not projectsByContext.hasKey(context):
projectsByContext[context] = newCountTable[string]()
let projectName =
if issue.hasProp("project"): issue["project"]
else: NO_PROJECT
projectsByContext[context].inc(issue["project"])
if not projectsByContext.hasKey(context):
projectsByContext[context] = newCountTable[string]()
projectsByContext[context].inc(projectName)
for (context, projects) in pairs(projectsByContext):
stdout.writeLine(withColor(
linesToPrint.add(termFmt(
ctx.getIssueContextDisplayName(context) & ":",
fgYellow) & termReset)
stdout.writeLine("")
fg=cYellow))
linesToPrint.add("")
var toList = toHashSet(toSeq(keys(projects)))
@@ -154,22 +166,29 @@ proc listProjects*(ctx: CliContext, filter = none[IssueFilter]()) =
for project in projectsCfg[context]:
if project.name in toList:
toList.excl(project.name)
stdout.writeLine(" " & project.name &
linesToPrint.add(" " & 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)")
linesToPrint.add(" " & projectName & " (" & $count & " issues)")
projectsCfg[context].add(ProjectCfg(name: projectName, milestoneOrder: @[]))
projectsCfgChanged = true
stdout.writeLine("")
linesToPrint.add("")
if projectsCfgChanged: ctx.saveProjectsConfiguration(projectsCfg)
if isatty(stdout):
stdout.writeLine(linesToPrint.join("\p"))
else:
stdout.writeLine(stripAnsi(linesToPrint.join("\p")))
proc listMilestones*(ctx: CliContext, filter = none[IssueFilter]()) =
var linesToPrint = newSeq[string]()
ctx.loadAllIssues()
if filter.isSome: ctx.filterIssues(filter.get)
@@ -184,18 +203,24 @@ proc listMilestones*(ctx: CliContext, filter = none[IssueFilter]()) =
if values(project.milestones) --> all(it.len == 0):
continue
stdout.writeLine(withColor(project.name, fgBlue, bold = true, bright=true))
stdout.writeLine(withColor(
linesToPrint.add(termFmt(project.name, cBrightBlue, style = { tsBold }))
linesToPrint.add(termFmt(
"".repeat(runeLen(stripAnsi(project.name))),
fgBlue, bold = true))
cBrightBlue, style = { tsBold} ))
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)")
linesToPrint.add(" " & milestone & " (" & $issueCount & " issues)")
linesToPrint.add("")
if isatty(stdout):
stdout.writeLine(linesToPrint.join("\p"))
else:
stdout.writeLine(stripAnsi(linesToPrint.join("\p")))
stdout.writeLine("")
proc formatProjectIssue(
ctx: CliContext,
@@ -204,31 +229,31 @@ proc formatProjectIssue(
var firstLine = ""
if issue.state == IssueState.Done:
firstLine &= withColor("", fgBlack, bold=true, bright=true)
firstLine &= termFmt("", fg = cBrightBlack, style = { tsBold })
else:
case issue.getPriority
of IssuePriority.essential:
firstLine &= withColor("", fgRed, bold=true, bright=true)
firstLine &= termFmt("", fg=cBrightRed, style={ tsBold })
of IssuePriority.vital:
firstLine &= withColor("", fgYellow, bold=true, bright=true)
firstLine &= termFmt("", fg=cBrightYellow, style={ tsBold })
of IssuePriority.important:
firstLine &= withColor("", fgBlue, bold=true, bright=true)
firstLine &= termFmt("", fg=cBrightBlue, style={ tsBold })
of IssuePriority.optional:
firstLine &= withColor("", fgBlack, bold=false, bright=true)
firstLine &= termFmt("", fg=cBrightBlack)
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)
firstLine = termFmt(stripAnsi(firstLine), fg=cBrightBlack)
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))
result = origLines --> map(termFmt(stripAnsi(it), fg = cBrightBlack))
proc formatParentIssue*(
ctx: CliContext,
@@ -240,7 +265,7 @@ proc formatParentIssue*(
for child in sorted(children, cmp):
let childLines = ctx.formatProjectIssue(child, width - 3)
result.add(childLines --> map(withColor("", fgBlack, bright=true) & it))
result.add(childLines --> map(termFmt("", fg=cBrightBlack) & it))
result.add("")
@@ -252,12 +277,11 @@ proc formatMilestone*(
availWidth: int): seq[string] =
result = @[""]
result.add(withColor(milestone, fgWhite, bold=true))
result.add(withColor("".repeat(availWidth), fgWhite))
result.add(termFmt(milestone, fg=cWhite, style={tsBold}))
result.add(termFmt("".repeat(availWidth), fg=cWhite))
var parentsToChildren = issues -->
filter(it.hasProp("parent")) -->
group(it["parent"])
filter(it.hasProp("parent")).group(it["parent"])
var issuesToFormat = sorted(issues, cmp) -->
filter(not it.hasProp("parent"))
@@ -282,7 +306,7 @@ proc findShortestColumn(columns: seq[seq[string]]): int =
proc joinColumns(columns: seq[seq[string]], columnWidth: int): seq[string] =
let maxLines = columns --> map(it.len) --> max()
let maxLines = columns --> map(it.len).max()
for lineNo in 0 ..< maxLines:
var newLine = ""
@@ -297,26 +321,28 @@ proc joinColumns(columns: seq[seq[string]], columnWidth: int): seq[string] =
proc showProject*(ctx: CliContext, project: Project) =
var linesToPrint = newSeq[string]()
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))
linesToPrint.add("")
linesToPrint.add(termFmt(
"" & "".repeat(project.name.runeLen + 2) &
"" & "".repeat(fullWidth - project.name.runeLen - 4) & "",
fg=cBlue, style={tsBold}))
linesToPrint.add(
termFmt("", fg=cBlue, style={tsBold}) &
termFmt(project.name, fg=cBrightBlue, style={tsBold}) &
termFmt("" & " ".repeat(fullWidth - project.name.runeLen - 4) & "",
fg=cBlue, style={tsBold}))
linesToPrint.add(termFmt(
"" & "".repeat(project.name.runeLen + 2) &
"" & " ".repeat(fullWidth - project.name.runeLen - 4) & "",
fg=cBlue, style={tsBold}))
let milestoneTexts: seq[seq[string]] = project.milestoneOrder -->
filter(project.milestones.hasKey(it) and project.milestones[it].len > 0) -->
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)
@@ -329,15 +355,21 @@ proc showProject*(ctx: CliContext, project: Project) =
for line in joinedLines:
let padLen = fullWidth - runeLen(stripAnsi(line)) - 3
stdout.writeLine(
withColor("", fgBlue) &
linesToPrint.add(
termFmt("", fg=cBlue) &
line &
" ".repeat(padLen) &
withColor("", fgBlue))
termFmt("", fg=cBlue))
stdout.writeLine(withColor(
linesToPrint.add(termFmt(
"" & "".repeat(terminalWidth() - 2) & "",
fgBlue, bold=true))
fg=cBlue, style={tsBold}))
if isatty(stdout):
stdout.writeLine(linesToPrint.join("\p"))
else:
stdout.writeLine(stripAnsi(linesToPrint.join("\p")))
proc showProjectBoard*(ctx: CliContext, filter = none[IssueFilter]()) =
@@ -347,12 +379,20 @@ proc showProjectBoard*(ctx: CliContext, filter = none[IssueFilter]()) =
let projectsCfg = ctx.loadProjectsConfiguration()
let projectsDb = ctx.buildDb(projectsCfg)
for (context, projects) in pairs(projectsDb):
if projectsDb.len > 1:
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(
stdout.writeLine(termFmt(
ctx.getIssueContextDisplayName(context) & ":",
fgYellow, bold=true))
fg=cYellow, style={tsBold}))
stdout.writeLine("")
for p in projects:
+13 -2
View File
@@ -7,9 +7,10 @@
# libpit directly rather than calling the pit cli executable. Unfortunately
# this would require a non-trivial rewrite.
import asyncdispatch, cliutils, docopt, jester, json, logging, options, sequtils, strutils
import nre except toSeq
import asyncdispatch, docopt, jester, json, logging, options, sequtils, strutils
import cliutils/procutil
import pit/ansiterm
import pit/libpit
import pit/cliconstants
@@ -23,6 +24,16 @@ const TXT = "text/plain"
proc raiseEx(reason: string): void = raise newException(Exception, reason)
proc queryParamsToCliArgs[T](queryParams: T): seq[string] =
result = @[]
for k, v in queryParams:
if k.startsWith("arg"):
result.add(v)
else:
result.add("--" & k)
if v != "true":
result.add(v)
template halt(code: HttpCode,
headers: RawHeaders,
content: string): void =
+29 -1
View File
@@ -1 +1,29 @@
doAssert(1 + 1 == 2)
import std/[options, tables, times, unittest]
import pit/libpit
suite "regex-backed issue behavior":
test "summary and full-text filters are case-insensitive":
let issue = Issue(
summary: "Fix the Widget",
details: "Failure happens in the renderer",
properties: newTable[string, string](),
tags: @[])
check @[issue].filter(summaryMatchFilter("widget")).len == 1
check @[issue].filter(summaryMatchFilter("missing")).len == 0
check @[issue].filter(fullMatchFilter("RENDERER")).len == 1
test "recurrence values parse captures":
let issue = Issue(
summary: "repeating",
properties: newTable[string, string](),
tags: @[])
issue["recurrence"] = "after 2 weeks, abc123"
let recurrence = issue.getRecurrence()
check recurrence.isSome
check recurrence.get.isFromCompletion
check recurrence.get.interval == weeks(2)
check recurrence.get.cloneId == some("abc123")