A follow-up question on a task produced no event at all, so an unattended conversation stalled silently. --comments polls the account's assigned tasks (including done ones, where follow-ups land) and fires for comments written by anyone else. Guards: comments by the account itself never fire, so a replying agent cannot wake itself; on first sight of a task the newest comment id is adopted without firing, so enabling this does not replay history; the comment id is recorded before the command runs, so a wedged harness is not relaunched every poll.
568 lines
22 KiB
Python
Executable File
568 lines
22 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""
|
|
vk -- Vikunja from the command line, for agent harnesses that can shell out.
|
|
|
|
Every subcommand takes --json for machine-readable output. The default is a
|
|
compact line format that is still trivially parseable (leading `#<id>`), so a
|
|
harness can grep it without asking for JSON.
|
|
|
|
Auth resolution order: flags, then VIKUNJA_TOKEN / VIKUNJA_URL / VIKUNJA_PROFILE,
|
|
then ~/.config/vikunja-agents/config.json. See `vk bootstrap` to create a token.
|
|
"""
|
|
|
|
import argparse
|
|
import getpass
|
|
import json
|
|
import os
|
|
import subprocess
|
|
import sys
|
|
import time
|
|
from datetime import datetime, timedelta, timezone
|
|
|
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
from vkclient import CONFIG_PATH, Vikunja, VikunjaError, load_config, save_config # noqa: E402
|
|
|
|
# What an agent bot needs and nothing more: it works tasks, talks in comments,
|
|
# and reads the project list to know where to file things. It cannot touch
|
|
# users, sharing, or its own account (Vikunja excludes `user_*` and `tokens`
|
|
# from token-grantable routes anyway).
|
|
DEFAULT_PERMISSIONS = {
|
|
"tasks": ["read_all", "read_one", "create", "update"],
|
|
"tasks_comments": ["read_all", "create"],
|
|
"tasks_assignees": ["read_all", "create"],
|
|
"tasks_labels": ["read_all", "create"],
|
|
# `projectusers` resolves a username to an id when assigning; the
|
|
# instance-wide /users search is not token-grantable.
|
|
"projects": ["read_all", "read_one", "projectusers"],
|
|
"labels": ["read_all"],
|
|
}
|
|
|
|
|
|
def out(args, data, lines):
|
|
if args.json:
|
|
json.dump(data, sys.stdout, indent=2, default=str)
|
|
sys.stdout.write("\n")
|
|
else:
|
|
for line in lines:
|
|
print(line)
|
|
|
|
|
|
def task_line(t):
|
|
mark = "x" if t.get("done") else " "
|
|
who = ",".join(a.get("username", "?") for a in (t.get("assignees") or []))
|
|
labels = ",".join(l.get("title", "") for l in (t.get("labels") or []))
|
|
bits = [f"[{mark}] #{t.get('id')}", t.get("title", "")]
|
|
if who:
|
|
bits.append(f"@{who}")
|
|
if labels:
|
|
bits.append(f"+{labels}")
|
|
return " ".join(bits)
|
|
|
|
|
|
# -- commands -------------------------------------------------------------
|
|
|
|
|
|
def cmd_bootstrap(vk, args):
|
|
"""Password login -> mint an API token -> store it. Run once per bot."""
|
|
password = args.password or os.environ.get("VIKUNJA_PASSWORD") or getpass.getpass(
|
|
f"Password for {args.username}: "
|
|
)
|
|
jwt = vk.login(args.username, password, args.totp)
|
|
|
|
wanted = json.loads(args.permissions) if args.permissions else DEFAULT_PERMISSIONS
|
|
granted, missing = vk.token_permissions(jwt, wanted)
|
|
if missing:
|
|
print(f"note: server does not offer {', '.join(missing)} -- skipped", file=sys.stderr)
|
|
if not granted:
|
|
raise VikunjaError("None of the requested permissions exist on this server.")
|
|
|
|
expires = (datetime.now(timezone.utc) + timedelta(days=args.days)).replace(microsecond=0)
|
|
created = vk.create_token(
|
|
jwt, args.title or f"{args.username} ({os.uname().nodename})", granted,
|
|
expires.isoformat().replace("+00:00", "Z"),
|
|
)
|
|
token = created.get("token")
|
|
if not token:
|
|
raise VikunjaError(f"Token created but not returned: {created}")
|
|
|
|
profile = args.profile or args.username
|
|
config = load_config()
|
|
config.setdefault("url", vk.url)
|
|
config.setdefault("profiles", {})[profile] = {
|
|
"url": vk.url,
|
|
"token": token,
|
|
# Recorded because /user is unreachable with an API token; `--mine`
|
|
# and `whoami` read the username from here.
|
|
"username": args.username,
|
|
}
|
|
config.setdefault("default_profile", profile)
|
|
save_config(config)
|
|
|
|
print(f"Token stored as profile '{profile}' in {CONFIG_PATH}")
|
|
print(f"Permissions: {json.dumps(granted)}")
|
|
print(f"Expires: {expires.isoformat()}")
|
|
print(f"Token: {token}")
|
|
|
|
|
|
def cmd_profile(vk, args):
|
|
"""Store a token minted elsewhere, reading it from stdin.
|
|
|
|
Bot users have no password, so `bootstrap` cannot be used for them: their
|
|
owner mints the token instead (`owner_id` on PUT /tokens) and it arrives
|
|
out-of-band. Reading from stdin keeps it out of argv and shell history.
|
|
"""
|
|
token = sys.stdin.read().strip()
|
|
if not token:
|
|
raise VikunjaError("No token on stdin. Try: pbpaste | vk profile <name> --username <user>")
|
|
|
|
url = args.url or vk.url
|
|
probe = Vikunja(url=url, token=token)
|
|
probe.username = args.username
|
|
# Fail before writing: a token that cannot read projects is not worth saving.
|
|
projects = probe.projects()
|
|
|
|
config = load_config()
|
|
config.setdefault("url", url)
|
|
config.setdefault("profiles", {})[args.name] = {
|
|
"url": url,
|
|
"token": token,
|
|
"username": args.username,
|
|
}
|
|
config.setdefault("default_profile", args.name)
|
|
save_config(config)
|
|
out(args, {"profile": args.name, "username": args.username, "projects": len(projects)},
|
|
[f"stored profile '{args.name}' for {args.username} in {CONFIG_PATH}",
|
|
f"token verified: {len(projects)} project(s) visible"])
|
|
|
|
|
|
def cmd_whoami(vk, args):
|
|
who = {"username": vk.whoami(), "url": vk.url}
|
|
out(args, who, [f"{who['username'] or '(unknown)'} @ {who['url']}"])
|
|
|
|
|
|
def cmd_projects(vk, args):
|
|
p = vk.projects()
|
|
out(args, p, [f"#{x['id']} {x.get('title')}" for x in p])
|
|
|
|
|
|
def cmd_tasks(vk, args):
|
|
filters = list(args.filter or [])
|
|
if args.mine:
|
|
filters.append(f"assignees in '{vk.whoami()}'")
|
|
if not args.all:
|
|
filters.append("done = false")
|
|
tasks = vk.tasks(
|
|
project_id=args.project,
|
|
filter=" && ".join(filters) or None,
|
|
sort_by=args.sort,
|
|
limit=args.limit,
|
|
)
|
|
out(args, tasks, [task_line(t) for t in tasks] or ["(no tasks)"])
|
|
|
|
|
|
def cmd_show(vk, args):
|
|
t = vk.task(args.task_id)
|
|
comments = vk.comments(args.task_id) if args.comments else []
|
|
lines = [task_line(t), f"project: {t.get('project_id')}"]
|
|
if t.get("description"):
|
|
lines += ["", t["description"]]
|
|
for c in comments:
|
|
author = (c.get("author") or {}).get("username", "?")
|
|
lines += ["", f"--- {author} @ {c.get('created')}", c.get("comment", "")]
|
|
out(args, {"task": t, "comments": comments}, lines)
|
|
|
|
|
|
def cmd_create(vk, args):
|
|
t = vk.create_task(args.project, args.title, args.description, args.priority, args.due)
|
|
if args.assign:
|
|
user = vk.find_user(args.assign, args.project)
|
|
if not user:
|
|
raise VikunjaError(f"No such user: {args.assign}")
|
|
vk.assign(t["id"], user["id"])
|
|
t = vk.task(t["id"])
|
|
out(args, t, [task_line(t)])
|
|
|
|
|
|
def cmd_comment(vk, args):
|
|
text = args.text if args.text != "-" else sys.stdin.read()
|
|
c = vk.comment(args.task_id, text)
|
|
out(args, c, [f"commented on #{args.task_id}"])
|
|
|
|
|
|
def cmd_update(vk, args):
|
|
fields = {}
|
|
if args.title:
|
|
fields["title"] = args.title
|
|
if args.description:
|
|
fields["description"] = args.description
|
|
if args.priority is not None:
|
|
fields["priority"] = args.priority
|
|
if args.done:
|
|
fields["done"] = True
|
|
if args.undone:
|
|
fields["done"] = False
|
|
if not fields:
|
|
raise VikunjaError("Nothing to update -- pass at least one field.")
|
|
t = vk.update_task(args.task_id, **fields)
|
|
out(args, t, [task_line(t)])
|
|
|
|
|
|
def cmd_done(vk, args):
|
|
t = vk.update_task(args.task_id, done=True)
|
|
out(args, t, [task_line(t)])
|
|
|
|
|
|
def cmd_assign(vk, args):
|
|
task = vk.task(args.task_id)
|
|
user = vk.find_user(args.username, task["project_id"])
|
|
if not user:
|
|
raise VikunjaError(f"No user '{args.username}' with access to project {task['project_id']}")
|
|
vk.assign(args.task_id, user["id"])
|
|
t = vk.task(args.task_id)
|
|
out(args, t, [task_line(t)])
|
|
|
|
|
|
STATE_DIR = os.path.expanduser(
|
|
os.environ.get("VIKUNJA_STATE_DIR", "~/.local/state/vikunja-agents")
|
|
)
|
|
SEEN_TTL = 30 * 86400 # forget handled tasks after a month so state stays small
|
|
|
|
|
|
def _state_path(profile):
|
|
return os.path.join(STATE_DIR, f"watch-{profile or 'default'}.json")
|
|
|
|
|
|
def _load_seen(path):
|
|
"""State for both triggers.
|
|
|
|
Shape: {"tasks": {task_id: ts}, "comments": {task_id: last_comment_id}}.
|
|
Older files were a bare {task_id: ts} map; those are migrated in place so an
|
|
upgrade does not re-fire every task already handled.
|
|
"""
|
|
try:
|
|
with open(path) as f:
|
|
state = json.load(f)
|
|
except (FileNotFoundError, ValueError):
|
|
return {"tasks": {}, "comments": {}}
|
|
|
|
if "tasks" not in state:
|
|
state = {"tasks": state, "comments": {}}
|
|
state.setdefault("comments", {})
|
|
|
|
cutoff = time.time() - SEEN_TTL
|
|
state["tasks"] = {k: v for k, v in state["tasks"].items() if v > cutoff}
|
|
return state
|
|
|
|
|
|
def _save_seen(path, seen):
|
|
os.makedirs(os.path.dirname(path), exist_ok=True)
|
|
tmp = path + ".tmp"
|
|
with open(tmp, "w") as f:
|
|
json.dump(seen, f)
|
|
os.replace(tmp, path) # atomic; a crash mid-write can't corrupt the state
|
|
|
|
|
|
def _substitute(template, task, vk):
|
|
values = {
|
|
"task_id": task.get("id"),
|
|
"title": task.get("title"),
|
|
"project_id": task.get("project_id"),
|
|
"priority": task.get("priority"),
|
|
"url": f"{vk.url}/tasks/{task.get('id')}",
|
|
"labels": ",".join(l.get("title", "") for l in (task.get("labels") or [])),
|
|
}
|
|
out_str = template
|
|
for key, value in values.items():
|
|
out_str = out_str.replace("{" + key + "}", "" if value is None else str(value))
|
|
return out_str
|
|
|
|
|
|
def _run_command(args, command, env_extra, log):
|
|
"""Run the configured command once. Never raises."""
|
|
env = dict(os.environ)
|
|
env.update({k: str(v) for k, v in env_extra.items() if v is not None})
|
|
if args.profile:
|
|
env["VIKUNJA_PROFILE"] = args.profile
|
|
try:
|
|
result = subprocess.run(command, cwd=args.cwd, env=env,
|
|
stdin=subprocess.DEVNULL, timeout=args.timeout)
|
|
log(f"finished rc={result.returncode}")
|
|
except subprocess.TimeoutExpired:
|
|
log(f"timed out after {args.timeout}s")
|
|
except FileNotFoundError:
|
|
log(f"FAILED: no such command: {command[0]}")
|
|
except Exception as e:
|
|
log(f"FAILED: {e!r}")
|
|
|
|
|
|
def _poll_comments(vk, args, state, state_file, me, log):
|
|
"""Fire for comments written by someone other than this account.
|
|
|
|
Scans the account's assigned tasks *including done ones* -- a follow-up
|
|
question almost always lands on a task that was just closed. On first sight
|
|
of a task the newest comment id is recorded without firing, so enabling this
|
|
on an existing board does not replay months of history.
|
|
"""
|
|
filters = [f"assignees in '{me}'"] + list(args.filter or [])
|
|
tasks = vk.tasks(project_id=args.project, filter=" && ".join(filters), sort_by="id")
|
|
|
|
for task in tasks:
|
|
key = str(task["id"])
|
|
try:
|
|
comments = vk.comments(task["id"])
|
|
except VikunjaError as e:
|
|
log(f"comment poll failed for #{task['id']}: {e}")
|
|
continue
|
|
if not comments:
|
|
continue
|
|
|
|
newest = max(int(c["id"]) for c in comments)
|
|
if key not in state["comments"]:
|
|
# First time we have looked at this task: adopt, do not replay.
|
|
state["comments"][key] = newest
|
|
_save_seen(state_file, state)
|
|
continue
|
|
|
|
last = int(state["comments"][key])
|
|
fresh = [c for c in comments
|
|
if int(c["id"]) > last
|
|
and (c.get("author") or {}).get("username") != me]
|
|
if not fresh:
|
|
state["comments"][key] = max(last, newest)
|
|
continue
|
|
|
|
state["comments"][key] = newest
|
|
_save_seen(state_file, state) # at-most-once, same as task triggers
|
|
|
|
for c in fresh:
|
|
author = (c.get("author") or {}).get("username", "?")
|
|
body = c.get("comment", "")
|
|
log(f"comment on #{task['id']} by {author}: {body[:60]!r}")
|
|
if args.dry_run:
|
|
continue
|
|
command = [_substitute(part, task, vk) for part in args.command]
|
|
_run_command(args, command, {
|
|
"VIKUNJA_TASK_ID": task["id"],
|
|
"VIKUNJA_TASK_TITLE": task.get("title") or "",
|
|
"VIKUNJA_TASK_URL": f"{vk.url}/tasks/{task['id']}",
|
|
"VIKUNJA_TRIGGER": "comment",
|
|
"VIKUNJA_COMMENT_ID": c.get("id"),
|
|
"VIKUNJA_COMMENT_AUTHOR": author,
|
|
"VIKUNJA_COMMENT": body,
|
|
}, lambda m: log(f"task #{task['id']} {m}"))
|
|
|
|
|
|
def cmd_watch(vk, args):
|
|
"""Poll this bot's assigned queue and run a command for each new task.
|
|
|
|
Pull rather than push, deliberately. Vikunja's WebSocket only accepts a JWT
|
|
(not an API token) and its subscribable events are notifications and timers
|
|
-- no task events -- so there is nothing to subscribe to. Polling also means
|
|
a harness on another machine needs only outbound HTTPS: no inbound port, no
|
|
SSH key, and nothing to reconfigure when a device moves or sits behind NAT.
|
|
"""
|
|
me = vk.whoami()
|
|
if not me:
|
|
raise VikunjaError("Cannot determine my own username; re-run `vk bootstrap`.")
|
|
|
|
# argparse.REMAINDER hands back the `--` separator itself; drop it so the
|
|
# command is what the user actually typed after it.
|
|
if args.command and args.command[0] == "--":
|
|
args.command = args.command[1:]
|
|
if not args.command:
|
|
raise VikunjaError("No command given. Put it after `--`, e.g. "
|
|
"`vk watch -- nullclaw --task {task_id}`")
|
|
|
|
state_file = args.state or _state_path(args.profile or me)
|
|
state = _load_seen(state_file)
|
|
seen = state["tasks"]
|
|
log = lambda msg: print(f"{datetime.now().isoformat(timespec='seconds')} {msg}", flush=True)
|
|
|
|
triggers = "new tasks" + (" + comments" if args.comments else "")
|
|
log(f"watching {triggers} as {me} every {args.interval}s "
|
|
f"({'dry run' if args.dry_run else ' '.join(args.command)})")
|
|
|
|
backoff = args.interval
|
|
while True:
|
|
try:
|
|
filters = [f"assignees in '{me}'", "done = false"] + list(args.filter or [])
|
|
tasks = vk.tasks(project_id=args.project, filter=" && ".join(filters),
|
|
sort_by="id")
|
|
backoff = args.interval
|
|
except VikunjaError as e:
|
|
# Never exit on a transient API failure -- this runs unattended.
|
|
log(f"poll failed: {e}")
|
|
time.sleep(min(backoff, 600))
|
|
backoff = min(backoff * 2, 600)
|
|
if args.once:
|
|
return
|
|
continue
|
|
|
|
for task in tasks:
|
|
key = str(task["id"])
|
|
if key in seen:
|
|
continue
|
|
|
|
# Record before running, not after: at-most-once. A harness that
|
|
# crashes the machine should not be relaunched on every poll.
|
|
seen[key] = time.time()
|
|
_save_seen(state_file, state)
|
|
|
|
command = [_substitute(part, task, vk) for part in args.command]
|
|
log(f"task #{task['id']} {task.get('title')!r} -> {' '.join(command)}")
|
|
if args.dry_run:
|
|
continue
|
|
|
|
_run_command(args, command, {
|
|
"VIKUNJA_TASK_ID": task["id"],
|
|
"VIKUNJA_TASK_TITLE": task.get("title") or "",
|
|
"VIKUNJA_TASK_URL": f"{vk.url}/tasks/{task['id']}",
|
|
"VIKUNJA_TRIGGER": "task",
|
|
}, lambda m: log(f"task #{task['id']} {m}"))
|
|
|
|
if args.comments:
|
|
try:
|
|
_poll_comments(vk, args, state, state_file, me, log)
|
|
except VikunjaError as e:
|
|
log(f"comment poll failed: {e}")
|
|
|
|
if args.once:
|
|
return
|
|
time.sleep(args.interval)
|
|
|
|
|
|
def build_parser():
|
|
p = argparse.ArgumentParser(prog="vk", description=__doc__,
|
|
formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
p.add_argument("--url")
|
|
p.add_argument("--token")
|
|
p.add_argument("--profile", help="Named profile from the config file.")
|
|
p.add_argument("--json", action="store_true", help="Emit raw JSON.")
|
|
|
|
# The same global flags again, accepted *after* the subcommand -- `vk tasks
|
|
# --json` is what anyone actually types. SUPPRESS keeps an unspecified flag
|
|
# here from clobbering one given before the subcommand.
|
|
common = argparse.ArgumentParser(add_help=False)
|
|
common.add_argument("--url", default=argparse.SUPPRESS)
|
|
common.add_argument("--token", default=argparse.SUPPRESS)
|
|
common.add_argument("--profile", default=argparse.SUPPRESS)
|
|
common.add_argument("--json", action="store_true", default=argparse.SUPPRESS,
|
|
help="Emit raw JSON.")
|
|
|
|
sub = p.add_subparsers(dest="command", required=True)
|
|
_add = sub.add_parser
|
|
|
|
def add_parser(name, **kw):
|
|
kw.setdefault("parents", [common])
|
|
return _add(name, **kw)
|
|
|
|
sub.add_parser = add_parser
|
|
|
|
b = sub.add_parser("bootstrap", help="Mint and store an API token for a bot account.")
|
|
b.add_argument("--username", required=True)
|
|
b.add_argument("--password", help="Prompted for, or $VIKUNJA_PASSWORD, if omitted.")
|
|
b.add_argument("--totp")
|
|
b.add_argument("--title", help="Token title shown in Vikunja's UI.")
|
|
b.add_argument("--days", type=int, default=365)
|
|
b.add_argument("--permissions", help="JSON permission map; defaults to a task-work set.")
|
|
b.set_defaults(func=cmd_bootstrap)
|
|
|
|
pr = sub.add_parser(
|
|
"profile",
|
|
help="Store a token minted elsewhere (reads it from stdin).",
|
|
description="For bot users, whose owner mints the token for them.\n"
|
|
"Example: vk profile bot-nullclaw --username bot-nullclaw < token.txt",
|
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
)
|
|
pr.add_argument("name", help="Profile name to store it under.")
|
|
pr.add_argument("--username", required=True, help="The Vikunja account the token belongs to.")
|
|
pr.set_defaults(func=cmd_profile)
|
|
|
|
sub.add_parser("whoami", help="Show the account this token belongs to.").set_defaults(func=cmd_whoami)
|
|
sub.add_parser("projects", help="List projects.").set_defaults(func=cmd_projects)
|
|
|
|
t = sub.add_parser("tasks", help="List tasks (undone by default).")
|
|
t.add_argument("--project", type=int)
|
|
t.add_argument("--mine", action="store_true", help="Only tasks assigned to me.")
|
|
t.add_argument("--all", action="store_true", help="Include done tasks.")
|
|
t.add_argument("--filter", action="append", help="Raw Vikunja filter, repeatable (ANDed).")
|
|
t.add_argument("--sort", default="due_date")
|
|
t.add_argument("--limit", type=int)
|
|
t.set_defaults(func=cmd_tasks)
|
|
|
|
s = sub.add_parser("show", help="Show one task.")
|
|
s.add_argument("task_id", type=int)
|
|
s.add_argument("--comments", action="store_true")
|
|
s.set_defaults(func=cmd_show)
|
|
|
|
c = sub.add_parser("create", help="Create a task.")
|
|
c.add_argument("--project", type=int, required=True)
|
|
c.add_argument("--title", required=True)
|
|
c.add_argument("--description")
|
|
c.add_argument("--priority", type=int)
|
|
c.add_argument("--due", help="RFC3339 timestamp.")
|
|
c.add_argument("--assign", help="Username to assign it to.")
|
|
c.set_defaults(func=cmd_create)
|
|
|
|
m = sub.add_parser("comment", help="Comment on a task ('-' reads stdin).")
|
|
m.add_argument("task_id", type=int)
|
|
m.add_argument("text")
|
|
m.set_defaults(func=cmd_comment)
|
|
|
|
u = sub.add_parser("update", help="Update task fields.")
|
|
u.add_argument("task_id", type=int)
|
|
u.add_argument("--title")
|
|
u.add_argument("--description")
|
|
u.add_argument("--priority", type=int)
|
|
u.add_argument("--done", action="store_true")
|
|
u.add_argument("--undone", action="store_true")
|
|
u.set_defaults(func=cmd_update)
|
|
|
|
d = sub.add_parser("done", help="Mark a task done.")
|
|
d.add_argument("task_id", type=int)
|
|
d.set_defaults(func=cmd_done)
|
|
|
|
w = sub.add_parser(
|
|
"watch",
|
|
help="Poll my assigned queue and run a command per new task.",
|
|
description="Run on each device that hosts a harness. Outbound HTTPS only.\n"
|
|
"Example: vk watch --interval 30 -- nullclaw --task {task_id}",
|
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
)
|
|
w.add_argument("--interval", type=int, default=60, help="Seconds between polls.")
|
|
w.add_argument("--project", type=int, help="Only watch one project.")
|
|
w.add_argument("--filter", action="append", help="Extra Vikunja filter, repeatable.")
|
|
w.add_argument("--cwd", help="Working directory for the command.")
|
|
w.add_argument("--timeout", type=int, default=3600, help="Per-task timeout in seconds.")
|
|
w.add_argument("--state", help="Override the handled-task state file.")
|
|
w.add_argument("--once", action="store_true", help="One pass then exit (for cron).")
|
|
w.add_argument("--dry-run", action="store_true", help="Log what would run; run nothing.")
|
|
w.add_argument("--comments", action="store_true",
|
|
help="Also fire when someone else comments on one of my tasks "
|
|
"(includes done tasks -- follow-ups usually land there).")
|
|
w.add_argument("command", nargs=argparse.REMAINDER,
|
|
help="Command to run, after `--`. Placeholders: {task_id} {title} "
|
|
"{project_id} {priority} {labels} {url}")
|
|
w.set_defaults(func=cmd_watch)
|
|
|
|
a = sub.add_parser("assign", help="Assign a task to a user.")
|
|
a.add_argument("task_id", type=int)
|
|
a.add_argument("username")
|
|
a.set_defaults(func=cmd_assign)
|
|
return p
|
|
|
|
|
|
def main():
|
|
args = build_parser().parse_args()
|
|
try:
|
|
args.func(Vikunja(args.url, args.token, args.profile), args)
|
|
except VikunjaError as e:
|
|
print(f"error: {e}", file=sys.stderr)
|
|
sys.exit(1)
|
|
except KeyboardInterrupt:
|
|
sys.exit(130)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|