code-context-control 2.54.0__py3-none-any.whl → 2.56.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
cli/c3.py CHANGED
@@ -92,7 +92,7 @@ console = Console() if HAS_RICH else None
92
92
  # Config
93
93
  CONFIG_DIR = ".c3"
94
94
  CONFIG_FILE = ".c3/config.json"
95
- __version__ = "2.54.0"
95
+ __version__ = "2.55.0"
96
96
 
97
97
 
98
98
  def _command_deps() -> CommandDeps:
@@ -248,6 +248,7 @@ _C3_MCP_ALLOW = [
248
248
  "mcp__c3__c3_memory", "mcp__c3__c3_validate", "mcp__c3__c3_edit",
249
249
  "mcp__c3__c3_agent", "mcp__c3__c3_delegate", "mcp__c3__c3_edits",
250
250
  "mcp__c3__c3_impact", "mcp__c3__c3_shell", "mcp__c3__c3_bitbucket",
251
+ "mcp__c3__c3_jira",
251
252
  "mcp__c3__c3_project", "mcp__c3__c3_task", "mcp__c3__c3_artifacts",
252
253
  ]
253
254
 
@@ -5737,6 +5738,204 @@ def _bb_cmd_set_default(args, project_path: str) -> None:
5737
5738
  print(f"[OK] Default repo: {args.project}/{args.repo}")
5738
5739
 
5739
5740
 
