Add support for issue recurrence.

This commit is contained in:
Jonathan Bernard 2021-09-17 13:49:42 -05:00
parent b25d2be164
commit 7bccd83e23
6 changed files with 271 additions and 139 deletions

View File

@ -1,6 +1,6 @@
# Package
version = "4.14.0"
version = "4.16.0"
author = "Jonathan Bernard"
description = "Personal issue tracker."
license = "MIT"

93
src/online-help.txt Normal file
View File

@ -0,0 +1,93 @@
Issue States:
PIT organizes issues around their state, which is one of:
current - issues actively being worked
todo-today - issues planned for today
pending - issues that are blocked by some third-party
done - issues that have been completely resolved
todo - issues that need to be done in the future
dormant - issues that are low-priority, to be tracked, but hidden
by default
Issue Properties:
PIT supports adding arbitrary properties to any issue to track any metadata
about the issue the user may wish. There are several properties that have
special behavior attached to them. They are:
created
If present, expected to be an ISO 8601-formatted date that represents the
time when the issue was created.
completed
If present, expected to be an ISO 8601-formatted date that represents the
time when the issue moved to the "done" state. PIT will add this
property automatically when you use the "done" command, and can filter on
this value.
context
Allows issues to be organized into contexts. The -c option is short-hand
for '-p context:<context-name>' and the 'list contexts' command will show
all values of 'context' set in existing issues.
delegated-to
When an issue now belongs to someone else, but needs to be monitored for
completion, this allows you to keep the issue in its current state but
note how it has been delegated. When present PIT will prepend this value
to the issue summary with an accent color.
hide-until
When present, expected to be an ISO 8601-formatted date and used to
supress the display of the issue until on or after the given date.
pending
When an issue is blocked by a third party, this property can be used to
capture details about the dependency When present PIT will display this
value after the issue summary.
recurrence
When an issue is moved to the "done" state, if the issue has a valid
"recurrence" property, PIT will create a new issue and set the
"hide-until" property for that new issue depending on the recurrence
definition.
A valid recurrence value has a time value and optionally has an source
issue ID. For example:
every 5 days, 10a544
The first word, "every", is expected to be either "every" or "after".
The second portion is expected to be a time period. Supported time units
are "hour", "day", "week", "month", an "year", along with the plural
forms (e.g. "5 days", "8 hours", etc.).
The final portion is the source issue ID. This is optional. When a source
issue ID is given, the new issue is created as a clone of the given
issue. When not given, the issue being closed is used for cloning.
The "every" and "after" keywords allow the user to choose whether the new
issue is created based on the creation time ("every") or the completion
time ("after") of the issue being closed based.
Examples:
every day
every 2 days
after 2 days
every week
after 12 hours
every 2 weeks, 10a544
tags
If present, expected to be a comma-delimited list of text tags. The -g
option is a short-hand for '-p tags:<tags-value>'.

View File

