watch: fire on new comments, not just new tasks
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.
This commit is contained in:
@@ -109,6 +109,26 @@ Available in the command: `{task_id}` `{title}` `{project_id}` `{priority}`
|
||||
`{labels}` `{url}`. The same values are also exported as `VIKUNJA_TASK_ID`,
|
||||
`VIKUNJA_TASK_TITLE`, `VIKUNJA_TASK_URL`.
|
||||
|
||||
### Replying to comments
|
||||
|
||||
Add `--comments` and the watcher also fires when **someone else** comments on one
|
||||
of your tasks:
|
||||
|
||||
```sh
|
||||
vk watch --comments --interval 30 -- nullclaw --task {task_id}
|
||||
```
|
||||
|
||||
- Scans your assigned tasks **including done ones** — a follow-up question almost
|
||||
always lands on a task that was just closed.
|
||||
- Your own comments never fire it, so an agent replying cannot wake itself.
|
||||
- On first sight of a task the newest comment id is adopted **without firing**, so
|
||||
turning this on does not replay the whole history. The flip side: a comment
|
||||
already sitting there when you enable it will not fire either. To pick it up,
|
||||
delete that task's entry from the `comments` map in the state file.
|
||||
- The command gets `VIKUNJA_TRIGGER=comment` plus `VIKUNJA_COMMENT`,
|
||||
`VIKUNJA_COMMENT_AUTHOR`, `VIKUNJA_COMMENT_ID` alongside the usual task vars.
|
||||
Task triggers set `VIKUNJA_TRIGGER=task`.
|
||||
|
||||
### Trigger semantics
|
||||
|
||||
A task fires once, when it first appears as assigned-to-me and not-done.
|
||||
|
||||
@@ -233,13 +233,25 @@ def _state_path(profile):
|
||||
|
||||
|
||||
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:
|
||||
seen = json.load(f)
|
||||
state = json.load(f)
|
||||
except (FileNotFoundError, ValueError):
|
||||
return {}
|
||||
return {"tasks": {}, "comments": {}}
|
||||
|
||||
if "tasks" not in state:
|
||||
state = {"tasks": state, "comments": {}}
|
||||
state.setdefault("comments", {})
|
||||
|
||||
cutoff = time.time() - SEEN_TTL
|
||||
return {k: v for k, v in seen.items() if v > cutoff}
|
||||
state["tasks"] = {k: v for k, v in state["tasks"].items() if v > cutoff}
|
||||
return state
|
||||
|
||||
|
||||
def _save_seen(path, seen):
|
||||
@@ -265,6 +277,81 @@ def _substitute(template, task, vk):
|
||||
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.
|
||||
|
||||
@@ -287,10 +374,12 @@ def cmd_watch(vk, args):
|
||||
"`vk watch -- nullclaw --task {task_id}`")
|
||||
|
||||
state_file = args.state or _state_path(args.profile or me)
|
||||
seen = _load_seen(state_file)
|
||||
state = _load_seen(state_file)
|
||||
seen = state["tasks"]
|
||||
log = lambda msg: print(f"{datetime.now().isoformat(timespec='seconds')} {msg}", flush=True)
|
||||
|
||||
log(f"watching as {me} every {args.interval}s "
|
||||
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
|
||||
@@ -317,29 +406,25 @@ def cmd_watch(vk, args):
|
||||
# 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, seen)
|
||||
_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
|
||||
|
||||
env = dict(os.environ)
|
||||
env["VIKUNJA_TASK_ID"] = str(task["id"])
|
||||
env["VIKUNJA_TASK_TITLE"] = task.get("title") or ""
|
||||
env["VIKUNJA_TASK_URL"] = f"{vk.url}/tasks/{task['id']}"
|
||||
if args.profile:
|
||||
env["VIKUNJA_PROFILE"] = args.profile
|
||||
_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:
|
||||
result = subprocess.run(command, cwd=args.cwd, env=env,
|
||||
stdin=subprocess.DEVNULL, timeout=args.timeout)
|
||||
log(f"task #{task['id']} finished rc={result.returncode}")
|
||||
except subprocess.TimeoutExpired:
|
||||
log(f"task #{task['id']} timed out after {args.timeout}s")
|
||||
except FileNotFoundError:
|
||||
log(f"task #{task['id']} FAILED: no such command: {command[0]}")
|
||||
except Exception as e:
|
||||
log(f"task #{task['id']} FAILED: {e!r}")
|
||||
_poll_comments(vk, args, state, state_file, me, log)
|
||||
except VikunjaError as e:
|
||||
log(f"comment poll failed: {e}")
|
||||
|
||||
if args.once:
|
||||
return
|
||||
@@ -452,6 +537,9 @@ def build_parser():
|
||||
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}")
|
||||
|
||||
Reference in New Issue
Block a user