git-aware-coding-agent 1.0.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.
- avos_cli/__init__.py +3 -0
- avos_cli/agents/avos_ask_agent.md +47 -0
- avos_cli/agents/avos_ask_agent_JSON_converter.md +78 -0
- avos_cli/agents/avos_hisotry_agent_JSON_converter.md +92 -0
- avos_cli/agents/avos_history_agent.md +58 -0
- avos_cli/agents/git_diff_agent.md +63 -0
- avos_cli/artifacts/__init__.py +17 -0
- avos_cli/artifacts/base.py +47 -0
- avos_cli/artifacts/commit_builder.py +35 -0
- avos_cli/artifacts/doc_builder.py +30 -0
- avos_cli/artifacts/issue_builder.py +37 -0
- avos_cli/artifacts/pr_builder.py +50 -0
- avos_cli/cli/__init__.py +1 -0
- avos_cli/cli/main.py +504 -0
- avos_cli/commands/__init__.py +1 -0
- avos_cli/commands/ask.py +541 -0
- avos_cli/commands/connect.py +363 -0
- avos_cli/commands/history.py +549 -0
- avos_cli/commands/hook_install.py +260 -0
- avos_cli/commands/hook_sync.py +231 -0
- avos_cli/commands/ingest.py +506 -0
- avos_cli/commands/ingest_pr.py +239 -0
- avos_cli/config/__init__.py +1 -0
- avos_cli/config/hash_store.py +93 -0
- avos_cli/config/lock.py +122 -0
- avos_cli/config/manager.py +180 -0
- avos_cli/config/state.py +90 -0
- avos_cli/exceptions.py +272 -0
- avos_cli/models/__init__.py +58 -0
- avos_cli/models/api.py +75 -0
- avos_cli/models/artifacts.py +99 -0
- avos_cli/models/config.py +56 -0
- avos_cli/models/diff.py +117 -0
- avos_cli/models/query.py +234 -0
- avos_cli/parsers/__init__.py +21 -0
- avos_cli/parsers/artifact_ref_extractor.py +173 -0
- avos_cli/parsers/reference_parser.py +117 -0
- avos_cli/services/__init__.py +1 -0
- avos_cli/services/chronology_service.py +68 -0
- avos_cli/services/citation_validator.py +134 -0
- avos_cli/services/context_budget_service.py +104 -0
- avos_cli/services/diff_resolver.py +398 -0
- avos_cli/services/diff_summary_service.py +141 -0
- avos_cli/services/git_client.py +351 -0
- avos_cli/services/github_client.py +443 -0
- avos_cli/services/llm_client.py +312 -0
- avos_cli/services/memory_client.py +323 -0
- avos_cli/services/query_fallback_formatter.py +108 -0
- avos_cli/services/reply_output_service.py +341 -0
- avos_cli/services/sanitization_service.py +218 -0
- avos_cli/utils/__init__.py +1 -0
- avos_cli/utils/dotenv_load.py +50 -0
- avos_cli/utils/hashing.py +22 -0
- avos_cli/utils/logger.py +77 -0
- avos_cli/utils/output.py +232 -0
- avos_cli/utils/sanitization_diagnostics.py +81 -0
- avos_cli/utils/time_helpers.py +56 -0
- git_aware_coding_agent-1.0.0.dist-info/METADATA +390 -0
- git_aware_coding_agent-1.0.0.dist-info/RECORD +62 -0
- git_aware_coding_agent-1.0.0.dist-info/WHEEL +4 -0
- git_aware_coding_agent-1.0.0.dist-info/entry_points.txt +2 -0
- git_aware_coding_agent-1.0.0.dist-info/licenses/LICENSE +201 -0
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
"""Single-PR ingest command orchestrator.
|
|
2
|
+
|
|
3
|
+
Implements `avos ingest-pr org/repo PR_NUMBER` to ingest a specific PR
|
|
4
|
+
after it has been pushed/merged. Reuses the existing PR artifact builder
|
|
5
|
+
and hash store for deduplication.
|
|
6
|
+
|
|
7
|
+
Exit codes:
|
|
8
|
+
0: success (stored or skipped)
|
|
9
|
+
1: precondition failure (config missing, invalid args)
|
|
10
|
+
2: hard external failure (GitHub API, Memory API)
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
from avos_cli.artifacts.pr_builder import PRThreadBuilder
|
|
19
|
+
from avos_cli.config.hash_store import IngestHashStore
|
|
20
|
+
from avos_cli.config.manager import load_config
|
|
21
|
+
from avos_cli.exceptions import AvosError, ConfigurationNotInitializedError
|
|
22
|
+
from avos_cli.models.artifacts import PRArtifact
|
|
23
|
+
from avos_cli.services.github_client import GitHubClient
|
|
24
|
+
from avos_cli.services.memory_client import AvosMemoryClient
|
|
25
|
+
from avos_cli.utils.logger import get_logger
|
|
26
|
+
from avos_cli.utils.output import (
|
|
27
|
+
print_error,
|
|
28
|
+
print_info,
|
|
29
|
+
print_json,
|
|
30
|
+
render_kv_panel,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
_log = get_logger("commands.ingest_pr")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class IngestPROrchestrator:
|
|
37
|
+
"""Orchestrates the `avos ingest-pr` command.
|
|
38
|
+
|
|
39
|
+
Fetches a single PR by number, builds its artifact, checks for
|
|
40
|
+
duplicates via content hash, and stores in Avos Memory.
|
|
41
|
+
|
|
42
|
+
Args:
|
|
43
|
+
memory_client: Avos Memory API client.
|
|
44
|
+
github_client: GitHub REST API client.
|
|
45
|
+
hash_store: Content hash store for deduplication.
|
|
46
|
+
repo_root: Path to the repository root.
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
def __init__(
|
|
50
|
+
self,
|
|
51
|
+
memory_client: AvosMemoryClient,
|
|
52
|
+
github_client: GitHubClient,
|
|
53
|
+
hash_store: IngestHashStore,
|
|
54
|
+
repo_root: Path,
|
|
55
|
+
) -> None:
|
|
56
|
+
self._memory = memory_client
|
|
57
|
+
self._github = github_client
|
|
58
|
+
self._hash_store = hash_store
|
|
59
|
+
self._repo_root = repo_root
|
|
60
|
+
self._pr_builder = PRThreadBuilder()
|
|
61
|
+
|
|
62
|
+
def run(
|
|
63
|
+
self, repo_slug: str, pr_number: int, json_output: bool = False
|
|
64
|
+
) -> int:
|
|
65
|
+
"""Execute the single-PR ingest flow.
|
|
66
|
+
|
|
67
|
+
Args:
|
|
68
|
+
repo_slug: Repository identifier in 'org/repo' format.
|
|
69
|
+
pr_number: PR number to ingest.
|
|
70
|
+
json_output: If True, emit JSON output instead of human UI.
|
|
71
|
+
|
|
72
|
+
Returns:
|
|
73
|
+
Exit code: 0 success, 1 precondition, 2 hard error.
|
|
74
|
+
"""
|
|
75
|
+
if not self._validate_slug(repo_slug):
|
|
76
|
+
if json_output:
|
|
77
|
+
print_json(
|
|
78
|
+
success=False,
|
|
79
|
+
data=None,
|
|
80
|
+
error={
|
|
81
|
+
"code": "REPOSITORY_CONTEXT_ERROR",
|
|
82
|
+
"message": "Invalid repo slug. Expected 'org/repo'.",
|
|
83
|
+
"hint": None,
|
|
84
|
+
"retryable": False,
|
|
85
|
+
},
|
|
86
|
+
)
|
|
87
|
+
else:
|
|
88
|
+
print_error("[REPOSITORY_CONTEXT_ERROR] Invalid repo slug. Expected 'org/repo'.")
|
|
89
|
+
return 1
|
|
90
|
+
|
|
91
|
+
owner, repo = repo_slug.split("/", 1)
|
|
92
|
+
|
|
93
|
+
try:
|
|
94
|
+
config = load_config(self._repo_root)
|
|
95
|
+
except ConfigurationNotInitializedError as e:
|
|
96
|
+
if json_output:
|
|
97
|
+
print_json(
|
|
98
|
+
success=False,
|
|
99
|
+
data=None,
|
|
100
|
+
error={
|
|
101
|
+
"code": "CONFIG_NOT_INITIALIZED",
|
|
102
|
+
"message": str(e),
|
|
103
|
+
"hint": "Run 'avos connect org/repo' first.",
|
|
104
|
+
"retryable": False,
|
|
105
|
+
},
|
|
106
|
+
)
|
|
107
|
+
else:
|
|
108
|
+
print_error(f"[CONFIG_NOT_INITIALIZED] {e}")
|
|
109
|
+
return 1
|
|
110
|
+
except AvosError as e:
|
|
111
|
+
if json_output:
|
|
112
|
+
print_json(
|
|
113
|
+
success=False,
|
|
114
|
+
data=None,
|
|
115
|
+
error={
|
|
116
|
+
"code": e.code,
|
|
117
|
+
"message": str(e),
|
|
118
|
+
"hint": getattr(e, "hint", None),
|
|
119
|
+
"retryable": getattr(e, "retryable", False),
|
|
120
|
+
},
|
|
121
|
+
)
|
|
122
|
+
else:
|
|
123
|
+
print_error(f"[{e.code}] {e}")
|
|
124
|
+
return 1
|
|
125
|
+
|
|
126
|
+
memory_id = config.memory_id
|
|
127
|
+
|
|
128
|
+
try:
|
|
129
|
+
pr_detail = self._github.get_pr_details(owner, repo, pr_number)
|
|
130
|
+
except AvosError as e:
|
|
131
|
+
if json_output:
|
|
132
|
+
print_json(
|
|
133
|
+
success=False,
|
|
134
|
+
data=None,
|
|
135
|
+
error={
|
|
136
|
+
"code": e.code,
|
|
137
|
+
"message": f"Failed to fetch PR #{pr_number}: {e}",
|
|
138
|
+
"hint": getattr(e, "hint", None),
|
|
139
|
+
"retryable": getattr(e, "retryable", True),
|
|
140
|
+
},
|
|
141
|
+
)
|
|
142
|
+
else:
|
|
143
|
+
print_error(f"[{e.code}] Failed to fetch PR #{pr_number}: {e}")
|
|
144
|
+
return 2
|
|
145
|
+
|
|
146
|
+
artifact = self._build_pr_artifact(repo, owner, pr_detail)
|
|
147
|
+
text = self._pr_builder.build(artifact)
|
|
148
|
+
content_hash = self._pr_builder.content_hash(artifact)
|
|
149
|
+
|
|
150
|
+
if self._hash_store.contains(content_hash):
|
|
151
|
+
result = {
|
|
152
|
+
"pr_number": pr_number,
|
|
153
|
+
"action": "skipped",
|
|
154
|
+
"note_id": None,
|
|
155
|
+
"reason": "already_ingested",
|
|
156
|
+
}
|
|
157
|
+
if json_output:
|
|
158
|
+
print_json(success=True, data=result, error=None)
|
|
159
|
+
else:
|
|
160
|
+
print_info(f"PR #{pr_number} already ingested. Skipping.")
|
|
161
|
+
return 0
|
|
162
|
+
|
|
163
|
+
try:
|
|
164
|
+
note_response = self._memory.add_memory(memory_id=memory_id, content=text)
|
|
165
|
+
note_id = note_response.note_id
|
|
166
|
+
except AvosError as e:
|
|
167
|
+
if json_output:
|
|
168
|
+
print_json(
|
|
169
|
+
success=False,
|
|
170
|
+
data=None,
|
|
171
|
+
error={
|
|
172
|
+
"code": e.code,
|
|
173
|
+
"message": f"Failed to store PR #{pr_number}: {e}",
|
|
174
|
+
"hint": getattr(e, "hint", None),
|
|
175
|
+
"retryable": getattr(e, "retryable", True),
|
|
176
|
+
},
|
|
177
|
+
)
|
|
178
|
+
else:
|
|
179
|
+
print_error(f"[{e.code}] Failed to store PR #{pr_number}: {e}")
|
|
180
|
+
return 2
|
|
181
|
+
|
|
182
|
+
self._hash_store.add(content_hash, "pr", str(pr_number))
|
|
183
|
+
self._hash_store.save()
|
|
184
|
+
|
|
185
|
+
result = {
|
|
186
|
+
"pr_number": pr_number,
|
|
187
|
+
"action": "stored",
|
|
188
|
+
"note_id": note_id,
|
|
189
|
+
"reason": None,
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
if json_output:
|
|
193
|
+
print_json(success=True, data=result, error=None)
|
|
194
|
+
else:
|
|
195
|
+
render_kv_panel(
|
|
196
|
+
f"PR #{pr_number} Ingested",
|
|
197
|
+
[
|
|
198
|
+
("Title", artifact.title[:60] + "..." if len(artifact.title) > 60 else artifact.title),
|
|
199
|
+
("Author", artifact.author),
|
|
200
|
+
("Files", str(len(artifact.files))),
|
|
201
|
+
("Note ID", note_id[:12] + "..." if len(note_id) > 12 else note_id),
|
|
202
|
+
],
|
|
203
|
+
style="success",
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
return 0
|
|
207
|
+
|
|
208
|
+
def _build_pr_artifact(
|
|
209
|
+
self, repo: str, owner: str, pr_detail: dict[str, Any]
|
|
210
|
+
) -> PRArtifact:
|
|
211
|
+
"""Transform GitHub PR detail dict into a PRArtifact."""
|
|
212
|
+
files = [f["filename"] for f in pr_detail.get("files", [])]
|
|
213
|
+
comments = pr_detail.get("comments", [])
|
|
214
|
+
reviews = pr_detail.get("reviews", [])
|
|
215
|
+
discussion_parts: list[str] = []
|
|
216
|
+
for c in comments:
|
|
217
|
+
user = c.get("user", {}).get("login", "unknown")
|
|
218
|
+
discussion_parts.append(f"{user}: {c.get('body', '')}")
|
|
219
|
+
for r in reviews:
|
|
220
|
+
user = r.get("user", {}).get("login", "unknown")
|
|
221
|
+
discussion_parts.append(f"{user} ({r.get('state', '')}): {r.get('body', '')}")
|
|
222
|
+
|
|
223
|
+
return PRArtifact(
|
|
224
|
+
repo=f"{owner}/{repo}",
|
|
225
|
+
pr_number=pr_detail["number"],
|
|
226
|
+
title=pr_detail.get("title", ""),
|
|
227
|
+
author=pr_detail.get("user", {}).get("login", "unknown"),
|
|
228
|
+
merged_date=pr_detail.get("merged_at"),
|
|
229
|
+
files=files,
|
|
230
|
+
description=pr_detail.get("body"),
|
|
231
|
+
discussion="\n".join(discussion_parts) if discussion_parts else None,
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
@staticmethod
|
|
235
|
+
def _validate_slug(slug: str) -> bool:
|
|
236
|
+
if not slug or "/" not in slug:
|
|
237
|
+
return False
|
|
238
|
+
parts = slug.split("/", 1)
|
|
239
|
+
return bool(parts[0]) and bool(parts[1])
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Configuration management for AVOS CLI."""
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""Ingest hash store for artifact deduplication.
|
|
2
|
+
|
|
3
|
+
Manages .avos/ingest_hashes.json which maps content hashes to metadata.
|
|
4
|
+
Used by the ingest command to skip already-stored artifacts on re-runs.
|
|
5
|
+
Each entry stores artifact_type, source_id, and stored_at timestamp.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import time
|
|
12
|
+
from datetime import datetime, timezone
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
from avos_cli.config.state import atomic_write
|
|
16
|
+
from avos_cli.utils.logger import get_logger
|
|
17
|
+
|
|
18
|
+
_log = get_logger("config.hash_store")
|
|
19
|
+
|
|
20
|
+
_HASH_STORE_FILENAME = "ingest_hashes.json"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class IngestHashStore:
|
|
24
|
+
"""Manages the content hash store for ingest deduplication.
|
|
25
|
+
|
|
26
|
+
Args:
|
|
27
|
+
avos_dir: Path to the .avos directory.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
def __init__(self, avos_dir: Path) -> None:
|
|
31
|
+
self._path = avos_dir / _HASH_STORE_FILENAME
|
|
32
|
+
self._store: dict[str, dict[str, str]] = {}
|
|
33
|
+
|
|
34
|
+
def load(self) -> None:
|
|
35
|
+
"""Load the hash store from disk. Quarantines corrupt files."""
|
|
36
|
+
if not self._path.exists():
|
|
37
|
+
self._store = {}
|
|
38
|
+
return
|
|
39
|
+
|
|
40
|
+
try:
|
|
41
|
+
content = self._path.read_text(encoding="utf-8")
|
|
42
|
+
if not content.strip():
|
|
43
|
+
self._store = {}
|
|
44
|
+
return
|
|
45
|
+
data = json.loads(content)
|
|
46
|
+
if not isinstance(data, dict):
|
|
47
|
+
_log.warning("Hash store is not a dict, treating as corrupt")
|
|
48
|
+
self._quarantine()
|
|
49
|
+
self._store = {}
|
|
50
|
+
return
|
|
51
|
+
self._store = data
|
|
52
|
+
except (json.JSONDecodeError, UnicodeDecodeError):
|
|
53
|
+
_log.warning("Corrupt hash store, quarantining: %s", self._path)
|
|
54
|
+
self._quarantine()
|
|
55
|
+
self._store = {}
|
|
56
|
+
|
|
57
|
+
def save(self) -> None:
|
|
58
|
+
"""Persist the hash store to disk atomically."""
|
|
59
|
+
content = json.dumps(self._store, indent=2, sort_keys=True)
|
|
60
|
+
self._path.parent.mkdir(parents=True, exist_ok=True)
|
|
61
|
+
atomic_write(self._path, content)
|
|
62
|
+
|
|
63
|
+
def contains(self, content_hash: str) -> bool:
|
|
64
|
+
"""Check if a content hash is already stored."""
|
|
65
|
+
return content_hash in self._store
|
|
66
|
+
|
|
67
|
+
def add(self, content_hash: str, artifact_type: str, source_id: str) -> None:
|
|
68
|
+
"""Add a content hash with metadata. Idempotent for duplicates."""
|
|
69
|
+
if content_hash in self._store:
|
|
70
|
+
return
|
|
71
|
+
self._store[content_hash] = {
|
|
72
|
+
"artifact_type": artifact_type,
|
|
73
|
+
"source_id": source_id,
|
|
74
|
+
"stored_at": datetime.now(tz=timezone.utc).isoformat(),
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
def get_entry(self, content_hash: str) -> dict[str, str] | None:
|
|
78
|
+
"""Get the metadata entry for a hash, or None if not found."""
|
|
79
|
+
return self._store.get(content_hash)
|
|
80
|
+
|
|
81
|
+
def count(self) -> int:
|
|
82
|
+
"""Return the number of stored hashes."""
|
|
83
|
+
return len(self._store)
|
|
84
|
+
|
|
85
|
+
def _quarantine(self) -> None:
|
|
86
|
+
"""Move a corrupt hash store file to a .corrupt backup."""
|
|
87
|
+
ts = int(time.time())
|
|
88
|
+
corrupt_path = self._path.with_suffix(f"{self._path.suffix}.corrupt.{ts}")
|
|
89
|
+
try:
|
|
90
|
+
self._path.rename(corrupt_path)
|
|
91
|
+
_log.warning("Quarantined: %s -> %s", self._path, corrupt_path)
|
|
92
|
+
except OSError as e:
|
|
93
|
+
_log.error("Failed to quarantine %s: %s", self._path, e)
|
avos_cli/config/lock.py
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"""Ingest lock manager for preventing concurrent ingest runs.
|
|
2
|
+
|
|
3
|
+
Uses a JSON lock file (.avos/ingest.lock) containing PID and timestamp.
|
|
4
|
+
Supports stale-lock detection via PID liveness check and configurable
|
|
5
|
+
time threshold (default 1 hour).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
import time
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
from avos_cli.exceptions import IngestLockError
|
|
16
|
+
from avos_cli.utils.logger import get_logger
|
|
17
|
+
|
|
18
|
+
_log = get_logger("config.lock")
|
|
19
|
+
|
|
20
|
+
_LOCK_FILENAME = "ingest.lock"
|
|
21
|
+
_DEFAULT_STALE_THRESHOLD = 3600 # 1 hour
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class IngestLockManager:
|
|
25
|
+
"""Manages the ingest lock file for single-process exclusion.
|
|
26
|
+
|
|
27
|
+
Args:
|
|
28
|
+
avos_dir: Path to the .avos directory.
|
|
29
|
+
stale_threshold_seconds: Seconds after which a lock with a dead PID
|
|
30
|
+
is considered stale and can be broken.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
def __init__(
|
|
34
|
+
self,
|
|
35
|
+
avos_dir: Path,
|
|
36
|
+
stale_threshold_seconds: int = _DEFAULT_STALE_THRESHOLD,
|
|
37
|
+
) -> None:
|
|
38
|
+
self._lock_path = avos_dir / _LOCK_FILENAME
|
|
39
|
+
self._stale_threshold = stale_threshold_seconds
|
|
40
|
+
|
|
41
|
+
def acquire(self) -> None:
|
|
42
|
+
"""Acquire the ingest lock.
|
|
43
|
+
|
|
44
|
+
Raises:
|
|
45
|
+
IngestLockError: If another live process holds the lock.
|
|
46
|
+
"""
|
|
47
|
+
if self._lock_path.exists():
|
|
48
|
+
existing = self._read_lock()
|
|
49
|
+
if existing is None:
|
|
50
|
+
_log.warning("Removing corrupt lock file: %s", self._lock_path)
|
|
51
|
+
self._lock_path.unlink(missing_ok=True)
|
|
52
|
+
elif self._is_stale(existing):
|
|
53
|
+
_log.warning(
|
|
54
|
+
"Breaking stale lock (pid=%d, age=%.0fs)",
|
|
55
|
+
int(existing["pid"]),
|
|
56
|
+
time.time() - float(existing["acquired_at"]),
|
|
57
|
+
)
|
|
58
|
+
self._lock_path.unlink(missing_ok=True)
|
|
59
|
+
else:
|
|
60
|
+
pid = int(existing["pid"])
|
|
61
|
+
raise IngestLockError(
|
|
62
|
+
f"Ingest lock held by pid {pid}",
|
|
63
|
+
holder_pid=pid,
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
self._write_lock()
|
|
67
|
+
|
|
68
|
+
def release(self) -> None:
|
|
69
|
+
"""Release the ingest lock. Safe to call even if not held."""
|
|
70
|
+
self._lock_path.unlink(missing_ok=True)
|
|
71
|
+
|
|
72
|
+
def is_locked(self) -> bool:
|
|
73
|
+
"""Check whether the lock file exists."""
|
|
74
|
+
return self._lock_path.exists()
|
|
75
|
+
|
|
76
|
+
def __enter__(self) -> IngestLockManager:
|
|
77
|
+
self.acquire()
|
|
78
|
+
return self
|
|
79
|
+
|
|
80
|
+
def __exit__(self, exc_type: object, exc_val: object, exc_tb: object) -> None:
|
|
81
|
+
self.release()
|
|
82
|
+
|
|
83
|
+
def _write_lock(self) -> None:
|
|
84
|
+
"""Write the lock file with current PID and timestamp."""
|
|
85
|
+
data = {"pid": os.getpid(), "acquired_at": time.time()}
|
|
86
|
+
self._lock_path.write_text(json.dumps(data), encoding="utf-8")
|
|
87
|
+
|
|
88
|
+
def _read_lock(self) -> dict[str, int | float] | None:
|
|
89
|
+
"""Read and parse the lock file. Returns None if corrupt."""
|
|
90
|
+
try:
|
|
91
|
+
content = self._lock_path.read_text(encoding="utf-8")
|
|
92
|
+
if not content.strip():
|
|
93
|
+
return None
|
|
94
|
+
raw = json.loads(content)
|
|
95
|
+
if not isinstance(raw, dict):
|
|
96
|
+
return None
|
|
97
|
+
if "pid" not in raw or "acquired_at" not in raw:
|
|
98
|
+
return None
|
|
99
|
+
return {"pid": int(raw["pid"]), "acquired_at": float(raw["acquired_at"])}
|
|
100
|
+
except (json.JSONDecodeError, OSError, ValueError, TypeError):
|
|
101
|
+
return None
|
|
102
|
+
|
|
103
|
+
def _is_stale(self, lock_data: dict[str, int | float]) -> bool:
|
|
104
|
+
"""Determine if a lock is stale (old AND holder PID is dead).
|
|
105
|
+
|
|
106
|
+
A lock is stale only when BOTH conditions are met:
|
|
107
|
+
1. The lock age exceeds the stale threshold.
|
|
108
|
+
2. The holder PID is no longer alive.
|
|
109
|
+
"""
|
|
110
|
+
age = time.time() - float(lock_data["acquired_at"])
|
|
111
|
+
if age < self._stale_threshold:
|
|
112
|
+
return False
|
|
113
|
+
return not self._pid_alive(int(lock_data["pid"]))
|
|
114
|
+
|
|
115
|
+
@staticmethod
|
|
116
|
+
def _pid_alive(pid: int) -> bool:
|
|
117
|
+
"""Check if a process with the given PID is alive."""
|
|
118
|
+
try:
|
|
119
|
+
os.kill(pid, 0)
|
|
120
|
+
return True
|
|
121
|
+
except (OSError, ProcessLookupError):
|
|
122
|
+
return False
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
"""Configuration manager for AVOS CLI.
|
|
2
|
+
|
|
3
|
+
Handles repo root detection, config load/save with environment variable
|
|
4
|
+
overlay, and .avos directory management. Config resolution priority:
|
|
5
|
+
env vars > config file > defaults.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
from pydantic import ValidationError
|
|
16
|
+
|
|
17
|
+
from avos_cli.config.state import atomic_write, read_json_safe
|
|
18
|
+
from avos_cli.exceptions import (
|
|
19
|
+
ConfigurationNotInitializedError,
|
|
20
|
+
ConfigurationValidationError,
|
|
21
|
+
RepositoryContextError,
|
|
22
|
+
)
|
|
23
|
+
from avos_cli.models.config import RepoConfig
|
|
24
|
+
|
|
25
|
+
_CONFIG_FILENAME = "config.json"
|
|
26
|
+
_AVOS_DIR = ".avos"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def find_repo_root(start: Path) -> Path:
|
|
30
|
+
"""Walk up from start directory to find the Git repository root.
|
|
31
|
+
|
|
32
|
+
Detects both standard repos (.git directory) and worktrees (.git file).
|
|
33
|
+
|
|
34
|
+
Args:
|
|
35
|
+
start: Directory to start searching from.
|
|
36
|
+
|
|
37
|
+
Returns:
|
|
38
|
+
Path to the repository root.
|
|
39
|
+
|
|
40
|
+
Raises:
|
|
41
|
+
RepositoryContextError: If no .git is found.
|
|
42
|
+
"""
|
|
43
|
+
current = start.resolve()
|
|
44
|
+
while True:
|
|
45
|
+
git_path = current / ".git"
|
|
46
|
+
if git_path.exists():
|
|
47
|
+
return current
|
|
48
|
+
parent = current.parent
|
|
49
|
+
if parent == current:
|
|
50
|
+
break
|
|
51
|
+
current = parent
|
|
52
|
+
raise RepositoryContextError(f"No Git repository found at or above: {start}")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def load_config(repo_root: Path) -> RepoConfig:
|
|
56
|
+
"""Load repository configuration from .avos/config.json with env overlay.
|
|
57
|
+
|
|
58
|
+
Resolution priority: env vars > config file values > defaults.
|
|
59
|
+
|
|
60
|
+
Args:
|
|
61
|
+
repo_root: Path to the repository root (must contain .avos/).
|
|
62
|
+
|
|
63
|
+
Returns:
|
|
64
|
+
Validated RepoConfig instance.
|
|
65
|
+
|
|
66
|
+
Raises:
|
|
67
|
+
ConfigurationNotInitializedError: If .avos/config.json doesn't exist.
|
|
68
|
+
ConfigurationValidationError: If config is malformed or invalid.
|
|
69
|
+
"""
|
|
70
|
+
config_path = repo_root / _AVOS_DIR / _CONFIG_FILENAME
|
|
71
|
+
|
|
72
|
+
if not config_path.exists():
|
|
73
|
+
raise ConfigurationNotInitializedError()
|
|
74
|
+
|
|
75
|
+
raw_data = read_json_safe(config_path)
|
|
76
|
+
if raw_data is None:
|
|
77
|
+
raise ConfigurationValidationError(
|
|
78
|
+
f"Config file is corrupt or unreadable: {config_path}"
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
data: dict[str, Any] = dict(raw_data)
|
|
82
|
+
_apply_env_overlay(data)
|
|
83
|
+
|
|
84
|
+
try:
|
|
85
|
+
return RepoConfig(**data)
|
|
86
|
+
except ValidationError as e:
|
|
87
|
+
raise ConfigurationValidationError(
|
|
88
|
+
f"Invalid configuration in {config_path}: {e}"
|
|
89
|
+
) from e
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def connected_repo_slug(repo_root: Path) -> str | None:
|
|
93
|
+
"""Return the repository slug persisted by ``avos connect`` (authoritative context).
|
|
94
|
+
|
|
95
|
+
After a successful connect, ``repo`` in ``.avos/config.json`` is the
|
|
96
|
+
canonical ``org/repo`` for this working copy. Pass it as ``default_repo``
|
|
97
|
+
to :class:`~avos_cli.parsers.reference_parser.ReferenceParser` when the
|
|
98
|
+
user omits owner/repo (e.g. ``PR #1245``, ``Commit 8c3a1b2``).
|
|
99
|
+
|
|
100
|
+
Args:
|
|
101
|
+
repo_root: Git repository root containing ``.avos/config.json``.
|
|
102
|
+
|
|
103
|
+
Returns:
|
|
104
|
+
Connected slug, or ``None`` if the project was never connected.
|
|
105
|
+
|
|
106
|
+
Raises:
|
|
107
|
+
ConfigurationValidationError: If the config file exists but is invalid.
|
|
108
|
+
"""
|
|
109
|
+
try:
|
|
110
|
+
return load_config(repo_root).repo
|
|
111
|
+
except ConfigurationNotInitializedError:
|
|
112
|
+
return None
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _apply_env_overlay(data: dict[str, Any]) -> None:
|
|
116
|
+
"""Apply environment variable overrides to config data dict.
|
|
117
|
+
|
|
118
|
+
Env vars take precedence over file values. Supports:
|
|
119
|
+
AVOS_API_KEY, AVOS_API_URL, GITHUB_TOKEN, AVOS_DEVELOPER,
|
|
120
|
+
AVOS_LLM_PROVIDER, AVOS_LLM_MODEL.
|
|
121
|
+
"""
|
|
122
|
+
env_map = {
|
|
123
|
+
"AVOS_API_KEY": "api_key",
|
|
124
|
+
"AVOS_API_URL": "api_url",
|
|
125
|
+
"GITHUB_TOKEN": "github_token",
|
|
126
|
+
"AVOS_DEVELOPER": "developer",
|
|
127
|
+
}
|
|
128
|
+
for env_var, config_key in env_map.items():
|
|
129
|
+
value = os.environ.get(env_var)
|
|
130
|
+
if value is not None:
|
|
131
|
+
data[config_key] = value
|
|
132
|
+
|
|
133
|
+
llm_data = data.get("llm", {})
|
|
134
|
+
if not isinstance(llm_data, dict):
|
|
135
|
+
llm_data = {}
|
|
136
|
+
llm_provider_env = os.environ.get("AVOS_LLM_PROVIDER")
|
|
137
|
+
llm_model_env = os.environ.get("AVOS_LLM_MODEL")
|
|
138
|
+
if llm_provider_env:
|
|
139
|
+
llm_data["provider"] = llm_provider_env
|
|
140
|
+
if llm_model_env:
|
|
141
|
+
llm_data["model"] = llm_model_env
|
|
142
|
+
|
|
143
|
+
# Align model with provider when the user did not set AVOS_LLM_MODEL.
|
|
144
|
+
# Defaults: openai -> gpt-4o, anthropic -> claude-sonnet-4-5-20250929.
|
|
145
|
+
provider = (llm_data.get("provider") or "openai").lower()
|
|
146
|
+
if not llm_model_env:
|
|
147
|
+
model = llm_data.get("model")
|
|
148
|
+
if provider == "openai":
|
|
149
|
+
if model is None or (isinstance(model, str) and model.startswith("claude-")):
|
|
150
|
+
llm_data["model"] = "gpt-4o"
|
|
151
|
+
elif provider == "anthropic" and (
|
|
152
|
+
model is None
|
|
153
|
+
or (
|
|
154
|
+
isinstance(model, str)
|
|
155
|
+
and (
|
|
156
|
+
model.startswith("gpt-")
|
|
157
|
+
or model.startswith("o1")
|
|
158
|
+
or model.startswith("o3")
|
|
159
|
+
)
|
|
160
|
+
)
|
|
161
|
+
):
|
|
162
|
+
llm_data["model"] = "claude-sonnet-4-5-20250929"
|
|
163
|
+
data["llm"] = llm_data
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def save_config(repo_root: Path, config_data: dict[str, Any]) -> None:
|
|
167
|
+
"""Save configuration data to .avos/config.json atomically.
|
|
168
|
+
|
|
169
|
+
Creates the .avos directory if it doesn't exist. Uses atomic
|
|
170
|
+
write with restrictive permissions (0o600).
|
|
171
|
+
|
|
172
|
+
Args:
|
|
173
|
+
repo_root: Path to the repository root.
|
|
174
|
+
config_data: Configuration dictionary to persist.
|
|
175
|
+
"""
|
|
176
|
+
avos_dir = repo_root / _AVOS_DIR
|
|
177
|
+
avos_dir.mkdir(parents=True, exist_ok=True)
|
|
178
|
+
config_path = avos_dir / _CONFIG_FILENAME
|
|
179
|
+
content = json.dumps(config_data, indent=2, sort_keys=True)
|
|
180
|
+
atomic_write(config_path, content, permissions=0o600)
|