5741
+ def cmd_jira(args):
5742
+ """Jira Cloud / Data Center credential + workspace management."""
5743
+ sub = getattr(args, "jira_cmd", None)
5744
+ if not sub:
5745
+ print("Usage: c3 jira {login,logout,status,use,set-default} [args]")
5746
+ return
5747
+
5748
+ project_path = getattr(args, "project_path", ".") or "."
5749
+
5750
+ if sub == "login":
5751
+ _jira_cmd_login(args, project_path)
5752
+ elif sub == "logout":
5753
+ _jira_cmd_logout(args, project_path)
5754
+ elif sub == "status":
5755
+ _jira_cmd_status(args, project_path)
5756
+ elif sub == "use":
5757
+ _jira_cmd_use(args, project_path)
5758
+ elif sub == "set-default":
5759
+ _jira_cmd_set_default(args, project_path)
5760
+ else:
5761
+ print(f"Unknown jira subcommand: {sub}")
5762
+
5763
+
5764
+ def _jira_deployment_for(args, base_url: str) -> str:
5765
+ """Explicit --deployment wins; *.atlassian.net infers cloud; else unknown."""
5766
+ dep = getattr(args, "deployment", "") or ""
5767
+ if dep:
5768
+ return dep
5769
+ host = base_url.split("//", 1)[-1].split("/", 1)[0].lower()
5770
+ if host.endswith(".atlassian.net") or host.endswith(".jira.com"):
5771
+ return "cloud"
5772
+ return ""
5773
+
5774
+
5775
+ def _jira_cmd_login(args, project_path: str) -> None:
5776
+ import getpass
5777
+
5778
+ from services import jira_credentials as jr_creds
5779
+ from services.jira_client import JiraClient, JiraError
5780
+
5781
+ # --global stores the account in ~/.c3/config.json so it is reusable in
5782
+ # every C3 project (load_jira_config falls back to it automatically).
5783
+ if getattr(args, "use_global", False):
5784
+ project_path = str(Path.home())
5785
+
5786
+ base_url = (args.url or "").rstrip("/")
5787
+ deployment = _jira_deployment_for(args, base_url)
5788
+ if not deployment:
5789
+ print("[error] --deployment cloud|data_center is required for self-hosted URLs.")
5790
+ return
5791
+ name = getattr(args, "name", "") or ""
5792
+ if not name:
5793
+ host = base_url.split("//", 1)[-1].split("/", 1)[0]
5794
+ name = host.split(".")[0] or "default"
5795
+
5796
+ prompt_label = "Email" if deployment == "cloud" else "Username"
5797
+ username = args.username or input(f"{prompt_label} for {base_url}: ").strip()
5798
+ if not username:
5799
+ print("Login cancelled -- username required.")
5800
+ return
5801
+ token_label = "API token" if deployment == "cloud" else "Personal Access Token"
5802
+ token = args.token or getpass.getpass(f"{token_label} for {username}: ").strip()
5803
+ if not token:
5804
+ print("Login cancelled -- token required.")
5805
+ return
5806
+
5807
+ try:
5808
+ jr_creds.save_credentials(
5809
+ name, base_url, username, token,
5810
+ deployment=deployment,
5811
+ project_path=project_path,
5812
+ set_default=not getattr(args, "no_set_default", False),
5813
+ verify_tls=not getattr(args, "insecure", False),
5814
+ ca_bundle=getattr(args, "ca_bundle", "") or "",
5815
+ allow_insecure=getattr(args, "insecure", False),
5816
+ )
5817
+ except jr_creds.JiraCredentialError as exc:
5818
+ print(f"[error] {exc}")
5819
+ return
5820
+
5821
+ scope = "global (~/.c3)" if getattr(args, "use_global", False) else "project"
5822
+ print(f"[OK] Stored credentials for {username}@{base_url} as '{name}' [{deployment}, {scope}]")
5823
+
5824
+ # Connection probe -- skippable for offline setup (--no-verify-login) and
5825
+ # non-fatal on failure (token might be valid but the network blocked).
5826
+ if getattr(args, "no_verify_login", False):
5827
+ print(" Connection probe skipped (--no-verify-login) -- verify later with `c3 jira status`.")
5828
+ return
5829
+ try:
5830
+ client = JiraClient(
5831
+ base_url, username, token,
5832
+ deployment=deployment,
5833
+ verify_tls=not getattr(args, "insecure", False),
5834
+ ca_bundle=getattr(args, "ca_bundle", "") or "",
5835
+ )
5836
+ info = client.server_info()
5837
+ print(f" Server: Jira {info.get('version', '?')} ({base_url})")
5838
+ except (JiraError, ValueError) as exc:
5839
+ print(f"[warn] Connection probe failed: {exc}")
5840
+ print(" Token saved anyway -- re-test with `c3 jira status`.")
5841
+ return
5842
+ try:
5843
+ me = client.myself()
5844
+ if me:
5845
+ print(
5846
+ f" Auth as: {me.get('displayName', username)} "
5847
+ f"<{me.get('emailAddress', '?')}>"
5848
+ )
5849
+ except JiraError:
5850
+ pass
5851
+
5852
+
5853
+ def _jira_cmd_logout(args, project_path: str) -> None:
5854
+ from core.config import load_jira_config
5855
+ from services import jira_credentials as jr_creds
5856
+
5857
+ name = getattr(args, "name", "") or ""
5858
+ if not name:
5859
+ name = load_jira_config(project_path).get("default_account", "")
5860
+ if not name:
5861
+ print("[error] No account specified and no default account configured.")
5862
+ return
5863
+ removed = jr_creds.delete_credentials(name, project_path=project_path)
5864
+ if removed:
5865
+ print(f"[OK] Removed Jira account '{name}'")
5866
+ else:
5867
+ print(f"[warn] Nothing to remove for '{name}'")
5868
+
5869
+
5870
+ def _jira_cmd_status(args, project_path: str) -> None:
5871
+ from core.config import load_jira_config
5872
+ from services import jira_credentials as jr_creds
5873
+ from services.jira_client import JiraClient, JiraError
5874
+
5875
+ cfg = load_jira_config(project_path)
5876
+ accounts = cfg.get("accounts") or {}
5877
+ default = cfg.get("default_account", "")
5878
+
5879
+ print("[jira:status]")
5880
+ print(f" Default : {default or '-'}")
5881
+ print(f" Accounts ({len(accounts)}):")
5882
+ for acct_name, a in accounts.items():
5883
+ if not isinstance(a, dict):
5884
+ continue
5885
+ marker = "*" if acct_name == default else " "
5886
+ print(
5887
+ f" {marker} {acct_name}: {a.get('username', '?')}@{a.get('base_url', '?')} "
5888
+ f"[{a.get('deployment', '?')}] project={a.get('default_project') or '-'}"
5889
+ )
5890
+
5891
+ entry = accounts.get(default) if isinstance(accounts.get(default), dict) else None
5892
+ if not entry:
5893
+ print(" Connection: (no default account)")
5894
+ return
5895
+ token = jr_creds.load_token(entry.get("base_url", ""), entry.get("username", ""))
5896
+ if not token:
5897
+ print(" Connection: FAIL -- no token in keyring")
5898
+ return
5899
+ try:
5900
+ client = JiraClient(
5901
+ entry["base_url"], entry.get("username", ""), token,
5902
+ deployment=entry.get("deployment", "cloud"),
5903
+ verify_tls=bool(entry.get("verify_tls", True)),
5904
+ ca_bundle=entry.get("ca_bundle", ""),
5905
+ )
5906
+ info = client.server_info()
5907
+ me = client.myself()
5908
+ print(
5909
+ f" Connection: OK (Jira {info.get('version', '?')} "
5910
+ f"as {me.get('displayName', '?')})"
5911
+ )
5912
+ except (JiraError, ValueError) as exc:
5913
+ print(f" Connection: FAIL -- {exc}")
5914
+
5915
+
5916
+ def _jira_cmd_use(args, project_path: str) -> None:
5917
+ from services import jira_credentials as jr_creds
5918
+ try:
5919
+ jr_creds.set_default_account(args.name, project_path=project_path)
5920
+ except jr_creds.JiraCredentialError as exc:
5921
+ print(f"[error] {exc}")
5922
+ return
5923
+ print(f"[OK] Default Jira account: {args.name}")
5924
+
5925
+
5926
+ def _jira_cmd_set_default(args, project_path: str) -> None:
5927
+ from services import jira_credentials as jr_creds
5928
+ try:
5929
+ jr_creds.set_default_project(
5930
+ args.project, name=getattr(args, "name", "") or "",
5931
+ project_path=project_path,
5932
+ )
5933
+ except jr_creds.JiraCredentialError as exc:
5934
+ print(f"[error] {exc}")
5935
+ return
5936
+ print(f"[OK] Default Jira project: {args.project}")
5937
+
5938
+
5740
5939
  def cmd_oracle(args):
