diff --git a/CHANGELOG.md b/CHANGELOG.md index f858fcf..b8afa79 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,23 @@ All notable changes to the AIM CLI and Control Hub project will be documented in The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.5.0] - 2026-06-11 + +Roadmap Phase 5 (final) — GitHub sync. Projects AIM tasks onto GitHub Issues +and Projects so a team gets a familiar board; AIM stays the agent's working +layer. Zero-dependency: shells out to the `gh` CLI (no OAuth libraries). + +### Added +- **`aim github push [id] [--all] [--project N]`** — create/update a GitHub + issue per task (idempotent via a stored `**GitHub Issue:**` number). Maps + done → closed, otherwise open; optionally adds issues to a Project (v2). +- **`aim github status`** — show each task's linked issue. +- **`aim github create-project `** — create a GitHub Project (v2) for the + repo owner. +- Tasks carry a `githubIssue` field (a `**GitHub Issue:**` line). + +--- + ## [1.4.0] - 2026-06-11 Roadmap Phase 4 — spec-driven development. Makes the existing `spec`/`plan` diff --git a/README.md b/README.md index 33b0a21..75377c4 100644 --- a/README.md +++ b/README.md @@ -119,6 +119,23 @@ every tool, so the next session (in any client) does not repeat the mistake. --- +## 🐙 GitHub sync (`aim github`) + +Project AIM tasks onto GitHub Issues + Projects so your team gets a familiar +board, while AIM stays the agent's working layer. Zero-dependency — it shells out +to the `gh` CLI (requires `gh auth login`). + +```bash +aim github create-project "AIM Roadmap" # create a Project (v2) +aim github push --all --project 3 # create/update an issue per task, add to the project +aim github status # show task <-> issue linkage +``` + +Issues are idempotent (the issue number is stored back in each task); a `done` +task closes its issue. + +--- + ## 🩺 Context Health (`aim doctor`) AIM's wedge is fighting **context drift** — stale rules and decisions that make diff --git a/aim/__init__.py b/aim/__init__.py index 8ceb83f..5764e81 100644 --- a/aim/__init__.py +++ b/aim/__init__.py @@ -1,2 +1,2 @@ # AIM: AI Memory & Specialist Agent Suite package -__version__ = "1.4.0" +__version__ = "1.5.0" diff --git a/aim/aim_cli.py b/aim/aim_cli.py index f434906..b58901a 100644 --- a/aim/aim_cli.py +++ b/aim/aim_cli.py @@ -321,6 +321,7 @@ def parse_task_file(path): "id": "", "title": "", "status": "todo", "priority": "medium", "assignee": "unassigned", "timeSpent": 0, "parent": None, "dependsOn": [], "labels": [], "spec": "", "plan": "", + "githubIssue": None, "description": "", "ac": [], "notes": "", "extraSections": [] } @@ -362,6 +363,9 @@ def parse_task_file(path): elif line.startswith("**Plan:**"): val = line.replace("**Plan:**", "").strip() meta["plan"] = val if val and val != "none" else "" + elif line.startswith("**GitHub Issue:**"): + val = line.replace("**GitHub Issue:**", "").replace("#", "").strip() + meta["githubIssue"] = int(val) if val.isdigit() else None for header, body in sections: key = header.strip().lower() @@ -404,6 +408,7 @@ def render_task_content(meta): labels_str = f"**Labels:** {', '.join(meta['labels'])}" if meta.get("labels") else "**Labels:** none" spec_str = f"**Spec:** {meta['spec']}" if meta.get("spec") else "**Spec:** none" plan_str = f"**Plan:** {meta['plan']}" if meta.get("plan") else "**Plan:** none" + gh_str = f"**GitHub Issue:** #{meta['githubIssue']}" if meta.get("githubIssue") else "**GitHub Issue:** none" # Preserve user notes; only the "Last updated" stamp line is managed. notes = meta.get("notes", "") @@ -429,6 +434,7 @@ def render_task_content(meta): {labels_str} {spec_str} {plan_str} +{gh_str} ## Description {meta['description']} @@ -923,6 +929,54 @@ def cmd_validate(args): print(f"[-] Found {total} issue(s).") sys.exit(1) +# ========================================== +# 7.6. GITHUB SYNC COMMANDS +# ========================================== +def cmd_github(args): + ensure_directories() + core = _core() + if not core.github_available(): + print("[-] GitHub CLI (gh) not found or not authenticated.") + print(" Install gh (https://cli.github.com) and run `gh auth login`.") + sys.exit(1) + + if args.github_action == "create-project": + try: + data = core.create_project(args.title) + except RuntimeError as e: + print(f"[-] {e}") + sys.exit(1) + print(f"[+] Created project #{data.get('number')}: {data.get('url')}") + print(f"[*] Push tasks into it with: aim github push --all --project {data.get('number')}") + + elif args.github_action == "push": + try: + if args.id: + results = [core.push_task(args.id, project=args.project)] + else: + results = core.push_all(project=args.project) + except RuntimeError as e: + print(f"[-] {e}") + sys.exit(1) + if not results: + print("[*] No tasks to push.") + return + for r in results: + print(f"[+] TASK-{r['taskId']} -> issue #{r['issue']} ({r['action']}, status={r['status']})") + if args.project: + print(f"[+] Linked issues to project #{args.project}.") + + elif args.github_action == "status": + rows = core.github_status() + if not rows: + print("[*] No tasks found.") + return + print(f"{'Task':<6} {'Issue':<8} {'Status':<12} {'Title'}") + print("-" * 70) + for r in rows: + issue = f"#{r['issue']}" if r["issue"] else "-" + print(f"{r['taskId']:<6} {issue:<8} {r['status']:<12} {r['title'][:40]}") + # ========================================== # 7.5. SPEC COMMANDS (spec-driven development) # ========================================== @@ -2093,6 +2147,17 @@ def main(): import_spec_p.add_argument("--name", help="Override the spec name (default: directory name)") spec_sub.add_parser("coverage", help="Show spec-link coverage across tasks") + # github + github_parser = subparsers.add_parser("github", help="Sync tasks to GitHub Issues / Projects (via the gh CLI)") + github_sub = github_parser.add_subparsers(dest="github_action", required=True) + gh_push = github_sub.add_parser("push", help="Create/update a GitHub issue per task (idempotent)") + gh_push.add_argument("id", type=int, nargs="?", help="Task ID (omit, or use --all, to push every task)") + gh_push.add_argument("--all", action="store_true", help="Push every task (default when no ID is given)") + gh_push.add_argument("--project", type=int, help="Also add issues to this Project (v2) number") + github_sub.add_parser("status", help="Show task <-> GitHub issue linkage") + gh_proj = github_sub.add_parser("create-project", help="Create a GitHub Project (v2) for this repo") + gh_proj.add_argument("title", help="Project title") + # ingest ingest_parser = subparsers.add_parser("ingest", help="Import existing hand-written rule files (CLAUDE.md, .cursorrules, ...) into AIM") ingest_parser.add_argument("--dry-run", action="store_true", help="Preview what would be imported without writing") @@ -2193,6 +2258,8 @@ def main(): cmd_validate(args) elif args.command == "spec": cmd_spec(args) + elif args.command == "github": + cmd_github(args) elif args.command == "ingest": cmd_ingest(args) elif args.command == "doctor": diff --git a/aim/core.py b/aim/core.py index 2aa0485..8d136bd 100644 --- a/aim/core.py +++ b/aim/core.py @@ -11,6 +11,7 @@ """ import datetime import getpass +import json import os import re import subprocess @@ -612,6 +613,146 @@ def collect_status(): } +# ========================================== +# GitHub sync — `aim github` (Issues / Projects via the gh CLI) +# ========================================== +def _gh(args, input_text=None): + """Run a gh CLI command in the workspace root. Returns stdout on success; + raises RuntimeError (with stderr) on failure or if gh is unavailable. + Isolated as a seam so the sync logic is unit-testable without real gh.""" + cli = _cli() + try: + result = subprocess.run( + ["gh", *args], cwd=cli.ROOT_DIR, capture_output=True, + text=True, input=input_text, timeout=60, + ) + except (FileNotFoundError, OSError) as e: + raise RuntimeError("GitHub CLI (gh) is not installed or not on PATH.") from e + except subprocess.SubprocessError as e: + raise RuntimeError(f"gh command failed: {e}") from e + if result.returncode != 0: + raise RuntimeError((result.stderr or result.stdout or "gh command failed").strip()) + return result.stdout + + +def github_available(): + """True if gh is installed and authenticated.""" + try: + _gh(["auth", "status"]) + return True + except RuntimeError: + return False + + +def repo_slug(): + """owner/repo for the current workspace, via gh.""" + return _gh(["repo", "view", "--json", "nameWithOwner", "-q", ".nameWithOwner"]).strip() + + +_ISSUE_NUM_RE = re.compile(r"/issues/(\d+)\s*$") + + +def parse_issue_number(url): + m = _ISSUE_NUM_RE.search((url or "").strip()) + return int(m.group(1)) if m else None + + +def task_to_issue_body(task): + """Render an AIM task as a GitHub issue body, ending with a hidden marker + that ties the issue back to the AIM task id.""" + lines = [] + if task.get("description"): + lines.append(task["description"].strip()) + lines.append("") + if task.get("ac"): + lines.append("### Acceptance Criteria") + for ac in task["ac"]: + box = "x" if ac.get("checked") else " " + lines.append(f"- [{box}] {ac['text']}") + lines.append("") + if task.get("dependsOn"): + lines.append("**Depends on:** " + ", ".join(f"AIM task-{d}" for d in task["dependsOn"])) + lines.append("") + lines.append("---") + lines.append(f"_Synced from AIM · task-{task['id']} · do not edit this marker_") + lines.append(f"<!-- aim-task:{task['id']} -->") + return "\n".join(lines).strip() + + +def _issue_title(task): + return f"[AIM-{task['id']}] {task['title']}" + + +def push_task(task_id, project=None): + """Create or update a GitHub issue for one AIM task (idempotent via the + stored githubIssue number). Maps done->closed, else open. Optionally adds + the issue to a Project v2. Returns {taskId, issue, action, status}.""" + cli = _cli() + task = get_task(task_id) + if task is None: + raise RuntimeError(f"Task {task_id} not found.") + + title = _issue_title(task) + body = task_to_issue_body(task) + issue = task.get("githubIssue") + status = task.get("status", "todo").lower() + + if issue: + _gh(["issue", "edit", str(issue), "--title", title, "--body", body]) + action = "updated" + else: + out = _gh(["issue", "create", "--title", title, "--body", body]) + url = out.strip().splitlines()[-1] if out.strip() else "" + issue = parse_issue_number(url) + if issue is None: + raise RuntimeError(f"Could not parse issue number from gh output: {out!r}") + task["githubIssue"] = issue + cli.write_task_file(task) + action = "created" + + # Reflect AIM status on the issue: done -> closed, otherwise open. + try: + if status == "done": + _gh(["issue", "close", str(issue)]) + else: + _gh(["issue", "reopen", str(issue)]) + except RuntimeError: + pass # already in that state, or not permitted — non-fatal + + if project: + try: + slug = repo_slug() + owner = slug.split("/")[0] + issue_url = f"https://github.com/{slug}/issues/{issue}" + _gh(["project", "item-add", str(project), "--owner", owner, "--url", issue_url]) + except RuntimeError: + pass # project linking is best-effort + + return {"taskId": task["id"], "issue": issue, "action": action, "status": status} + + +def push_all(project=None): + """Push every task to GitHub. Returns a list of per-task results.""" + tasks, _errors = load_tasks() + return [push_task(t["id"], project=project) for t in tasks] + + +def github_status(): + """Per-task linkage view: {taskId, title, status, issue}.""" + tasks, _errors = load_tasks() + return [{"taskId": t["id"], "title": t["title"], + "status": t.get("status", "todo"), "issue": t.get("githubIssue")} + for t in tasks] + + +def create_project(title): + """Create a GitHub Project (v2) owned by the repo owner. Returns the parsed + JSON (number, url, ...).""" + owner = repo_slug().split("/")[0] + out = _gh(["project", "create", "--owner", owner, "--title", title, "--format", "json"]) + return json.loads(out) + + # ========================================== # Spec-driven development — `aim spec` # ========================================== diff --git a/docs/cli-reference.md b/docs/cli-reference.md index e260a16..61a4676 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -51,6 +51,7 @@ This document provides a comprehensive command-line reference for **AIM** (AI Me 9. [AI Assistant Integration](#9-ai-assistant-integration) * [`aim mcp`](#aim-mcp) * [`aim browser`](#aim-browser) + * [`aim github`](#aim-github) 10. [Demo Workspace](#10-demo-workspace) * [`aim demo`](#aim-demo) @@ -496,6 +497,34 @@ Launch the AIM Control Hub web dashboard (Kanban board, docs library, memory, ti aim browser -p 7000 --no-open ``` +### `aim github` +Sync tasks to GitHub Issues / Projects via the `gh` CLI (no extra dependencies; +requires `gh` installed and `gh auth login`). GitHub becomes the team's display +layer while AIM stays the agent's working layer. + +#### `aim github push` +Create or update a GitHub issue per task — idempotent via the `**GitHub Issue:**` +number stored back in each task. Maps a `done` task to a closed issue, otherwise +open. +* **Arguments:** `id` (optional) — a single task; omit to push all tasks. +* **Options:** `--project N` — also add the issues to Project (v2) number `N`. +* **Example:** + ```bash + aim github push --all --project 3 + ``` + +#### `aim github status` +Show each task and its linked GitHub issue (or `-` if not pushed). +* **Usage:** `aim github status` + +#### `aim github create-project` +Create a GitHub Project (v2) owned by the repo owner. +* **Arguments:** `title` (Required). +* **Example:** + ```bash + aim github create-project "AIM Roadmap" + ``` + --- ## 10. Demo Workspace diff --git a/tests/test_github.py b/tests/test_github.py new file mode 100644 index 0000000..7b48ad1 --- /dev/null +++ b/tests/test_github.py @@ -0,0 +1,94 @@ +from aim import aim_cli, core + + +# ---------- task model: githubIssue round-trip ---------- + +def test_github_issue_roundtrip(workspace): + t = core.create_task("syncable") + full = core.get_task(t["id"]) + full["githubIssue"] = 42 + aim_cli.write_task_file(full) + reread = core.get_task(t["id"]) + assert reread["githubIssue"] == 42 + with open(f"{aim_cli.TASKS_DIR}/task-{t['id']}.md", encoding="utf-8") as f: + assert "**GitHub Issue:** #42" in f.read() + + +# ---------- pure helpers ---------- + +def test_parse_issue_number(): + assert core.parse_issue_number("https://github.com/o/r/issues/57\n") == 57 + assert core.parse_issue_number("nope") is None + + +def test_task_to_issue_body(): + task = {"id": 3, "title": "X", "description": "Do the thing", + "ac": [{"text": "works", "checked": True}, {"text": "tested", "checked": False}], + "dependsOn": [1, 2]} + body = core.task_to_issue_body(task) + assert "Do the thing" in body + assert "- [x] works" in body + assert "- [ ] tested" in body + assert "AIM task-1" in body + assert "<!-- aim-task:3 -->" in body + + +# ---------- push_task with a mocked gh seam ---------- + +def test_push_task_creates_and_stores_issue(workspace, monkeypatch): + core.create_task("first") # id 1 + calls = [] + + def fake_gh(args, input_text=None): + calls.append(args) + if args[:2] == ["issue", "create"]: + return "https://github.com/phuonghx/aim-cli/issues/101\n" + return "" + + monkeypatch.setattr(core, "_gh", fake_gh) + result = core.push_task(1) + assert result["action"] == "created" + assert result["issue"] == 101 + # stored back into the task file + assert core.get_task(1)["githubIssue"] == 101 + # opened (status not done -> reopen attempted) + assert any(a[:2] == ["issue", "reopen"] for a in calls) + + +def test_push_task_updates_when_linked_and_closes_done(workspace, monkeypatch): + core.create_task("done task", status="done") # id 1 + full = core.get_task(1) + full["githubIssue"] = 55 + aim_cli.write_task_file(full) + calls = [] + monkeypatch.setattr(core, "_gh", lambda args, input_text=None: calls.append(args) or "") + + result = core.push_task(1) + assert result["action"] == "updated" + assert result["issue"] == 55 + assert any(a == ["issue", "edit", "55", "--title", core._issue_title(core.get_task(1)), + "--body", core.task_to_issue_body(core.get_task(1))] for a in calls) + assert any(a[:3] == ["issue", "close", "55"] for a in calls) # done -> closed + + +def test_github_status(workspace, monkeypatch): + core.create_task("a") + b = core.create_task("b") + full = core.get_task(b["id"]) + full["githubIssue"] = 7 + aim_cli.write_task_file(full) + rows = {r["taskId"]: r for r in core.github_status()} + assert rows[1]["issue"] is None + assert rows[2]["issue"] == 7 + + +def test_create_project_parses_json(workspace, monkeypatch): + def fake_gh(args, input_text=None): + if args[:2] == ["repo", "view"]: + return "phuonghx/aim-cli\n" + if args[:2] == ["project", "create"]: + return '{"number": 9, "url": "https://github.com/users/phuonghx/projects/9"}' + return "" + monkeypatch.setattr(core, "_gh", fake_gh) + data = core.create_project("AIM Roadmap") + assert data["number"] == 9