diff --git a/container/entrypoint.py b/container/entrypoint.py index 4c85b1c..cd19ba8 100644 --- a/container/entrypoint.py +++ b/container/entrypoint.py @@ -47,6 +47,8 @@ LITELLM_BASE_URL = os.environ.get("LITELLM_BASE_URL", "https://llm.ctz.fyi") VIKUNJA_BASE_URL = os.environ.get("VIKUNJA_BASE_URL", "https://tasks.ctz.fyi") FORGEJO_BASE_URL = os.environ.get("FORGEJO_BASE_URL", "https://git.ctz.fyi") IN_REVIEW_BUCKET_ID = os.environ.get("IN_REVIEW_BUCKET_ID", "") +VIKUNJA_PROJECT_ID = os.environ.get("VIKUNJA_PROJECT_ID", "") +VIKUNJA_VIEW_ID = os.environ.get("VIKUNJA_VIEW_ID", "") HOME = Path(os.environ.get("HOME", "/home/agent")) CONFIG_DIR = HOME / ".config" / "opencode" @@ -145,6 +147,16 @@ def write_opencode_config(secrets: dict, role: str) -> None: log.warning("No agent file found at %s", agent_md_src) +def configure_git_identity() -> None: + """Set git commit name to Janet, keeping the agent's service account email.""" + try: + subprocess.run(["git", "config", "--global", "user.name", "Janet"], check=True) + subprocess.run(["git", "config", "--global", "user.email", f"svc-ag-{AGENT_ROLE}@ad.ctz.fyi"], check=True) + log.info("Git identity set: Janet ", AGENT_ROLE) + except Exception as e: + log.warning("Could not set git identity: %s", e) + + def build_prompt(task_id: str, task_title: str) -> str: return f"""You are the AutoJanet agent for role: {AGENT_ROLE} @@ -154,10 +166,12 @@ Your current task (Vikunja task #{task_id}): Instructions: 1. Read the task carefully. Fetch full task details from Vikunja if needed. 2. Complete the task using the tools available to you. -3. If you wrote code, open a PR on Forgejo — do not merge it yourself. -4. Leave a comment on the Vikunja task summarising what you did and linking any PR. -5. Do not mark the task as done — the entrypoint will move it to In Review when you finish. -6. Do not ask for confirmation — act autonomously within your role constraints. +3. If you wrote code, commit with a message that includes "refs #{task_id}" so the task + is linked in Forgejo. Example: "feat: add login endpoint refs #{task_id}" +4. Open a PR on Forgejo — do not merge it yourself. +5. Leave a comment on the Vikunja task summarising what you did and linking any PR. +6. Do not mark the task as done — the entrypoint will move it to In Review when you finish. +7. Do not ask for confirmation — act autonomously within your role constraints. """ @@ -169,22 +183,22 @@ def run_opencode(prompt: str) -> int: return result.returncode -def move_task_to_in_review(vikunja_token: str) -> None: - """Move the task bucket to In Review after work is complete.""" - if not IN_REVIEW_BUCKET_ID: - log.warning("IN_REVIEW_BUCKET_ID not set, skipping bucket move") +def move_task_to_bucket(pm_token: str, bucket_id: str) -> None: + """Move the task to a bucket using the kanban view endpoint (requires pm token).""" + if not all([bucket_id, VIKUNJA_PROJECT_ID, VIKUNJA_VIEW_ID]): + log.warning("Missing bucket/project/view IDs, skipping bucket move") return try: resp = httpx.post( - f"{VIKUNJA_BASE_URL}/api/v1/tasks/{TASK_ID}", - headers={"Authorization": f"Bearer {vikunja_token}", "Content-Type": "application/json"}, - json={"bucket_id": int(IN_REVIEW_BUCKET_ID)}, + f"{VIKUNJA_BASE_URL}/api/v1/projects/{VIKUNJA_PROJECT_ID}/views/{VIKUNJA_VIEW_ID}/buckets/{bucket_id}/tasks", + headers={"Authorization": f"Bearer {pm_token}", "Content-Type": "application/json"}, + json={"task_id": int(TASK_ID)}, timeout=10, ) resp.raise_for_status() - log.info("Moved task %s → In Review (bucket %s)", TASK_ID, IN_REVIEW_BUCKET_ID) + log.info("Moved task %s → bucket %s", TASK_ID, bucket_id) except Exception as e: - log.warning("Failed to move task to In Review: %s", e) + log.warning("Failed to move task to bucket %s: %s", bucket_id, e) def main() -> None: @@ -195,21 +209,22 @@ def main() -> None: secrets = fetch_role_secrets(bao_token, AGENT_ROLE) write_opencode_config(secrets, AGENT_ROLE) + configure_git_identity() - # Fetch vikunja token for bucket moves - vikunja_token = "" + # Fetch pm token for bucket moves (admin operation) + pm_token = "" try: - vikunja_token = get_secret(bao_token, f"autojanet/{AGENT_ROLE}/vikunja-token", "token") - log.info("Fetched vikunja-token") + pm_token = get_secret(bao_token, "autojanet/pm/vikunja-token", "token") + log.info("Fetched pm vikunja-token") except Exception as e: - log.warning("Could not fetch vikunja-token: %s", e) + log.warning("Could not fetch pm vikunja-token: %s", e) prompt = build_prompt(TASK_ID, TASK_TITLE) rc = run_opencode(prompt) # Move to In Review regardless of exit code — work was attempted - if vikunja_token: - move_task_to_in_review(vikunja_token) + if pm_token: + move_task_to_bucket(pm_token, IN_REVIEW_BUCKET_ID) if rc != 0: log.error("opencode exited with code %d", rc) diff --git a/dispatcher/dispatcher.py b/dispatcher/dispatcher.py index e888495..e356444 100644 --- a/dispatcher/dispatcher.py +++ b/dispatcher/dispatcher.py @@ -187,6 +187,42 @@ def unclaim_task(vikunja_token: str, task_id: int, todo_id: int) -> None: log.warning("Failed to unclaim task %d: %s", task_id, e) +def sweep_done_tasks( + vikunja_token: str, + project_id: int, + view_id: int, + done_bucket_id: int, +) -> None: + """Find tasks marked done=True that aren't in the Done bucket and move them.""" + tasks = [] + page = 1 + while True: + batch = vikunja_get(vikunja_token, f"projects/{project_id}/tasks", page=page, per_page=50) + if not batch: + break + tasks.extend(batch) + if len(batch) < 50: + break + page += 1 + + moved = 0 + for task in tasks: + if task.get("done"): + task_id = task["id"] + try: + vikunja_post( + vikunja_token, + f"projects/{project_id}/views/{view_id}/buckets/{done_bucket_id}/tasks", + {"task_id": task_id}, + ) + log.info("Swept done task %d → Done bucket", task_id) + moved += 1 + except Exception as e: + log.warning("Failed to sweep task %d to Done: %s", task_id, e) + + log.info("Done sweep: moved %d tasks", moved) + + # ── Kubernetes ──────────────────────────────────────────────────────────────── def load_k8s_config() -> None: @@ -217,6 +253,8 @@ def spawn_agent_job( task_id: int, task_title: str, in_review_bucket_id: int, + project_id: int, + view_id: int, ) -> None: name = job_name(role, task_id) if job_already_exists(batch_v1, name): @@ -264,6 +302,8 @@ def spawn_agent_job( k8s_client.V1EnvVar(name="LITELLM_BASE_URL", value="https://llm.ctz.fyi"), k8s_client.V1EnvVar(name="FORGEJO_BASE_URL", value="https://git.ctz.fyi"), k8s_client.V1EnvVar(name="IN_REVIEW_BUCKET_ID", value=str(in_review_bucket_id)), + k8s_client.V1EnvVar(name="VIKUNJA_PROJECT_ID", value=str(project_id)), + k8s_client.V1EnvVar(name="VIKUNJA_VIEW_ID", value=str(view_id)), k8s_client.V1EnvVar( name="OPENBAO_ROLE_ID", value_from=k8s_client.V1EnvVarSource( @@ -323,8 +363,9 @@ def main() -> None: todo_id = buckets.get(BUCKET_TODO) in_progress_id = buckets.get(BUCKET_IN_PROGRESS) in_review_id = buckets.get(BUCKET_IN_REVIEW) + done_id = buckets.get(BUCKET_DONE) - if not all([todo_id, in_progress_id, in_review_id]): + if not all([todo_id, in_progress_id, in_review_id, done_id]): log.error("Could not find all standard buckets. Found: %s", list(buckets.keys())) sys.exit(1) @@ -332,6 +373,9 @@ def main() -> None: load_k8s_config() batch_v1 = k8s_client.BatchV1Api() + # Sweep: move any done=True tasks into the Done bucket + sweep_done_tasks(vikunja_token, project_id, view_id, done_id) + # Scan Todo bucket for claimable tasks tasks = list_todo_tasks(vikunja_token, project_id, todo_id) log.info("Found %d candidate tasks in Todo bucket", len(tasks)) @@ -351,7 +395,7 @@ def main() -> None: continue try: - spawn_agent_job(batch_v1, role, task_id, title, in_review_id) + spawn_agent_job(batch_v1, role, task_id, title, in_review_id, project_id, view_id) claimed += 1 except Exception as e: log.error("Failed to spawn job for task %d: %s", task_id, e)