entelligence-cli 0.1.75__tar.gz → 0.1.76__tar.gz
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.
- {entelligence_cli-0.1.75 → entelligence_cli-0.1.76}/PKG-INFO +1 -1
- {entelligence_cli-0.1.75 → entelligence_cli-0.1.76}/entelligence_cli/git_operations.py +151 -0
- {entelligence_cli-0.1.75 → entelligence_cli-0.1.76}/entelligence_cli/upload/manifest.py +4 -0
- {entelligence_cli-0.1.75 → entelligence_cli-0.1.76}/entelligence_cli/upload/models.py +5 -0
- {entelligence_cli-0.1.75 → entelligence_cli-0.1.76}/entelligence_cli/upload/runner.py +38 -4
- {entelligence_cli-0.1.75 → entelligence_cli-0.1.76}/entelligence_cli.egg-info/PKG-INFO +1 -1
- {entelligence_cli-0.1.75 → entelligence_cli-0.1.76}/entelligence_cli.egg-info/SOURCES.txt +2 -1
- {entelligence_cli-0.1.75 → entelligence_cli-0.1.76}/pyproject.toml +1 -1
- entelligence_cli-0.1.76/tests/test_upload_repo_filter.py +274 -0
- entelligence_cli-0.1.76/tests/test_upload_repo_roots.py +161 -0
- entelligence_cli-0.1.75/tests/test_upload_repo_filter.py +0 -93
- {entelligence_cli-0.1.75 → entelligence_cli-0.1.76}/README.md +0 -0
- {entelligence_cli-0.1.75 → entelligence_cli-0.1.76}/entelligence_cli/__init__.py +0 -0
- {entelligence_cli-0.1.75 → entelligence_cli-0.1.76}/entelligence_cli/api_client.py +0 -0
- {entelligence_cli-0.1.75 → entelligence_cli-0.1.76}/entelligence_cli/ascii-art.txt +0 -0
- {entelligence_cli-0.1.75 → entelligence_cli-0.1.76}/entelligence_cli/browser_login.py +0 -0
- {entelligence_cli-0.1.75 → entelligence_cli-0.1.76}/entelligence_cli/cli.py +0 -0
- {entelligence_cli-0.1.75 → entelligence_cli-0.1.76}/entelligence_cli/commands/__init__.py +0 -0
- {entelligence_cli-0.1.75 → entelligence_cli-0.1.76}/entelligence_cli/commands/auth.py +0 -0
- {entelligence_cli-0.1.75 → entelligence_cli-0.1.76}/entelligence_cli/commands/incident.py +0 -0
- {entelligence_cli-0.1.75 → entelligence_cli-0.1.76}/entelligence_cli/commands/insights.py +0 -0
- {entelligence_cli-0.1.75 → entelligence_cli-0.1.76}/entelligence_cli/commands/review.py +0 -0
- {entelligence_cli-0.1.75 → entelligence_cli-0.1.76}/entelligence_cli/commands/router.py +0 -0
- {entelligence_cli-0.1.75 → entelligence_cli-0.1.76}/entelligence_cli/commands/security.py +0 -0
- {entelligence_cli-0.1.75 → entelligence_cli-0.1.76}/entelligence_cli/commands/update.py +0 -0
- {entelligence_cli-0.1.75 → entelligence_cli-0.1.76}/entelligence_cli/config.py +0 -0
- {entelligence_cli-0.1.75 → entelligence_cli-0.1.76}/entelligence_cli/exceptions.py +0 -0
- {entelligence_cli-0.1.75 → entelligence_cli-0.1.76}/entelligence_cli/router/__init__.py +0 -0
- {entelligence_cli-0.1.75 → entelligence_cli-0.1.76}/entelligence_cli/router/bedrock.py +0 -0
- {entelligence_cli-0.1.75 → entelligence_cli-0.1.76}/entelligence_cli/router/catalog.py +0 -0
- {entelligence_cli-0.1.75 → entelligence_cli-0.1.76}/entelligence_cli/router/claude_settings.py +0 -0
- {entelligence_cli-0.1.75 → entelligence_cli-0.1.76}/entelligence_cli/router/compress.py +0 -0
- {entelligence_cli-0.1.75 → entelligence_cli-0.1.76}/entelligence_cli/router/daemon.py +0 -0
- {entelligence_cli-0.1.75 → entelligence_cli-0.1.76}/entelligence_cli/router/engine.py +0 -0
- {entelligence_cli-0.1.75 → entelligence_cli-0.1.76}/entelligence_cli/router/gateway.py +0 -0
- {entelligence_cli-0.1.75 → entelligence_cli-0.1.76}/entelligence_cli/router/gemini_translate.py +0 -0
- {entelligence_cli-0.1.75 → entelligence_cli-0.1.76}/entelligence_cli/router/openai_translate.py +0 -0
- {entelligence_cli-0.1.75 → entelligence_cli-0.1.76}/entelligence_cli/router/proxy.py +0 -0
- {entelligence_cli-0.1.75 → entelligence_cli-0.1.76}/entelligence_cli/router/state.py +0 -0
- {entelligence_cli-0.1.75 → entelligence_cli-0.1.76}/entelligence_cli/scheduling.py +0 -0
- {entelligence_cli-0.1.75 → entelligence_cli-0.1.76}/entelligence_cli/textual_ui.py +0 -0
- {entelligence_cli-0.1.75 → entelligence_cli-0.1.76}/entelligence_cli/ui/__init__.py +0 -0
- {entelligence_cli-0.1.75 → entelligence_cli-0.1.76}/entelligence_cli/ui/interactive.py +0 -0
- {entelligence_cli-0.1.75 → entelligence_cli-0.1.76}/entelligence_cli/ui/loaders.py +0 -0
- {entelligence_cli-0.1.75 → entelligence_cli-0.1.76}/entelligence_cli/ui/output.py +0 -0
- {entelligence_cli-0.1.75 → entelligence_cli-0.1.76}/entelligence_cli/ui/styles.py +0 -0
- {entelligence_cli-0.1.75 → entelligence_cli-0.1.76}/entelligence_cli/update_checker.py +0 -0
- {entelligence_cli-0.1.75 → entelligence_cli-0.1.76}/entelligence_cli/upload/__init__.py +0 -0
- {entelligence_cli-0.1.75 → entelligence_cli-0.1.76}/entelligence_cli/upload/conductor.py +0 -0
- {entelligence_cli-0.1.75 → entelligence_cli-0.1.76}/entelligence_cli/upload/config.py +0 -0
- {entelligence_cli-0.1.75 → entelligence_cli-0.1.76}/entelligence_cli/upload/discovery.py +0 -0
- {entelligence_cli-0.1.75 → entelligence_cli-0.1.76}/entelligence_cli/upload/pii.py +0 -0
- {entelligence_cli-0.1.75 → entelligence_cli-0.1.76}/entelligence_cli/upload/secrets.py +0 -0
- {entelligence_cli-0.1.75 → entelligence_cli-0.1.76}/entelligence_cli/upload/stores.py +0 -0
- {entelligence_cli-0.1.75 → entelligence_cli-0.1.76}/entelligence_cli/wrapped_game.py +0 -0
- {entelligence_cli-0.1.75 → entelligence_cli-0.1.76}/entelligence_cli.egg-info/dependency_links.txt +0 -0
- {entelligence_cli-0.1.75 → entelligence_cli-0.1.76}/entelligence_cli.egg-info/entry_points.txt +0 -0
- {entelligence_cli-0.1.75 → entelligence_cli-0.1.76}/entelligence_cli.egg-info/requires.txt +0 -0
- {entelligence_cli-0.1.75 → entelligence_cli-0.1.76}/entelligence_cli.egg-info/top_level.txt +0 -0
- {entelligence_cli-0.1.75 → entelligence_cli-0.1.76}/setup.cfg +0 -0
- {entelligence_cli-0.1.75 → entelligence_cli-0.1.76}/tests/test_attach_fix_smoke.py +0 -0
- {entelligence_cli-0.1.75 → entelligence_cli-0.1.76}/tests/test_attach_ticket_smoke.py +0 -0
- {entelligence_cli-0.1.75 → entelligence_cli-0.1.76}/tests/test_conductor.py +0 -0
- {entelligence_cli-0.1.75 → entelligence_cli-0.1.76}/tests/test_discovery_opencode.py +0 -0
- {entelligence_cli-0.1.75 → entelligence_cli-0.1.76}/tests/test_incident_smoke.py +0 -0
- {entelligence_cli-0.1.75 → entelligence_cli-0.1.76}/tests/test_pii.py +0 -0
- {entelligence_cli-0.1.75 → entelligence_cli-0.1.76}/tests/test_security_commands.py +0 -0
- {entelligence_cli-0.1.75 → entelligence_cli-0.1.76}/tests/test_update_command.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: entelligence-cli
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.76
|
|
4
4
|
Summary: AI-powered code review assistant for your terminal - Get instant, intelligent feedback on your code changes
|
|
5
5
|
Author-email: EntelligenceAI <info@entelligence.ai>
|
|
6
6
|
Project-URL: Homepage, https://entelligence.ai
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
+
import json
|
|
3
4
|
import os
|
|
4
5
|
import re
|
|
5
6
|
import subprocess
|
|
7
|
+
from collections import Counter
|
|
6
8
|
from collections.abc import Iterable
|
|
7
9
|
from pathlib import Path
|
|
8
10
|
|
|
@@ -524,3 +526,152 @@ def build_cwd_repos(cwds: Iterable[str | Path | None]) -> dict[str, dict[str, st
|
|
|
524
526
|
entry["base"] = base
|
|
525
527
|
out[key] = entry
|
|
526
528
|
return out
|
|
529
|
+
|
|
530
|
+
|
|
531
|
+
# ── file-touch repo resolution (the parent-folder fallback) ──────────────────
|
|
532
|
+
#
|
|
533
|
+
# When a session's cwd is a *parent* folder (dev started the agent above the
|
|
534
|
+
# repo, or in a container of repos), `git -C <cwd>` finds no repo and the upload
|
|
535
|
+
# gate drops the whole session — even though its edited files live in real,
|
|
536
|
+
# possibly-connected sub-repos. These helpers recover the repo from the files the
|
|
537
|
+
# session actually touched, so those sessions upload instead of vanishing.
|
|
538
|
+
|
|
539
|
+
_WRITE_TOOLS = frozenset({"Edit", "Write", "MultiEdit", "NotebookEdit"})
|
|
540
|
+
_READ_TOOLS = frozenset({"Read", "NotebookRead"})
|
|
541
|
+
_dir_repo_cache: dict[str, str | None] = {}
|
|
542
|
+
|
|
543
|
+
|
|
544
|
+
def repo_for_path(file_path: str | Path) -> str | None:
|
|
545
|
+
"""``owner/repo`` of the git repo containing ``file_path``, or None.
|
|
546
|
+
|
|
547
|
+
Walks up from the file's directory via ``git -C <dir> remote get-url origin``
|
|
548
|
+
(git resolves the enclosing repo). Cached per directory — one session touches
|
|
549
|
+
many files across a handful of dirs.
|
|
550
|
+
"""
|
|
551
|
+
directory = str(Path(file_path).expanduser().parent)
|
|
552
|
+
if directory not in _dir_repo_cache:
|
|
553
|
+
_dir_repo_cache[directory] = parse_owner_repo(get_remote_url(directory))
|
|
554
|
+
return _dir_repo_cache[directory]
|
|
555
|
+
|
|
556
|
+
|
|
557
|
+
def _majority_repo(paths: Iterable[str | Path]) -> str | None:
|
|
558
|
+
counts: Counter[str] = Counter()
|
|
559
|
+
for p in paths:
|
|
560
|
+
repo = repo_for_path(p)
|
|
561
|
+
if repo:
|
|
562
|
+
counts[repo] += 1
|
|
563
|
+
return counts.most_common(1)[0][0] if counts else None
|
|
564
|
+
|
|
565
|
+
|
|
566
|
+
def session_repo_from_transcript(transcript_path: str | Path) -> str | None:
|
|
567
|
+
"""Resolve a session's repo from the files its tool calls touched.
|
|
568
|
+
|
|
569
|
+
Write-precedence majority: the repo most of the session's WRITTEN files live
|
|
570
|
+
in (that's where the work landed). Reads are only a fallback for sessions
|
|
571
|
+
that never wrote (planning/research) — a read often hits a reference or
|
|
572
|
+
dependency repo, not the one being worked on. Returns None when nothing
|
|
573
|
+
resolves (no file ops, or none under a git repo).
|
|
574
|
+
|
|
575
|
+
The fallback for sessions started from a parent folder whose cwd doesn't
|
|
576
|
+
resolve — see the module note above.
|
|
577
|
+
|
|
578
|
+
Claude Code transcripts only: Codex wraps records in a ``payload`` object
|
|
579
|
+
with a different tool-call encoding, so nothing matches here and Codex
|
|
580
|
+
sessions return None (they stay gated on cwd resolution, as before).
|
|
581
|
+
"""
|
|
582
|
+
written: list[str] = []
|
|
583
|
+
read: list[str] = []
|
|
584
|
+
try:
|
|
585
|
+
with open(transcript_path, encoding="utf-8") as fh:
|
|
586
|
+
for line in fh:
|
|
587
|
+
try:
|
|
588
|
+
obj = json.loads(line)
|
|
589
|
+
except (json.JSONDecodeError, ValueError):
|
|
590
|
+
continue
|
|
591
|
+
message = obj.get("message")
|
|
592
|
+
if not isinstance(message, dict):
|
|
593
|
+
continue
|
|
594
|
+
for block in message.get("content") or []:
|
|
595
|
+
if not isinstance(block, dict) or block.get("type") != "tool_use":
|
|
596
|
+
continue
|
|
597
|
+
inp = block.get("input")
|
|
598
|
+
if not isinstance(inp, dict):
|
|
599
|
+
# A malformed line can carry a non-dict input (list /
|
|
600
|
+
# string); `.get` on it would raise AttributeError and,
|
|
601
|
+
# since it's outside the OSError guard, crash the whole
|
|
602
|
+
# upload loop. Skip it — one bad line isn't fatal.
|
|
603
|
+
continue
|
|
604
|
+
fp = inp.get("file_path") or inp.get("notebook_path")
|
|
605
|
+
if not isinstance(fp, str) or not fp:
|
|
606
|
+
continue
|
|
607
|
+
if block.get("name") in _WRITE_TOOLS:
|
|
608
|
+
written.append(fp)
|
|
609
|
+
elif block.get("name") in _READ_TOOLS:
|
|
610
|
+
read.append(fp)
|
|
611
|
+
except OSError:
|
|
612
|
+
return None
|
|
613
|
+
return _majority_repo(written) or _majority_repo(read)
|
|
614
|
+
|
|
615
|
+
|
|
616
|
+
_dir_root_cache: dict[str, tuple[str, str] | None] = {}
|
|
617
|
+
|
|
618
|
+
|
|
619
|
+
def repo_root_for_path(file_path: str | Path) -> tuple[str, str] | None:
|
|
620
|
+
"""``(git_toplevel, owner/repo)`` of the repo containing ``file_path``.
|
|
621
|
+
|
|
622
|
+
Git-truth resolution (``rev-parse --show-toplevel`` + origin URL),
|
|
623
|
+
cached per directory like ``repo_for_path``. None when the path isn't
|
|
624
|
+
inside a repo, or the repo has no parseable origin.
|
|
625
|
+
"""
|
|
626
|
+
directory = str(Path(file_path).expanduser().parent)
|
|
627
|
+
if directory not in _dir_root_cache:
|
|
628
|
+
root = get_toplevel(directory)
|
|
629
|
+
repo = parse_owner_repo(get_remote_url(directory)) if root else None
|
|
630
|
+
_dir_root_cache[directory] = (root, repo) if root and repo else None
|
|
631
|
+
return _dir_root_cache[directory]
|
|
632
|
+
|
|
633
|
+
|
|
634
|
+
def touched_repo_roots(transcript_path: str | Path) -> dict[str, str]:
|
|
635
|
+
"""``{git_toplevel: owner/repo}`` for every repo this session's tool calls touched.
|
|
636
|
+
|
|
637
|
+
The manifest stamp that makes per-TASK multi-repo attribution possible
|
|
638
|
+
at ingest: the backend has no git on disk, so it prefix-matches each
|
|
639
|
+
task's touched-file paths against these roots. One entry per repo
|
|
640
|
+
(roots, not files) resolved with real git — exact where name-matching
|
|
641
|
+
would guess (nested repos, worktrees, coincidental folder names).
|
|
642
|
+
|
|
643
|
+
Cheap on big transcripts: lines are pre-filtered on a ``tool_use``
|
|
644
|
+
substring before JSON parsing, and git runs once per distinct
|
|
645
|
+
directory (cached).
|
|
646
|
+
|
|
647
|
+
Claude Code transcripts only (same shape caveat as
|
|
648
|
+
``session_repo_from_transcript``); Codex yields an empty map.
|
|
649
|
+
"""
|
|
650
|
+
roots: dict[str, str] = {}
|
|
651
|
+
try:
|
|
652
|
+
with open(transcript_path, encoding="utf-8") as fh:
|
|
653
|
+
for line in fh:
|
|
654
|
+
if '"tool_use"' not in line:
|
|
655
|
+
continue
|
|
656
|
+
try:
|
|
657
|
+
obj = json.loads(line)
|
|
658
|
+
except (json.JSONDecodeError, ValueError):
|
|
659
|
+
continue
|
|
660
|
+
message = obj.get("message")
|
|
661
|
+
if not isinstance(message, dict):
|
|
662
|
+
continue
|
|
663
|
+
for block in message.get("content") or []:
|
|
664
|
+
if not isinstance(block, dict) or block.get("type") != "tool_use":
|
|
665
|
+
continue
|
|
666
|
+
inp = block.get("input")
|
|
667
|
+
if not isinstance(inp, dict):
|
|
668
|
+
continue
|
|
669
|
+
fp = inp.get("file_path") or inp.get("notebook_path")
|
|
670
|
+
if not isinstance(fp, str) or not fp:
|
|
671
|
+
continue
|
|
672
|
+
resolved = repo_root_for_path(fp)
|
|
673
|
+
if resolved:
|
|
674
|
+
roots[resolved[0]] = resolved[1]
|
|
675
|
+
except OSError:
|
|
676
|
+
return roots
|
|
677
|
+
return roots
|
|
@@ -129,6 +129,10 @@ def build_manifest(
|
|
|
129
129
|
"total_bytes": sum(e["size_bytes"] for e in entries),
|
|
130
130
|
"entelligence": plan.entelligence.model_dump() if plan.entelligence else None,
|
|
131
131
|
"cwd_repos": cwd_repos,
|
|
132
|
+
# `{git_toplevel: owner/repo}` for every repo this home's sessions'
|
|
133
|
+
# tool calls touched — the ingest side prefix-matches task files
|
|
134
|
+
# against these roots for per-task multi-repo attribution.
|
|
135
|
+
"repo_roots": plan.repo_roots.get(home_label, {}),
|
|
132
136
|
"sessions": entries,
|
|
133
137
|
}
|
|
134
138
|
return json.dumps(body, indent=2).encode()
|
|
@@ -88,6 +88,11 @@ class UploadPlan(BaseModel):
|
|
|
88
88
|
sessions: list[Session]
|
|
89
89
|
entelligence: EntelligenceIdentity | None = None
|
|
90
90
|
cwd_repos: dict[str, dict[str, str]] = Field(default_factory=dict)
|
|
91
|
+
# Per-home `{git_toplevel: owner/repo}` for every repo the home's
|
|
92
|
+
# sessions' TOOL CALLS touched (not just their cwds) — keyed by
|
|
93
|
+
# home_label. The ingest side prefix-matches each task's touched files
|
|
94
|
+
# against these roots for multi-repo attribution.
|
|
95
|
+
repo_roots: dict[str, dict[str, str]] = Field(default_factory=dict)
|
|
91
96
|
|
|
92
97
|
@property
|
|
93
98
|
def total_bytes(self) -> int:
|
|
@@ -32,7 +32,7 @@ from ..ui.output import console
|
|
|
32
32
|
from .config import load_config, save_config
|
|
33
33
|
from .discovery import auto_detect_homes, discover_all, homes_from_config, scan_sessions
|
|
34
34
|
from .manifest import build_manifest, resolve_entelligence_identity
|
|
35
|
-
from .models import UploadConfig, UploadPlan
|
|
35
|
+
from .models import AgentKind, UploadConfig, UploadPlan
|
|
36
36
|
from .pii import Identity, redact_pii
|
|
37
37
|
from .secrets import redact_bytes
|
|
38
38
|
from .stores import LocalFsStore, PresignedS3Store
|
|
@@ -306,7 +306,9 @@ def _partition_by_connected_repos(sessions, cwd_repos, connected):
|
|
|
306
306
|
Args:
|
|
307
307
|
sessions: Discovered ``Session`` objects to partition.
|
|
308
308
|
cwd_repos: ``{cwd_str: {"repo": "owner/repo", "base": ...}}`` from
|
|
309
|
-
``git_operations.build_cwd_repos``.
|
|
309
|
+
``git_operations.build_cwd_repos``. **Mutated as a side effect**:
|
|
310
|
+
a repo recovered from a session's touched files is stamped back
|
|
311
|
+
in under that session's cwd — see the inline comment for why.
|
|
310
312
|
connected: Lowercased ``owner/repo`` names the org has connected.
|
|
311
313
|
|
|
312
314
|
Returns:
|
|
@@ -318,12 +320,30 @@ def _partition_by_connected_repos(sessions, cwd_repos, connected):
|
|
|
318
320
|
for s in sessions:
|
|
319
321
|
entry = cwd_repos.get(str(s.cwd)) if s.cwd is not None else None
|
|
320
322
|
repo = (entry or {}).get("repo")
|
|
323
|
+
if not repo:
|
|
324
|
+
# cwd didn't resolve — e.g. the session was started from a PARENT
|
|
325
|
+
# folder, not the repo itself. Recover the repo from the files it
|
|
326
|
+
# edited (write-precedence majority) so parent-folder sessions upload
|
|
327
|
+
# instead of being silently dropped at the gate.
|
|
328
|
+
recovered = git_operations.session_repo_from_transcript(s.abs_path)
|
|
329
|
+
if recovered and s.cwd is not None:
|
|
330
|
+
# Stamp the recovery into cwd_repos so the manifest carries it.
|
|
331
|
+
# The backend's ingest-side repo resolver reads this map (its
|
|
332
|
+
# only git-truth source for synced transcripts); without the
|
|
333
|
+
# stamp the session would upload but its tasks would then drop
|
|
334
|
+
# at the backend's signed-up-repo gate — the same silent drop,
|
|
335
|
+
# one stage later.
|
|
336
|
+
cwd_repos[str(s.cwd)] = {**(entry or {}), "repo": recovered}
|
|
337
|
+
repo = recovered
|
|
338
|
+
# When cwd is None there's no key to stamp under, so the backend
|
|
339
|
+
# could never resolve it — leave `repo` unset so the session fails
|
|
340
|
+
# CLOSED (skipped) instead of uploading only to drop at ingest.
|
|
321
341
|
if repo and repo.lower() in connected:
|
|
322
342
|
kept.append(s)
|
|
323
343
|
else:
|
|
324
344
|
# Fail-CLOSED: skip anything we can't positively place in a connected
|
|
325
|
-
# repo — both non-connected repos and sessions whose cwd
|
|
326
|
-
# (no git remote / dir gone).
|
|
345
|
+
# repo — both non-connected repos and sessions whose cwd + touched
|
|
346
|
+
# files both failed to resolve (no git remote / dir gone).
|
|
327
347
|
skipped.append(s)
|
|
328
348
|
skipped_repo_counts[repo or "(unresolved repo)"] += 1
|
|
329
349
|
return kept, skipped, skipped_repo_counts
|
|
@@ -452,6 +472,19 @@ def run_transcript_upload(
|
|
|
452
472
|
if scanned:
|
|
453
473
|
scan_sessions(sessions, workers=scan_workers)
|
|
454
474
|
|
|
475
|
+
# --- Touched-repo roots (multi-repo attribution stamp) ------------------
|
|
476
|
+
# One session can edit repo B from a cwd in repo A; the cwd_repos map
|
|
477
|
+
# can't see that. Walk each kept session's tool calls and git-resolve the
|
|
478
|
+
# toplevel of every touched repo — the manifest stamp the ingest side
|
|
479
|
+
# prefix-matches task files against. Per home, since manifests are.
|
|
480
|
+
repo_roots: dict[str, dict[str, str]] = {}
|
|
481
|
+
for s in sessions:
|
|
482
|
+
if s.agent is not AgentKind.CLAUDE_CODE:
|
|
483
|
+
continue # Codex tool calls have a different shape; nothing to walk
|
|
484
|
+
roots = git_operations.touched_repo_roots(s.abs_path)
|
|
485
|
+
if roots:
|
|
486
|
+
repo_roots.setdefault(s.home_label, {}).update(roots)
|
|
487
|
+
|
|
455
488
|
plan = UploadPlan(
|
|
456
489
|
org_uuid=identity.org_uuid,
|
|
457
490
|
user_uuid=identity.user_uuid,
|
|
@@ -460,6 +493,7 @@ def run_transcript_upload(
|
|
|
460
493
|
sessions=sessions,
|
|
461
494
|
entelligence=identity,
|
|
462
495
|
cwd_repos=cwd_repos,
|
|
496
|
+
repo_roots=repo_roots,
|
|
463
497
|
)
|
|
464
498
|
|
|
465
499
|
console.print(f"\n[bold green]{len(sessions)}[/bold green] session(s) ready to upload:")
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: entelligence-cli
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.76
|
|
4
4
|
Summary: AI-powered code review assistant for your terminal - Get instant, intelligent feedback on your code changes
|
|
5
5
|
Author-email: EntelligenceAI <info@entelligence.ai>
|
|
6
6
|
Project-URL: Homepage, https://entelligence.ai
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "entelligence-cli"
|
|
7
|
-
version = "0.1.
|
|
7
|
+
version = "0.1.76"
|
|
8
8
|
description = "AI-powered code review assistant for your terminal - Get instant, intelligent feedback on your code changes"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
authors = [
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
"""Fail-closed guarantees for the transcript upload's connected-repo pre-filter.
|
|
2
|
+
|
|
3
|
+
Only sessions that positively resolve to a repo the org has connected may be
|
|
4
|
+
uploaded. These lock in that contract at the two decision points:
|
|
5
|
+
``_partition_by_connected_repos`` (per-session keep/skip) and
|
|
6
|
+
``_fetch_connected_repos`` (abort vs. empty set).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
from datetime import UTC, datetime
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
import click
|
|
16
|
+
import pytest
|
|
17
|
+
|
|
18
|
+
from entelligence_cli.upload.models import AgentKind, Session
|
|
19
|
+
from entelligence_cli.upload.runner import (
|
|
20
|
+
_fetch_connected_repos,
|
|
21
|
+
_partition_by_connected_repos,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
CONNECTED = {"entelligence-ai/backend"}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _session(cwd: str) -> Session:
|
|
29
|
+
"""Minimal Session — only ``.cwd`` affects the pre-filter."""
|
|
30
|
+
return Session(
|
|
31
|
+
agent=AgentKind.CLAUDE_CODE,
|
|
32
|
+
home_label="h",
|
|
33
|
+
abs_path=Path(f"{cwd}/x.jsonl"),
|
|
34
|
+
rel_path=Path("x.jsonl"),
|
|
35
|
+
size_bytes=1,
|
|
36
|
+
mtime=datetime(2026, 1, 1, tzinfo=UTC),
|
|
37
|
+
cwd=Path(cwd),
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def test_partition_keeps_only_connected_repo_sessions():
|
|
42
|
+
kept, skipped, counts = _partition_by_connected_repos(
|
|
43
|
+
[_session("/w/backend"), _session("/w/personal")],
|
|
44
|
+
{
|
|
45
|
+
"/w/backend": {"repo": "Entelligence-AI/backend"}, # case-insensitive match
|
|
46
|
+
"/w/personal": {"repo": "someone/personal"},
|
|
47
|
+
},
|
|
48
|
+
CONNECTED,
|
|
49
|
+
)
|
|
50
|
+
assert [str(s.cwd) for s in kept] == ["/w/backend"]
|
|
51
|
+
assert [str(s.cwd) for s in skipped] == ["/w/personal"]
|
|
52
|
+
assert counts["someone/personal"] == 1
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def test_partition_skips_unresolved_repo_fail_closed():
|
|
56
|
+
"""A session whose cwd doesn't resolve to a repo is SKIPPED, not kept."""
|
|
57
|
+
kept, skipped, counts = _partition_by_connected_repos(
|
|
58
|
+
[_session("/w/nogit")],
|
|
59
|
+
{},
|
|
60
|
+
CONNECTED, # cwd absent from the map => unresolved
|
|
61
|
+
)
|
|
62
|
+
assert kept == []
|
|
63
|
+
assert [str(s.cwd) for s in skipped] == ["/w/nogit"]
|
|
64
|
+
assert counts["(unresolved repo)"] == 1
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def test_fetch_connected_repos_aborts_when_unverifiable():
|
|
68
|
+
"""A fetch error raises (fail-closed) instead of returning None / uploading all."""
|
|
69
|
+
|
|
70
|
+
class _Boom:
|
|
71
|
+
def fetch_organization_repos(self, org_uuid):
|
|
72
|
+
raise RuntimeError("network down")
|
|
73
|
+
|
|
74
|
+
with pytest.raises(click.ClickException):
|
|
75
|
+
_fetch_connected_repos(_Boom(), "org-uuid")
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def test_fetch_connected_repos_error_body_aborts():
|
|
79
|
+
class _ErrBody:
|
|
80
|
+
def fetch_organization_repos(self, org_uuid):
|
|
81
|
+
return {"Error": "unauthorized"}
|
|
82
|
+
|
|
83
|
+
with pytest.raises(click.ClickException):
|
|
84
|
+
_fetch_connected_repos(_ErrBody(), "org-uuid")
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def test_fetch_connected_repos_empty_for_zero_connected():
|
|
88
|
+
"""Zero connected repos => empty set (=> nothing eligible), not None."""
|
|
89
|
+
|
|
90
|
+
class _Empty:
|
|
91
|
+
def fetch_organization_repos(self, org_uuid):
|
|
92
|
+
return {"Repositories": []}
|
|
93
|
+
|
|
94
|
+
assert _fetch_connected_repos(_Empty(), "org-uuid") == set()
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
# ── parent-folder recovery: resolve from touched files (write-precedence) ─────
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _write_transcript(path: Path, ops: list[tuple[str, str]]) -> None:
|
|
101
|
+
"""``ops`` = list of ``(tool_name, file_path)`` → a minimal CC transcript."""
|
|
102
|
+
lines = [
|
|
103
|
+
json.dumps(
|
|
104
|
+
{
|
|
105
|
+
"message": {
|
|
106
|
+
"content": [{"type": "tool_use", "name": tool, "input": {"file_path": fp}}]
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
)
|
|
110
|
+
for tool, fp in ops
|
|
111
|
+
]
|
|
112
|
+
path.write_text("\n".join(lines) + "\n")
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def test_session_repo_write_beats_read(tmp_path, monkeypatch):
|
|
116
|
+
"""Writes decide the repo; reads of a reference repo can't outvote them."""
|
|
117
|
+
from entelligence_cli import git_operations
|
|
118
|
+
|
|
119
|
+
monkeypatch.setattr(
|
|
120
|
+
git_operations,
|
|
121
|
+
"repo_for_path",
|
|
122
|
+
lambda fp: "Org/backend" if "/work/backend/" in str(fp) else "Org/old-service",
|
|
123
|
+
)
|
|
124
|
+
t = tmp_path / "s.jsonl"
|
|
125
|
+
_write_transcript(
|
|
126
|
+
t,
|
|
127
|
+
[
|
|
128
|
+
("Read", "/ref/old-service/a.py"), # 2 reads of a reference repo
|
|
129
|
+
("Read", "/ref/old-service/b.py"),
|
|
130
|
+
("Edit", "/work/backend/x.py"), # 1 write of the work repo
|
|
131
|
+
],
|
|
132
|
+
)
|
|
133
|
+
assert git_operations.session_repo_from_transcript(t) == "Org/backend"
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def test_session_repo_read_fallback_when_no_writes(tmp_path, monkeypatch):
|
|
137
|
+
"""No writes → fall back to the read-majority repo (planning session)."""
|
|
138
|
+
from entelligence_cli import git_operations
|
|
139
|
+
|
|
140
|
+
monkeypatch.setattr(git_operations, "repo_for_path", lambda fp: "Org/backend")
|
|
141
|
+
t = tmp_path / "s.jsonl"
|
|
142
|
+
_write_transcript(t, [("Read", "/x/backend/a.py"), ("Read", "/x/backend/b.py")])
|
|
143
|
+
assert git_operations.session_repo_from_transcript(t) == "Org/backend"
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def test_partition_recovers_parent_folder_session_from_files(tmp_path, monkeypatch):
|
|
147
|
+
"""A session started from a PARENT folder (cwd unresolved) is KEPT when its
|
|
148
|
+
edited files resolve to a connected repo — the parent-folder / Wasif case."""
|
|
149
|
+
from entelligence_cli import git_operations
|
|
150
|
+
|
|
151
|
+
monkeypatch.setattr(
|
|
152
|
+
git_operations,
|
|
153
|
+
"repo_for_path",
|
|
154
|
+
lambda fp: "Entelligence-AI/backend" if "backend" in str(fp) else None,
|
|
155
|
+
)
|
|
156
|
+
t = tmp_path / "s.jsonl"
|
|
157
|
+
_write_transcript(t, [("Edit", "/Users/wasif/projects/backend/src/x.py")])
|
|
158
|
+
s = Session(
|
|
159
|
+
agent=AgentKind.CLAUDE_CODE,
|
|
160
|
+
home_label="h",
|
|
161
|
+
abs_path=t,
|
|
162
|
+
rel_path=Path("s.jsonl"),
|
|
163
|
+
size_bytes=1,
|
|
164
|
+
mtime=datetime(2026, 1, 1, tzinfo=UTC),
|
|
165
|
+
cwd=Path("/Users/wasif/projects"), # a parent folder, not the repo itself
|
|
166
|
+
)
|
|
167
|
+
kept, skipped, _ = _partition_by_connected_repos([s], {}, CONNECTED)
|
|
168
|
+
assert len(kept) == 1 and skipped == []
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def test_partition_stamps_recovered_repo_into_cwd_repos(tmp_path, monkeypatch):
|
|
172
|
+
"""A file-touch-recovered repo is stamped back into ``cwd_repos`` so the
|
|
173
|
+
manifest carries it — the backend's ingest-side repo gate resolves tasks
|
|
174
|
+
from that map, so without the stamp the session would upload but its
|
|
175
|
+
tasks would drop at ingest (the same silent drop, one stage later)."""
|
|
176
|
+
from entelligence_cli import git_operations
|
|
177
|
+
|
|
178
|
+
monkeypatch.setattr(git_operations, "repo_for_path", lambda fp: "Entelligence-AI/backend")
|
|
179
|
+
t = tmp_path / "s.jsonl"
|
|
180
|
+
_write_transcript(t, [("Edit", "/Users/wasif/projects/backend/src/x.py")])
|
|
181
|
+
s = Session(
|
|
182
|
+
agent=AgentKind.CLAUDE_CODE,
|
|
183
|
+
home_label="h",
|
|
184
|
+
abs_path=t,
|
|
185
|
+
rel_path=Path("s.jsonl"),
|
|
186
|
+
size_bytes=1,
|
|
187
|
+
mtime=datetime(2026, 1, 1, tzinfo=UTC),
|
|
188
|
+
cwd=Path("/Users/wasif/projects"),
|
|
189
|
+
)
|
|
190
|
+
cwd_repos: dict = {}
|
|
191
|
+
_partition_by_connected_repos([s], cwd_repos, CONNECTED)
|
|
192
|
+
assert cwd_repos["/Users/wasif/projects"] == {"repo": "Entelligence-AI/backend"}
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def test_session_repo_none_for_codex_shaped_transcript(tmp_path, monkeypatch):
|
|
196
|
+
"""Codex transcripts (payload-wrapped records) yield no file ops here →
|
|
197
|
+
None. Documents the known coverage limit: the fallback is CC-only."""
|
|
198
|
+
from entelligence_cli import git_operations
|
|
199
|
+
|
|
200
|
+
monkeypatch.setattr(git_operations, "repo_for_path", lambda fp: "Org/backend")
|
|
201
|
+
t = tmp_path / "codex.jsonl"
|
|
202
|
+
codex_line = {
|
|
203
|
+
"type": "response_item",
|
|
204
|
+
"payload": {
|
|
205
|
+
"type": "function_call",
|
|
206
|
+
"name": "shell",
|
|
207
|
+
"arguments": '{"command": ["apply_patch", "/w/backend/x.py"]}',
|
|
208
|
+
},
|
|
209
|
+
}
|
|
210
|
+
t.write_text(json.dumps(codex_line) + "\n")
|
|
211
|
+
assert git_operations.session_repo_from_transcript(t) is None
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def test_session_repo_survives_non_dict_input(tmp_path, monkeypatch):
|
|
215
|
+
"""A tool_use block whose `input` is a non-dict (malformed line) must not
|
|
216
|
+
crash — it's skipped, and valid blocks in the same file still resolve."""
|
|
217
|
+
from entelligence_cli import git_operations
|
|
218
|
+
|
|
219
|
+
monkeypatch.setattr(git_operations, "repo_for_path", lambda fp: "Org/backend")
|
|
220
|
+
t = tmp_path / "s.jsonl"
|
|
221
|
+
t.write_text(
|
|
222
|
+
"\n".join(
|
|
223
|
+
[
|
|
224
|
+
# input is a string, not a dict → would AttributeError on .get
|
|
225
|
+
json.dumps(
|
|
226
|
+
{
|
|
227
|
+
"message": {
|
|
228
|
+
"content": [{"type": "tool_use", "name": "Edit", "input": "oops"}]
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
),
|
|
232
|
+
# a well-formed write in the same file still resolves
|
|
233
|
+
json.dumps(
|
|
234
|
+
{
|
|
235
|
+
"message": {
|
|
236
|
+
"content": [
|
|
237
|
+
{
|
|
238
|
+
"type": "tool_use",
|
|
239
|
+
"name": "Edit",
|
|
240
|
+
"input": {"file_path": "/w/backend/x.py"},
|
|
241
|
+
}
|
|
242
|
+
]
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
),
|
|
246
|
+
]
|
|
247
|
+
)
|
|
248
|
+
+ "\n"
|
|
249
|
+
)
|
|
250
|
+
assert git_operations.session_repo_from_transcript(t) == "Org/backend"
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def test_partition_skips_recovered_repo_when_cwd_is_none(tmp_path, monkeypatch):
|
|
254
|
+
"""A cwd=None session whose files resolve to a connected repo is SKIPPED,
|
|
255
|
+
not kept — with no cwd there's no manifest key to stamp, so the backend
|
|
256
|
+
could never resolve it (uploading would just drop it at ingest)."""
|
|
257
|
+
from entelligence_cli import git_operations
|
|
258
|
+
|
|
259
|
+
monkeypatch.setattr(git_operations, "repo_for_path", lambda fp: "Entelligence-AI/backend")
|
|
260
|
+
t = tmp_path / "s.jsonl"
|
|
261
|
+
_write_transcript(t, [("Edit", "/Users/wasif/projects/backend/src/x.py")])
|
|
262
|
+
s = Session(
|
|
263
|
+
agent=AgentKind.CLAUDE_CODE,
|
|
264
|
+
home_label="h",
|
|
265
|
+
abs_path=t,
|
|
266
|
+
rel_path=Path("s.jsonl"),
|
|
267
|
+
size_bytes=1,
|
|
268
|
+
mtime=datetime(2026, 1, 1, tzinfo=UTC),
|
|
269
|
+
cwd=None, # no cwd → nothing to stamp under
|
|
270
|
+
)
|
|
271
|
+
cwd_repos: dict = {}
|
|
272
|
+
kept, skipped, _ = _partition_by_connected_repos([s], cwd_repos, CONNECTED)
|
|
273
|
+
assert kept == [] and len(skipped) == 1
|
|
274
|
+
assert cwd_repos == {} # nothing stamped
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
"""The `repo_roots` manifest stamp — per-session touched-repo git toplevels.
|
|
2
|
+
|
|
3
|
+
One session can edit repo B from a cwd in repo A, so `cwd_repos` (cwd-keyed)
|
|
4
|
+
can't see every repo a session touched. `touched_repo_roots` walks the
|
|
5
|
+
transcript's tool calls and git-resolves each touched repo's toplevel; the
|
|
6
|
+
manifest carries the map so the ingest side can prefix-match task files
|
|
7
|
+
against it (per-task multi-repo attribution).
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
from datetime import UTC, datetime
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
from entelligence_cli import git_operations
|
|
17
|
+
from entelligence_cli.upload.manifest import build_manifest
|
|
18
|
+
from entelligence_cli.upload.models import AgentKind, Session, UploadPlan
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _write_transcript(path: Path, ops: list[tuple[str, str]]) -> None:
|
|
22
|
+
lines = [
|
|
23
|
+
json.dumps(
|
|
24
|
+
{
|
|
25
|
+
"message": {
|
|
26
|
+
"content": [{"type": "tool_use", "name": tool, "input": {"file_path": fp}}]
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
)
|
|
30
|
+
for tool, fp in ops
|
|
31
|
+
]
|
|
32
|
+
path.write_text("\n".join(lines) + "\n")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _fake_root_resolver(mapping: dict[str, tuple[str, str]]):
|
|
36
|
+
"""dir-prefix → (root, repo); mirrors repo_root_for_path's contract."""
|
|
37
|
+
|
|
38
|
+
def _resolve(fp):
|
|
39
|
+
for prefix, resolved in mapping.items():
|
|
40
|
+
if str(fp).startswith(prefix):
|
|
41
|
+
return resolved
|
|
42
|
+
return None
|
|
43
|
+
|
|
44
|
+
return _resolve
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def test_touched_repo_roots_collects_every_touched_repo(tmp_path, monkeypatch):
|
|
48
|
+
"""Edits + reads across two repos → one root entry per repo; files under
|
|
49
|
+
no repo (e.g. /tmp scratch) contribute nothing."""
|
|
50
|
+
monkeypatch.setattr(
|
|
51
|
+
git_operations,
|
|
52
|
+
"repo_root_for_path",
|
|
53
|
+
_fake_root_resolver(
|
|
54
|
+
{
|
|
55
|
+
"/w/backend/": ("/w/backend", "Org/backend"),
|
|
56
|
+
"/w/cli/": ("/w/cli", "Org/cli"),
|
|
57
|
+
}
|
|
58
|
+
),
|
|
59
|
+
)
|
|
60
|
+
t = tmp_path / "s.jsonl"
|
|
61
|
+
_write_transcript(
|
|
62
|
+
t,
|
|
63
|
+
[
|
|
64
|
+
("Edit", "/w/backend/a.py"),
|
|
65
|
+
("Read", "/w/cli/b.py"),
|
|
66
|
+
("Write", "/tmp/scratch.py"), # not in any repo
|
|
67
|
+
],
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
assert git_operations.touched_repo_roots(t) == {
|
|
71
|
+
"/w/backend": "Org/backend",
|
|
72
|
+
"/w/cli": "Org/cli",
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def test_touched_repo_roots_tolerates_malformed_lines(tmp_path, monkeypatch):
|
|
77
|
+
"""Non-dict input / non-JSON lines are skipped, not fatal."""
|
|
78
|
+
monkeypatch.setattr(
|
|
79
|
+
git_operations,
|
|
80
|
+
"repo_root_for_path",
|
|
81
|
+
_fake_root_resolver({"/w/": ("/w", "Org/w")}),
|
|
82
|
+
)
|
|
83
|
+
t = tmp_path / "s.jsonl"
|
|
84
|
+
t.write_text(
|
|
85
|
+
"not json at all\n"
|
|
86
|
+
+ json.dumps(
|
|
87
|
+
{"message": {"content": [{"type": "tool_use", "name": "Edit", "input": "oops"}]}}
|
|
88
|
+
)
|
|
89
|
+
+ "\n"
|
|
90
|
+
+ json.dumps(
|
|
91
|
+
{
|
|
92
|
+
"message": {
|
|
93
|
+
"content": [
|
|
94
|
+
{"type": "tool_use", "name": "Edit", "input": {"file_path": "/w/x.py"}}
|
|
95
|
+
]
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
)
|
|
99
|
+
+ "\n"
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
assert git_operations.touched_repo_roots(t) == {"/w": "Org/w"}
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def test_touched_repo_roots_codex_shape_yields_empty(tmp_path, monkeypatch):
|
|
106
|
+
monkeypatch.setattr(
|
|
107
|
+
git_operations, "repo_root_for_path", _fake_root_resolver({"/w/": ("/w", "Org/w")})
|
|
108
|
+
)
|
|
109
|
+
t = tmp_path / "codex.jsonl"
|
|
110
|
+
t.write_text(json.dumps({"type": "response_item", "payload": {"type": "function_call"}}) + "\n")
|
|
111
|
+
|
|
112
|
+
assert git_operations.touched_repo_roots(t) == {}
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _session(home_label: str, cwd: str, path: Path) -> Session:
|
|
116
|
+
return Session(
|
|
117
|
+
agent=AgentKind.CLAUDE_CODE,
|
|
118
|
+
home_label=home_label,
|
|
119
|
+
abs_path=path,
|
|
120
|
+
rel_path=Path("s.jsonl"),
|
|
121
|
+
size_bytes=1,
|
|
122
|
+
mtime=datetime(2026, 1, 1, tzinfo=UTC),
|
|
123
|
+
cwd=Path(cwd),
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def test_manifest_carries_repo_roots_scoped_to_home(tmp_path):
|
|
128
|
+
"""Each home's manifest gets ITS `repo_roots`, not another home's."""
|
|
129
|
+
t = tmp_path / "s.jsonl"
|
|
130
|
+
t.write_text("{}\n")
|
|
131
|
+
plan = UploadPlan(
|
|
132
|
+
org_uuid="org",
|
|
133
|
+
user_uuid="user",
|
|
134
|
+
hostname="host",
|
|
135
|
+
uploader="dev",
|
|
136
|
+
sessions=[_session("h1", "/w/backend", t), _session("h2", "/w/cli", t)],
|
|
137
|
+
repo_roots={
|
|
138
|
+
"h1": {"/w/backend": "Org/backend"},
|
|
139
|
+
"h2": {"/w/cli": "Org/cli"},
|
|
140
|
+
},
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
body = json.loads(build_manifest(plan, datetime(2026, 7, 2, tzinfo=UTC), "h1", redacted=False))
|
|
144
|
+
assert body["repo_roots"] == {"/w/backend": "Org/backend"}
|
|
145
|
+
|
|
146
|
+
body2 = json.loads(build_manifest(plan, datetime(2026, 7, 2, tzinfo=UTC), "h2", redacted=False))
|
|
147
|
+
assert body2["repo_roots"] == {"/w/cli": "Org/cli"}
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def test_manifest_repo_roots_empty_when_unstamped(tmp_path):
|
|
151
|
+
t = tmp_path / "s.jsonl"
|
|
152
|
+
t.write_text("{}\n")
|
|
153
|
+
plan = UploadPlan(
|
|
154
|
+
org_uuid="org",
|
|
155
|
+
user_uuid="user",
|
|
156
|
+
hostname="host",
|
|
157
|
+
uploader="dev",
|
|
158
|
+
sessions=[_session("h1", "/w/x", t)],
|
|
159
|
+
)
|
|
160
|
+
body = json.loads(build_manifest(plan, datetime(2026, 7, 2, tzinfo=UTC), "h1", redacted=False))
|
|
161
|
+
assert body["repo_roots"] == {}
|
|
@@ -1,93 +0,0 @@
|
|
|
1
|
-
"""Fail-closed guarantees for the transcript upload's connected-repo pre-filter.
|
|
2
|
-
|
|
3
|
-
Only sessions that positively resolve to a repo the org has connected may be
|
|
4
|
-
uploaded. These lock in that contract at the two decision points:
|
|
5
|
-
``_partition_by_connected_repos`` (per-session keep/skip) and
|
|
6
|
-
``_fetch_connected_repos`` (abort vs. empty set).
|
|
7
|
-
"""
|
|
8
|
-
|
|
9
|
-
from __future__ import annotations
|
|
10
|
-
|
|
11
|
-
from datetime import UTC, datetime
|
|
12
|
-
from pathlib import Path
|
|
13
|
-
|
|
14
|
-
import click
|
|
15
|
-
import pytest
|
|
16
|
-
|
|
17
|
-
from entelligence_cli.upload.models import AgentKind, Session
|
|
18
|
-
from entelligence_cli.upload.runner import (
|
|
19
|
-
_fetch_connected_repos,
|
|
20
|
-
_partition_by_connected_repos,
|
|
21
|
-
)
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
CONNECTED = {"entelligence-ai/backend"}
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
def _session(cwd: str) -> Session:
|
|
28
|
-
"""Minimal Session — only ``.cwd`` affects the pre-filter."""
|
|
29
|
-
return Session(
|
|
30
|
-
agent=AgentKind.CLAUDE_CODE,
|
|
31
|
-
home_label="h",
|
|
32
|
-
abs_path=Path(f"{cwd}/x.jsonl"),
|
|
33
|
-
rel_path=Path("x.jsonl"),
|
|
34
|
-
size_bytes=1,
|
|
35
|
-
mtime=datetime(2026, 1, 1, tzinfo=UTC),
|
|
36
|
-
cwd=Path(cwd),
|
|
37
|
-
)
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
def test_partition_keeps_only_connected_repo_sessions():
|
|
41
|
-
kept, skipped, counts = _partition_by_connected_repos(
|
|
42
|
-
[_session("/w/backend"), _session("/w/personal")],
|
|
43
|
-
{
|
|
44
|
-
"/w/backend": {"repo": "Entelligence-AI/backend"}, # case-insensitive match
|
|
45
|
-
"/w/personal": {"repo": "someone/personal"},
|
|
46
|
-
},
|
|
47
|
-
CONNECTED,
|
|
48
|
-
)
|
|
49
|
-
assert [str(s.cwd) for s in kept] == ["/w/backend"]
|
|
50
|
-
assert [str(s.cwd) for s in skipped] == ["/w/personal"]
|
|
51
|
-
assert counts["someone/personal"] == 1
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
def test_partition_skips_unresolved_repo_fail_closed():
|
|
55
|
-
"""A session whose cwd doesn't resolve to a repo is SKIPPED, not kept."""
|
|
56
|
-
kept, skipped, counts = _partition_by_connected_repos(
|
|
57
|
-
[_session("/w/nogit")],
|
|
58
|
-
{},
|
|
59
|
-
CONNECTED, # cwd absent from the map => unresolved
|
|
60
|
-
)
|
|
61
|
-
assert kept == []
|
|
62
|
-
assert [str(s.cwd) for s in skipped] == ["/w/nogit"]
|
|
63
|
-
assert counts["(unresolved repo)"] == 1
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
def test_fetch_connected_repos_aborts_when_unverifiable():
|
|
67
|
-
"""A fetch error raises (fail-closed) instead of returning None / uploading all."""
|
|
68
|
-
|
|
69
|
-
class _Boom:
|
|
70
|
-
def fetch_organization_repos(self, org_uuid):
|
|
71
|
-
raise RuntimeError("network down")
|
|
72
|
-
|
|
73
|
-
with pytest.raises(click.ClickException):
|
|
74
|
-
_fetch_connected_repos(_Boom(), "org-uuid")
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
def test_fetch_connected_repos_error_body_aborts():
|
|
78
|
-
class _ErrBody:
|
|
79
|
-
def fetch_organization_repos(self, org_uuid):
|
|
80
|
-
return {"Error": "unauthorized"}
|
|
81
|
-
|
|
82
|
-
with pytest.raises(click.ClickException):
|
|
83
|
-
_fetch_connected_repos(_ErrBody(), "org-uuid")
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
def test_fetch_connected_repos_empty_for_zero_connected():
|
|
87
|
-
"""Zero connected repos => empty set (=> nothing eligible), not None."""
|
|
88
|
-
|
|
89
|
-
class _Empty:
|
|
90
|
-
def fetch_organization_repos(self, org_uuid):
|
|
91
|
-
return {"Repositories": []}
|
|
92
|
-
|
|
93
|
-
assert _fetch_connected_repos(_Empty(), "org-uuid") == set()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{entelligence_cli-0.1.75 → entelligence_cli-0.1.76}/entelligence_cli/router/claude_settings.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{entelligence_cli-0.1.75 → entelligence_cli-0.1.76}/entelligence_cli/router/gemini_translate.py
RENAMED
|
File without changes
|
{entelligence_cli-0.1.75 → entelligence_cli-0.1.76}/entelligence_cli/router/openai_translate.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{entelligence_cli-0.1.75 → entelligence_cli-0.1.76}/entelligence_cli.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
{entelligence_cli-0.1.75 → entelligence_cli-0.1.76}/entelligence_cli.egg-info/entry_points.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|