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 +201 -1
- cli/commands/parser.py +42 -0
- cli/mcp_server.py +44 -0
- cli/server.py +229 -0
- cli/tools/jira.py +459 -0
- cli/ui/app.js +4 -0
- cli/ui/components/jira.js +345 -0
- {code_context_control-2.54.0.dist-info → code_context_control-2.56.0.dist-info}/METADATA +37 -2
- {code_context_control-2.54.0.dist-info → code_context_control-2.56.0.dist-info}/RECORD +23 -16
- core/config.py +66 -0
- oracle/services/chat_engine.py +161 -380
- services/claude_md.py +6 -4
- services/jira_client.py +319 -0
- services/jira_cloud.py +222 -0
- services/jira_credentials.py +250 -0
- services/jira_data_center.py +196 -0
- services/jira_links.py +117 -0
- services/scanner.py +48 -13
- services/session_manager.py +9 -4
- {code_context_control-2.54.0.dist-info → code_context_control-2.56.0.dist-info}/WHEEL +0 -0
- {code_context_control-2.54.0.dist-info → code_context_control-2.56.0.dist-info}/entry_points.txt +0 -0
- {code_context_control-2.54.0.dist-info → code_context_control-2.56.0.dist-info}/licenses/LICENSE +0 -0
- {code_context_control-2.54.0.dist-info → code_context_control-2.56.0.dist-info}/top_level.txt +0 -0
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.
|
|
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*."""
|