diff --git a/CHANGELOG.md b/CHANGELOG.md index 34a4378..1ee27f2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,64 @@ All notable changes to Code Context Control (C3) are documented here. The format is loosely based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [2.158.0] - 2026-10-20 + +### Added — `format="json"` for c3_jira and c3_bitbucket + +Programs that read these tools, such as the Work Agents Work page, had to +parse text replies capped at 2400 tokens, which cut long descriptions and +comment threads. With `format="json"` the read actions return the normalized +data instead: `{"ok": true, "action": ..., "data": {...}}`, under a +400,000-character cap. A reply over the cap shrinks its largest fields until +it fits, so the output is always valid JSON, and `"truncated"` names the +fields that were cut. Comments and PR activities keep their newest entries. +Errors come back as `{"ok": false, "action", "error", "status"?, "code"?}`. +Writes and actions with no structured form return their text reply in +`data.text`. Text stays the default, so agents see no change. + +- `cli/tools/_json_reply.py`: the shared envelope and trimming. +- `cli/mcp_server.py`: the auto-snapshot notice is no longer appended to a + JSON reply, which would have made it invalid. +- `c3_bitbucket` JSON covers whoami, list_projects, list_repos, get_repo, + readme, get_file, list_prs, get_pr, get_pr_diff, get_pr_activities + (oldest first, replies nested), get_pr_commits, pr_issues, list_pr_tasks, + list_branches, list_commits and build_status. Commit DTOs carry no author + email. + +### Added — Jira: rendered HTML, issue types, development panel + +- `get_issue` requests `expand=renderedFields`. The DTO gains + `description_html` and a `body_html` on each comment (the server's own + HTML, unsanitized: a caller that displays it must sanitize it), plus `id`, + `status_category`, `priority`, `issue_type_subtask`, `issue_type_icon`, + `parent_type`, `subtasks`, `components`, `fix_versions`, `due`, issue + links and attachments with their URLs. +- `search` requests issue type and priority, so its rows carry them along + with the status category. `validate="warn"` sends `validateQuery=warn` on + Data Center, so `key in (...)` naming a missing issue returns the others + instead of failing. Cloud's `/search/jql` has no such parameter and + ignores it. +- New read action `dev_status(issue)`: pull requests, branches and commits + from the issue's development panel (`/rest/dev-status/1.0/issue/detail`). + That API is internal and some instances block it; HTTP 401/403/404/405 on + the first call returns `{"available": false, "reason": ...}`. + +### Added — Bitbucket: README, files, linked Jira issues, richer repos and branches + +- `get_repo` shows the description, default branch, browse URL and clone + links. +- New `readme(project, repo, at)`: picks the first `README*` in the repo + root (Markdown first, then `.rst`, `.txt`, no extension) and returns its + raw text. New + `get_file(project, repo, path, at)` uses the same code. Both refuse binary + files and cap text at 256 KB. +- New `pr_issues(pr_id)`: the Jira issues Bitbucket links to a pull request, + through `/rest/jira/1.0/` (needs the Bitbucket–Jira integration). +- `list_branches` sends `details=true`. When the server provides branch + metadata, rows gain ahead/behind counts, the last commit, Jira keys, the + outgoing pull request and build counts. It also takes `filter_text` and + `order` (`alphabetical` or `modification`). + ## [2.157.2] - 2026-10-16 ### Fixed — the shell scans stop refusing commands that name no protected file diff --git a/cli/c3.py b/cli/c3.py index e17088b..ce74d55 100644 --- a/cli/c3.py +++ b/cli/c3.py @@ -91,7 +91,7 @@ # Config CONFIG_DIR = ".c3" CONFIG_FILE = ".c3/config.json" -__version__ = "2.157.2" +__version__ = "2.158.0" # The PreToolUse matcher for native subagent calls (installer and hub migration). AGENT_MATCHER = "Agent|Task" diff --git a/cli/mcp_server.py b/cli/mcp_server.py index 609ffa7..0922868 100644 --- a/cli/mcp_server.py +++ b/cli/mcp_server.py @@ -768,10 +768,13 @@ def _finalize_response(ctx: Context, tool_name: str, args: dict, reasoning=f"Budget at {snap_pct}% — auto-snapshot before potential /clear", description="", summary="", event_type="auto", svc=svc, finalize=lambda *a, **kw: "") - response += ( - f"\n\n[c3:auto_snapshot] Budget {snap_pct}%. Snapshot saved. " - f"Tell user to /clear, then restore." - ) + # A format="json" reply must stay parseable: no text appended. + from cli.tools._json_reply import is_json_reply + if not is_json_reply(response): + response += ( + f"\n\n[c3:auto_snapshot] Budget {snap_pct}%. Snapshot saved. " + f"Tell user to /clear, then restore." + ) svc.activity_log.log("auto_snapshot", {"budget_pct": snap_pct}) except Exception: pass @@ -1289,11 +1292,20 @@ async def c3_bitbucket( webhook_id: int = 0, comment_id: int = 0, limit: int = 50, + path: str = "", + at: str = "", + filter_text: str = "", + order: str = "", + format: str = "", ctx: Context = None, ) -> str: """BITBUCKET (Data Center / Server) — see and act on PRs, branches, builds, repo admin. - actions: status, whoami, list_projects, list_repos, get_repo, + actions: status, whoami, list_projects, list_repos, get_repo (description, + default branch, browse and clone URLs), + readme ([at=branch/commit] — the root README, markdown preferred), + get_file (path [at] — one text file, 256 KB cap, binary refused), list_prs, get_pr, get_pr_diff, get_pr_activities, get_pr_commits, + pr_issues (pr_id — Jira issues Bitbucket's Jira integration links to the PR), create_pr, update_pr (pr_id + any of title/description/reviewers/to_branch — unchanged fields are preserved; reviewers is the FULL comma-separated replacement set), comment_pr, @@ -1305,10 +1317,13 @@ async def c3_bitbucket( list_pr_tasks (state filters OPEN/RESOLVED), create_pr_task (pr_id, body — a blocker task on the PR), resolve_pr_task (pr_id, comment_id from list_pr_tasks), - list_branches, create_branch, delete_branch, - list_commits, list_activity, build_status, + list_branches ([filter_text] [order=MODIFICATION|ALPHABETICAL]), create_branch, + delete_branch, list_commits ([branch] [path]), list_activity, build_status (commit), repo_settings, update_repo_settings, list_webhooks, create_webhook, delete_webhook, list_permissions. + format="json": reads return {"ok", "action", "data"} JSON (normalized fields, a + ~400k-char cap that trims lists and sets "truncated"); writes return the text + reply in data.text; errors return {"ok": false, "error"}. For programs, not agents. project/repo fall back to bitbucket.default_project/default_repo from .c3/config.json. Tokens live in the OS keyring — `c3 bitbucket login` to set them up first.""" svc = _svc(ctx) @@ -1324,7 +1339,8 @@ def finalize(fname, fargs, fresp, fsumm, **kw): description=description, reviewers=reviewers, name=name, url=url, events=events, start_point=start_point, commit=commit, settings=settings, webhook_id=webhook_id, comment_id=comment_id, - limit=limit, + limit=limit, path=path, at=at, filter_text=filter_text, order=order, + format=format, ) @@ -1357,10 +1373,16 @@ async def c3_jira( file_path: str = "", delete_subtasks: bool = False, limit: int = 25, + validate: str = "", + format: str = "", ctx: Context = None, ) -> str: """JIRA (Cloud + Data Center) — search, read, create, update, link, sprint, comment, transition, assign issues. - actions: status, whoami, search (jql), get_issue (issue — shows parent, links, attachments), + actions: status, whoami, search (jql [validate=warn — Data Center: unknown keys in + `key in (...)` become warnings instead of failing the query]), + get_issue (issue — shows type, parent, subtasks, links, attachments), + dev_status (issue — pull requests, branches and commits from Jira's development panel; + some instances disable that API, which reports as unavailable), my_issues, list_projects, list_transitions (issue), get_create_metadata (project, issue_type), search_users (query), list_link_types, list_boards ([project]), list_sprints (board_id [sprint_state=active|future|closed]), @@ -1383,6 +1405,10 @@ async def c3_jira( get_create_metadata reflects Jira's field configuration, not the create screen — if create_issue rejects a listed field ('not on the appropriate screen'), create without it and set it via update_issue (the edit screen is configured separately). + format="json": reads return {"ok", "action", "data"} JSON (normalized fields incl. + rendered HTML description/comments, a ~400k-char cap that trims lists and sets + "truncated"); writes return the text reply in data.text; errors return + {"ok": false, "error"}. For programs, not agents. project falls back to the account's default_project; account to jira.default_account. Mutating actions are ledger-logged. Tokens live in the OS keyring — `c3 jira login` first.""" svc = _svc(ctx) @@ -1402,6 +1428,7 @@ def finalize(fname, fargs, fresp, fsumm, **kw): sprint_id=sprint_id, sprint_state=sprint_state, time_spent=time_spent, file_path=file_path, delete_subtasks=delete_subtasks, limit=limit, + validate=validate, format=format, ) diff --git a/cli/tools/_json_reply.py b/cli/tools/_json_reply.py new file mode 100644 index 0000000..ccd15b2 --- /dev/null +++ b/cli/tools/_json_reply.py @@ -0,0 +1,90 @@ +"""JSON replies for c3_jira / c3_bitbucket ``format="json"``. + +Programs (dashboards, the Work Agents Work page) read these tools too, and +the text replies are capped at a few thousand tokens for agents. JSON mode +returns the normalized data instead, under a much larger character cap that +trims list and string fields so the reply always stays valid JSON: + + {"ok": true, "action": "get_issue", "data": {...}, "truncated": ["comments"]} + {"ok": false, "action": "get_issue", "error": "...", "status": 404} +""" +from __future__ import annotations + +import json +from typing import Any + +JSON_CHAR_CAP = 400_000 + +# Lists ordered oldest first: trimming keeps their newest entries. +_KEEP_TAIL = {"comments", "activities"} + +# A string shorter than this is not worth cutting; the next field is. +_MIN_CUT = 1000 + + +def wants_json(kwargs: dict) -> bool: + return str(kwargs.get("format") or "").strip().lower() == "json" + + +def _dumps(obj: Any) -> str: + return json.dumps(obj, ensure_ascii=False, default=str) + + +def _fit(data: dict, cap: int, overhead: int) -> list[str]: + """Shrink the largest top-level fields of *data* in place until the + serialized dict fits in ``cap - overhead`` characters. Returns the names + of the fields that were cut.""" + truncated: list[str] = [] + budget = max(0, cap - overhead) + for _ in range(200): + if len(_dumps(data)) <= budget: + break + sizes = {k: len(_dumps(v)) for k, v in data.items()} + key = max(sizes, key=sizes.get) + value = data[key] + if isinstance(value, list) and len(value) > 1: + keep = max(1, len(value) * 3 // 4) + data[key] = value[-keep:] if key in _KEEP_TAIL else value[:keep] + elif isinstance(value, str) and len(value) > _MIN_CUT: + data[key] = value[: len(value) * 3 // 4] + elif isinstance(value, (list, dict)) and value: + data[key] = [] if isinstance(value, list) else {} + else: + data[key] = None + if key not in truncated: + truncated.append(key) + return truncated + + +def ok(action: str, data: Any, *, cap: int = JSON_CHAR_CAP) -> str: + """Serialize a successful read. A non-dict *data* is wrapped as + ``{"values": data}`` so every reply trims the same way.""" + if not isinstance(data, dict): + data = {"values": data} + data = dict(data) + head = {"ok": True, "action": action} + truncated = _fit(data, cap, len(_dumps(head)) + 64) + out = {**head, "data": data} + if truncated: + out["truncated"] = truncated + return _dumps(out) + + +def error(action: str, message: str, *, status: int = 0, code: str = "") -> str: + out: dict[str, Any] = {"ok": False, "action": action, "error": message} + if status: + out["status"] = status + if code: + out["code"] = code + return _dumps(out) + + +def text(action: str, resp: str) -> str: + """An action with no structured form (writes, diffs) in JSON mode: the + text reply rides in ``data.text`` so the caller still gets JSON.""" + return ok(action, {"text": resp}) + + +def is_json_reply(response: str) -> bool: + """True for a reply built here; callers must not append text to it.""" + return response.startswith('{"ok": ') diff --git a/cli/tools/bitbucket.py b/cli/tools/bitbucket.py index 03fa7ee..6b42082 100644 --- a/cli/tools/bitbucket.py +++ b/cli/tools/bitbucket.py @@ -5,13 +5,15 @@ project+repo from ``.c3/config.json``. Long responses are capped at ``_RESPONSE_TOKEN_CAP`` to protect the -caller's context budget. +caller's context budget. ``format="json"`` returns normalized JSON for +programs instead (see cli/tools/_json_reply.py). """ from __future__ import annotations import json from typing import Any +from cli.tools import _json_reply as jr from core import count_tokens from core.config import load_bitbucket_config from services import bitbucket_credentials as bb_creds @@ -41,8 +43,30 @@ "create_branch", "delete_branch", "list_commits", "list_activity", "repo_settings", "update_repo_settings", "list_webhooks", "create_webhook", "delete_webhook", "list_permissions", + "readme", "get_file", "pr_issues", } +# Actions that need a pr_id. +_PR_ACTIONS = { + "get_pr", "get_pr_diff", "get_pr_activities", "get_pr_commits", + "pr_issues", "update_pr", "comment_pr", "update_pr_comment", + "delete_pr_comment", "list_pr_tasks", "create_pr_task", + "resolve_pr_task", "approve_pr", "unapprove_pr", "needs_work_pr", + "decline_pr", "merge_pr", +} + +_VALID_ACTIONS = sorted( + {"status", "whoami", "list_projects", "build_status"} | _REPO_SCOPED +) + +# Branch metadata keys Bitbucket adds with details=true (plugins permitting). +_META = "com.atlassian.bitbucket.server." +_META_AHEAD_BEHIND = _META + "bitbucket-branch:ahead-behind-metadata-provider" +_META_LAST_COMMIT = _META + "bitbucket-branch:latest-commit-metadata" +_META_JIRA = _META + "bitbucket-jira:branch-list-jira-issues" +_META_OUTGOING_PR = _META + "bitbucket-ref-metadata:outgoing-pull-request-metadata" +_META_BUILDS = _META + "bitbucket-build:build-status-metadata" + def _cap(resp: str) -> str: """Truncate response if over the token cap (matches cli/tools/search.py).""" @@ -110,6 +134,18 @@ def _resolve_repo(action: str, kwargs: dict, svc) -> tuple[str, str, str]: return project, repo, "" +def _href(links: dict | None, rel: str = "self") -> str: + """The first href under ``links[rel]`` of a Bitbucket entity.""" + for link in (links or {}).get(rel) or []: + if isinstance(link, dict) and link.get("href"): + return link["href"] + return "" + + +def _filter_text(kwargs: dict) -> str: + return kwargs.get("filter_text") or kwargs.get("filter") or "" + + # ── Formatters ──────────────────────────────────────────── @@ -148,9 +184,7 @@ def _format_branch(b: dict) -> str: name = b.get("displayId", b.get("id", "?")) latest = b.get("latestCommit", "")[:8] is_default = " [default]" if b.get("isDefault") else "" - behind = b.get("metadata", {}).get( - "com.atlassian.bitbucket.server.bitbucket-branch:ahead-behind-metadata-provider" - ) + behind = b.get("metadata", {}).get(_META_AHEAD_BEHIND) ab = "" if isinstance(behind, dict): ab = f" +{behind.get('ahead',0)}/-{behind.get('behind',0)}" @@ -239,16 +273,65 @@ def _act_list_repos(client: BitbucketDataCenterClient, project: str) -> str: return "\n".join(lines) +def _default_branch(client, project: str, repo: str) -> str: + """The default branch name; empty for an empty repo or on any error.""" + try: + return (client.get_default_branch(project, repo) or {}).get("displayId", "") + except BitbucketError: + return "" + + def _act_get_repo(client: BitbucketDataCenterClient, project: str, repo: str) -> str: r = client.get_repo(project, repo) - return ( - f"[bitbucket:repo] {project}/{repo}\n" - f" name: {r.get('name','?')}\n" - f" scmId: {r.get('scmId','?')}\n" - f" public: {r.get('public','?')}\n" - f" state: {r.get('state','?')}\n" - f" links: {[l.get('href') for l in (r.get('links',{}).get('clone') or [])]}" - ) + lines = [ + f"[bitbucket:repo] {project}/{repo}", + f" name: {r.get('name','?')}", + ] + if r.get("description"): + lines.append(f" description: {r['description']}") + lines += [ + f" default: {_default_branch(client, project, repo) or '-'}", + f" url: {_href(r.get('links')) or '-'}", + f" scmId: {r.get('scmId','?')}", + f" public: {r.get('public','?')}", + f" state: {r.get('state','?')}", + f" links: {[l.get('href') for l in (r.get('links',{}).get('clone') or [])]}", + ] + return "\n".join(lines) + + +def _act_readme(client, project: str, repo: str, kwargs: dict) -> str: + f = client.readme(project, repo, at=kwargs.get("at") or "") + if not f.get("found"): + return f"[bitbucket:readme] {project}/{repo}: no README at the root" + return _format_file("readme", project, repo, f) + + +def _act_get_file(client, project: str, repo: str, kwargs: dict) -> str: + path = (kwargs.get("path") or "").strip() + if not path: + return "[bitbucket:get_file:error] path is required" + f = client.get_file(project, repo, path, at=kwargs.get("at") or "") + return _format_file("file", project, repo, f) + + +def _format_file(tag: str, project: str, repo: str, f: dict) -> str: + where = f"{f.get('path')}@{f['at']}" if f.get("at") else f.get("path") + head = f"[bitbucket:{tag}] {project}/{repo} {where} ({f.get('size', 0)} bytes" + if f.get("binary"): + return f"{head}): binary file, not shown" + if f.get("truncated"): + head += ", truncated" + return f"{head})\n{f.get('text', '')}" + + +def _act_pr_issues(client, project: str, repo: str, pr_id: int) -> str: + issues = client.pr_issues(project, repo, pr_id) + if not issues: + return f"[bitbucket:pr_issues] {project}/{repo}#{pr_id}: 0 issue(s)" + out = [f"[bitbucket:pr_issues] {project}/{repo}#{pr_id}: {len(issues)} issue(s)"] + out.extend(f" {i.get('key', '?'):16} {i.get('url', '')}" for i in issues) + return "\n".join(out) def _act_list_prs(client, project: str, repo: str, kwargs: dict) -> str: @@ -490,7 +573,10 @@ def _act_merge_pr(client, project: str, repo: str, pr_id: int, kwargs: dict) -> def _act_list_branches(client, project: str, repo: str, kwargs: dict) -> str: - branches = client.list_branches(project, repo, filter_text=kwargs.get("filter", "")) + branches = client.list_branches( + project, repo, filter_text=_filter_text(kwargs), + order=kwargs.get("order") or "", + ) if not branches: return f"[bitbucket:branches] {project}/{repo}: (none)" out = [f"[bitbucket:branches] {project}/{repo} - {len(branches)} branch(es)"] @@ -632,6 +718,364 @@ def _act_list_permissions(client, project: str, repo: str) -> str: return "\n".join(out) +# ── JSON replies (format="json") ────────────────────────── + + +class _UsageError(Exception): + """A missing or bad argument found while building a JSON reply.""" + + +def _user_dto(u: dict | None) -> dict: + u = u or {} + return {"name": u.get("name") or u.get("slug") or "", + "display_name": u.get("displayName") or u.get("name") or ""} + + +def _reviewer_dto(p: dict) -> dict: + return {**_user_dto(p.get("user")), "approved": bool(p.get("approved")), + "status": p.get("status", "")} + + +def _ref_repo(ref: dict) -> str: + """``PROJ/slug`` of the repository a PR ref points into.""" + repo = (ref or {}).get("repository") or {} + key = (repo.get("project") or {}).get("key", "") + slug = repo.get("slug", "") + return f"{key}/{slug}" if key and slug else "" + + +def _project_dto(p: dict) -> dict: + return {"key": p.get("key", ""), "name": p.get("name", ""), + "description": p.get("description", ""), + "public": bool(p.get("public")), "url": _href(p.get("links"))} + + +def _repo_dto(r: dict) -> dict: + proj = r.get("project") or {} + return { + "project": proj.get("key", ""), + "project_name": proj.get("name", ""), + "slug": r.get("slug", ""), + "name": r.get("name", ""), + "description": r.get("description", ""), + "state": r.get("state", ""), + "scm_id": r.get("scmId", ""), + "public": bool(r.get("public")), + "forkable": bool(r.get("forkable")), + "url": _href(r.get("links")), + "clone": [{"name": c.get("name", ""), "href": c.get("href", "")} + for c in (r.get("links") or {}).get("clone") or [] + if isinstance(c, dict)], + } + + +def _pr_dto(pr: dict) -> dict: + src = pr.get("fromRef") or {} + dst = pr.get("toRef") or {} + props = pr.get("properties") or {} + return { + "id": pr.get("id"), + "version": pr.get("version"), + "title": pr.get("title", ""), + "description": pr.get("description", ""), + "state": pr.get("state", ""), + "draft": bool(pr.get("draft")), + "author": _user_dto((pr.get("author") or {}).get("user")), + "reviewers": [_reviewer_dto(r) for r in pr.get("reviewers") or []], + "source_branch": src.get("displayId", ""), + "source_commit": src.get("latestCommit", ""), + "source_repository": _ref_repo(src), + "destination_branch": dst.get("displayId", ""), + "destination_commit": dst.get("latestCommit", ""), + "repository": _ref_repo(dst), + "created": pr.get("createdDate"), + "updated": pr.get("updatedDate"), + "closed": pr.get("closedDate"), + "url": _href(pr.get("links")), + "open_tasks": props.get("openTaskCount", pr.get("openTaskCount", 0)), + "resolved_tasks": props.get("resolvedTaskCount", 0), + "comment_count": props.get("commentCount", 0), + "merge_result": (props.get("mergeResult") or {}).get("outcome", ""), + } + + +def _commit_dto(c: dict) -> dict: + keys = (c.get("properties") or {}).get("jira-key") or [] + return { + "id": c.get("id", ""), + "display_id": c.get("displayId") or (c.get("id") or "")[:11], + "message": c.get("message", ""), + "author": _user_dto(c.get("author")), + "author_timestamp": c.get("authorTimestamp"), + "committer": _user_dto(c.get("committer")), + "committer_timestamp": c.get("committerTimestamp"), + "parents": [p.get("id", "") for p in c.get("parents") or [] + if isinstance(p, dict)], + "jira_keys": [k for k in keys if isinstance(k, str)] + if isinstance(keys, list) else [], + } + + +def _branch_dto(b: dict) -> dict: + meta = b.get("metadata") or {} + ab = meta.get(_META_AHEAD_BEHIND) + last = meta.get(_META_LAST_COMMIT) + issues = meta.get(_META_JIRA) + outgoing = meta.get(_META_OUTGOING_PR) + builds = meta.get(_META_BUILDS) + pr = outgoing.get("pullRequest") if isinstance(outgoing, dict) else None + return { + "name": b.get("displayId", ""), + "id": b.get("id", ""), + "latest_commit": b.get("latestCommit", ""), + "is_default": bool(b.get("isDefault")), + "ahead": ab.get("ahead") if isinstance(ab, dict) else None, + "behind": ab.get("behind") if isinstance(ab, dict) else None, + "last_commit": _commit_dto(last) if isinstance(last, dict) else None, + "jira_keys": [i["key"] for i in issues + if isinstance(i, dict) and i.get("key")] + if isinstance(issues, list) else [], + "pull_request": {"id": pr.get("id"), "state": pr.get("state", ""), + "title": pr.get("title", "")} + if isinstance(pr, dict) else None, + "builds": builds if isinstance(builds, dict) else None, + } + + +def _build_dto(b: dict) -> dict: + return {"state": b.get("state", ""), "key": b.get("key", ""), + "name": b.get("name", ""), "url": b.get("url", ""), + "description": b.get("description", ""), + "date_added": b.get("dateAdded")} + + +def _comment_dto(c: dict, depth: int = 0) -> dict: + replies = (c.get("comments") or []) if depth < 5 else [] + return { + "id": c.get("id"), + "text": c.get("text", ""), + "author": _user_dto(c.get("author")), + "created": c.get("createdDate"), + "updated": c.get("updatedDate"), + "severity": c.get("severity", ""), + "state": c.get("state", ""), + "replies": [_comment_dto(r, depth + 1) for r in replies + if isinstance(r, dict)], + } + + +def _activity_dto(a: dict) -> dict: + out = {"id": a.get("id"), "action": a.get("action", ""), + "created": a.get("createdDate"), "user": _user_dto(a.get("user"))} + if isinstance(a.get("comment"), dict): + out["comment_action"] = a.get("commentAction", "") + out["comment"] = _comment_dto(a["comment"]) + anchor = a.get("commentAnchor") or {} + if anchor: + out["anchor"] = {"path": anchor.get("path", ""), + "line": anchor.get("line"), + "line_type": anchor.get("lineType", "")} + return out + + +def _task_dto(t: dict) -> dict: + return {"id": t.get("id"), "text": t.get("text", ""), + "state": t.get("state", ""), "severity": t.get("severity", ""), + "author": _user_dto(t.get("author")), + "created": t.get("createdDate")} + + +def _json_read(client, action: str, project: str, repo: str, pr_id: int, + kwargs: dict) -> dict | None: + """The structured data behind a read action, or None for an action + with no structured form (writes, admin), which the caller wraps as text.""" + limit = int(kwargs.get("limit") or 0) + at = kwargs.get("at") or "" + if action == "whoami": + u = client.whoami() + return {"name": u.get("name", ""), "slug": u.get("slug", ""), + "display_name": u.get("displayName", ""), + "email": u.get("emailAddress", ""), + "active": u.get("active")} + if action == "list_projects": + name = kwargs.get("name", "") or kwargs.get("query", "") + return {"projects": [_project_dto(p) for p in client.list_projects(name=name)]} + if action == "list_repos": + return {"project": project, + "repos": [_repo_dto(r) for r in client.list_repos(project)]} + if action == "get_repo": + return {**_repo_dto(client.get_repo(project, repo)), + "default_branch": _default_branch(client, project, repo)} + if action == "readme": + return {"project": project, "repo": repo, + **client.readme(project, repo, at=at)} + if action == "get_file": + path = (kwargs.get("path") or "").strip() + if not path: + raise _UsageError("path is required for get_file") + return {"project": project, "repo": repo, + **client.get_file(project, repo, path, at=at)} + if action == "list_prs": + state = (kwargs.get("state") or "OPEN").upper() + prs = client.list_pull_requests( + project, repo, state=state, + author=kwargs.get("author", ""), + reviewer=kwargs.get("reviewer", ""), + limit=limit or 50, + ) + return {"project": project, "repo": repo, "state": state, + "pull_requests": [_pr_dto(pr) for pr in prs]} + if action == "get_pr": + return _pr_dto(client.get_pull_request(project, repo, pr_id)) + if action == "get_pr_diff": + return {"id": pr_id, "diff": client.get_pr_diff( + project, repo, pr_id, + context_lines=int(kwargs.get("context_lines", 3)))} + if action == "get_pr_activities": + acts = client.get_pr_activities(project, repo, pr_id, limit=limit or 50) + # Bitbucket lists newest first; oldest first lets trimming keep the newest. + return {"id": pr_id, "activities": [_activity_dto(a) for a in reversed(acts)]} + if action == "get_pr_commits": + return {"id": pr_id, "commits": [ + _commit_dto(c) for c in client.get_pr_commits(project, repo, pr_id)]} + if action == "pr_issues": + return {"id": pr_id, "issues": [ + {"key": i.get("key", ""), "url": i.get("url", "")} + for i in client.pr_issues(project, repo, pr_id)]} + if action == "list_pr_tasks": + state = (kwargs.get("state") or "").strip() + return {"id": pr_id, "tasks": [ + _task_dto(t) for t in client.list_pr_tasks(project, repo, pr_id, state=state)]} + if action == "list_branches": + branches = client.list_branches( + project, repo, filter_text=_filter_text(kwargs), + order=kwargs.get("order") or "", + ) + if limit: + branches = branches[:limit] + return {"project": project, "repo": repo, + "branches": [_branch_dto(b) for b in branches]} + if action == "list_commits": + commits = client.list_commits( + project, repo, branch=kwargs.get("branch", ""), + path=kwargs.get("path", ""), limit=limit or 30, + ) + return {"project": project, "repo": repo, + "commits": [_commit_dto(c) for c in commits]} + if action == "build_status": + commit = kwargs.get("commit") or kwargs.get("commit_hash") or "" + if not commit: + raise _UsageError("commit hash is required for build_status") + return {"commit": commit, + "builds": [_build_dto(b) for b in client.get_build_status(commit)]} + return None + + +def _status_data(client, err: str, svc) -> dict: + project_path = getattr(svc, "project_path", ".") or "." + cfg = load_bitbucket_config(project_path) + active = cfg.get("active") or {} + data: dict[str, Any] = { + "base_url": active.get("base_url", ""), + "username": active.get("username", ""), + "default_project": cfg.get("default_project") or "", + "default_repo": cfg.get("default_repo") or "", + "accounts": [{"username": a.get("username", ""), + "base_url": a.get("base_url", "")} + for a in cfg.get("accounts") or [] if isinstance(a, dict)], + "connected": False, + "formats": ["text", "json"], + } + if client is None: + data["error"] = err or "[bitbucket:no-account]" + return data + try: + props = client.application_properties() + data.update(connected=True, version=props.get("version", "")) + except BitbucketError as exc: + data["error"] = str(exc) + return data + + +def _dispatch_text(client, action: str, project: str, repo: str, pr_id: int, + kwargs: dict) -> str: + """Run *action* and return its text reply.""" + if action == "whoami": + return _act_whoami(client) + if action == "list_projects": + return _act_list_projects(client, kwargs) + if action == "list_repos": + return _act_list_repos(client, project) + if action == "get_repo": + return _act_get_repo(client, project, repo) + if action == "readme": + return _act_readme(client, project, repo, kwargs) + if action == "get_file": + return _act_get_file(client, project, repo, kwargs) + if action == "list_prs": + return _act_list_prs(client, project, repo, kwargs) + if action == "get_pr": + return _act_get_pr(client, project, repo, pr_id) + if action == "get_pr_diff": + return _act_get_pr_diff(client, project, repo, pr_id, kwargs) + if action == "get_pr_activities": + return _act_get_pr_activities(client, project, repo, pr_id) + if action == "get_pr_commits": + return _act_get_pr_commits(client, project, repo, pr_id) + if action == "pr_issues": + return _act_pr_issues(client, project, repo, pr_id) + if action == "create_pr": + return _act_create_pr(client, project, repo, kwargs) + if action == "update_pr": + return _act_update_pr(client, project, repo, pr_id, kwargs) + if action == "comment_pr": + return _act_comment_pr(client, project, repo, pr_id, kwargs) + if action == "update_pr_comment": + return _act_update_pr_comment(client, project, repo, pr_id, kwargs) + if action == "delete_pr_comment": + return _act_delete_pr_comment(client, project, repo, pr_id, kwargs) + if action == "list_pr_tasks": + return _act_list_pr_tasks(client, project, repo, pr_id, kwargs) + if action == "create_pr_task": + return _act_create_pr_task(client, project, repo, pr_id, kwargs) + if action == "resolve_pr_task": + return _act_resolve_pr_task(client, project, repo, pr_id, kwargs) + if action == "approve_pr": + return _act_approve_pr(client, project, repo, pr_id) + if action == "unapprove_pr": + return _act_unapprove_pr(client, project, repo, pr_id) + if action == "needs_work_pr": + return _act_needs_work_pr(client, project, repo, pr_id) + if action == "decline_pr": + return _act_decline_pr(client, project, repo, pr_id) + if action == "merge_pr": + return _act_merge_pr(client, project, repo, pr_id, kwargs) + if action == "list_branches": + return _act_list_branches(client, project, repo, kwargs) + if action == "create_branch": + return _act_create_branch(client, project, repo, kwargs) + if action == "delete_branch": + return _act_delete_branch(client, project, repo, kwargs) + if action == "list_commits": + return _act_list_commits(client, project, repo, kwargs) + if action == "list_activity": + return _act_list_activity(client, project, repo, kwargs) + if action == "build_status": + return _act_build_status(client, kwargs) + if action == "repo_settings": + return _act_repo_settings(client, project, repo) + if action == "update_repo_settings": + return _act_update_repo_settings(client, project, repo, kwargs) + if action == "list_webhooks": + return _act_list_webhooks(client, project, repo) + if action == "create_webhook": + return _act_create_webhook(client, project, repo, kwargs) + if action == "delete_webhook": + return _act_delete_webhook(client, project, repo, kwargs) + # list_permissions — the action set is closed by the _VALID_ACTIONS gate + return _act_list_permissions(client, project, repo) + + # ── Ledger logging for mutations ────────────────────────── @@ -678,166 +1122,73 @@ def handle_bitbucket(action: str, svc, finalize, **kwargs) -> str: ``svc`` provides ``project_path``, ``edit_ledger``, ``activity_log``, ``session_mgr``. ``finalize(tool, args, response, summary)`` logs the call. + ``format="json"`` returns ``{"ok", "action", "data"}`` JSON instead of + the capped text reply (see cli/tools/_json_reply.py). """ action = (action or "").strip().lower() args_for_log = {k: v for k, v in kwargs.items() if k not in {"token", "secret"}} args_for_log["action"] = action + want_json = jr.wants_json(kwargs) + + def fail(msg: str, summary: str, *, status: int = 0) -> str: + if want_json: + return finalize(_TOOL, args_for_log, + jr.error(action, msg, status=status, code=summary), + summary, ok=False) + return finalize(_TOOL, args_for_log, msg, summary) if not action: - return finalize(_TOOL, args_for_log, - "[bitbucket:error] action is required", "error") + return fail("[bitbucket:error] action is required", "error") client, err = _build_client(svc) # `status` is special — it can render the config without a working client. if action == "status": + if want_json: + data = _status_data(client, err, svc) + return finalize(_TOOL, args_for_log, jr.ok("status", data), "status") resp = _act_status(client, err, svc) return finalize(_TOOL, args_for_log, _cap(resp), "status") if client is None: - return finalize(_TOOL, args_for_log, err, "no-account") + return fail(err, "no-account") + + if action not in _VALID_ACTIONS: + return fail( + f"[bitbucket:unknown-action] '{action}'. Valid: {', '.join(_VALID_ACTIONS)}", + "unknown-action", + ) # Resolve project/repo for repo-scoped actions. project = repo = "" if action in _REPO_SCOPED: project, repo, scope_err = _resolve_repo(action, kwargs, svc) if scope_err: - return finalize(_TOOL, args_for_log, scope_err, "missing-arg") + return fail(scope_err, "missing-arg") pr_id = int(kwargs.get("pr_id") or kwargs.get("id") or 0) + if action in _PR_ACTIONS and not pr_id: + return fail("[bitbucket:error] pr_id required", "missing-arg") + data = None try: - if action == "whoami": - resp = _act_whoami(client) - elif action == "list_projects": - resp = _act_list_projects(client, kwargs) - elif action == "list_repos": - resp = _act_list_repos(client, project) - elif action == "get_repo": - resp = _act_get_repo(client, project, repo) - elif action == "list_prs": - resp = _act_list_prs(client, project, repo, kwargs) - elif action == "get_pr": - if not pr_id: - return finalize(_TOOL, args_for_log, "[bitbucket:error] pr_id required", "missing-arg") - resp = _act_get_pr(client, project, repo, pr_id) - elif action == "get_pr_diff": - if not pr_id: - return finalize(_TOOL, args_for_log, "[bitbucket:error] pr_id required", "missing-arg") - resp = _act_get_pr_diff(client, project, repo, pr_id, kwargs) - elif action == "get_pr_activities": - if not pr_id: - return finalize(_TOOL, args_for_log, "[bitbucket:error] pr_id required", "missing-arg") - resp = _act_get_pr_activities(client, project, repo, pr_id) - elif action == "get_pr_commits": - if not pr_id: - return finalize(_TOOL, args_for_log, "[bitbucket:error] pr_id required", "missing-arg") - resp = _act_get_pr_commits(client, project, repo, pr_id) - elif action == "create_pr": - resp = _act_create_pr(client, project, repo, kwargs) - elif action == "update_pr": - if not pr_id: - return finalize(_TOOL, args_for_log, "[bitbucket:error] pr_id required", "missing-arg") - resp = _act_update_pr(client, project, repo, pr_id, kwargs) - elif action == "comment_pr": - if not pr_id: - return finalize(_TOOL, args_for_log, "[bitbucket:error] pr_id required", "missing-arg") - resp = _act_comment_pr(client, project, repo, pr_id, kwargs) - elif action == "update_pr_comment": - if not pr_id: - return finalize(_TOOL, args_for_log, "[bitbucket:error] pr_id required", "missing-arg") - resp = _act_update_pr_comment(client, project, repo, pr_id, kwargs) - elif action == "delete_pr_comment": - if not pr_id: - return finalize(_TOOL, args_for_log, "[bitbucket:error] pr_id required", "missing-arg") - resp = _act_delete_pr_comment(client, project, repo, pr_id, kwargs) - elif action == "list_pr_tasks": - if not pr_id: - return finalize(_TOOL, args_for_log, "[bitbucket:error] pr_id required", "missing-arg") - resp = _act_list_pr_tasks(client, project, repo, pr_id, kwargs) - elif action == "create_pr_task": - if not pr_id: - return finalize(_TOOL, args_for_log, "[bitbucket:error] pr_id required", "missing-arg") - resp = _act_create_pr_task(client, project, repo, pr_id, kwargs) - elif action == "resolve_pr_task": - if not pr_id: - return finalize(_TOOL, args_for_log, "[bitbucket:error] pr_id required", "missing-arg") - resp = _act_resolve_pr_task(client, project, repo, pr_id, kwargs) - elif action == "approve_pr": - if not pr_id: - return finalize(_TOOL, args_for_log, "[bitbucket:error] pr_id required", "missing-arg") - resp = _act_approve_pr(client, project, repo, pr_id) - elif action == "unapprove_pr": - if not pr_id: - return finalize(_TOOL, args_for_log, "[bitbucket:error] pr_id required", "missing-arg") - resp = _act_unapprove_pr(client, project, repo, pr_id) - elif action == "needs_work_pr": - if not pr_id: - return finalize(_TOOL, args_for_log, "[bitbucket:error] pr_id required", "missing-arg") - resp = _act_needs_work_pr(client, project, repo, pr_id) - elif action == "decline_pr": - if not pr_id: - return finalize(_TOOL, args_for_log, "[bitbucket:error] pr_id required", "missing-arg") - resp = _act_decline_pr(client, project, repo, pr_id) - elif action == "merge_pr": - if not pr_id: - return finalize(_TOOL, args_for_log, "[bitbucket:error] pr_id required", "missing-arg") - resp = _act_merge_pr(client, project, repo, pr_id, kwargs) - elif action == "list_branches": - resp = _act_list_branches(client, project, repo, kwargs) - elif action == "create_branch": - resp = _act_create_branch(client, project, repo, kwargs) - elif action == "delete_branch": - resp = _act_delete_branch(client, project, repo, kwargs) - elif action == "list_commits": - resp = _act_list_commits(client, project, repo, kwargs) - elif action == "list_activity": - resp = _act_list_activity(client, project, repo, kwargs) - elif action == "build_status": - resp = _act_build_status(client, kwargs) - elif action == "repo_settings": - resp = _act_repo_settings(client, project, repo) - elif action == "update_repo_settings": - resp = _act_update_repo_settings(client, project, repo, kwargs) - elif action == "list_webhooks": - resp = _act_list_webhooks(client, project, repo) - elif action == "create_webhook": - resp = _act_create_webhook(client, project, repo, kwargs) - elif action == "delete_webhook": - resp = _act_delete_webhook(client, project, repo, kwargs) - elif action == "list_permissions": - resp = _act_list_permissions(client, project, repo) - else: - valid = sorted([ - "status", "whoami", - "list_projects", "list_repos", "get_repo", - "list_prs", "get_pr", "get_pr_diff", "get_pr_activities", - "get_pr_commits", - "create_pr", "update_pr", "comment_pr", "update_pr_comment", - "delete_pr_comment", "approve_pr", - "unapprove_pr", "needs_work_pr", "decline_pr", "merge_pr", - "list_pr_tasks", "create_pr_task", "resolve_pr_task", - "list_branches", "create_branch", "delete_branch", - "list_commits", "list_activity", "build_status", - "repo_settings", "update_repo_settings", - "list_webhooks", "create_webhook", "delete_webhook", - "list_permissions", - ]) - return finalize( - _TOOL, args_for_log, - f"[bitbucket:unknown-action] '{action}'. Valid: {', '.join(valid)}", - "unknown-action", - ) + if want_json: + data = _json_read(client, action, project, repo, pr_id, kwargs) + if data is None: + resp = _dispatch_text(client, action, project, repo, pr_id, kwargs) + except _UsageError as exc: + return fail(f"[bitbucket:error] {exc}", "missing-arg") except BitbucketError as exc: - return finalize( - _TOOL, args_for_log, - f"[bitbucket:api-error] {exc}", f"http-{exc.status}", - ) + return fail(f"[bitbucket:api-error] {exc}", f"http-{exc.status}", + status=exc.status) except Exception as exc: # pragma: no cover — defensive - return finalize( - _TOOL, args_for_log, - f"[bitbucket:internal-error] {type(exc).__name__}: {exc}", "error", - ) + return fail(f"[bitbucket:internal-error] {type(exc).__name__}: {exc}", "error") + if data is not None: + return finalize(_TOOL, args_for_log, jr.ok(action, data), action) _log_mutation(svc, action, project, repo, kwargs, resp) + if want_json: + if ":error]" in resp.split("\n", 1)[0]: + return fail(resp, "error") + return finalize(_TOOL, args_for_log, jr.text(action, resp), action) return finalize(_TOOL, args_for_log, _cap(resp), action) diff --git a/cli/tools/jira.py b/cli/tools/jira.py index cc0394e..9a723c7 100644 --- a/cli/tools/jira.py +++ b/cli/tools/jira.py @@ -9,6 +9,7 @@ from pathlib import Path from typing import Any +from cli.tools import _json_reply as jr from core import count_tokens from core.config import load_jira_config from services import jira_credentials as jr_creds @@ -25,7 +26,7 @@ } _VALID_ACTIONS = sorted([ - "status", "whoami", "search", "get_issue", "my_issues", + "status", "whoami", "search", "get_issue", "my_issues", "dev_status", "list_projects", "list_transitions", "get_create_metadata", "search_users", "list_link_types", "list_boards", "list_sprints", "list_worklogs", @@ -34,6 +35,14 @@ "move_to_sprint", "move_to_backlog", "add_worklog", "attach_file", ]) +# Actions that need an `issue` key. +_ISSUE_ACTIONS = { + "get_issue", "dev_status", "list_transitions", "update_issue", + "comment", "transition", "assign", "link_issues", + "delete_issue", "move_to_sprint", "move_to_backlog", + "add_worklog", "list_worklogs", "attach_file", +} + # Attachment uploads are read fully into memory before the multipart POST; # Jira's own default cap is 10MB — this local guard just prevents runaway # reads before the server would reject the file anyway. @@ -122,8 +131,24 @@ def _format_issue_full(i: dict) -> str: ] if i.get("labels"): lines.append(f" labels: {', '.join(i['labels'])}") + if i.get("components"): + lines.append(f" components: {', '.join(i['components'])}") + if i.get("fix_versions"): + lines.append(f" fix versions: {', '.join(i['fix_versions'])}") + if i.get("due"): + lines.append(f" due: {i['due']}") if i.get("parent"): - lines.append(f" parent: {i['parent']}") + about = " ".join( + p for p in ( + f"({i['parent_type']})" if i.get("parent_type") else "", + i.get("parent_summary") or "", + ) if p + ) + lines.append(f" parent: {i['parent']} {about}".rstrip()) + for s in i.get("subtasks", []): + lines.append( + f" subtask: {s.get('key')} [{s.get('status')}] {s.get('summary')}" + ) for link in i.get("links", []): status = f" [{link['status']}]" if link.get("status") else "" lines.append( @@ -152,6 +177,35 @@ def _format_search(result: dict, label: str) -> str: lines.extend(f" {_format_issue(i)}" for i in issues) if result.get("next_cursor"): lines.append(f" next: pass cursor={result['next_cursor']}") + lines.extend(f" warning: {w}" for w in result.get("warnings", [])) + return "\n".join(lines) + + +def _format_dev_status(d: dict) -> str: + key = d.get("issue", "") + if not d.get("available"): + return f"[jira:dev_status] {key}: {d.get('reason') or 'not available'}" + prs = d.get("pull_requests", []) + branches = d.get("branches", []) + commits = d.get("commits", []) + lines = [ + f"[jira:dev_status] {key}: {len(prs)} pull request(s), " + f"{len(branches)} branch(es), {len(commits)} commit(s)" + ] + for p in prs: + lines.append( + f" pr {p.get('repository')}#{p.get('id')} [{p.get('status')}] " + f"{p.get('title')} — {p.get('author')} {p.get('url')}".rstrip() + ) + for b in branches: + lines.append(f" branch {b.get('repository')}:{b.get('name')}") + for c in commits: + message = (c.get("message") or "").splitlines()[:1] + lines.append( + f" commit {c.get('repository')}@{(c.get('id') or '')[:12]} " + f"{message[0] if message else ''}".rstrip() + ) + lines.extend(f" warning: {e}" for e in d.get("errors", [])) return "\n".join(lines) @@ -198,16 +252,28 @@ def _act_search(client: JiraClient, kwargs: dict) -> str: jql = (kwargs.get("jql") or "").strip() if not jql: return "[jira:error] jql is required for search" - result = client.search( + return _format_search(_search(client, jql, kwargs), "search") + + +def _search(client: JiraClient, jql: str, kwargs: dict) -> dict: + return client.search( jql, fields=kwargs.get("fields_list") or "", limit=int(kwargs.get("limit") or 25), cursor=kwargs.get("cursor") or "", + validate=(kwargs.get("validate") or "").strip().lower(), ) - return _format_search(result, "search") def _act_my_issues(client: JiraClient, entry: dict, kwargs: dict) -> str: + result = client.search( + _my_issues_jql(entry, kwargs), + limit=int(kwargs.get("limit") or 25), cursor=kwargs.get("cursor") or "", + ) + return _format_search(result, "my_issues") + + +def _my_issues_jql(entry: dict, kwargs: dict) -> str: clauses = ["assignee = currentUser()"] project = (kwargs.get("project") or "").strip() or entry.get("default_project", "") if project: @@ -215,11 +281,7 @@ def _act_my_issues(client: JiraClient, entry: dict, kwargs: dict) -> str: category = (kwargs.get("status_category") or "").strip() if category: clauses.append(f"statusCategory = {_jql_quote(category)}") - jql = " AND ".join(clauses) + " ORDER BY updated DESC" - result = client.search( - jql, limit=int(kwargs.get("limit") or 25), cursor=kwargs.get("cursor") or "" - ) - return _format_search(result, "my_issues") + return " AND ".join(clauses) + " ORDER BY updated DESC" def _act_list_projects(client: JiraClient, kwargs: dict) -> str: @@ -629,98 +691,221 @@ def _log_mutation(svc, action: str, issue: str, project: str, # ── Main entrypoint ─────────────────────────────────────── +# ── JSON mode ───────────────────────────────────────────── + + +class _UsageError(Exception): + """A missing or bad argument found while building a JSON reply.""" + + +def _json_read(client: JiraClient, entry: dict, action: str, issue: str, + kwargs: dict) -> dict | None: + """The structured data behind a read action, or None for an action + with no structured form (the writes), which the caller wraps as text.""" + limit = int(kwargs.get("limit") or 0) + if action == "whoami": + me = client.myself() + return { + "display_name": me.get("displayName", ""), + "id": me.get("accountId") or me.get("key") or me.get("name") or "", + "name": me.get("name", ""), + "email": me.get("emailAddress", ""), + } + if action == "search": + jql = (kwargs.get("jql") or "").strip() + if not jql: + raise _UsageError("jql is required for search") + return _search(client, jql, kwargs) + if action == "my_issues": + return client.search( + _my_issues_jql(entry, kwargs), limit=limit or 25, + cursor=kwargs.get("cursor") or "", + ) + if action == "get_issue": + return client.get_issue(issue) + if action == "dev_status": + return client.dev_status(issue) + if action == "list_projects": + return {"projects": client.list_projects( + query=kwargs.get("query") or "", limit=limit or 50)} + if action == "list_transitions": + return {"issue": issue, "transitions": client.list_transitions(issue)} + if action == "get_create_metadata": + project = (kwargs.get("project") or "").strip() \ + or entry.get("default_project", "") + issue_type = (kwargs.get("issue_type") or "").strip() + if not project or not issue_type: + raise _UsageError( + "project and issue_type are required for get_create_metadata") + return client.get_create_metadata(project, issue_type) + if action == "search_users": + query = (kwargs.get("query") or "").strip() + if not query: + raise _UsageError("query is required for search_users") + return {"users": client.search_users(query, limit=limit or 10)} + if action == "list_link_types": + return {"link_types": client.list_link_types()} + if action == "list_boards": + project = (kwargs.get("project") or "").strip() \ + or entry.get("default_project", "") + return {"boards": client.list_boards(project=project, limit=limit or 50)} + if action == "list_sprints": + board_id = int(kwargs.get("board_id") or 0) + if not board_id: + raise _UsageError( + "board_id is required for list_sprints — find it via list_boards") + return {"board_id": board_id, "sprints": client.list_sprints( + board_id, state=(kwargs.get("sprint_state") or "").strip(), + limit=limit or 50)} + if action == "list_worklogs": + return {"issue": issue, "worklogs": client.list_worklogs(issue)} + return None + + +def _status_data(client: JiraClient | None, err: str, entry: dict, svc) -> dict: + project_path = getattr(svc, "project_path", ".") or "." + accounts = load_jira_config(project_path).get("accounts") or {} + data: dict[str, Any] = { + "accounts": [ + {"name": name, "username": a.get("username", ""), + "base_url": a.get("base_url", ""), + "deployment": a.get("deployment", "")} + for name, a in accounts.items() if isinstance(a, dict) + ], + "account": entry.get("name", ""), + "deployment": entry.get("deployment", ""), + "base_url": entry.get("base_url", ""), + "default_project": entry.get("default_project", ""), + "connected": False, + "formats": ["text", "json"], + } + if client is None: + data["error"] = err or "[jira:no-account]" + return data + try: + info = client.server_info() + me = client.myself() + data.update( + connected=True, + version=info.get("version", ""), + user=me.get("displayName", ""), + ) + except JiraError as exc: + data["error"] = str(exc) + return data + + +def _dispatch_text(client: JiraClient, entry: dict, action: str, issue: str, + kwargs: dict) -> str: + """Run *action* and return its text reply.""" + if action == "whoami": + return _act_whoami(client) + if action == "search": + return _act_search(client, kwargs) + if action == "my_issues": + return _act_my_issues(client, entry, kwargs) + if action == "get_issue": + return _format_issue_full(client.get_issue(issue)) + if action == "dev_status": + return _format_dev_status(client.dev_status(issue)) + if action == "list_projects": + return _act_list_projects(client, kwargs) + if action == "list_transitions": + return _act_list_transitions(client, issue) + if action == "get_create_metadata": + return _act_create_metadata(client, entry, kwargs) + if action == "search_users": + return _act_search_users(client, kwargs) + if action == "list_link_types": + return _act_list_link_types(client) + if action == "link_issues": + return _act_link_issues(client, issue, kwargs) + if action == "unlink_issues": + return _act_unlink_issues(client, kwargs) + if action == "delete_issue": + return _act_delete_issue(client, issue, kwargs) + if action == "list_boards": + return _act_list_boards(client, entry, kwargs) + if action == "list_sprints": + return _act_list_sprints(client, kwargs) + if action == "move_to_sprint": + return _act_move_to_sprint(client, issue, kwargs) + if action == "move_to_backlog": + return _act_move_to_backlog(client, issue) + if action == "add_worklog": + return _act_add_worklog(client, issue, kwargs) + if action == "list_worklogs": + return _act_list_worklogs(client, issue) + if action == "attach_file": + return _act_attach_file(client, issue, kwargs) + if action == "create_issue": + return _act_create_issue(client, entry, kwargs) + if action == "update_issue": + return _act_update_issue(client, issue, kwargs) + if action == "comment": + return _act_comment(client, issue, kwargs) + if action == "transition": + return _act_transition(client, issue, kwargs) + # assign — the action set is closed by the _VALID_ACTIONS gate + return _act_assign(client, issue, kwargs) + + def handle_jira(action: str, svc, finalize, **kwargs) -> str: """Dispatch a Jira action. ``svc`` provides ``project_path``, ``edit_ledger``, ``activity_log``. ``finalize(tool, args, response, summary)`` logs the call. + ``format="json"`` returns ``{"ok", "action", "data"}`` JSON instead of + the capped text reply (see cli/tools/_json_reply.py). """ action = (action or "").strip().lower() args_for_log = {k: v for k, v in kwargs.items() if k not in {"token", "secret"}} args_for_log["action"] = action + want_json = jr.wants_json(kwargs) + + def fail(msg: str, summary: str, *, status: int = 0) -> str: + if want_json: + return finalize(_TOOL, args_for_log, + jr.error(action, msg, status=status, code=summary), + summary, ok=False) + return finalize(_TOOL, args_for_log, msg, summary) if not action: - return finalize(_TOOL, args_for_log, "[jira:error] action is required", "error") + return fail("[jira:error] action is required", "error") client, entry, err = _build_client(svc, kwargs.get("account") or "") # `status` renders the config even without a working client. if action == "status": + if want_json: + data = _status_data(client, err, entry, svc) + return finalize(_TOOL, args_for_log, jr.ok("status", data), "status") resp = _act_status(client, err, entry, svc) return finalize(_TOOL, args_for_log, _cap(resp), "status") if client is None: - return finalize(_TOOL, args_for_log, err, "no-account") + return fail(err, "no-account") if action not in _VALID_ACTIONS: - return finalize( - _TOOL, args_for_log, + return fail( f"[jira:unknown-action] '{action}'. Valid: {', '.join(_VALID_ACTIONS)}", "unknown-action", ) issue = (kwargs.get("issue") or "").strip() - if action in {"get_issue", "list_transitions", "update_issue", - "comment", "transition", "assign", "link_issues", - "delete_issue", "move_to_sprint", "move_to_backlog", - "add_worklog", "list_worklogs", "attach_file"} \ - and not issue: - return finalize( - _TOOL, args_for_log, - f"[jira:error] issue key is required for {action}", "missing-arg", - ) + if action in _ISSUE_ACTIONS and not issue: + return fail(f"[jira:error] issue key is required for {action}", "missing-arg") project_for_log = (kwargs.get("project") or "").strip() \ or entry.get("default_project", "") + data = None try: - if action == "whoami": - resp = _act_whoami(client) - elif action == "search": - resp = _act_search(client, kwargs) - elif action == "my_issues": - resp = _act_my_issues(client, entry, kwargs) - elif action == "get_issue": - resp = _format_issue_full(client.get_issue(issue)) - elif action == "list_projects": - resp = _act_list_projects(client, kwargs) - elif action == "list_transitions": - resp = _act_list_transitions(client, issue) - elif action == "get_create_metadata": - resp = _act_create_metadata(client, entry, kwargs) - elif action == "search_users": - resp = _act_search_users(client, kwargs) - elif action == "list_link_types": - resp = _act_list_link_types(client) - elif action == "link_issues": - resp = _act_link_issues(client, issue, kwargs) - elif action == "unlink_issues": - resp = _act_unlink_issues(client, kwargs) - elif action == "delete_issue": - resp = _act_delete_issue(client, issue, kwargs) - elif action == "list_boards": - resp = _act_list_boards(client, entry, kwargs) - elif action == "list_sprints": - resp = _act_list_sprints(client, kwargs) - elif action == "move_to_sprint": - resp = _act_move_to_sprint(client, issue, kwargs) - elif action == "move_to_backlog": - resp = _act_move_to_backlog(client, issue) - elif action == "add_worklog": - resp = _act_add_worklog(client, issue, kwargs) - elif action == "list_worklogs": - resp = _act_list_worklogs(client, issue) - elif action == "attach_file": - resp = _act_attach_file(client, issue, kwargs) - elif action == "create_issue": - resp = _act_create_issue(client, entry, kwargs) - elif action == "update_issue": - resp = _act_update_issue(client, issue, kwargs) - elif action == "comment": - resp = _act_comment(client, issue, kwargs) - elif action == "transition": - resp = _act_transition(client, issue, kwargs) - else: # assign — action set is closed by the _VALID_ACTIONS gate above - resp = _act_assign(client, issue, kwargs) + if want_json: + data = _json_read(client, entry, action, issue, kwargs) + if data is None: + resp = _dispatch_text(client, entry, action, issue, kwargs) + except _UsageError as exc: + return fail(f"[jira:error] {exc}", "missing-arg") except JiraError as exc: msg = f"[jira:api-error] {exc}" if "appropriate screen" in str(exc): @@ -737,12 +922,15 @@ def handle_jira(action: str, svc, finalize, **kwargs) -> str: "Jira admin must add it to the screen before the API can " "set it." ) - return finalize(_TOOL, args_for_log, msg, f"http-{exc.status}") + return fail(msg, f"http-{exc.status}", status=exc.status) except Exception as exc: # pragma: no cover — defensive - return finalize( - _TOOL, args_for_log, - f"[jira:internal-error] {type(exc).__name__}: {exc}", "error", - ) + return fail(f"[jira:internal-error] {type(exc).__name__}: {exc}", "error") + if data is not None: + return finalize(_TOOL, args_for_log, jr.ok(action, data), action) _log_mutation(svc, action, issue, project_for_log, kwargs, resp) + if want_json: + if resp.startswith(("[jira:error]", "[jira:missing-fields]")): + return fail(resp, "error") + return finalize(_TOOL, args_for_log, jr.text(action, resp), action) return finalize(_TOOL, args_for_log, _cap(resp), action) diff --git a/pyproject.toml b/pyproject.toml index 8df9fee..0aea095 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "code-context-control" -version = "2.157.2" +version = "2.158.0" description = "Local MCP code-intelligence for AI coding tools: surgical search/read/edit, agent-config version history, path-level access + masking guards, and a multi-project hub." readme = "README.md" requires-python = ">=3.10" diff --git a/services/bitbucket_client.py b/services/bitbucket_client.py index 737fc2d..fcf0522 100644 --- a/services/bitbucket_client.py +++ b/services/bitbucket_client.py @@ -6,7 +6,8 @@ (which uses a different host, auth model, and API path). Surface: - * Read — projects, repos, PRs, branches, commits, builds, activity + * Read — projects, repos, PRs, branches, commits, builds, activity, + README / raw files, a PR's linked Jira issues * PR writes — create / comment / approve / unapprove / decline / merge * Branch — create / delete * Admin — repo settings, webhooks, permissions @@ -28,6 +29,14 @@ _API = "/rest/api/1.0" _BUILD_API = "/rest/build-status/1.0" _BRANCH_UTILS = "/rest/branch-utils/1.0" +_JIRA_INTEGRATION = "/rest/jira/1.0" + +# Raw file reads (README, get_file) return at most this many bytes of text. +_FILE_CAP = 256 * 1024 +# A NUL byte in this many leading bytes marks a file as binary. +_BINARY_SNIFF = 8192 +# README picking: markdown first, then the other common forms. +_README_ORDER = (".md", ".markdown", ".rst", ".txt", "") try: from importlib.metadata import version as _pkg_version @@ -49,6 +58,30 @@ def __init__(self, message: str, *, status: int = 0, path: str = "", body: Any = self.body = body +def pick_readme(names: list[str]) -> str: + """The README to show from a list of root file names: ``README.md`` + before ``README.markdown``, ``.rst``, ``.txt``, no extension, then any + other ``README.*``. Matching ignores case. Empty when there is none.""" + def ext(name: str) -> str: + dot = name.find(".") + return name[dot:].lower() if dot >= 0 else "" + + def stem(name: str) -> str: + dot = name.find(".") + return (name[:dot] if dot >= 0 else name).lower() + + found = [n for n in names if "/" not in n and stem(n) == "readme"] + if not found: + return "" + + def rank(name: str) -> tuple[int, str]: + e = ext(name) + order = _README_ORDER.index(e) if e in _README_ORDER else len(_README_ORDER) + return order, name.lower() + + return min(found, key=rank) + + class BitbucketDataCenterClient: """Minimal Bitbucket Data Center / Server REST client.""" @@ -221,12 +254,77 @@ def get_default_branch(self, project_key: str, repo_slug: str) -> dict: "GET", f"/projects/{project_key}/repos/{repo_slug}/default-branch" ) + # ── Files (read) ────────────────────────────────────── + + def list_root_files( + self, project_key: str, repo_slug: str, *, at: str = "" + ) -> list[str]: + """Names of the files (not directories) at the repo root.""" + page = self._request( + "GET", f"/projects/{project_key}/repos/{repo_slug}/browse", + params={"at": at, "limit": 1000}, + ) + children = ((page or {}).get("children") or {}).get("values") or [] + names = [] + for child in children: + if child.get("type") != "FILE": + continue + name = (child.get("path") or {}).get("toString") \ + or (child.get("path") or {}).get("name") or "" + if name: + names.append(name) + return names + + def get_file( + self, project_key: str, repo_slug: str, path: str, *, at: str = "", + max_bytes: int = _FILE_CAP, + ) -> dict: + """A text file's content via ``/raw``. Binary files (a NUL byte near + the start) come back with ``binary`` set and no text; text past + *max_bytes* is cut and ``truncated`` set.""" + clean = path.strip().lstrip("/") + payload = self._request( + "GET", + f"/projects/{project_key}/repos/{repo_slug}/raw/" + f"{urllib.parse.quote(clean, safe='/')}", + params={"at": at}, raw=True, accept="text/plain", + ) + payload = payload if isinstance(payload, bytes) else b"" + out = {"path": clean, "at": at, "size": len(payload), + "binary": False, "truncated": False, "text": ""} + if b"\x00" in payload[:_BINARY_SNIFF]: + out["binary"] = True + return out + if len(payload) > max_bytes: + payload = payload[:max_bytes] + out["truncated"] = True + out["text"] = payload.decode("utf-8", errors="replace") + return out + + def readme( + self, project_key: str, repo_slug: str, *, at: str = "" + ) -> dict: + """The root README (markdown preferred), as ``get_file`` returns it + plus ``found``. No README gives ``found: False`` and an empty path.""" + name = pick_readme(self.list_root_files(project_key, repo_slug, at=at)) + if not name: + return {"found": False, "path": "", "at": at, "size": 0, + "binary": False, "truncated": False, "text": ""} + out = self.get_file(project_key, repo_slug, name, at=at) + out["found"] = True + return out + def list_branches( - self, project_key: str, repo_slug: str, *, filter_text: str = "" + self, project_key: str, repo_slug: str, *, filter_text: str = "", + order: str = "", ) -> list[dict]: + """``order`` is MODIFICATION (most recently changed first) or + ALPHABETICAL; empty keeps the server default.""" params = {"details": "true"} if filter_text: params["filterText"] = filter_text + if order: + params["orderBy"] = order.upper() return list( self._paged( f"/projects/{project_key}/repos/{repo_slug}/branches", params=params @@ -418,6 +516,21 @@ def get_pr_commits( f"/projects/{project_key}/repos/{repo_slug}/pull-requests/{pr_id}/commits" )) + def pr_issues( + self, project_key: str, repo_slug: str, pr_id: int + ) -> list[dict]: + """Jira issues Bitbucket's Jira integration links to a PR: + ``[{"key": "PROJ-1", "url": "..."}]``. Needs the integration set up; + without it the endpoint 404s.""" + res = self._request( + "GET", + f"/projects/{project_key}/repos/{repo_slug}/pull-requests/{pr_id}/issues", + api_root=_JIRA_INTEGRATION, + ) + if isinstance(res, dict): + res = res.get("values") or [] + return [i for i in res if isinstance(i, dict)] if isinstance(res, list) else [] + def get_pr_comment( self, project_key: str, repo_slug: str, pr_id: int, comment_id: int ) -> dict: diff --git a/services/jira_client.py b/services/jira_client.py index 564f203..4275aaa 100644 --- a/services/jira_client.py +++ b/services/jira_client.py @@ -202,9 +202,26 @@ def normalize_issue(raw: dict, text_of: Callable[[Any], str]) -> dict: "updated": f.get("updated", ""), "labels": f.get("labels") or [], } + issue_type = f.get("issuetype") or {} + if issue_type: + dto["issue_type_subtask"] = bool(issue_type.get("subtask")) + dto["issue_type_icon"] = issue_type.get("iconUrl", "") parent = f.get("parent") if isinstance(parent, dict) and parent.get("key"): dto["parent"] = parent["key"] + parent_fields = parent.get("fields") or {} + if parent_fields: + dto["parent_summary"] = parent_fields.get("summary", "") + dto["parent_type"] = (parent_fields.get("issuetype") or {}).get("name", "") + subtasks = f.get("subtasks") + if isinstance(subtasks, list) and subtasks: + dto["subtasks"] = [normalize_subtask(s) for s in subtasks if isinstance(s, dict)] + if isinstance(f.get("components"), list): + dto["components"] = [c.get("name", "") for c in f["components"] if isinstance(c, dict)] + if isinstance(f.get("fixVersions"), list): + dto["fix_versions"] = [v.get("name", "") for v in f["fixVersions"] if isinstance(v, dict)] + if f.get("duedate"): + dto["due"] = f["duedate"] links = f.get("issuelinks") if isinstance(links, list) and links: dto["links"] = [ @@ -215,14 +232,141 @@ def normalize_issue(raw: dict, text_of: Callable[[Any], str]) -> dict: dto["attachments"] = [normalize_attachment(a) for a in attachments] if "description" in f: dto["description"] = text_of(f.get("description")) + # expand=renderedFields: the server's own HTML for rich-text fields + # (wiki markup on DC, ADF on Cloud). Unsanitized: callers that display + # it must sanitize it themselves. + rendered = raw.get("renderedFields") or {} + if isinstance(rendered.get("description"), str) and rendered["description"]: + dto["description_html"] = rendered["description"] + rendered_comments = { + str(c.get("id", "")): c.get("body", "") + for c in ((rendered.get("comment") or {}).get("comments") or []) + if isinstance(c, dict) + } comment_block = f.get("comment") if isinstance(comment_block, dict): dto["comments"] = [ normalize_comment(c, text_of) for c in comment_block.get("comments", []) ] + for c in dto["comments"]: + html = rendered_comments.get(str(c.get("id", ""))) + if isinstance(html, str) and html: + c["body_html"] = html + if comment_block.get("total") is not None: + dto["comment_total"] = comment_block.get("total") return dto +_DEV_STATUS = "/rest/dev-status/1.0/issue/detail" + + +def _dev_commit(raw: dict, repo: dict) -> dict: + return { + "id": raw.get("id", ""), + "display_id": raw.get("displayId", ""), + "message": raw.get("message", ""), + "author": (raw.get("author") or {}).get("name", ""), + "timestamp": raw.get("authorTimestamp", ""), + "url": raw.get("url", ""), + "merge": bool(raw.get("merge")), + "files": raw.get("fileCount", 0), + "repository": repo.get("name", ""), + "repository_url": repo.get("url", ""), + } + + +def _dev_pull_request(raw: dict) -> dict: + return { + "id": str(raw.get("id", "")).lstrip("#"), + "title": raw.get("name", ""), + "url": raw.get("url", ""), + "status": raw.get("status", ""), + "author": (raw.get("author") or {}).get("name", ""), + "source_branch": (raw.get("source") or {}).get("branch", ""), + "destination_branch": (raw.get("destination") or {}).get("branch", ""), + "repository": raw.get("repositoryName", ""), + "repository_url": raw.get("repositoryUrl", ""), + "updated": raw.get("lastUpdate", ""), + "comment_count": raw.get("commentCount", 0), + "reviewers": [ + {"name": r.get("name", ""), "approved": bool(r.get("approved"))} + for r in (raw.get("reviewers") or []) if isinstance(r, dict) + ], + } + + +def _dev_branch(raw: dict) -> dict: + repo = raw.get("repository") or {} + last = raw.get("lastCommit") or {} + return { + "name": raw.get("name", ""), + "url": raw.get("url", ""), + "create_pr_url": raw.get("createPullRequestUrl", ""), + "repository": repo.get("name", ""), + "repository_url": repo.get("url", ""), + "last_commit": _dev_commit(last, repo) if last else {}, + } + + +def fetch_dev_status(transport: "JiraTransport", key: str, issue_id: str, *, + application_type: str) -> dict: + """Read the development panel through Jira's internal dev-status API + (one call per data type). The API is undocumented and some instances + block it: a refusal on the first call returns ``available: False``.""" + out: dict[str, Any] = { + "available": True, "issue": key, "issue_id": issue_id, + "pull_requests": [], "branches": [], "commits": [], "errors": [], + } + for data_type in ("pullrequest", "branch", "repository"): + try: + page = transport.request( + "GET", _DEV_STATUS, + params={"issueId": issue_id, "applicationType": application_type, + "dataType": data_type}, + ) + except JiraError as exc: + if exc.status in (401, 403, 404, 405): + if data_type == "pullrequest": + return {"available": False, "issue": key, "issue_id": issue_id, + "reason": f"dev-status API unavailable (HTTP {exc.status})"} + out["errors"].append(f"{data_type}: HTTP {exc.status}") + continue + raise + if not isinstance(page, dict): + continue + for detail in page.get("detail") or []: + if not isinstance(detail, dict): + continue + out["pull_requests"].extend( + _dev_pull_request(p) for p in detail.get("pullRequests") or [] + if isinstance(p, dict)) + out["branches"].extend( + _dev_branch(b) for b in detail.get("branches") or [] if isinstance(b, dict)) + for repo in detail.get("repositories") or []: + if isinstance(repo, dict): + out["commits"].extend( + _dev_commit(c, repo) for c in repo.get("commits") or [] + if isinstance(c, dict)) + for err in page.get("errors") or []: + text = err.get("error") if isinstance(err, dict) else err + if text: + out["errors"].append(f"{data_type}: {text}") + return out + + +def normalize_subtask(raw: dict) -> dict: + f = raw.get("fields") or {} + status = f.get("status") or {} + return { + "key": raw.get("key", ""), + "summary": f.get("summary", ""), + "status": status.get("name", ""), + "status_category": (status.get("statusCategory") or {}).get("key", ""), + "issue_type": (f.get("issuetype") or {}).get("name", ""), + "priority": (f.get("priority") or {}).get("name", ""), + } + + def normalize_issue_link(raw: dict) -> dict: """Flatten one ``issuelinks`` entry to ``{id, description, issue, status}`` read from THIS issue's perspective — e.g. ``blocks PROJ-9`` — using the @@ -236,11 +380,16 @@ def normalize_issue_link(raw: dict) -> dict: other, description = raw["inwardIssue"], link_type.get("inward", "") else: return {} + other_fields = other.get("fields") or {} + status = other_fields.get("status") or {} return { "id": raw.get("id", ""), "description": description or link_type.get("name", "link"), "issue": other.get("key", ""), - "status": ((other.get("fields") or {}).get("status") or {}).get("name", ""), + "status": status.get("name", ""), + "status_category": (status.get("statusCategory") or {}).get("key", ""), + "summary": other_fields.get("summary", ""), + "issue_type": (other_fields.get("issuetype") or {}).get("name", ""), } @@ -254,13 +403,18 @@ def normalize_comment(raw: dict, text_of: Callable[[Any], str]) -> dict: def normalize_attachment(raw: dict) -> dict: - return { + dto = { "id": raw.get("id", ""), "filename": raw.get("filename", ""), "size": raw.get("size", 0), "author": (raw.get("author") or {}).get("displayName", ""), "created": raw.get("created", ""), } + for src, dst in (("content", "url"), ("thumbnail", "thumbnail"), + ("mimeType", "mime_type")): + if raw.get(src): + dto[dst] = raw[src] + return dto def normalize_board(raw: dict) -> dict: @@ -367,14 +521,26 @@ def myself(self) -> dict: return self._backend.myself() # Issues — read - def search(self, jql: str, *, fields: str = "", limit: int = 25, cursor: str = "") -> dict: + def search(self, jql: str, *, fields: str = "", limit: int = 25, cursor: str = "", + validate: str = "") -> dict: """Run JQL. Returns ``{"issues": [dto...], "next_cursor": str}`` — - pass ``next_cursor`` back in to fetch the next page ("" = done).""" + pass ``next_cursor`` back in to fetch the next page ("" = done). + ``validate="warn"`` (Data Center) turns an unknown key or project in + the JQL into a warning instead of an error.""" + if validate: + return self._backend.search(jql, fields=fields, limit=limit, cursor=cursor, + validate=validate) return self._backend.search(jql, fields=fields, limit=limit, cursor=cursor) def get_issue(self, key: str, *, include_comments: bool = True) -> dict: return self._backend.get_issue(key, include_comments=include_comments) + def dev_status(self, key: str) -> dict: + """Jira's development panel for an issue: linked pull requests, + branches and commits. ``{"available": False, "reason"}`` when the + instance does not expose the (internal) dev-status API.""" + return self._backend.dev_status(key) + def list_projects(self, *, query: str = "", limit: int = 50) -> list[dict]: return self._backend.list_projects(query=query, limit=limit) diff --git a/services/jira_cloud.py b/services/jira_cloud.py index f8b88fc..a1ee95a 100644 --- a/services/jira_cloud.py +++ b/services/jira_cloud.py @@ -13,6 +13,7 @@ from services.jira_client import ( JiraTransport, encode_multipart_file, + fetch_dev_status, normalize_attachment, normalize_board, normalize_comment, @@ -97,7 +98,10 @@ def myself(self) -> dict: return self._t.request("GET", f"{_API}/myself") # Read - def search(self, jql: str, *, fields: str = "", limit: int = 25, cursor: str = "") -> dict: + def search(self, jql: str, *, fields: str = "", limit: int = 25, cursor: str = "", + validate: str = "") -> dict: + # /search/jql has no validateQuery parameter; ``validate`` is accepted + # for facade parity and ignored. params = { "jql": jql, "maxResults": limit, @@ -110,12 +114,24 @@ def search(self, jql: str, *, fields: str = "", limit: int = 25, cursor: str = " return {"issues": issues, "next_cursor": page.get("nextPageToken", "") or ""} def get_issue(self, key: str, *, include_comments: bool = True) -> dict: - fields = _DEFAULT_FIELDS + ",description,parent,issuelinks,attachment" + fields = _DEFAULT_FIELDS + ( + ",description,parent,issuelinks,attachment,subtasks,components," + "fixVersions,duedate" + ) if include_comments: fields += ",comment" - raw = self._t.request("GET", f"{_API}/issue/{key}", params={"fields": fields}) + raw = self._t.request( + "GET", f"{_API}/issue/{key}", + params={"fields": fields, "expand": "renderedFields"}, + ) return normalize_issue(raw, text_from_adf) + def dev_status(self, key: str) -> dict: + issue_id = key if key.isdigit() else str( + self._t.request("GET", f"{_API}/issue/{key}", params={"fields": "summary"}) + .get("id", "")) + return fetch_dev_status(self._t, key, issue_id, application_type="bitbucket") + def list_projects(self, *, query: str = "", limit: int = 50) -> list[dict]: page = self._t.request( "GET", f"{_API}/project/search", diff --git a/services/jira_data_center.py b/services/jira_data_center.py index 5fe9833..63c25f1 100644 --- a/services/jira_data_center.py +++ b/services/jira_data_center.py @@ -12,6 +12,7 @@ JiraError, JiraTransport, encode_multipart_file, + fetch_dev_status, normalize_attachment, normalize_board, normalize_comment, @@ -28,6 +29,10 @@ "summary,status,issuetype,priority,assignee,reporter,project," "created,updated,labels" ) +_DETAIL_FIELDS = ( + ",description,parent,issuelinks,attachment,subtasks,components," + "fixVersions,duedate" +) # createmeta paging. DC caps maxResults server-side; _MAX_PAGES is a runaway # guard, not an expected ceiling. @@ -137,34 +142,43 @@ def myself(self) -> dict: return self._t.request("GET", f"{_API}/myself") # Read - def search(self, jql: str, *, fields: str = "", limit: int = 25, cursor: str = "") -> dict: + def search(self, jql: str, *, fields: str = "", limit: int = 25, cursor: str = "", + validate: str = "") -> dict: try: start_at = max(0, int(cursor)) if cursor else 0 except ValueError: start_at = 0 - page = self._t.request( - "GET", f"{_API}/search", - params={ - "jql": jql, - "startAt": start_at, - "maxResults": limit, - "fields": fields or _DEFAULT_FIELDS, - }, - ) + params = { + "jql": jql, + "startAt": start_at, + "maxResults": limit, + "fields": fields or _DEFAULT_FIELDS, + } + # validateQuery=warn: a `key in (...)` naming a missing issue or + # project returns the rest plus warnings instead of a 400. + if validate in ("warn", "false"): + params["validateQuery"] = validate + page = self._t.request("GET", f"{_API}/search", params=params) issues = [normalize_issue(i, _text_of) for i in page.get("issues", [])] total = int(page.get("total", 0) or 0) next_offset = start_at + len(issues) next_cursor = str(next_offset) if issues and next_offset < total else "" - return {"issues": issues, "next_cursor": next_cursor} + out = {"issues": issues, "next_cursor": next_cursor, "total": total} + if page.get("warningMessages"): + out["warnings"] = list(page["warningMessages"]) + return out def get_issue(self, key: str, *, include_comments: bool = True) -> dict: - fields = _DEFAULT_FIELDS + ",description,parent,issuelinks,attachment" + fields = _DEFAULT_FIELDS + _DETAIL_FIELDS epic_field = self._epic_link_field_id() if epic_field: fields += f",{epic_field}" if include_comments: fields += ",comment" - raw = self._t.request("GET", f"{_API}/issue/{key}", params={"fields": fields}) + raw = self._t.request( + "GET", f"{_API}/issue/{key}", + params={"fields": fields, "expand": "renderedFields"}, + ) dto = normalize_issue(raw, _text_of) if epic_field and not dto.get("parent"): epic_key = (raw.get("fields") or {}).get(epic_field) @@ -172,6 +186,13 @@ def get_issue(self, key: str, *, include_comments: bool = True) -> dict: dto["parent"] = epic_key return dto + def dev_status(self, key: str) -> dict: + issue_id = key if key.isdigit() else str( + self._t.request("GET", f"{_API}/issue/{key}", params={"fields": "summary"}) + .get("id", "")) + # applicationType=stash is Bitbucket Server / Data Center. + return fetch_dev_status(self._t, key, issue_id, application_type="stash") + def list_projects(self, *, query: str = "", limit: int = 50) -> list[dict]: projects = self._t.request("GET", f"{_API}/project") rows = [ diff --git a/tests/test_bitbucket_client.py b/tests/test_bitbucket_client.py index 442603d..4b16cf1 100644 --- a/tests/test_bitbucket_client.py +++ b/tests/test_bitbucket_client.py @@ -398,6 +398,108 @@ def fake_urlopen(req, timeout=None, context=None): self.assertIn("/pull-requests/42/participants/jane%20doe", captured["url"]) self.assertEqual(captured["body"], {"status": "NEEDS_WORK"}) + def _raw(self, payload: bytes, seen: list): + def fake_urlopen(req, timeout=None, context=None): + seen.append((req.full_url, req.get_header("Accept"))) + return _Resp(payload) + return fake_urlopen + + def test_get_file_quotes_path_and_passes_at(self): + seen: list = [] + with mock.patch("urllib.request.urlopen", side_effect=self._raw(b"hello", seen)): + f = self.client.get_file("PROJ", "repo", "/docs/my notes.md", at="refs/heads/dev") + url, accept = seen[0] + self.assertIn("/rest/api/1.0/projects/PROJ/repos/repo/raw/docs/my%20notes.md?", url) + self.assertIn("at=refs%2Fheads%2Fdev", url) + self.assertEqual(accept, "text/plain") + self.assertEqual(f, {"path": "docs/my notes.md", "at": "refs/heads/dev", + "size": 5, "binary": False, "truncated": False, + "text": "hello"}) + + def test_get_file_refuses_binary(self): + seen: list = [] + with mock.patch("urllib.request.urlopen", + side_effect=self._raw(b"\x89PNG\x00\x00data", seen)): + f = self.client.get_file("PROJ", "repo", "logo.png") + self.assertTrue(f["binary"]) + self.assertEqual(f["text"], "") + self.assertEqual(f["size"], 10) + + def test_get_file_caps_large_text(self): + seen: list = [] + big = b"a" * (256 * 1024 + 10) + with mock.patch("urllib.request.urlopen", side_effect=self._raw(big, seen)): + f = self.client.get_file("PROJ", "repo", "big.txt") + self.assertTrue(f["truncated"]) + self.assertEqual(len(f["text"]), 256 * 1024) + self.assertEqual(f["size"], len(big)) + + def test_pick_readme_prefers_markdown_at_root(self): + from services.bitbucket_client import pick_readme + self.assertEqual(pick_readme(["README.txt", "readme.md", "README"]), "readme.md") + self.assertEqual(pick_readme(["Readme.rst", "README"]), "Readme.rst") + self.assertEqual(pick_readme(["README", "README.adoc"]), "README") + self.assertEqual(pick_readme(["docs/README.md", "main.py", "READMEFIRST.md"]), "") + self.assertEqual(pick_readme([]), "") + + def test_readme_browses_root_then_fetches_raw(self): + seen: list = [] + browse = {"children": {"values": [ + {"type": "DIRECTORY", "path": {"toString": "README.md"}}, + {"type": "FILE", "path": {"toString": "README.txt"}}, + {"type": "FILE", "path": {"name": "README.markdown"}}, + ]}} + + def fake_urlopen(req, timeout=None, context=None): + seen.append(req.full_url) + if "/browse" in req.full_url: + return _ok(browse) + return _Resp(b"# Title") + + with mock.patch("urllib.request.urlopen", side_effect=fake_urlopen): + f = self.client.readme("PROJ", "repo", at="dev") + self.assertIn("/repos/repo/browse?at=dev", seen[0]) + self.assertIn("/raw/README.markdown", seen[1]) + self.assertTrue(f["found"]) + self.assertEqual(f["text"], "# Title") + + def test_readme_missing_is_not_found(self): + with mock.patch("urllib.request.urlopen", + return_value=_ok({"children": {"values": []}})): + f = self.client.readme("PROJ", "repo") + self.assertFalse(f["found"]) + self.assertEqual(f["path"], "") + + def test_pr_issues_uses_jira_integration_api(self): + captured = {} + + def fake_urlopen(req, timeout=None, context=None): + captured["url"] = req.full_url + return _ok([{"key": "PROJ-7", "url": "https://jira/browse/PROJ-7"}]) + + with mock.patch("urllib.request.urlopen", side_effect=fake_urlopen): + issues = self.client.pr_issues("PROJ", "repo", 12) + self.assertEqual( + captured["url"], + "https://bb.example.com/rest/jira/1.0/projects/PROJ/repos/repo/pull-requests/12/issues", + ) + self.assertEqual(issues[0]["key"], "PROJ-7") + + def test_list_branches_sends_details_filter_and_order(self): + captured = {} + + def fake_urlopen(req, timeout=None, context=None): + captured["url"] = req.full_url + return _ok({"values": [{"displayId": "main"}], "isLastPage": True}) + + with mock.patch("urllib.request.urlopen", side_effect=fake_urlopen): + rows = self.client.list_branches("PROJ", "repo", filter_text="feat", + order="modification") + self.assertEqual(rows, [{"displayId": "main"}]) + self.assertIn("details=true", captured["url"]) + self.assertIn("filterText=feat", captured["url"]) + self.assertIn("orderBy=MODIFICATION", captured["url"]) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_bitbucket_tool.py b/tests/test_bitbucket_tool.py index 80587fd..a9f4edf 100644 --- a/tests/test_bitbucket_tool.py +++ b/tests/test_bitbucket_tool.py @@ -1,6 +1,7 @@ """Tests for cli/tools/bitbucket.py — handle_bitbucket dispatch.""" from __future__ import annotations +import json import sys import tempfile import unittest @@ -111,10 +112,83 @@ def decline_pr(self, project, repo, pr_id, *, version): self._record("decline_pr", project, repo, pr_id, version=version) return {"state": "DECLINED"} - def list_branches(self, project, repo, *, filter_text=""): - self._record("list_branches", project, repo, filter_text=filter_text) - return [{"id": "refs/heads/main", "displayId": "main", "isDefault": True, - "latestCommit": "abc1234567"}] + def list_branches(self, project, repo, *, filter_text="", order=""): + self._record("list_branches", project, repo, filter_text=filter_text, + order=order) + return [ + {"id": "refs/heads/main", "displayId": "main", "isDefault": True, + "latestCommit": "abc1234567"}, + {"id": "refs/heads/feature/PROJ-7", "displayId": "feature/PROJ-7", + "isDefault": False, "latestCommit": "def7654321", + "metadata": { + tool._META_AHEAD_BEHIND: {"ahead": 2, "behind": 5}, + tool._META_LAST_COMMIT: { + "id": "def7654321", "displayId": "def7654", + "message": "PROJ-7 wire it", "author": {"name": "bob"}, + "authorTimestamp": 1700000000000}, + tool._META_JIRA: + [{"key": "PROJ-7", "url": "https://jira/browse/PROJ-7"}], + tool._META_OUTGOING_PR: + {"pullRequest": {"id": 12, "state": "OPEN", "title": "Wire it"}}, + }}, + ] + + def get_repo(self, project, repo): + self._record("get_repo", project, repo) + return {"slug": repo, "name": "Repo", "description": "The repo", + "project": {"key": project, "name": "Project"}, + "scmId": "git", "state": "AVAILABLE", "public": False, + "links": {"self": [{"href": "https://bb/projects/PROJ/repos/repo/browse"}], + "clone": [{"name": "http", "href": "https://bb/scm/proj/repo.git"}, + {"name": "ssh", "href": "ssh://git@bb:7999/proj/repo.git"}]}} + + def get_default_branch(self, project, repo): + self._record("get_default_branch", project, repo) + return {"id": "refs/heads/main", "displayId": "main"} + + def readme(self, project, repo, *, at=""): + self._record("readme", project, repo, at=at) + return {"found": True, "path": "README.md", "at": at, "size": 9, + "binary": False, "truncated": False, "text": "# Repo\nhi"} + + def get_file(self, project, repo, path, *, at=""): + self._record("get_file", project, repo, path, at=at) + return {"path": path, "at": at, "size": 3, "binary": False, + "truncated": False, "text": "abc"} + + def pr_issues(self, project, repo, pr_id): + self._record("pr_issues", project, repo, pr_id) + return [{"key": "PROJ-7", "url": "https://jira/browse/PROJ-7"}] + + def get_pr_activities(self, project, repo, pr_id, *, limit=50): + self._record("get_pr_activities", project, repo, pr_id, limit=limit) + # Bitbucket returns newest first. + return [ + {"id": 3, "action": "COMMENTED", "createdDate": 3000, + "user": {"name": "bob", "displayName": "Bob"}, + "commentAction": "ADDED", + "comment": {"id": 30, "text": "**Looks** good", + "author": {"name": "bob"}, "createdDate": 3000, + "comments": [{"id": 31, "text": "thanks", + "author": {"name": "alice"}}]}, + "commentAnchor": {"path": "a.py", "line": 4, "lineType": "ADDED"}}, + {"id": 1, "action": "OPENED", "createdDate": 1000, + "user": {"name": "alice", "displayName": "Alice"}}, + ] + + def list_commits(self, project, repo, *, branch="", path="", limit=50): + self._record("list_commits", project, repo, branch=branch, path=path, + limit=limit) + return [{"id": "abc1234567", "displayId": "abc1234", + "message": "PROJ-7 fix", "author": {"name": "alice", + "emailAddress": "a@x"}, "authorTimestamp": 1700000000000, + "parents": [{"id": "0001"}], + "properties": {"jira-key": ["PROJ-7"]}}] + + def get_build_status(self, commit): + self._record("get_build_status", commit) + return [{"state": "SUCCESSFUL", "key": "ci", "name": "CI", + "url": "https://ci/1", "dateAdded": 1700000000000}] class _StubLedger: @@ -145,7 +219,7 @@ def _captured_finalize(): seen: list[tuple] = [] def finalize(name, args, resp, summary, **kw): - seen.append((name, dict(args), resp, summary)) + seen.append((name, dict(args), resp, summary, kw)) return resp return finalize, seen @@ -349,6 +423,39 @@ def test_repo_scoped_action_without_defaults_errors(self): res = tool.handle_bitbucket("list_branches", self.svc, finalize) self.assertIn("project and repo are required", res) + def test_get_repo_text_shows_description_default_and_url(self): + finalize, _ = _captured_finalize() + res = tool.handle_bitbucket("get_repo", self.svc, finalize) + self.assertIn("description: The repo", res) + self.assertIn("default: main", res) + self.assertIn("https://bb/projects/PROJ/repos/repo/browse", res) + + def test_readme_and_get_file_text(self): + finalize, _ = _captured_finalize() + res = tool.handle_bitbucket("readme", self.svc, finalize, at="dev") + self.assertIn("[bitbucket:readme] PROJ/repo README.md@dev", res) + self.assertIn("# Repo", res) + res = tool.handle_bitbucket("get_file", self.svc, finalize) + self.assertIn("path is required", res) + res = tool.handle_bitbucket("get_file", self.svc, finalize, path="src/a.py") + self.assertIn("src/a.py (3 bytes)", res) + + def test_pr_issues_text_needs_pr_id(self): + finalize, _ = _captured_finalize() + res = tool.handle_bitbucket("pr_issues", self.svc, finalize) + self.assertIn("pr_id required", res) + res = tool.handle_bitbucket("pr_issues", self.svc, finalize, pr_id=12) + self.assertIn("1 issue(s)", res) + self.assertIn("PROJ-7", res) + + def test_list_branches_passes_filter_and_order(self): + finalize, _ = _captured_finalize() + tool.handle_bitbucket("list_branches", self.svc, finalize, + filter_text="feat", order="modification") + kw = self.client.calls[0][2] + self.assertEqual(kw["filter_text"], "feat") + self.assertEqual(kw["order"], "modification") + def test_cap_clamps_single_overlong_line(self): # Issue 7: one line with no newlines must still be clamped by chars. long_line = "x" * 200_000 @@ -360,5 +467,136 @@ def test_cap_clamps_single_overlong_line(self): self.assertLess(len(out), len(long_line)) +class TestBitbucketJson(unittest.TestCase): + """format="json" replies.""" + + setUp = TestBitbucketTool.setUp + tearDown = TestBitbucketTool.tearDown + + def _json(self, action, **kw): + finalize, seen = _captured_finalize() + res = tool.handle_bitbucket(action, self.svc, finalize, format="json", **kw) + self.seen = seen + return json.loads(res) + + def test_status_json_lists_formats(self): + out = self._json("status") + self.assertTrue(out["ok"]) + self.assertEqual(out["data"]["formats"], ["text", "json"]) + self.assertTrue(out["data"]["connected"]) + self.assertEqual(out["data"]["version"], "8.5.0") + self.assertEqual(out["data"]["default_project"], "PROJ") + + def test_get_repo_json(self): + d = self._json("get_repo")["data"] + self.assertEqual(d["default_branch"], "main") + self.assertEqual(d["description"], "The repo") + self.assertEqual(d["url"], "https://bb/projects/PROJ/repos/repo/browse") + self.assertEqual([c["name"] for c in d["clone"]], ["http", "ssh"]) + + def test_list_prs_and_get_pr_json(self): + out = self._json("list_prs") + self.assertEqual(out["action"], "list_prs") + pr = out["data"]["pull_requests"][0] + self.assertEqual((pr["id"], pr["source_branch"], pr["destination_branch"]), + (1, "f", "main")) + pr = self._json("get_pr", pr_id=5)["data"] + self.assertEqual(pr["id"], 5) + self.assertEqual(pr["version"], 7) + self.assertEqual(pr["author"]["display_name"], "Alice") + self.assertEqual(pr["reviewers"], []) + + def test_branches_json_reads_metadata(self): + rows = self._json("list_branches")["data"]["branches"] + main, feat = rows + self.assertTrue(main["is_default"]) + self.assertIsNone(main["ahead"]) + self.assertEqual((feat["ahead"], feat["behind"]), (2, 5)) + self.assertEqual(feat["jira_keys"], ["PROJ-7"]) + self.assertEqual(feat["pull_request"]["id"], 12) + self.assertEqual(feat["last_commit"]["display_id"], "def7654") + self.assertEqual(len(self._json("list_branches", limit=1)["data"]["branches"]), 1) + + def test_activities_json_oldest_first_with_replies(self): + acts = self._json("get_pr_activities", pr_id=4)["data"]["activities"] + self.assertEqual([a["id"] for a in acts], [1, 3]) + c = acts[1] + self.assertEqual(c["comment"]["text"], "**Looks** good") + self.assertEqual(c["comment"]["replies"][0]["text"], "thanks") + self.assertEqual(c["anchor"], {"path": "a.py", "line": 4, "line_type": "ADDED"}) + + def test_commits_json_carries_jira_keys_without_email(self): + c = self._json("list_commits")["data"]["commits"][0] + self.assertEqual(c["jira_keys"], ["PROJ-7"]) + self.assertEqual(c["parents"], ["0001"]) + self.assertNotIn("a@x", json.dumps(c)) + + def test_readme_get_file_pr_issues_json(self): + d = self._json("readme")["data"] + self.assertTrue(d["found"]) + self.assertEqual(d["path"], "README.md") + d = self._json("get_file", path="src/a.py", at="dev")["data"] + self.assertEqual((d["path"], d["text"]), ("src/a.py", "abc")) + self.assertEqual(self.client.calls[-1][2]["at"], "dev") + d = self._json("pr_issues", pr_id=12)["data"] + self.assertEqual(d["issues"], [{"key": "PROJ-7", "url": "https://jira/browse/PROJ-7"}]) + + def test_build_status_json(self): + d = self._json("build_status", commit="abc")["data"] + self.assertEqual(d["builds"][0]["state"], "SUCCESSFUL") + out = self._json("build_status") + self.assertFalse(out["ok"]) + self.assertEqual(out["code"], "missing-arg") + + def test_missing_path_is_json_error(self): + out = self._json("get_file") + self.assertFalse(out["ok"]) + self.assertIn("path is required", out["error"]) + self.assertEqual(self.seen[-1][4], {"ok": False}) + + def test_missing_pr_id_and_unknown_action_are_json_errors(self): + out = self._json("get_pr") + self.assertEqual((out["ok"], out["code"]), (False, "missing-arg")) + out = self._json("explode") + self.assertEqual(out["code"], "unknown-action") + self.assertIn("readme", out["error"]) + + def test_api_error_json_carries_status(self): + from services.bitbucket_client import BitbucketError + with mock.patch.object(self.client, "get_pull_request", + side_effect=BitbucketError("gone", status=404)): + out = self._json("get_pr", pr_id=5) + self.assertFalse(out["ok"]) + self.assertEqual(out["status"], 404) + self.assertEqual(out["code"], "http-404") + + def test_write_in_json_mode_returns_text_and_logs(self): + out = self._json("merge_pr", pr_id=42) + self.assertTrue(out["ok"]) + self.assertIn("[bitbucket:merged]", out["data"]["text"]) + self.assertEqual(len(self.svc.edit_ledger.entries), 1) + + def test_write_validation_error_in_json_mode_is_not_ok(self): + out = self._json("update_pr_comment", pr_id=4) + self.assertFalse(out["ok"]) + self.assertIn("comment_id and body are required", out["error"]) + self.assertEqual(self.svc.edit_ledger.entries, []) + + def test_large_diff_trims_to_valid_json(self): + with mock.patch.object(self.client, "get_pr_diff", create=True, + return_value="+" * 500_000): + finalize, _ = _captured_finalize() + res = tool.handle_bitbucket("get_pr_diff", self.svc, finalize, + format="json", pr_id=4) + out = json.loads(res) + self.assertLessEqual(len(res), 400_000) + self.assertEqual(out["truncated"], ["diff"]) + + def test_text_default_unchanged(self): + finalize, _ = _captured_finalize() + res = tool.handle_bitbucket("get_pr", self.svc, finalize, pr_id=5) + self.assertTrue(res.startswith("[bitbucket:pr]")) + + if __name__ == "__main__": unittest.main() diff --git a/tests/test_jira_client.py b/tests/test_jira_client.py index 3d113dc..27f6c8a 100644 --- a/tests/test_jira_client.py +++ b/tests/test_jira_client.py @@ -461,9 +461,11 @@ def test_get_issue_surfaces_parent_and_links(self): self.assertEqual(dto["parent"], "PROJ-42") self.assertEqual(dto["links"], [ {"id": "77", "description": "blocks", "issue": "PROJ-9", - "status": "Open"}, + "status": "Open", "status_category": "", "summary": "", + "issue_type": ""}, {"id": "78", "description": "is blocked by", "issue": "PROJ-3", - "status": ""}, + "status": "", "status_category": "", "summary": "", + "issue_type": ""}, ]) # The narrowed field list must actually request the new fields. url = m.call_args[0][0].get_full_url() @@ -676,5 +678,112 @@ def test_get_issue_surfaces_attachments(self): self.assertIn("attachment", m.call_args[0][0].get_full_url()) +class TestRichIssueAndDevStatus(unittest.TestCase): + def setUp(self): + from services import jira_data_center + # No epic-link lookup: the DC get_issue goes straight to the issue. + jira_data_center._EPIC_FIELD_CACHE["https://jira.corp.example"] = "" + + def tearDown(self): + from services import jira_data_center + jira_data_center._EPIC_FIELD_CACHE.clear() + + def test_get_issue_rendered_html_type_and_subtasks(self): + issue = json.loads(json.dumps(_ISSUE)) + issue["fields"]["issuetype"] = {"name": "Story", "subtask": False, + "iconUrl": "https://j/story.svg"} + issue["fields"]["description"] = "h1. Title" + issue["fields"]["components"] = [{"name": "api"}] + issue["fields"]["fixVersions"] = [{"name": "1.2"}] + issue["fields"]["duedate"] = "2026-11-01" + issue["fields"]["subtasks"] = [{ + "key": "PROJ-2", "fields": { + "summary": "child", "status": { + "name": "Done", "statusCategory": {"key": "done"}}, + "issuetype": {"name": "Sub-task"}}}] + issue["fields"]["comment"] = {"total": 1, "comments": [ + {"id": "9", "author": {"displayName": "Carol"}, "body": "*hi*", + "created": "2026-07-03"}]} + issue["renderedFields"] = { + "description": "
x
"} + out = self._json("get_issue", issue="PROJ-1") + self.assertTrue(out["ok"]) + self.assertEqual(out["action"], "get_issue") + self.assertEqual(out["data"]["issue_type"], "Bug") + self.assertEqual(out["data"]["description_html"], "x
") + self.assertNotIn("truncated", out) + + def test_json_search_passes_validate(self): + out = self._json("search", jql="key in (PROJ-1)", validate="warn") + self.assertEqual(out["data"]["issues"][0]["key"], "PROJ-1") + name, _a, kw = self.client.calls[-1] + self.assertEqual(name, "search") + self.assertEqual(kw["validate"], "warn") + + def test_json_list_wraps_rows(self): + out = self._json("list_transitions", issue="PROJ-1") + self.assertEqual(out["data"]["issue"], "PROJ-1") + self.assertEqual(len(out["data"]["transitions"]), 2) + + def test_json_errors_are_json(self): + out = self._json("get_issue") + self.assertFalse(out["ok"]) + self.assertIn("issue key is required", out["error"]) + out = self._json("search") + self.assertFalse(out["ok"]) + self.assertIn("jql is required", out["error"]) + self.client.fail_with = JiraError("Issue does not exist", status=404) + out = self._json("get_issue", issue="PROJ-404") + self.assertFalse(out["ok"]) + self.assertEqual(out["status"], 404) + + def test_json_write_carries_text_and_logs(self): + out = self._json("comment", issue="PROJ-1", body="hello") + self.assertTrue(out["ok"]) + self.assertIn("PROJ-1", out["data"]["text"]) + self.assertEqual(len(self.svc.edit_ledger.entries), 1) + + def test_json_status_lists_formats(self): + out = self._json("status") + self.assertTrue(out["data"]["connected"]) + self.assertIn("json", out["data"]["formats"]) + self.assertEqual(out["data"]["version"], "9.4.0") + + def test_json_truncation_stays_valid(self): + self.client.issue_extra = {"comments": [ + {"id": str(n), "author": "C", "created": "", "body": "y" * 2000} + for n in range(400)]} + out = self._json("get_issue", issue="PROJ-1") + self.assertEqual(out["truncated"], ["comments"]) + comments = out["data"]["comments"] + self.assertLess(len(comments), 400) + self.assertEqual(comments[-1]["id"], "399") # newest kept + + def test_dev_status_text(self): + resp = self._run("dev_status", issue="PROJ-1") + self.assertIn("1 pull request(s), 1 branch(es), 1 commit(s)", resp) + self.assertIn("pr api#12 [OPEN] PROJ-1 fix", resp) + self.assertIn("commit api@abc123def456 PROJ-1 fix", resp) + + def test_get_issue_text_shows_subtasks_and_parent(self): + self.client.issue_extra = { + "parent": "PROJ-0", "parent_type": "Epic", + "parent_summary": "Big thing", + "subtasks": [{"key": "PROJ-2", "status": "Done", "summary": "child"}], + } + resp = self._run("get_issue", issue="PROJ-1") + self.assertIn("parent: PROJ-0 (Epic) Big thing", resp) + self.assertIn("subtask: PROJ-2 [Done] child", resp) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_json_reply.py b/tests/test_json_reply.py new file mode 100644 index 0000000..e679dcf --- /dev/null +++ b/tests/test_json_reply.py @@ -0,0 +1,88 @@ +"""Tests for cli/tools/_json_reply.py — format="json" replies.""" +from __future__ import annotations + +import json +import sys +import unittest +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(REPO_ROOT)) + +from cli.tools import _json_reply as jr + + +class TestWantsJson(unittest.TestCase): + def test_flag_values(self): + self.assertTrue(jr.wants_json({"format": "json"})) + self.assertTrue(jr.wants_json({"format": " JSON "})) + self.assertFalse(jr.wants_json({"format": "text"})) + self.assertFalse(jr.wants_json({"format": ""})) + self.assertFalse(jr.wants_json({"format": None})) + self.assertFalse(jr.wants_json({})) + + +class TestOk(unittest.TestCase): + def test_dict_data(self): + out = json.loads(jr.ok("get_issue", {"key": "PROJ-1"})) + self.assertEqual(out, {"ok": True, "action": "get_issue", "data": {"key": "PROJ-1"}}) + + def test_non_dict_is_wrapped(self): + out = json.loads(jr.ok("search", [1, 2])) + self.assertEqual(out["data"], {"values": [1, 2]}) + + def test_input_dict_not_mutated(self): + data = {"comments": list(range(5000))} + jr.ok("get_issue", data, cap=2000) + self.assertEqual(len(data["comments"]), 5000) + + def test_keep_tail_lists_keep_newest(self): + data = {"key": "PROJ-1", "comments": [{"n": i, "body": "x" * 50} for i in range(2000)]} + raw = jr.ok("get_issue", data, cap=20_000) + self.assertLessEqual(len(raw), 20_000) + out = json.loads(raw) + self.assertEqual(out["truncated"], ["comments"]) + self.assertEqual(out["data"]["key"], "PROJ-1") + self.assertEqual(out["data"]["comments"][-1]["n"], 1999) + + def test_other_lists_keep_head(self): + data = {"values": [{"n": i, "pad": "y" * 50} for i in range(2000)]} + out = json.loads(jr.ok("list_prs", data, cap=20_000)) + self.assertEqual(out["data"]["values"][0]["n"], 0) + self.assertEqual(out["truncated"], ["values"]) + + def test_long_string_is_cut(self): + raw = jr.ok("get_pr_diff", {"diff": "d" * 100_000}, cap=10_000) + self.assertLessEqual(len(raw), 10_000) + out = json.loads(raw) + self.assertTrue(out["data"]["diff"].startswith("ddd")) + self.assertEqual(out["truncated"], ["diff"]) + + def test_no_truncated_key_when_it_fits(self): + out = json.loads(jr.ok("whoami", {"name": "me"})) + self.assertNotIn("truncated", out) + + +class TestErrorAndText(unittest.TestCase): + def test_error_minimal(self): + out = json.loads(jr.error("get_issue", "boom")) + self.assertEqual(out, {"ok": False, "action": "get_issue", "error": "boom"}) + + def test_error_status_and_code(self): + out = json.loads(jr.error("get_issue", "gone", status=404, code="http-404")) + self.assertEqual(out["status"], 404) + self.assertEqual(out["code"], "http-404") + + def test_text_rides_in_data(self): + out = json.loads(jr.text("merge_pr", "[bitbucket:merged] PR 12")) + self.assertEqual(out["data"], {"text": "[bitbucket:merged] PR 12"}) + + def test_is_json_reply(self): + self.assertTrue(jr.is_json_reply(jr.ok("a", {}))) + self.assertTrue(jr.is_json_reply(jr.error("a", "e"))) + self.assertFalse(jr.is_json_reply("[jira:issue] PROJ-1")) + self.assertFalse(jr.is_json_reply('{"other": 1}')) + + +if __name__ == "__main__": + unittest.main()