5741
5940
  """Oracle dashboard server + Discovery API key management."""
5742
5941
  sub = getattr(args, "oracle_cmd", None)
@@ -6836,6 +7035,7 @@ def main():
6836
7035
  "sub": cmd_sub,
6837
7036
  "hub": cmd_hub,
6838
7037
  "bitbucket": cmd_bitbucket,
7038
+ "jira": cmd_jira,
6839
7039
  "oracle": cmd_oracle,
6840
7040
  "upgrade": cmd_upgrade,
6841
7041
  }
cli/commands/parser.py CHANGED
@@ -359,6 +359,48 @@ def build_parser(version: str, parse_cli_ide_arg):
359
359
  bb_default.add_argument("--repo", required=True, help="Repository slug")
360
360
  bb_default.add_argument("project_path", nargs="?", default=".")
361
361
 
362
+ # ── Jira Cloud / Data Center (v2.56.0) ──────────────────────────────
363
+ p_jira = subparsers.add_parser(
364
+ "jira",
365
+ help="Jira Cloud / Data Center credential + workspace management",
366
+ )
367
+ jira_subs = p_jira.add_subparsers(dest="jira_cmd")
368
+
369
+ jr_login = jira_subs.add_parser(
370
+ "login",
371
+ help="Authenticate with a Jira site (interactive token prompt)",
372
+ )
373
+ jr_login.add_argument("--url", required=True, help="Jira base URL (e.g. https://yoursite.atlassian.net)")
374
+ jr_login.add_argument("--deployment", choices=["cloud", "data_center"], default="", help="Deployment type (inferred as 'cloud' for *.atlassian.net URLs; required otherwise)")
375
+ jr_login.add_argument("--name", default="", help="Account name in the registry (default: derived from URL host)")
376
+ jr_login.add_argument("--username", help="Email (Cloud) or username (Data Center); prompted if omitted")
377
+ jr_login.add_argument("--token", help="API token (Cloud) / PAT (Data Center); prompted via getpass if omitted — preferred")
378
+ jr_login.add_argument("--no-set-default", action="store_true", help="Do not make this the default account")
379
+ jr_login.add_argument("--no-verify-login", action="store_true", help="Skip the connection probe (store credentials offline)")
380
+ jr_login.add_argument("--insecure", action="store_true", help="Disable TLS verification / allow http:// (local dev only)")
381
+ jr_login.add_argument("--ca-bundle", default="", help="Path to a custom CA bundle for self-signed enterprise certs")
382
+ jr_login.add_argument("--global", dest="use_global", action="store_true", help="Store the account in the global ~/.c3/config.json so it is reusable in every C3 project")
383
+ jr_login.add_argument("project_path", nargs="?", default=".")
384
+
385
+ jr_logout = jira_subs.add_parser("logout", help="Remove a Jira account from keyring + config")
386
+ jr_logout.add_argument("--name", default="", help="Account name (defaults to the default account)")
387
+ jr_logout.add_argument("project_path", nargs="?", default=".")
388
+
389
+ jr_status = jira_subs.add_parser("status", help="Show configured Jira accounts and connectivity")
390
+ jr_status.add_argument("project_path", nargs="?", default=".")
391
+
392
+ jr_use = jira_subs.add_parser("use", help="Switch the default Jira account")
393
+ jr_use.add_argument("--name", required=True, help="Registered account name")
394
+ jr_use.add_argument("project_path", nargs="?", default=".")
395
+
396
+ jr_default = jira_subs.add_parser(
397
+ "set-default",
398
+ help="Set the default Jira project key on an account",
399
+ )
400
+ jr_default.add_argument("--project", required=True, help="Jira project key (e.g. PROJ)")
401
+ jr_default.add_argument("--name", default="", help="Account name (defaults to the default account)")
402
+ jr_default.add_argument("project_path", nargs="?", default=".")
403
+
362
404
  # ── Oracle Discovery API (v2.32.0) ──────────────────────────────────
