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`,
|
`{labels}` `{url}`. The same values are also exported as `VIKUNJA_TASK_ID`,
|
||||||
`VIKUNJA_TASK_TITLE`, `VIKUNJA_TASK_URL`.
|
`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
|
### Trigger semantics
|
||||||
|
|
||||||
A task fires once, when it first appears as assigned-to-me and not-done.
|
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):
|
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:
|
try:
|
||||||
with open(path) as f:
|
with open(path) as f:
|
||||||
seen = json.load(f)
|
state = json.load(f)
|
||||||
except (FileNotFoundError, ValueError):
|
except (FileNotFoundError, ValueError):
|
||||||
return {}
|
return {"tasks": {}, "comments": {}}
|
||||||
|
|
||||||
|
if "tasks" not in state:
|
||||||
|
state = {"tasks": state, "comments": {}}
|
||||||
|
state.setdefault("comments", {})
|
||||||
|
|
||||||
cutoff = time.time() - SEEN_TTL
|
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):
|
def _save_seen(path, seen):
|
||||||
@@ -265,6 +277,81 @@ def _substitute(template, task, vk):
|
|||||||
return out_str
|
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):
|
def cmd_watch(vk, args):
|
||||||
"""Poll this bot's assigned queue and run a command for each new task.
|
"""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}`")
|
"`vk watch -- nullclaw --task {task_id}`")
|
||||||
|
|
||||||
state_file = args.state or _state_path(args.profile or me)
|
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 = 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)})")
|
f"({'dry run' if args.dry_run else ' '.join(args.command)})")
|
||||||
|
|
||||||
backoff = args.interval
|
backoff = args.interval
|
||||||
@@ -317,29 +406,25 @@ def cmd_watch(vk, args):
|
|||||||
# Record before running, not after: at-most-once. A harness that
|
# Record before running, not after: at-most-once. A harness that
|
||||||
# crashes the machine should not be relaunched on every poll.
|
# crashes the machine should not be relaunched on every poll.
|
||||||
seen[key] = time.time()
|
seen[key] = time.time()
|
||||||
_save_seen(state_file, seen)
|
_save_seen(state_file, state)
|
||||||
|
|
||||||
command = [_substitute(part, task, vk) for part in args.command]
|
command = [_substitute(part, task, vk) for part in args.command]
|
||||||
log(f"task #{task['id']} {task.get('title')!r} -> {' '.join(command)}")
|
log(f"task #{task['id']} {task.get('title')!r} -> {' '.join(command)}")
|
||||||
if args.dry_run:
|
if args.dry_run:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
env = dict(os.environ)
|
_run_command(args, command, {
|
||||||
env["VIKUNJA_TASK_ID"] = str(task["id"])
|
"VIKUNJA_TASK_ID": task["id"],
|
||||||
env["VIKUNJA_TASK_TITLE"] = task.get("title") or ""
|
"VIKUNJA_TASK_TITLE": task.get("title") or "",
|
||||||
env["VIKUNJA_TASK_URL"] = f"{vk.url}/tasks/{task['id']}"
|
"VIKUNJA_TASK_URL": f"{vk.url}/tasks/{task['id']}",
|
||||||
if args.profile:
|
"VIKUNJA_TRIGGER": "task",
|
||||||
env["VIKUNJA_PROFILE"] = args.profile
|
}, lambda m: log(f"task #{task['id']} {m}"))
|
||||||
|
|
||||||
|
if args.comments:
|
||||||
try:
|
try:
|
||||||
result = subprocess.run(command, cwd=args.cwd, env=env,
|
_poll_comments(vk, args, state, state_file, me, log)
|
||||||
stdin=subprocess.DEVNULL, timeout=args.timeout)
|
except VikunjaError as e:
|
||||||
log(f"task #{task['id']} finished rc={result.returncode}")
|
log(f"comment poll failed: {e}")
|
||||||
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}")
|
|
||||||
|
|
||||||
if args.once:
|
if args.once:
|
||||||
return
|
return
|
||||||
@@ -452,6 +537,9 @@ def build_parser():
|
|||||||
w.add_argument("--state", help="Override the handled-task state file.")
|
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("--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("--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,
|
w.add_argument("command", nargs=argparse.REMAINDER,
|
||||||
help="Command to run, after `--`. Placeholders: {task_id} {title} "
|
help="Command to run, after `--`. Placeholders: {task_id} {title} "
|
||||||
"{project_id} {priority} {labels} {url}")
|
"{project_id} {priority} {labels} {url}")
|
||||||
|
|||||||
Reference in New Issue
Block a user