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.
Files changed (68) hide show
  1. {entelligence_cli-0.1.75 → entelligence_cli-0.1.76}/PKG-INFO +1 -1
  2. {entelligence_cli-0.1.75 → entelligence_cli-0.1.76}/entelligence_cli/git_operations.py +151 -0
  3. {entelligence_cli-0.1.75 → entelligence_cli-0.1.76}/entelligence_cli/upload/manifest.py +4 -0
  4. {entelligence_cli-0.1.75 → entelligence_cli-0.1.76}/entelligence_cli/upload/models.py +5 -0
  5. {entelligence_cli-0.1.75 → entelligence_cli-0.1.76}/entelligence_cli/upload/runner.py +38 -4
  6. {entelligence_cli-0.1.75 → entelligence_cli-0.1.76}/entelligence_cli.egg-info/PKG-INFO +1 -1
  7. {entelligence_cli-0.1.75 → entelligence_cli-0.1.76}/entelligence_cli.egg-info/SOURCES.txt +2 -1
  8. {entelligence_cli-0.1.75 → entelligence_cli-0.1.76}/pyproject.toml +1 -1
  9. entelligence_cli-0.1.76/tests/test_upload_repo_filter.py +274 -0
  10. entelligence_cli-0.1.76/tests/test_upload_repo_roots.py +161 -0
  11. entelligence_cli-0.1.75/tests/test_upload_repo_filter.py +0 -93
  12. {entelligence_cli-0.1.75 → entelligence_cli-0.1.76}/README.md +0 -0
  13. {entelligence_cli-0.1.75 → entelligence_cli-0.1.76}/entelligence_cli/__init__.py +0 -0
  14. {entelligence_cli-0.1.75 → entelligence_cli-0.1.76}/entelligence_cli/api_client.py +0 -0
  15. {entelligence_cli-0.1.75 → entelligence_cli-0.1.76}/entelligence_cli/ascii-art.txt +0 -0
  16. {entelligence_cli-0.1.75 → entelligence_cli-0.1.76}/entelligence_cli/browser_login.py +0 -0
  17. {entelligence_cli-0.1.75 → entelligence_cli-0.1.76}/entelligence_cli/cli.py +0 -0
  18. {entelligence_cli-0.1.75 → entelligence_cli-0.1.76}/entelligence_cli/commands/__init__.py +0 -0
  19. {entelligence_cli-0.1.75 → entelligence_cli-0.1.76}/entelligence_cli/commands/auth.py +0 -0
  20. {entelligence_cli-0.1.75 → entelligence_cli-0.1.76}/entelligence_cli/commands/incident.py +0 -0
  21. {entelligence_cli-0.1.75 → entelligence_cli-0.1.76}/entelligence_cli/commands/insights.py +0 -0
  22. {entelligence_cli-0.1.75 → entelligence_cli-0.1.76}/entelligence_cli/commands/review.py +0 -0
  23. {entelligence_cli-0.1.75 → entelligence_cli-0.1.76}/entelligence_cli/commands/router.py +0 -0
  24. {entelligence_cli-0.1.75 → entelligence_cli-0.1.76}/entelligence_cli/commands/security.py +0 -0
  25. {entelligence_cli-0.1.75 → entelligence_cli-0.1.76}/entelligence_cli/commands/update.py +0 -0
  26. {entelligence_cli-0.1.75 → entelligence_cli-0.1.76}/entelligence_cli/config.py +0 -0
  27. {entelligence_cli-0.1.75 → entelligence_cli-0.1.76}/entelligence_cli/exceptions.py +0 -0
  28. {entelligence_cli-0.1.75 → entelligence_cli-0.1.76}/entelligence_cli/router/__init__.py +0 -0
  29. {entelligence_cli-0.1.75 → entelligence_cli-0.1.76}/entelligence_cli/router/bedrock.py +0 -0
  30. {entelligence_cli-0.1.75 → entelligence_cli-0.1.76}/entelligence_cli/router/catalog.py +0 -0
  31. {entelligence_cli-0.1.75 → entelligence_cli-0.1.76}/entelligence_cli/router/claude_settings.py +0 -0
  32. {entelligence_cli-0.1.75 → entelligence_cli-0.1.76}/entelligence_cli/router/compress.py +0 -0
  33. {entelligence_cli-0.1.75 → entelligence_cli-0.1.76}/entelligence_cli/router/daemon.py +0 -0
  34. {entelligence_cli-0.1.75 → entelligence_cli-0.1.76}/entelligence_cli/router/engine.py +0 -0
  35. {entelligence_cli-0.1.75 → entelligence_cli-0.1.76}/entelligence_cli/router/gateway.py +0 -0
  36. {entelligence_cli-0.1.75 → entelligence_cli-0.1.76}/entelligence_cli/router/gemini_translate.py +0 -0
  37. {entelligence_cli-0.1.75 → entelligence_cli-0.1.76}/entelligence_cli/router/openai_translate.py +0 -0
  38. {entelligence_cli-0.1.75 → entelligence_cli-0.1.76}/entelligence_cli/router/proxy.py +0 -0
  39. {entelligence_cli-0.1.75 → entelligence_cli-0.1.76}/entelligence_cli/router/state.py +0 -0
  40. {entelligence_cli-0.1.75 → entelligence_cli-0.1.76}/entelligence_cli/scheduling.py +0 -0
  41. {entelligence_cli-0.1.75 → entelligence_cli-0.1.76}/entelligence_cli/textual_ui.py +0 -0
  42. {entelligence_cli-0.1.75 → entelligence_cli-0.1.76}/entelligence_cli/ui/__init__.py +0 -0
  43. {entelligence_cli-0.1.75 → entelligence_cli-0.1.76}/entelligence_cli/ui/interactive.py +0 -0
  44. {entelligence_cli-0.1.75 → entelligence_cli-0.1.76}/entelligence_cli/ui/loaders.py +0 -0
  45. {entelligence_cli-0.1.75 → entelligence_cli-0.1.76}/entelligence_cli/ui/output.py +0 -0
  46. {entelligence_cli-0.1.75 → entelligence_cli-0.1.76}/entelligence_cli/ui/styles.py +0 -0
  47. {entelligence_cli-0.1.75 → entelligence_cli-0.1.76}/entelligence_cli/update_checker.py +0 -0
  48. {entelligence_cli-0.1.75 → entelligence_cli-0.1.76}/entelligence_cli/upload/__init__.py +0 -0
  49. {entelligence_cli-0.1.75 → entelligence_cli-0.1.76}/entelligence_cli/upload/conductor.py +0 -0
  50. {entelligence_cli-0.1.75 → entelligence_cli-0.1.76}/entelligence_cli/upload/config.py +0 -0
  51. {entelligence_cli-0.1.75 → entelligence_cli-0.1.76}/entelligence_cli/upload/discovery.py +0 -0
  52. {entelligence_cli-0.1.75 → entelligence_cli-0.1.76}/entelligence_cli/upload/pii.py +0 -0
  53. {entelligence_cli-0.1.75 → entelligence_cli-0.1.76}/entelligence_cli/upload/secrets.py +0 -0
  54. {entelligence_cli-0.1.75 → entelligence_cli-0.1.76}/entelligence_cli/upload/stores.py +0 -0
  55. {entelligence_cli-0.1.75 → entelligence_cli-0.1.76}/entelligence_cli/wrapped_game.py +0 -0
  56. {entelligence_cli-0.1.75 → entelligence_cli-0.1.76}/entelligence_cli.egg-info/dependency_links.txt +0 -0
  57. {entelligence_cli-0.1.75 → entelligence_cli-0.1.76}/entelligence_cli.egg-info/entry_points.txt +0 -0
  58. {entelligence_cli-0.1.75 → entelligence_cli-0.1.76}/entelligence_cli.egg-info/requires.txt +0 -0
  59. {entelligence_cli-0.1.75 → entelligence_cli-0.1.76}/entelligence_cli.egg-info/top_level.txt +0 -0
  60. {entelligence_cli-0.1.75 → entelligence_cli-0.1.76}/setup.cfg +0 -0
  61. {entelligence_cli-0.1.75 → entelligence_cli-0.1.76}/tests/test_attach_fix_smoke.py +0 -0
  62. {entelligence_cli-0.1.75 → entelligence_cli-0.1.76}/tests/test_attach_ticket_smoke.py +0 -0
  63. {entelligence_cli-0.1.75 → entelligence_cli-0.1.76}/tests/test_conductor.py +0 -0
  64. {entelligence_cli-0.1.75 → entelligence_cli-0.1.76}/tests/test_discovery_opencode.py +0 -0
  65. {entelligence_cli-0.1.75 → entelligence_cli-0.1.76}/tests/test_incident_smoke.py +0 -0
  66. {entelligence_cli-0.1.75 → entelligence_cli-0.1.76}/tests/test_pii.py +0 -0
  67. {entelligence_cli-0.1.75 → entelligence_cli-0.1.76}/tests/test_security_commands.py +0 -0
  68. {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.75
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 didn't resolve
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.75
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
@@ -61,4 +61,5 @@ tests/test_incident_smoke.py
61
61
  tests/test_pii.py
62
62
  tests/test_security_commands.py
63
63
  tests/test_update_command.py
64
- tests/test_upload_repo_filter.py
64
+ tests/test_upload_repo_filter.py
65
+ tests/test_upload_repo_roots.py
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "entelligence-cli"
7
- version = "0.1.75"
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()