363
405
  p_oracle = subparsers.add_parser(
364
406
  "oracle",
cli/mcp_server.py CHANGED
@@ -778,6 +778,50 @@ async def c3_bitbucket(
778
778
  )
779
779
 
780
780
 
781
+ @mcp.tool()
782
+ async def c3_jira(
783
+ action: str,
784
+ issue: str = "",
785
+ jql: str = "",
786
+ project: str = "",
787
+ issue_type: str = "",
788
+ summary: str = "",
789
+ description: str = "",
790
+ body: str = "",
791
+ body_format: str = "text",
792
+ transition: str = "",
793
+ user: str = "",
794
+ query: str = "",
795
+ fields: str = "",
796
+ status_category: str = "",
797
+ account: str = "",
798
+ cursor: str = "",
799
+ limit: int = 25,
800
+ ctx: Context = None,
801
+ ) -> str:
802
+ """JIRA (Cloud + Data Center) — search, read, create, comment, transition, assign issues.
803
+ actions: status, whoami, search (jql), get_issue (issue), my_issues,
804
+ list_projects, list_transitions (issue), get_create_metadata (project, issue_type),
805
+ search_users (query), create_issue (project, issue_type, summary [description] [fields=JSON]),
806
+ comment (issue, body), transition (issue, transition=id|name [body]), assign (issue, user).
807
+ project falls back to the account's default_project; account to jira.default_account.
808
+ Mutating actions are ledger-logged. Tokens live in the OS keyring — `c3 jira login` first."""
809
+ svc = _svc(ctx)
810
+
811
+ def finalize(fname, fargs, fresp, fsumm, **kw):
812
+ return _finalize_response(ctx, fname, fargs, fresp, fsumm, **kw)
813
+
814
+ from cli.tools.jira import handle_jira
815
+ return await asyncio.to_thread(
816
+ handle_jira, action, svc, finalize,
817
+ issue=issue, jql=jql, project=project, issue_type=issue_type,
818
+ summary=summary, description=description, body=body,
819
+ body_format=body_format, transition=transition, user=user,
820
+ query=query, fields=fields, status_category=status_category,
821
+ account=account, cursor=cursor, limit=limit,
822
+ )
823
+
824
+
781
825
  @mcp.tool()
782
826
  async def c3_project(
783
827
  action: str,
cli/server.py CHANGED
@@ -199,6 +199,7 @@ _UI_JS_FILES = [
199
199
  "ui/components/tasks.js",
200
200
  "ui/components/edits.js",
201
201
  "ui/components/bitbucket.js",
202
+ "ui/components/jira.js",
202
203
  "ui/components/instructions.js",
203
204
  "ui/components/settings.js",
204
205
  "ui/components/chat.js",
@@ -3588,6 +3589,234 @@ def api_bitbucket_permissions():
3588
3589
  })
3589
3590
 
3590
3591
 
3592
+ # ─── Jira (Cloud / Data Center) ──────────────────────────
3593
+ def _jira_client_and_entry():
3594
+ """Return (client, account_entry, error_response_or_None)."""
3595
+ from core.config import load_jira_config
3596
+ from services import jira_credentials as jr_creds
3597
+ from services.jira_client import JiraClient
3598
+
3599
+ cfg = load_jira_config(str(PROJECT_PATH))
3600
+ name = cfg.get("default_account", "")
3601
+ entry = (cfg.get("accounts") or {}).get(name)
3602
+ if not isinstance(entry, dict) or not entry.get("base_url"):
3603
+ return None, {}, (jsonify({"error": "no Jira account — run `c3 jira login`"}), 400)
3604
+ try:
3605
+ token = jr_creds.load_token(entry["base_url"], entry.get("username", ""))
3606
+ except RuntimeError as exc:
3607
+ return None, {}, (jsonify({"error": str(exc)}), 500)
3608
+ if not token:
3609
+ return None, {}, (jsonify({
3610
+ "error": f"no keyring token for {entry.get('username')}@{entry['base_url']}",
3611
+ }), 400)
3612
+ try:
3613
+ client = JiraClient(
3614
+ entry["base_url"], entry.get("username", ""), token,
3615
+ deployment=entry.get("deployment", "cloud"),
3616
+ verify_tls=bool(entry.get("verify_tls", True)),
3617
+ ca_bundle=entry.get("ca_bundle", ""),
3618
+ )
3619
+ except ValueError as exc:
3620
+ return None, {}, (jsonify({"error": str(exc)}), 400)
3621
+ return client, {**entry, "name": name}, None
3622
+
3623
+
3624
+ def _jira_handle(call):
3625
+ """Run a Jira API closure; translate JiraError to an HTTP response."""
3626
+ from services.jira_client import JiraError
3627
+ try:
3628
+ return jsonify(call())
3629
+ except JiraError as exc:
3630
+ return jsonify({
3631
+ "error": str(exc),
3632
+ "status": exc.status,
3633
+ "path": exc.path,
3634
+ }), 502 if exc.status == 0 else exc.status
3635
+ except Exception as exc:
3636
+ return jsonify({"error": f"{type(exc).__name__}: {exc}"}), 500
3637
+
3638
+
3639
+ def _jira_my_jql(entry: dict) -> str:
3640
+ clauses = ["assignee = currentUser()"]
3641
+ project = request.args.get("project") or entry.get("default_project", "")
3642
+ if project:
3643
+ safe = project.replace("\\", "\\\\").replace('"', '\\"')
3644
+ clauses.append(f'project = "{safe}"')
3645
+ return " AND ".join(clauses) + " ORDER BY updated DESC"
3646
+
3647
+
3648
+ @app.route('/api/jira/status')
3649
+ def api_jira_status():
3650
+ """Accounts registry, default account, and a connection probe."""
3651
+ from core.config import load_jira_config
3652
+ from services import jira_credentials as jr_creds
3653
+ from services.jira_client import JiraClient, JiraError
3654
+
3655
+ cfg = load_jira_config(str(PROJECT_PATH))
3656
+ out = {"config": cfg, "connection": {"ok": False}}
3657
+ name = cfg.get("default_account", "")
3658
+ entry = (cfg.get("accounts") or {}).get(name)
3659
+ if not isinstance(entry, dict) or not entry.get("base_url"):
3660
+ out["connection"]["error"] = "no default account"
3661
+ return jsonify(out)
3662
+ try:
3663
+ token = jr_creds.load_token(entry["base_url"], entry.get("username", ""))
3664
+ except RuntimeError as exc:
3665
+ out["connection"]["error"] = str(exc)
3666
+ return jsonify(out)
3667
+ if not token:
3668
+ out["connection"]["error"] = "no keyring token"
3669
+ return jsonify(out)
3670
+ try:
3671
+ client = JiraClient(
3672
+ entry["base_url"], entry.get("username", ""), token,
3673
+ deployment=entry.get("deployment", "cloud"),
3674
+ verify_tls=bool(entry.get("verify_tls", True)),
3675
+ ca_bundle=entry.get("ca_bundle", ""),
3676
+ )
3677
+ info = client.server_info()
3678
+ me = client.myself()
3679
+ out["connection"] = {
3680
+ "ok": True,
3681
+ "version": info.get("version", "?"),
3682
+ "user": me.get("displayName", ""),
3683
+ "deployment": entry.get("deployment", ""),
3684
+ }
3685
+ except (JiraError, ValueError) as exc:
3686
+ out["connection"] = {"ok": False, "error": str(exc)}
3687
+ return jsonify(out)
3688
+
3689
+
3690
+ @app.route('/api/jira/issues/my')
3691
+ def api_jira_my_issues():
3692
+ client, entry, err = _jira_client_and_entry()
3693
+ if err is not None:
3694
+ return err
3695
+ return _jira_handle(lambda: client.search(
3696
+ _jira_my_jql(entry),
3697
+ limit=int(request.args.get("limit", 50)),
3698
+ cursor=request.args.get("cursor", ""),
3699
+ ))
3700
+
3701
+
3702
+ @app.route('/api/jira/board')
3703
+ def api_jira_board():
3704
+ """My issues grouped by status category — the v1 'board' derives from
3705
+ plain JQL statusCategory grouping, no agile API involved."""
3706
+ client, entry, err = _jira_client_and_entry()
3707
+ if err is not None:
3708
+ return err
3709
+
3710
+ def call():
3711
+ result = client.search(_jira_my_jql(entry),
3712
+ limit=int(request.args.get("limit", 100)))
3713
+ columns = {"new": [], "indeterminate": [], "done": []}
3714
+ for issue in result.get("issues", []):
3715
+ columns.setdefault(
3716
+ issue.get("status_category") or "new", columns["new"]
3717
+ ).append(issue)
3718
+ return {"columns": columns, "next_cursor": result.get("next_cursor", "")}
3719
+ return _jira_handle(call)
3720
+
3721
+
3722
+ @app.route('/api/jira/search')
3723
+ def api_jira_search():
3724
+ client, entry, err = _jira_client_and_entry()
3725
+ if err is not None:
3726
+ return err
3727
+ jql = (request.args.get("jql") or "").strip()
3728
+ if not jql:
3729
+ return jsonify({"error": "jql query parameter is required"}), 400
3730
+ return _jira_handle(lambda: client.search(
3731
+ jql,
3732
+ limit=int(request.args.get("limit", 50)),
3733
+ cursor=request.args.get("cursor", ""),
3734
+ ))
3735
+
3736
+
3737
+ @app.route('/api/jira/issue/<key>')
3738
+ def api_jira_get_issue(key: str):
3739
+ client, entry, err = _jira_client_and_entry()
3740
+ if err is not None:
3741
+ return err
3742
+
3743
+ def call():
3744
+ issue = client.get_issue(key)
3745
+ issue["transitions"] = client.list_transitions(key)
3746
+ return issue
3747
+ return _jira_handle(call)
3748
+
3749
+
3750
+ @app.route('/api/jira/issue', methods=['POST'])
3751
+ def api_jira_create_issue():
3752
+ client, entry, err = _jira_client_and_entry()
3753
+ if err is not None:
3754
+ return err
3755
+ data = request.get_json(force=True) or {}
3756
+ project = data.get("project") or entry.get("default_project", "")
3757
+ issue_type = data.get("issue_type", "")
3758
+ summary = data.get("summary", "")
3759
+ if not project or not issue_type or not summary:
3760
+ return jsonify({"error": "project, issue_type, and summary are required"}), 400
3761
+ return _jira_handle(lambda: client.create_issue(
3762
+ project, issue_type, summary,
3763
+ description=data.get("description", ""),
3764
+ fields=data.get("fields") or None,
3765
+ ))
3766
+
3767
+
3768
+ @app.route('/api/jira/issue/<key>/transition', methods=['POST'])
3769
+ def api_jira_transition(key: str):
3770
+ client, entry, err = _jira_client_and_entry()
3771
+ if err is not None:
3772
+ return err
3773
+ data = request.get_json(force=True) or {}
3774
+ transition_id = str(data.get("transition", "")).strip()
3775
+ if not transition_id:
3776
+ return jsonify({"error": "transition id is required"}), 400
3777
+ return _jira_handle(lambda: client.transition_issue(
3778
+ key, transition_id, comment=data.get("comment", ""),
3779
+ ) or {"ok": True})
3780
+
3781
+
3782
+ @app.route('/api/jira/issue/<key>/comment', methods=['POST'])
3783
+ def api_jira_comment(key: str):
3784
+ client, entry, err = _jira_client_and_entry()
3785
+ if err is not None:
3786
+ return err
3787
+ data = request.get_json(force=True) or {}
3788
+ body = data.get("body", "")
3789
+ if not str(body).strip():
3790
+ return jsonify({"error": "body is required"}), 400
3791
+ return _jira_handle(lambda: client.add_comment(key, body))
3792
+
3793
+
3794
+ @app.route('/api/jira/activity')
3795
+ def api_jira_activity():
3796
+ """Local issue-key activity — current branch + edit-ledger scan.
3797
+ Pure local (no Jira call), so it works without a configured account."""
3798
+ from services import jira_links
3799
+ out = jira_links.branch_issue_keys(str(PROJECT_PATH))
3800
+ out["entries"] = jira_links.ledger_activity(
3801
+ str(PROJECT_PATH),
3802
+ key=request.args.get("key", ""),
3803
+ limit=int(request.args.get("limit", 25)),
3804
+ )
3805
+ return jsonify(out)
3806
+
3807
+
3808
+ @app.route('/api/jira/issue/<key>/assign', methods=['POST'])
3809
+ def api_jira_assign(key: str):
3810
+ client, entry, err = _jira_client_and_entry()
3811
+ if err is not None:
3812
+ return err
3813
+ data = request.get_json(force=True) or {}
3814
+ user = (data.get("user") or "").strip()
3815
+ if not user:
3816
+ return jsonify({"error": "user (deployment-native id) is required"}), 400
3817
+ return _jira_handle(lambda: client.assign_issue(key, user) or {"ok": True})
3818
+
3819
+
3591
3820
  # ─── Launch ──────────────────────────────────────────────
3592
3821
  def find_free_port(start: int = 3333, max_tries: int = 20) -> int:
3593
3822
  """Return the first free TCP port starting from *start*."""