@ -149,6 +149,10 @@ proc formatSection(ctx: CliContext, issues: seq[Issue], state: IssueState,
proc loadIssues(ctx: CliContext, state: IssueState) =
ctx.issues[state] = loadIssues(ctx.tasksDir / $state)
proc loadOpenIssues(ctx: CliContext) =
ctx.issues = newTable[IssueState, seq[Issue]]()
for state in [Current, TodoToday, Todo, Pending, Todo]: ctx.loadIssues(state)
proc loadAllIssues(ctx: CliContext) =
ctx.issues = newTable[IssueState, seq[Issue]]()
for state in IssueState: ctx.loadIssues(state)
@ -192,13 +196,14 @@ proc edit(issue: Issue) =
let editedIssue = loadIssue(issue.filepath)
editedIssue.store()
except:
fatal "pit: updated issue is invalid (ignoring edits): \n\t" &
fatal "updated issue is invalid (ignoring edits): \n\t" &
getCurrentExceptionMsg()
issue.store()
proc list(ctx: CliContext, filter: Option[IssueFilter], states: Option[seq[IssueState]], showToday, showFuture, verbose: bool) =
if states.isSome:
trace "listing issues for " & $states.get
for state in states.get:
ctx.loadIssues(state)
if filter.isSome: ctx.filterIssues(filter.get)
@ -207,10 +212,13 @@ proc list(ctx: CliContext, filter: Option[IssueFilter], states: Option[seq[Issue
it.hasProp("completed") and
sameDay(getTime().local, it.getDateTime("completed")))
stdout.write ctx.formatSection(ctx.issues[state], state, "", verbose)
trace "listing complete"
return
ctx.loadAllIssues()
if filter.isSome: ctx.filterIssues(filter.get)
ctx.loadOpenIssues()
if filter.isSome:
ctx.filterIssues(filter.get)
trace "filtered issues"
let today = showToday and [Current, TodoToday, Pending].anyIt(
ctx.issues.hasKey(it) and ctx.issues[it].len > 0)
@ -240,146 +248,35 @@ proc list(ctx: CliContext, filter: Option[IssueFilter], states: Option[seq[Issue
stdout.write ctx.formatSection(visibleIssues, s, indent, verbose)
trace "listing complete"
when isMainModule:
try:
let usage = """
Usage:
pit ( new | add) <summary> [<state>] [options]
pit list contexts [options]
pit list [<stateOrId>...] [options]
pit ( start | done | pending | todo-today | todo | suspend ) <id>... [options]
pit edit <ref>... [options]
pit tag <id>... [options]
pit untag <id>... [options]
pit reorder <state> [options]
pit delegate <id> <delegated-to>
pit hide-until <id> <date> [options]
pit ( delete | rm ) <id>... [options]
pit add-binary-property <id> <propName> <propSource> [options]
pit get-binary-property <id> <propName> <propDest> [options]
const usage = readFile("src/usage.txt")
const onlineHelp = readFile("src/online-help.txt")
Options:
-h, --help Print this usage and help information.
-p, --properties <props> Specify properties. Formatted as "key:val;key:val"
When used with the list command this option applies
a filter to the issues listed, only allowing those
which have all of the given properties.
-c, --context <ctxName> Shorthand for '-p context:<ctxName>'
-g, --tags <tags> Specify tags for an issue.
-T, --today Limit to today's issues.
-F, --future Limit to future issues.
-m, --match <pattern> Limit to issues whose summaries match the given
pattern (PCRE regex supported).
-M, --match-all <pat> Limit to the issues whose summaries or details
match the given pattern (PCRE regex supported).
-v, --verbose Show issue details when listing issues.
-q, --quiet Suppress verbose output.
-y, --yes Automatically answer "yes" to any prompts.
-C, --config <cfgFile> Location of the config file (defaults to $HOME/.pitrc)
-E, --echo-args Echo arguments (for debug purposes).
-d, --tasks-dir Path to the tasks directory (defaults to the value
configured in the .pitrc file)
--term-width <width> Manually set the terminal width to use.
--ptk Enable PTK integration for this command.
"""
let onlineHelp = """
Issue States
PIT organizes issues around their state, which is one of:
current - issues actively being worked
todo-today - issues planned for today
pending - issues that are blocked by some third-party
done - issues that have been completely resolved
todo - issues that need to be done in the future
dormant - issues that are low-priority, to be tracked, but hidden
by default
Issue Properties
PIT supports adding arbitrary properties to any issue to track any metadata
about the issue the user may wish. There are several properties that have
special behavior attached to them. They are:
created
If present, expected to be an ISO 8601-formatted date that represents the
time when the issue was created.
completed
If present, expected to be an ISO 8601-formatted date that represents the
time when the issue moved to the "done" state. PIT will add this
property automatically when you use the "done" command, and can filter on
this value.
context
Allows issues to be organized into contexts. The -c option is short-hand
for '-p context:<context-name>' and the 'list contexts' command will show
all values of 'context' set in existing issues.
delegated-to
When an issue now belongs to someone else, but needs to be monitored for
completion, this allows you to keep the issue in its current state but
note how it has been delegated. When present PIT will prepend this value
to the issue summary with an accent color.
hide-until
When present, expected to be an ISO 8601-formatted date and used to
supress the display of the issue until on or after the given date.
pending
When an issue is blocked by a third party, this property can be used to
capture details about the dependency When present PIT will display this
value after the issue summary.
recurrence
TODO, not yet implemented.
tags
If present, expected to be a comma-delimited list of text tags. The -g
option is a short-hand for '-p tags:<tags-value>'.
"""
logging.addHandler(newConsoleLogger())
let consoleLogger = newConsoleLogger(
levelThreshold=lvlInfo,
fmtStr="$app - $levelname: ")
logging.addHandler(consoleLogger)
# Parse arguments
let args = docopt(usage, version = PIT_VERSION)
if args["--debug"]:
consoleLogger.levelThreshold = lvlDebug
if args["--echo-args"]: stderr.writeLine($args)
if args["--help"]:
stderr.writeLine(usage)
if args["help"]:
stderr.writeLine(usage & "\n")
stderr.writeLine(onlineHelp)
quit()
let ctx = initContext(args)
trace "context initiated"
var propertiesOption = none(TableRef[string,string])
var tagsOption = none(seq[string])
@ -476,17 +373,22 @@ Issue Properties
if propertiesOption.isSome:
for k,v in propertiesOption.get:
issue[k] = v
if targetState == Done: issue["completed"] = getTime().local.formatIso8601
if targetState == Done:
issue["completed"] = getTime().local.formatIso8601
if issue.hasProp("recurrence") and issue.getRecurrence.isSome:
let nextIssue = ctx.tasksDir.nextRecurrence(issue.getRecurrence.get, issue)
ctx.tasksDir.store(nextIssue, Todo)
issue.changeState(ctx.tasksDir, targetState)
if ctx.triggerPtk or args["--ptk"]:
if targetState == Current:
let issue = ctx.tasksDir.loadIssueById($(args["<id>"][0]))
var cmd = "ptk start"
if issue.tags.len > 0 or issue.properties.hasKey("context"):
if issue.tags.len > 0 or issue.hasProp("context"):
let tags = concat(
issue.tags,
if issue.properties.hasKey("context"): @[issue.properties["context"]]
if issue.hasProp("context"): @[issue.properties["context"]]
else: @[]
)
cmd &= " -g \"" & tags.join(",") & "\""
@ -591,6 +493,7 @@ Issue Properties
# List all issues
else:
trace "listing all issues"
let showBoth = args["--today"] == args["--future"]
ctx.list(filterOption, statesOption, showBoth or args["--today"],
showBoth or args["--future"],
@ -624,6 +527,6 @@ Issue Properties
finally: close(propOut)
except:
fatal "pit: " & getCurrentExceptionMsg()
fatal getCurrentExceptionMsg()
#raise getCurrentException()
quit(QuitFailure)

View File

@ -1,7 +1,7 @@
import cliutils, docopt, json, logging, langutils, options, os,
sequtils, strutils, tables, times, timeutils, uuids
sequtils, strformat, strutils, tables, times, timeutils, uuids
from nre import find, match, re, Regex
import nre except toSeq
type
Issue* = ref object
@ -30,9 +30,28 @@ type
contexts*: TableRef[string, string]
cfg*: CombinedConfig
Recurrence* = object
cloneId*: Option[string]
interval*: TimeInterval
isFromCompletion*: bool
const DONE_FOLDER_FORMAT* = "yyyy-MM"
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 traceStartTime = cpuTime()
var lastTraceTime = traceStartTime
proc trace*(msg: string, diffFromLast = false) =
let curTraceTime = cpuTime()
if diffFromLast:
debug &"{(curTraceTime - lastTraceTime) * 1000:6.2f}ms {msg}"
else:
debug &"{cpuTime() - traceStartTime:08.4f} {msg}"
lastTraceTime = curTraceTime
proc displayName*(s: IssueState): string =
case s
@ -64,6 +83,30 @@ proc getDateTime*(issue: Issue, key: string, default: DateTime): DateTime =
proc setDateTime*(issue: Issue, key: string, dt: DateTime) =
issue.properties[key] = dt.formatIso8601
proc getRecurrence*(issue: Issue): Option[Recurrence] =
if not issue.hasProp("recurrence"): return none[Recurrence]()
let m = issue["recurrence"].match(RECURRENCE_PATTERN)
if not m.isSome:
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
else: 1
return some(Recurrence(
isFromCompletion: c[0].get == "after",
interval:
case c[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]))
## Issue filtering
proc initFilter*(): IssueFilter =
result = IssueFilter(
@ -221,6 +264,8 @@ proc storeOrder*(issues: seq[Issue], path: string) =
proc loadIssues*(path: string): seq[Issue] =
let orderFile = path / "order.txt"
trace "loading issues under " & path
let orderedIds =
if fileExists(orderFile):
toSeq(orderFile.lines)
@ -236,6 +281,7 @@ proc loadIssues*(path: string): seq[Issue] =
if extractFilename(path).match(ISSUE_FILE_PATTERN).isSome():
unorderedIssues.add((loadIssue(path), false))
trace "loaded " & $unorderedIssues.len & " issues", true
result = @[]
# Add all ordered issues in order
@ -250,6 +296,8 @@ proc loadIssues*(path: string): seq[Issue] =
if taggedIssue.ordered: continue
result.add(taggedIssue.issue)
trace "ordered the loaded issues", true
# Finally, save current order
result.storeOrder(path)
@ -261,6 +309,37 @@ proc changeState*(issue: Issue, tasksDir: string, newState: IssueState) =
proc delete*(issue: Issue) = removeFile(issue.filepath)
proc nextRecurrence*(tasksDir: string, rec: Recurrence, defaultIssue: Issue): Issue =
let baseIssue = if rec.cloneId.isSome: tasksDir.loadIssueById(rec.cloneId.get)
else: defaultIssue
let newProps = newTable[string,string]()
for k, v in baseIssue.properties:
if k != "created" and k != "completed":
newProps[k] = v
result = Issue(
id: genUUID(),
summary: baseIssue.summary,
properties: newProps,
tags: baseIssue.tags)
let now = getTime().local
let startDate =
if rec.isFromCompletion:
if baseIssue.hasProp("completed"): baseIssue.getDateTime("completed")
else: now
else:
if baseIssue.hasProp("created"): baseIssue.getDateTime("created")
else: now
# walk the calendar until the next recurrence that is after the current time.
var nextTime = startDate + rec.interval
while now > nextTime: nextTime += rec.interval
result.setDateTime("hide-until", nextTime)
## Utilities for working with issue collections.
proc filter*(issues: seq[Issue], filter: IssueFilter): seq[Issue] =
result = issues
@ -295,14 +374,14 @@ proc loadConfig*(args: Table[string, Value] = initTable[string, Value]()): PitCo
foldl(pitrcLocations, if len(a) > 0: a elif fileExists(b): b else: "")
if not fileExists(pitrcFilename):
warn "pit: could not find .pitrc file: " & pitrcFilename
warn "could not find .pitrc file: " & pitrcFilename
if isEmptyOrWhitespace(pitrcFilename):
pitrcFilename = $getEnv("HOME") & "/.pitrc"
var cfgFile: File
try:
cfgFile = open(pitrcFilename, fmWrite)
cfgFile.write("{\"tasksDir\": \"/path/to/tasks\"}")
except: warn "pit: could not write default .pitrc to " & pitrcFilename
except: warn "could not write default .pitrc to " & pitrcFilename
finally: close(cfgFile)
var cfgJson: JsonNode

View File

@ -1 +1 @@
const PIT_VERSION* = "4.15.0"
const PIT_VERSION* = "4.16.0"

57
src/usage.txt Normal file
View File

@ -0,0 +1,57 @@
Usage:
pit ( new | add) <summary> [<state>] [options]
pit list contexts [options]
pit list [<stateOrId>...] [options]
pit ( start | done | pending | todo-today | todo | suspend ) <id>... [options]
pit edit <ref>... [options]
pit tag <id>... [options]
pit untag <id>... [options]
pit reorder <state> [options]
pit delegate <id> <delegated-to>
pit hide-until <id> <date> [options]
pit ( delete | rm ) <id>... [options]
pit add-binary-property <id> <propName> <propSource> [options]
pit get-binary-property <id> <propName> <propDest> [options]
pit help
Options:
-h, --help Print this usage and help information.
-p, --properties <props> Specify properties. Formatted as "key:val;key:val"
When used with the list command this option applies
a filter to the issues listed, only allowing those
which have all of the given properties.
-c, --context <ctxName> Shorthand for '-p context:<ctxName>'
-g, --tags <tags> Specify tags for an issue.
-T, --today Limit to today's issues.
-F, --future Limit to future issues.
-m, --match <pattern> Limit to issues whose summaries match the given
pattern (PCRE regex supported).
-M, --match-all <pat> Limit to the issues whose summaries or details
match the given pattern (PCRE regex supported).
-v, --verbose Show issue details when listing issues.
-q, --quiet Suppress verbose output.
-y, --yes Automatically answer "yes" to any prompts.
-C, --config <cfgFile> Location of the config file (defaults to $HOME/.pitrc)
-E, --echo-args Echo arguments (for debug purposes).
-d, --tasks-dir Path to the tasks directory (defaults to the value
configured in the .pitrc file)
--term-width <width> Manually set the terminal width to use.
--ptk Enable PTK integration for this command.
--debug Enable debug-level log output.