@memorax/memorax-code 0.1.8 → 0.1.10
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.
- package/README.md +10 -0
- package/bin/memorax-code-codebuddy.mjs +4 -0
- package/bin/memorax-code-setup.mjs +182 -65
- package/bin/memorax-code.mjs +74 -45
- package/docs/configuration.md +82 -22
- package/docs/troubleshooting.md +69 -5
- package/lib/automatic-update.mjs +233 -0
- package/lib/memorax-code-adapter-common/src/automatic-update-state.d.mts +24 -0
- package/lib/memorax-code-adapter-common/src/automatic-update-state.mjs +73 -0
- package/lib/memorax-code-adapter-common/src/clients/codebuddy-command.mjs +82 -0
- package/lib/memorax-code-adapter-common/src/config-utils.d.mts +2 -0
- package/lib/memorax-code-adapter-common/src/config-utils.mjs +11 -0
- package/lib/memorax-code-adapter-common/src/hooks/client-hook-launcher.mjs +3 -0
- package/lib/memorax-code-adapter-common/src/hooks/ensure-backend-runner.mjs +45 -10
- package/lib/memorax-code-adapter-common/src/repo-memory/repo-memory-job-worker.mjs +88 -3
- package/lib/memorax-code-adapter-common/src/windows-cli-invocation.mjs +11 -1
- package/lib/memorax-code-backend/dist/app/backend-server.js +2 -2
- package/lib/memorax-code-backend/dist/app/memory-observability.js +2 -2
- package/lib/memorax-code-backend/dist/clients/codebuddy/jsonl-history.js +208 -0
- package/lib/memorax-code-backend/dist/clients/codebuddy/lifecycle.js +44 -0
- package/lib/memorax-code-backend/dist/clients/codebuddy/memory-hook-runtime.js +250 -0
- package/lib/memorax-code-backend/dist/clients/codebuddy/turn-id.js +16 -0
- package/lib/memorax-code-backend/dist/clients/codex/plugin-install.js +129 -7
- package/lib/memorax-code-backend/dist/clients/codex/rollout-turn.js +52 -2
- package/lib/memorax-code-backend/dist/config/memorax-code.js +14 -1
- package/lib/memorax-code-backend/dist/entrypoints/backend-cli.js +43 -6
- package/lib/memorax-code-backend/dist/lifecycle/active-clients.js +3 -0
- package/lib/memorax-code-backend/dist/lifecycle/automatic-update-scheduler.js +133 -0
- package/lib/memorax-code-backend/dist/lifecycle/client-plugin-removal.js +14 -2
- package/lib/memorax-code-backend/dist/lifecycle/client-selection.js +6 -4
- package/lib/memorax-code-backend/dist/lifecycle/orchestrator.js +48 -9
- package/lib/memorax-code-backend/dist/memory/cli.js +4 -2
- package/lib/memorax-code-backend/dist/memory/hook-command.js +31 -1
- package/lib/memorax-code-backend/dist/memory/reminder-trace-recorder.js +5 -1
- package/lib/memorax-code-backend/dist/memory/service.js +12 -0
- package/lib/memorax-code-backend/dist/shared/windows-cli-invocation.js +10 -1
- package/lib/memorax-code-backend/dist/trace/config.js +12 -0
- package/lib/memorax-code-backend/dist/trace/context.js +21 -1
- package/lib/memorax-code-backend/dist/trace/store.js +12 -1
- package/lib/memorax-code-backend/package.json +1 -1
- package/lib/memorax-code-claude-adapter/.claude-plugin/plugin.json +1 -1
- package/lib/memorax-code-claude-adapter/hooks/runtime-shell.json +1 -1
- package/lib/memorax-code-claude-adapter/package.json +1 -1
- package/lib/memorax-code-claude-adapter/runtime-hooks/ensure-backend.mjs +2 -0
- package/lib/memorax-code-claude-adapter/src/plugin-install.mjs +3 -0
- package/lib/memorax-code-claude-marketplace/plugins/memorax-code-claude-adapter/.claude-plugin/plugin.json +1 -1
- package/lib/memorax-code-claude-marketplace/plugins/memorax-code-claude-adapter/hooks/runtime-shell.json +1 -1
- package/lib/memorax-code-claude-marketplace/plugins/memorax-code-claude-adapter/memorax-code-adapter-common/src/automatic-update-state.d.mts +24 -0
- package/lib/memorax-code-claude-marketplace/plugins/memorax-code-claude-adapter/memorax-code-adapter-common/src/automatic-update-state.mjs +73 -0
- package/lib/memorax-code-claude-marketplace/plugins/memorax-code-claude-adapter/memorax-code-adapter-common/src/clients/codebuddy-command.mjs +82 -0
- package/lib/memorax-code-claude-marketplace/plugins/memorax-code-claude-adapter/memorax-code-adapter-common/src/config-utils.d.mts +2 -0
- package/lib/memorax-code-claude-marketplace/plugins/memorax-code-claude-adapter/memorax-code-adapter-common/src/config-utils.mjs +11 -0
- package/lib/memorax-code-claude-marketplace/plugins/memorax-code-claude-adapter/memorax-code-adapter-common/src/hooks/client-hook-launcher.mjs +3 -0
- package/lib/memorax-code-claude-marketplace/plugins/memorax-code-claude-adapter/memorax-code-adapter-common/src/hooks/ensure-backend-runner.mjs +45 -10
- package/lib/memorax-code-claude-marketplace/plugins/memorax-code-claude-adapter/memorax-code-adapter-common/src/repo-memory/repo-memory-job-worker.mjs +88 -3
- package/lib/memorax-code-claude-marketplace/plugins/memorax-code-claude-adapter/memorax-code-adapter-common/src/windows-cli-invocation.mjs +11 -1
- package/lib/memorax-code-claude-marketplace/plugins/memorax-code-claude-adapter/package.json +1 -1
- package/lib/memorax-code-claude-marketplace/plugins/memorax-code-claude-adapter/runtime-hooks/ensure-backend.mjs +2 -0
- package/lib/memorax-code-claude-marketplace/plugins/memorax-code-claude-adapter/src/plugin-install.mjs +3 -0
- package/lib/memorax-code-codebuddy-adapter/.codebuddy-plugin/plugin.json +7 -0
- package/lib/memorax-code-codebuddy-adapter/hooks/common-runtime.mjs +13 -0
- package/lib/memorax-code-codebuddy-adapter/hooks/hooks.json +38 -0
- package/lib/memorax-code-codebuddy-adapter/hooks/repo-memory-job.mjs +40 -0
- package/lib/memorax-code-codebuddy-adapter/hooks/runtime-hook.mjs +279 -0
- package/lib/memorax-code-codebuddy-adapter/package.json +9 -0
- package/lib/memorax-code-codebuddy-adapter/skills/memorax-code/SKILL.md +85 -0
- package/lib/memorax-code-codebuddy-adapter/skills/memorax-code/agents/claude.yaml +10 -0
- package/lib/memorax-code-codebuddy-adapter/skills/memorax-code/agents/openai.yaml +7 -0
- package/lib/memorax-code-codebuddy-adapter/skills/memorax-code/defaults.json +12 -0
- package/lib/memorax-code-codebuddy-adapter/skills/memorax-code/references/memorax-add.md +88 -0
- package/lib/memorax-code-codebuddy-adapter/skills/memorax-code/references/memorax-search.md +93 -0
- package/lib/memorax-code-codebuddy-adapter/skills/memorax-code/references/personal-read.md +46 -0
- package/lib/memorax-code-codebuddy-adapter/skills/memorax-code/references/personal-write.md +120 -0
- package/lib/memorax-code-codebuddy-adapter/skills/memorax-code/references/repo-build.md +319 -0
- package/lib/memorax-code-codebuddy-adapter/skills/memorax-code/references/repo-read.md +103 -0
- package/lib/memorax-code-codebuddy-adapter/skills/memorax-code/references/repo-templates.md +390 -0
- package/lib/memorax-code-codebuddy-adapter/skills/memorax-code/references/repo-update.md +127 -0
- package/lib/memorax-code-codebuddy-adapter/skills/memorax-code/scripts/collect_all.py +579 -0
- package/lib/memorax-code-codebuddy-adapter/skills/memorax-code/scripts/detect_updates.py +919 -0
- package/lib/memorax-code-codebuddy-adapter/skills/memorax-code/scripts/git_commit_facets.py +222 -0
- package/lib/memorax-code-codebuddy-adapter/skills/memorax-code/scripts/github_resource_facets.py +512 -0
- package/lib/memorax-code-codebuddy-adapter/skills/memorax-code/scripts/gitlab_resource_facets.py +517 -0
- package/lib/memorax-code-codebuddy-adapter/skills/memorax-code/scripts/prepare_repo_memory.py +411 -0
- package/lib/memorax-code-codebuddy-adapter/skills/memorax-code/scripts/user_profile_memory.py +528 -0
- package/lib/memorax-code-codebuddy-adapter/skills/memorax-code/scripts/validate_memory.py +248 -0
- package/lib/memorax-code-codebuddy-adapter/src/cli.mjs +58 -0
- package/lib/memorax-code-codebuddy-adapter/src/config.mjs +277 -0
- package/lib/memorax-code-codebuddy-adapter/src/hook-manifest.mjs +47 -0
- package/lib/memorax-code-codebuddy-adapter/src/runtime-observation.mjs +65 -0
- package/lib/memorax-code-codex-adapter/.codex-plugin/plugin.json +1 -1
- package/lib/memorax-code-codex-adapter/hooks/runtime-shell.json +1 -1
- package/lib/memorax-code-codex-adapter/package.json +1 -1
- package/lib/memorax-code-codex-adapter/runtime-hooks/ensure-backend.mjs +4 -1
- package/lib/memorax-code-dsh-adapter/package.json +2 -1
- package/lib/memorax-code-dsh-adapter/src/profile-lifecycle.mjs +1 -0
- package/lib/memorax-code-opencode-adapter/package.json +1 -1
- package/lib/npm-invocation.mjs +51 -8
- package/lib/resolve-codebuddy-command.mjs +112 -0
- package/lib/run-entrypoint.mjs +25 -1
- package/lib/windows-cli-invocation.mjs +11 -1
- package/lib/windows-user-path.mjs +218 -0
- package/package.json +5 -3
|
@@ -0,0 +1,919 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Detect incremental repo-memory updates from git and optional provider evidence."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import argparse
|
|
7
|
+
import json
|
|
8
|
+
import re
|
|
9
|
+
import shutil
|
|
10
|
+
import subprocess
|
|
11
|
+
import sys
|
|
12
|
+
import tempfile
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any
|
|
15
|
+
from urllib.parse import urlparse
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
SCRIPT_DIR = Path(__file__).resolve().parent
|
|
19
|
+
SKILL_DIR = SCRIPT_DIR.parent
|
|
20
|
+
BUILDER_SCRIPTS_DIR = SCRIPT_DIR
|
|
21
|
+
BUILDER_DEFAULTS_PATH = SKILL_DIR / "defaults.json"
|
|
22
|
+
BUILDER_SKILL_DIR = SKILL_DIR
|
|
23
|
+
BUILDER_HELPER_FILES = {
|
|
24
|
+
"defaults": BUILDER_DEFAULTS_PATH,
|
|
25
|
+
"validate_memory": BUILDER_SCRIPTS_DIR / "validate_memory.py",
|
|
26
|
+
"github_resource_facets": BUILDER_SCRIPTS_DIR / "github_resource_facets.py",
|
|
27
|
+
"gitlab_resource_facets": BUILDER_SCRIPTS_DIR / "gitlab_resource_facets.py",
|
|
28
|
+
}
|
|
29
|
+
PREFERRED_REMOTE_NAMES = ("upstream", "origin")
|
|
30
|
+
HISTORY_MODES = {"none", "commits-only", "local-only", "provider", "provider-required"}
|
|
31
|
+
FALLBACK_DEFAULTS = {
|
|
32
|
+
"repoHistory": {
|
|
33
|
+
"mode": "provider",
|
|
34
|
+
"limits": {
|
|
35
|
+
"prs": 30,
|
|
36
|
+
"issues": 30,
|
|
37
|
+
},
|
|
38
|
+
},
|
|
39
|
+
# Kept for compatibility with defaults.json v1.
|
|
40
|
+
"limits": {
|
|
41
|
+
"prs": 30,
|
|
42
|
+
"issues": 30,
|
|
43
|
+
},
|
|
44
|
+
"summaryChars": 4000,
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def run(cmd: list[str], cwd: Path) -> subprocess.CompletedProcess[str]:
|
|
49
|
+
return subprocess.run(cmd, cwd=cwd, text=True, capture_output=True)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def git(repo: Path, args: list[str], message: str) -> str:
|
|
53
|
+
if shutil.which("git") is None:
|
|
54
|
+
raise SystemExit("git is required for repo-memory incremental update detection")
|
|
55
|
+
result = run(["git", "-C", str(repo), *args], repo)
|
|
56
|
+
if result.returncode != 0:
|
|
57
|
+
raise SystemExit(f"{message}:\n{result.stderr.strip()}")
|
|
58
|
+
return result.stdout.strip()
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def load_json(path: Path, fallback: Any) -> Any:
|
|
62
|
+
if not path.exists():
|
|
63
|
+
return fallback
|
|
64
|
+
try:
|
|
65
|
+
return json.loads(path.read_text(encoding="utf-8"))
|
|
66
|
+
except json.JSONDecodeError as exc:
|
|
67
|
+
raise SystemExit(f"{path}: invalid JSON at line {exc.lineno}, column {exc.colno}: {exc.msg}") from exc
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def default_int(settings: dict[str, Any], path: list[str], fallback: int) -> int:
|
|
71
|
+
value: Any = settings
|
|
72
|
+
for key in path:
|
|
73
|
+
value = value.get(key) if isinstance(value, dict) else None
|
|
74
|
+
if value is None:
|
|
75
|
+
return fallback
|
|
76
|
+
if isinstance(value, bool) or not isinstance(value, int):
|
|
77
|
+
raise ValueError(f"{BUILDER_DEFAULTS_PATH}: {'.'.join(path)} must be an integer")
|
|
78
|
+
return value
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def default_limit(settings: dict[str, Any], key: str) -> int:
|
|
82
|
+
history = settings.get("repoHistory")
|
|
83
|
+
if isinstance(history, dict) and isinstance(history.get("limits"), dict) and history["limits"].get(key) is not None:
|
|
84
|
+
return default_int(settings, ["repoHistory", "limits", key], FALLBACK_DEFAULTS["repoHistory"]["limits"][key])
|
|
85
|
+
return default_int(settings, ["limits", key], FALLBACK_DEFAULTS["limits"][key])
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def default_history_mode(settings: dict[str, Any]) -> str:
|
|
89
|
+
history = settings.get("repoHistory")
|
|
90
|
+
if not isinstance(history, dict):
|
|
91
|
+
return "provider"
|
|
92
|
+
mode = history.get("mode", "provider")
|
|
93
|
+
if not isinstance(mode, str) or mode not in HISTORY_MODES:
|
|
94
|
+
raise ValueError(f"{BUILDER_DEFAULTS_PATH}: repoHistory.mode must be one of {', '.join(sorted(HISTORY_MODES))}")
|
|
95
|
+
return mode
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def history_collect(mode: str) -> dict[str, bool]:
|
|
99
|
+
return {
|
|
100
|
+
"commits": mode in {"commits-only", "local-only", "provider", "provider-required"},
|
|
101
|
+
"provider": mode in {"provider", "provider-required"},
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def load_default_settings() -> tuple[dict[str, Any], str]:
|
|
106
|
+
if not BUILDER_DEFAULTS_PATH.exists():
|
|
107
|
+
return FALLBACK_DEFAULTS, "hardcoded_fallback"
|
|
108
|
+
data = load_json(BUILDER_DEFAULTS_PATH, FALLBACK_DEFAULTS)
|
|
109
|
+
if not isinstance(data, dict):
|
|
110
|
+
raise ValueError(f"{BUILDER_DEFAULTS_PATH}: expected a JSON object")
|
|
111
|
+
return data, str(BUILDER_DEFAULTS_PATH)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def validate_range(parser: argparse.ArgumentParser, name: str, value: int, minimum: int, maximum: int) -> None:
|
|
115
|
+
if value < minimum or value > maximum:
|
|
116
|
+
parser.error(f"{name} must be from {minimum} to {maximum}")
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def apply_effective_settings(args: argparse.Namespace, parser: argparse.ArgumentParser) -> argparse.Namespace:
|
|
120
|
+
try:
|
|
121
|
+
defaults, source = load_default_settings()
|
|
122
|
+
default_mode = default_history_mode(defaults)
|
|
123
|
+
default_pr_limit = default_limit(defaults, "prs")
|
|
124
|
+
default_issue_limit = default_limit(defaults, "issues")
|
|
125
|
+
default_summary_chars = default_int(defaults, ["summaryChars"], FALLBACK_DEFAULTS["summaryChars"])
|
|
126
|
+
except ValueError as exc:
|
|
127
|
+
parser.error(str(exc))
|
|
128
|
+
|
|
129
|
+
history_mode = args.history_mode or default_mode
|
|
130
|
+
collect = history_collect(history_mode)
|
|
131
|
+
if args.provider_mode == "off":
|
|
132
|
+
if history_mode == "provider-required":
|
|
133
|
+
parser.error("--provider-mode off cannot be combined with --history-mode provider-required")
|
|
134
|
+
collect = {**collect, "provider": False}
|
|
135
|
+
pr_limit = args.pr_limit if args.pr_limit is not None else default_pr_limit
|
|
136
|
+
issue_limit = args.issue_limit if args.issue_limit is not None else default_issue_limit
|
|
137
|
+
summary_chars = args.summary_chars if args.summary_chars is not None else default_summary_chars
|
|
138
|
+
|
|
139
|
+
validate_range(parser, "--pr-limit", pr_limit, 1, 100)
|
|
140
|
+
validate_range(parser, "--issue-limit", issue_limit, 1, 100)
|
|
141
|
+
if summary_chars < 100:
|
|
142
|
+
parser.error("--summary-chars must be at least 100")
|
|
143
|
+
|
|
144
|
+
overrides: dict[str, Any] = {}
|
|
145
|
+
if args.history_mode is not None:
|
|
146
|
+
overrides["history_mode"] = args.history_mode
|
|
147
|
+
if args.pr_limit is not None:
|
|
148
|
+
overrides["pr_limit"] = args.pr_limit
|
|
149
|
+
if args.issue_limit is not None:
|
|
150
|
+
overrides["issue_limit"] = args.issue_limit
|
|
151
|
+
if args.summary_chars is not None:
|
|
152
|
+
overrides["summary_chars"] = args.summary_chars
|
|
153
|
+
|
|
154
|
+
args.history_mode = history_mode
|
|
155
|
+
args.history_collect = collect
|
|
156
|
+
args.pr_limit = pr_limit
|
|
157
|
+
args.issue_limit = issue_limit
|
|
158
|
+
args.summary_chars = summary_chars
|
|
159
|
+
args.effective_settings = {
|
|
160
|
+
"history": {
|
|
161
|
+
"mode": history_mode,
|
|
162
|
+
"collect": collect,
|
|
163
|
+
},
|
|
164
|
+
"limits": {
|
|
165
|
+
"prs": pr_limit,
|
|
166
|
+
"issues": issue_limit,
|
|
167
|
+
},
|
|
168
|
+
"summary_chars": summary_chars,
|
|
169
|
+
"source": source,
|
|
170
|
+
"overrides": overrides,
|
|
171
|
+
}
|
|
172
|
+
return args
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def file_fingerprint(path: Path) -> dict[str, Any]:
|
|
176
|
+
item: dict[str, Any] = {
|
|
177
|
+
"path": str(path),
|
|
178
|
+
"exists": path.exists(),
|
|
179
|
+
}
|
|
180
|
+
if path.exists():
|
|
181
|
+
stat = path.stat()
|
|
182
|
+
item["mtime_ns"] = stat.st_mtime_ns
|
|
183
|
+
item["size_bytes"] = stat.st_size
|
|
184
|
+
return item
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def builder_helper_report() -> dict[str, Any]:
|
|
188
|
+
return {
|
|
189
|
+
"skill_dir": str(BUILDER_SKILL_DIR),
|
|
190
|
+
"scripts_dir": str(BUILDER_SCRIPTS_DIR),
|
|
191
|
+
"files": {
|
|
192
|
+
name: file_fingerprint(path)
|
|
193
|
+
for name, path in BUILDER_HELPER_FILES.items()
|
|
194
|
+
},
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def parse_frontmatter(path: Path) -> dict[str, str]:
|
|
199
|
+
if not path.exists():
|
|
200
|
+
return {}
|
|
201
|
+
text = path.read_text(encoding="utf-8")
|
|
202
|
+
if not text.startswith("---\n"):
|
|
203
|
+
return {}
|
|
204
|
+
end = text.find("\n---", 4)
|
|
205
|
+
if end == -1:
|
|
206
|
+
return {}
|
|
207
|
+
fields: dict[str, str] = {}
|
|
208
|
+
for line in text[4:end].splitlines():
|
|
209
|
+
if ":" not in line:
|
|
210
|
+
continue
|
|
211
|
+
key, value = line.split(":", 1)
|
|
212
|
+
key = key.strip()
|
|
213
|
+
value = value.strip().strip('"').strip("'")
|
|
214
|
+
if key:
|
|
215
|
+
fields[key] = value
|
|
216
|
+
return fields
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def resolve_memory(repo: Path, memory_arg: str | None) -> Path:
|
|
220
|
+
return Path(memory_arg).expanduser().resolve() if memory_arg else repo / ".repo_memory"
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def body_after_frontmatter(text: str) -> str:
|
|
224
|
+
if not text.startswith("---\n"):
|
|
225
|
+
return text
|
|
226
|
+
end = text.find("\n---", 4)
|
|
227
|
+
if end == -1:
|
|
228
|
+
return text
|
|
229
|
+
return text[end + 4 :].lstrip("\n")
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def commit_baseline(memory: Path, repo: Path | None = None, head_sha: str = "") -> str:
|
|
233
|
+
profile_head = parse_frontmatter(memory / "PROFILE.md").get("local_head", "")
|
|
234
|
+
if profile_head:
|
|
235
|
+
return profile_head
|
|
236
|
+
text_path = memory / "resources" / "commits.md"
|
|
237
|
+
if text_path.exists():
|
|
238
|
+
text = body_after_frontmatter(text_path.read_text(encoding="utf-8"))
|
|
239
|
+
shas = re.findall(r"(?im)^-\s*SHA:\s*`?([0-9a-f]{7,40})`?", text)
|
|
240
|
+
if repo is not None and head_sha and shas:
|
|
241
|
+
nearest = nearest_ancestor(repo, shas, head_sha)
|
|
242
|
+
if nearest:
|
|
243
|
+
return nearest
|
|
244
|
+
if shas:
|
|
245
|
+
return shas[0]
|
|
246
|
+
return ""
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def is_ancestor(repo: Path, ancestor: str, descendant: str) -> bool:
|
|
250
|
+
if not ancestor:
|
|
251
|
+
return False
|
|
252
|
+
result = run(["git", "-C", str(repo), "merge-base", "--is-ancestor", ancestor, descendant], repo)
|
|
253
|
+
return result.returncode == 0
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def nearest_ancestor(repo: Path, candidates: list[str], descendant: str) -> str:
|
|
257
|
+
unique_candidates = list(dict.fromkeys(candidate for candidate in candidates if candidate))
|
|
258
|
+
if not unique_candidates:
|
|
259
|
+
return ""
|
|
260
|
+
result = run(["git", "-C", str(repo), "rev-list", descendant], repo)
|
|
261
|
+
if result.returncode != 0:
|
|
262
|
+
return ""
|
|
263
|
+
for sha in [line.strip() for line in result.stdout.splitlines() if line.strip()]:
|
|
264
|
+
for candidate in unique_candidates:
|
|
265
|
+
if sha.startswith(candidate):
|
|
266
|
+
return candidate
|
|
267
|
+
return ""
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def commit_delta(repo: Path, baseline_sha: str, head_sha: str) -> tuple[list[dict[str, Any]], dict[str, Any]]:
|
|
271
|
+
if not baseline_sha:
|
|
272
|
+
return [], {
|
|
273
|
+
"status": "skipped",
|
|
274
|
+
"reason": "missing_baseline_commit",
|
|
275
|
+
"message": "No stored local commit baseline was found in .repo_memory.",
|
|
276
|
+
}
|
|
277
|
+
if baseline_sha == head_sha:
|
|
278
|
+
return [], {"status": "current", "reason": ""}
|
|
279
|
+
if not is_ancestor(repo, baseline_sha, head_sha):
|
|
280
|
+
return [], {
|
|
281
|
+
"status": "skipped",
|
|
282
|
+
"reason": "baseline_not_ancestor_of_head",
|
|
283
|
+
"message": "Stored repo-memory commit baseline is not an ancestor of the current HEAD; history was probably rebased or force-pushed.",
|
|
284
|
+
}
|
|
285
|
+
shas = git(repo, ["rev-list", "--reverse", f"{baseline_sha}..{head_sha}"], "git could not list new commits")
|
|
286
|
+
out: list[dict[str, Any]] = []
|
|
287
|
+
for sha in [line.strip() for line in shas.splitlines() if line.strip()]:
|
|
288
|
+
fields = git(
|
|
289
|
+
repo,
|
|
290
|
+
["show", "-s", "--date=iso-strict", "--format=%H%x1f%h%x1f%an%x1f%ad%x1f%s", sha],
|
|
291
|
+
f"git could not read commit {sha}",
|
|
292
|
+
).split("\x1f")
|
|
293
|
+
full_sha, short_sha, author, authored_at, title = (fields + [""] * 5)[:5]
|
|
294
|
+
files = git(repo, ["show", "--format=", "--name-only", "--no-renames", sha], f"git could not list files for {sha}")
|
|
295
|
+
out.append({
|
|
296
|
+
"sha": full_sha,
|
|
297
|
+
"short_sha": short_sha,
|
|
298
|
+
"title": title,
|
|
299
|
+
"author": author,
|
|
300
|
+
"authored_at": authored_at,
|
|
301
|
+
"files": [line for line in files.splitlines() if line],
|
|
302
|
+
})
|
|
303
|
+
return out, {"status": "ok", "reason": "", "count": len(out)}
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def markdown_sections(path: Path) -> list[tuple[str, str]]:
|
|
307
|
+
if not path.exists():
|
|
308
|
+
return []
|
|
309
|
+
text = body_after_frontmatter(path.read_text(encoding="utf-8"))
|
|
310
|
+
matches = list(re.finditer(r"(?m)^##\s+(.+?)\s*$", text))
|
|
311
|
+
sections: list[tuple[str, str]] = []
|
|
312
|
+
for index, match in enumerate(matches):
|
|
313
|
+
start = match.end()
|
|
314
|
+
end = matches[index + 1].start() if index + 1 < len(matches) else len(text)
|
|
315
|
+
sections.append((match.group(1).strip(), text[start:end]))
|
|
316
|
+
return sections
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def markdown_field(body: str, label: str) -> str:
|
|
320
|
+
pattern = rf"(?im)^-\s*{re.escape(label)}:\s*(.+?)\s*$"
|
|
321
|
+
match = re.search(pattern, body)
|
|
322
|
+
if not match:
|
|
323
|
+
return ""
|
|
324
|
+
value = match.group(1).strip()
|
|
325
|
+
return value.strip().strip("`").strip()
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def first_code_value(text: str) -> str:
|
|
329
|
+
match = re.search(r"`([^`]+)`", text)
|
|
330
|
+
return match.group(1).strip() if match else text.strip()
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def section_number(title: str) -> int | None:
|
|
334
|
+
match = re.search(r"[#!#]\s*(\d+)", title)
|
|
335
|
+
return int(match.group(1)) if match else None
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def section_title(title: str) -> str:
|
|
339
|
+
return title.split(":", 1)[1].strip() if ":" in title else title.strip()
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def branch_parts(value: str) -> tuple[str, str]:
|
|
343
|
+
if "<-" not in value:
|
|
344
|
+
return "", ""
|
|
345
|
+
base, head = value.split("<-", 1)
|
|
346
|
+
return base.strip(), head.strip()
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def resource_items(memory: Path, resource_name: str) -> list[dict[str, Any]]:
|
|
350
|
+
path = memory / "resources" / resource_name
|
|
351
|
+
kind = "issue" if resource_name == "issues.md" else "pr"
|
|
352
|
+
out: list[dict[str, Any]] = []
|
|
353
|
+
for title, body in markdown_sections(path):
|
|
354
|
+
number = section_number(title)
|
|
355
|
+
if number is None:
|
|
356
|
+
continue
|
|
357
|
+
item: dict[str, Any] = {
|
|
358
|
+
"number": number,
|
|
359
|
+
"title": section_title(title),
|
|
360
|
+
"source": "resource",
|
|
361
|
+
}
|
|
362
|
+
state = first_code_value(markdown_field(body, "State"))
|
|
363
|
+
if state:
|
|
364
|
+
item["state"] = state
|
|
365
|
+
url = markdown_field(body, "URL")
|
|
366
|
+
if url:
|
|
367
|
+
item["url"] = url
|
|
368
|
+
if kind == "pr":
|
|
369
|
+
base_ref, head_ref = branch_parts(first_code_value(markdown_field(body, "Branch")))
|
|
370
|
+
if base_ref:
|
|
371
|
+
item["base_ref"] = base_ref
|
|
372
|
+
if head_ref:
|
|
373
|
+
item["head_ref"] = head_ref
|
|
374
|
+
out.append(item)
|
|
375
|
+
return out
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
def first_int(values: Any) -> int | None:
|
|
379
|
+
if isinstance(values, int):
|
|
380
|
+
return values
|
|
381
|
+
if isinstance(values, list):
|
|
382
|
+
for value in values:
|
|
383
|
+
if isinstance(value, int):
|
|
384
|
+
return value
|
|
385
|
+
return None
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def facet_number(facet: dict[str, Any], source_type: str) -> int | None:
|
|
389
|
+
values = facet.get("issues") if source_type == "issue" else facet.get("prs")
|
|
390
|
+
number = first_int(values)
|
|
391
|
+
if number is not None:
|
|
392
|
+
return number
|
|
393
|
+
match = re.search(r"\.(\d+)$", str(facet.get("facetId", "")))
|
|
394
|
+
return int(match.group(1)) if match else None
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
AUTHORING_FACET_KEYS = [
|
|
398
|
+
"summary",
|
|
399
|
+
"labels",
|
|
400
|
+
"commits",
|
|
401
|
+
"commit_headlines",
|
|
402
|
+
"review_decision",
|
|
403
|
+
"review_states",
|
|
404
|
+
"files",
|
|
405
|
+
"symbols",
|
|
406
|
+
"evidence",
|
|
407
|
+
"changed_files",
|
|
408
|
+
"additions",
|
|
409
|
+
"deletions",
|
|
410
|
+
]
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
def keep_value(value: Any) -> bool:
|
|
414
|
+
return value not in (None, "", [], {})
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
def facet_items(facets: Any, source_type: str) -> list[dict[str, Any]]:
|
|
418
|
+
if not isinstance(facets, list):
|
|
419
|
+
return []
|
|
420
|
+
out: list[dict[str, Any]] = []
|
|
421
|
+
for facet in facets:
|
|
422
|
+
if not isinstance(facet, dict) or facet.get("sourceType") != source_type:
|
|
423
|
+
continue
|
|
424
|
+
number = facet_number(facet, source_type)
|
|
425
|
+
if number is None:
|
|
426
|
+
continue
|
|
427
|
+
item: dict[str, Any] = {
|
|
428
|
+
"number": number,
|
|
429
|
+
"title": str(facet.get("title") or ""),
|
|
430
|
+
"state": str(facet.get("state") or ""),
|
|
431
|
+
"updated_at": str(facet.get("updatedAt") or facet.get("updated_at") or ""),
|
|
432
|
+
"url": str(facet.get("url") or ""),
|
|
433
|
+
"source": "provider_facet",
|
|
434
|
+
}
|
|
435
|
+
facet_id = str(facet.get("facetId") or "")
|
|
436
|
+
if facet_id:
|
|
437
|
+
item["raw_lookup"] = f"facetId={facet_id}"
|
|
438
|
+
for key in AUTHORING_FACET_KEYS:
|
|
439
|
+
if key in facet:
|
|
440
|
+
item[key] = facet[key]
|
|
441
|
+
if source_type == "pr":
|
|
442
|
+
item["base_ref"] = str(facet.get("base_ref") or "")
|
|
443
|
+
item["head_ref"] = str(facet.get("head_ref") or "")
|
|
444
|
+
item["merged_at"] = str(facet.get("mergedAt") or facet.get("merged_at") or "")
|
|
445
|
+
item["closed_at"] = str(facet.get("closedAt") or facet.get("closed_at") or "")
|
|
446
|
+
linked_issues = facet.get("issues")
|
|
447
|
+
if keep_value(linked_issues):
|
|
448
|
+
item["linked_issues"] = linked_issues
|
|
449
|
+
out.append({key: value for key, value in item.items() if keep_value(value)})
|
|
450
|
+
return out
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
def raw_provider_items(memory: Path, source_type: str) -> list[dict[str, Any]]:
|
|
454
|
+
out: list[dict[str, Any]] = []
|
|
455
|
+
for raw_name in ["github-facets.json", "gitlab-facets.json"]:
|
|
456
|
+
raw_path = memory / "raw" / raw_name
|
|
457
|
+
if raw_path.exists():
|
|
458
|
+
out.extend(facet_items(load_json(raw_path, []), source_type))
|
|
459
|
+
return out
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
def baseline_items(memory: Path, resource_name: str, source_type: str) -> list[dict[str, Any]]:
|
|
463
|
+
resource = resource_items(memory, resource_name)
|
|
464
|
+
raw_by_no = by_number(raw_provider_items(memory, source_type))
|
|
465
|
+
out: list[dict[str, Any]] = []
|
|
466
|
+
seen: set[int] = set()
|
|
467
|
+
for item in resource:
|
|
468
|
+
number = item_number(item)
|
|
469
|
+
if number is None:
|
|
470
|
+
continue
|
|
471
|
+
merged = dict(item)
|
|
472
|
+
merged.update(raw_by_no.get(number, {}))
|
|
473
|
+
if number in raw_by_no:
|
|
474
|
+
merged["source"] = "resource_with_raw_provider_facet"
|
|
475
|
+
out.append(merged)
|
|
476
|
+
seen.add(number)
|
|
477
|
+
for number, item in sorted(raw_by_no.items()):
|
|
478
|
+
if number not in seen:
|
|
479
|
+
out.append(item)
|
|
480
|
+
return out
|
|
481
|
+
|
|
482
|
+
|
|
483
|
+
def item_number(item: dict[str, Any]) -> int | None:
|
|
484
|
+
number = item.get("number")
|
|
485
|
+
return number if isinstance(number, int) else None
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
def by_number(items: list[dict[str, Any]]) -> dict[int, dict[str, Any]]:
|
|
489
|
+
out: dict[int, dict[str, Any]] = {}
|
|
490
|
+
for item in items:
|
|
491
|
+
number = item_number(item)
|
|
492
|
+
if number is not None:
|
|
493
|
+
out[number] = item
|
|
494
|
+
return out
|
|
495
|
+
|
|
496
|
+
|
|
497
|
+
def comparable_item(item: dict[str, Any]) -> str:
|
|
498
|
+
ignored_keys = {"raw_lookup", "source", "updated_at", "merged_at", "closed_at"}
|
|
499
|
+
normalized = {key: value for key, value in item.items() if key not in ignored_keys}
|
|
500
|
+
return json.dumps(normalized, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
|
|
501
|
+
|
|
502
|
+
|
|
503
|
+
def changed_numbers(existing: dict[int, dict[str, Any]], current: dict[int, dict[str, Any]]) -> list[int]:
|
|
504
|
+
changed: list[int] = []
|
|
505
|
+
for number, item in current.items():
|
|
506
|
+
old = existing.get(number)
|
|
507
|
+
if old is None:
|
|
508
|
+
continue
|
|
509
|
+
for key in ["updated_at", "state", "title", "head_ref", "base_ref", "merged_at", "closed_at"]:
|
|
510
|
+
if old.get(key) not in (None, "") and old.get(key) != item.get(key):
|
|
511
|
+
changed.append(number)
|
|
512
|
+
break
|
|
513
|
+
else:
|
|
514
|
+
common_keys = {
|
|
515
|
+
key
|
|
516
|
+
for key, value in old.items()
|
|
517
|
+
if key not in {"number", "raw_lookup", "source"} and value not in (None, "")
|
|
518
|
+
}
|
|
519
|
+
old_common = {key: old.get(key) for key in common_keys}
|
|
520
|
+
current_common = {key: item.get(key) for key in common_keys}
|
|
521
|
+
if old_common != current_common and comparable_item(old) != comparable_item(item):
|
|
522
|
+
changed.append(number)
|
|
523
|
+
return sorted(changed)
|
|
524
|
+
|
|
525
|
+
|
|
526
|
+
def remote_url_from_line(line: str) -> str:
|
|
527
|
+
parts = line.split()
|
|
528
|
+
return parts[1] if len(parts) >= 2 else ""
|
|
529
|
+
|
|
530
|
+
|
|
531
|
+
def remote_name_from_line(line: str) -> str:
|
|
532
|
+
parts = line.split()
|
|
533
|
+
return parts[0] if parts else ""
|
|
534
|
+
|
|
535
|
+
|
|
536
|
+
def normalize_remote_path(path: str) -> str:
|
|
537
|
+
repo_path = path.strip().lstrip("/").rstrip("/")
|
|
538
|
+
return repo_path[:-4] if repo_path.endswith(".git") else repo_path
|
|
539
|
+
|
|
540
|
+
|
|
541
|
+
def provider_from_host(host: str) -> str:
|
|
542
|
+
lower = host.lower()
|
|
543
|
+
if lower == "github.com" or "github" in lower:
|
|
544
|
+
return "github"
|
|
545
|
+
if lower == "gitlab.com" or "gitlab" in lower:
|
|
546
|
+
return "gitlab"
|
|
547
|
+
return ""
|
|
548
|
+
|
|
549
|
+
|
|
550
|
+
def parse_remote_url(url: str) -> dict[str, str]:
|
|
551
|
+
if not url:
|
|
552
|
+
return {"provider": "", "repo": "", "host": "", "url": ""}
|
|
553
|
+
if "://" not in url:
|
|
554
|
+
match = re.match(r"^(?:[^@/\s]+@)?(?P<host>[^:/\s]+):(?P<path>.+)$", url)
|
|
555
|
+
if match:
|
|
556
|
+
host = match.group("host")
|
|
557
|
+
return {
|
|
558
|
+
"provider": provider_from_host(host),
|
|
559
|
+
"repo": normalize_remote_path(match.group("path")),
|
|
560
|
+
"host": host,
|
|
561
|
+
"url": url,
|
|
562
|
+
}
|
|
563
|
+
parsed = urlparse(url)
|
|
564
|
+
host = parsed.hostname or ""
|
|
565
|
+
return {
|
|
566
|
+
"provider": provider_from_host(host),
|
|
567
|
+
"repo": normalize_remote_path(parsed.path),
|
|
568
|
+
"host": host,
|
|
569
|
+
"url": url,
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
|
|
573
|
+
def parse_code_host_repo(remotes: str) -> dict[str, str]:
|
|
574
|
+
candidates: list[dict[str, str]] = []
|
|
575
|
+
for line in remotes.splitlines():
|
|
576
|
+
if "(push)" in line:
|
|
577
|
+
continue
|
|
578
|
+
parsed = parse_remote_url(remote_url_from_line(line))
|
|
579
|
+
if parsed["provider"] and parsed["repo"]:
|
|
580
|
+
parsed["remote_name"] = remote_name_from_line(line)
|
|
581
|
+
candidates.append(parsed)
|
|
582
|
+
for preferred in PREFERRED_REMOTE_NAMES:
|
|
583
|
+
for candidate in candidates:
|
|
584
|
+
if candidate.get("remote_name") == preferred:
|
|
585
|
+
candidate["selection_reason"] = f"preferred_{preferred}_fetch_remote"
|
|
586
|
+
return candidate
|
|
587
|
+
if candidates:
|
|
588
|
+
candidates[0]["selection_reason"] = "first_supported_fetch_remote"
|
|
589
|
+
return candidates[0]
|
|
590
|
+
return {"provider": "", "repo": "", "host": "", "url": "", "remote_name": "", "selection_reason": "none"}
|
|
591
|
+
|
|
592
|
+
|
|
593
|
+
def provider_host(provider: str) -> str:
|
|
594
|
+
if provider == "github":
|
|
595
|
+
return "github.com"
|
|
596
|
+
if provider == "gitlab":
|
|
597
|
+
return "gitlab.com"
|
|
598
|
+
return ""
|
|
599
|
+
|
|
600
|
+
|
|
601
|
+
def provider_from_profile(memory: Path) -> dict[str, str]:
|
|
602
|
+
profile = parse_frontmatter(memory / "PROFILE.md")
|
|
603
|
+
provider = profile.get("code_host_provider", "")
|
|
604
|
+
repo = profile.get("repo_full_name", "")
|
|
605
|
+
if provider in {"github", "gitlab"} and repo and "/" in repo:
|
|
606
|
+
parsed_profile_url = parse_remote_url(str(profile.get("repo_url", "")))
|
|
607
|
+
host = parsed_profile_url["host"] if parsed_profile_url["provider"] == provider else provider_host(provider)
|
|
608
|
+
return {
|
|
609
|
+
"provider": provider,
|
|
610
|
+
"repo": repo,
|
|
611
|
+
"host": host,
|
|
612
|
+
"url": profile.get("repo_url", ""),
|
|
613
|
+
"remote_name": "",
|
|
614
|
+
"selection_reason": "profile_frontmatter",
|
|
615
|
+
}
|
|
616
|
+
return {}
|
|
617
|
+
|
|
618
|
+
|
|
619
|
+
def auth_args(command: str, host: str) -> list[str]:
|
|
620
|
+
args = ["auth", "status"]
|
|
621
|
+
if host and command in {"gh", "glab"}:
|
|
622
|
+
args.extend(["--hostname", host])
|
|
623
|
+
return args
|
|
624
|
+
|
|
625
|
+
|
|
626
|
+
def login_hint(command: str, host: str) -> str:
|
|
627
|
+
default_host = "github.com" if command == "gh" else "gitlab.com" if command == "glab" else ""
|
|
628
|
+
if host and host != default_host:
|
|
629
|
+
return f"{command} auth login --hostname {host}"
|
|
630
|
+
return f"{command} auth login"
|
|
631
|
+
|
|
632
|
+
|
|
633
|
+
def cli_evidence_state(command: str, host: str) -> tuple[str, bool]:
|
|
634
|
+
if shutil.which(command) is None:
|
|
635
|
+
return "cli_missing", False
|
|
636
|
+
try:
|
|
637
|
+
result = subprocess.run([command, *auth_args(command, host)], text=True, capture_output=True, timeout=8)
|
|
638
|
+
except subprocess.TimeoutExpired:
|
|
639
|
+
return "auth_check_timeout", True
|
|
640
|
+
return ("ready", True) if result.returncode == 0 else ("auth_required", True)
|
|
641
|
+
|
|
642
|
+
|
|
643
|
+
def provider_report(repo: Path, memory: Path, *, inspect_cli: bool = True) -> dict[str, Any]:
|
|
644
|
+
remote = provider_from_profile(memory) or parse_code_host_repo(git(repo, ["remote", "-v"], "git could not list remotes"))
|
|
645
|
+
provider = remote["provider"]
|
|
646
|
+
cli = "gh" if provider == "github" else "glab" if provider == "gitlab" else ""
|
|
647
|
+
evidence_state = "unavailable" if inspect_cli else "skipped_by_policy"
|
|
648
|
+
cli_available = False
|
|
649
|
+
if cli and inspect_cli:
|
|
650
|
+
evidence_state, cli_available = cli_evidence_state(cli, remote["host"])
|
|
651
|
+
return {
|
|
652
|
+
"name": provider,
|
|
653
|
+
"repo": remote["repo"],
|
|
654
|
+
"host": remote["host"],
|
|
655
|
+
"remote_url": remote["url"],
|
|
656
|
+
"remote_name": remote.get("remote_name", ""),
|
|
657
|
+
"selection_reason": remote.get("selection_reason", ""),
|
|
658
|
+
"evidence_state": evidence_state,
|
|
659
|
+
"cli": cli,
|
|
660
|
+
"cli_available": cli_available,
|
|
661
|
+
"login_hint": login_hint(cli, remote["host"]) if cli else "",
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
|
|
665
|
+
def provider_script(provider: str) -> str:
|
|
666
|
+
if provider == "github":
|
|
667
|
+
return "github_resource_facets.py"
|
|
668
|
+
if provider == "gitlab":
|
|
669
|
+
return "gitlab_resource_facets.py"
|
|
670
|
+
return ""
|
|
671
|
+
|
|
672
|
+
|
|
673
|
+
def fetch_provider_items(repo: Path, provider: dict[str, Any], snapshot_ref: str, pr_limit: int, issue_limit: int, summary_chars: int) -> tuple[dict[str, Any], list[dict[str, Any]], list[dict[str, Any]]]:
|
|
674
|
+
provider_name = str(provider.get("name") or "")
|
|
675
|
+
provider_repo = str(provider.get("repo") or "")
|
|
676
|
+
script_name = provider_script(provider_name)
|
|
677
|
+
if not script_name or not provider_repo or provider.get("evidence_state") != "ready":
|
|
678
|
+
return {
|
|
679
|
+
"attempted": False,
|
|
680
|
+
"reason": f"provider_evidence_state={provider.get('evidence_state', '') or 'unavailable'}",
|
|
681
|
+
}, [], []
|
|
682
|
+
|
|
683
|
+
script_path = BUILDER_SCRIPTS_DIR / script_name
|
|
684
|
+
if not script_path.exists():
|
|
685
|
+
return {"attempted": False, "reason": "memorax-code repo-build helpers are missing"}, [], []
|
|
686
|
+
|
|
687
|
+
with tempfile.TemporaryDirectory(prefix="memorax-code-repo-update.") as tmp:
|
|
688
|
+
tmp_root = Path(tmp)
|
|
689
|
+
raw = tmp_root / f"{provider_name}-facets.json"
|
|
690
|
+
facets = run(
|
|
691
|
+
[
|
|
692
|
+
sys.executable,
|
|
693
|
+
str(script_path),
|
|
694
|
+
"--repo",
|
|
695
|
+
provider_repo,
|
|
696
|
+
"--repo-path",
|
|
697
|
+
str(repo),
|
|
698
|
+
"--snapshot-ref",
|
|
699
|
+
snapshot_ref,
|
|
700
|
+
"--include",
|
|
701
|
+
"prs,issues",
|
|
702
|
+
"--pr-limit",
|
|
703
|
+
str(pr_limit),
|
|
704
|
+
"--issue-limit",
|
|
705
|
+
str(issue_limit),
|
|
706
|
+
"--state",
|
|
707
|
+
"all",
|
|
708
|
+
"--summary-chars",
|
|
709
|
+
str(summary_chars),
|
|
710
|
+
"--out",
|
|
711
|
+
str(raw),
|
|
712
|
+
*(["--hostname", str(provider.get("host"))] if provider.get("host") else []),
|
|
713
|
+
],
|
|
714
|
+
repo,
|
|
715
|
+
)
|
|
716
|
+
if facets.returncode != 0:
|
|
717
|
+
return {
|
|
718
|
+
"attempted": True,
|
|
719
|
+
"ok": False,
|
|
720
|
+
"exit_code": facets.returncode,
|
|
721
|
+
"stderr": facets.stderr.strip(),
|
|
722
|
+
}, [], []
|
|
723
|
+
raw_facets = load_json(raw, [])
|
|
724
|
+
prs = facet_items(raw_facets, "pr")
|
|
725
|
+
issues = facet_items(raw_facets, "issue")
|
|
726
|
+
return {
|
|
727
|
+
"attempted": True,
|
|
728
|
+
"ok": True,
|
|
729
|
+
"provider": provider_name,
|
|
730
|
+
"repo": provider_repo,
|
|
731
|
+
"pr_count": len(prs) if isinstance(prs, list) else 0,
|
|
732
|
+
"issue_count": len(issues) if isinstance(issues, list) else 0,
|
|
733
|
+
}, prs, issues
|
|
734
|
+
|
|
735
|
+
|
|
736
|
+
def delta_summary(existing: list[dict[str, Any]], current: list[dict[str, Any]]) -> dict[str, Any]:
|
|
737
|
+
existing_by_no = by_number(existing)
|
|
738
|
+
current_by_no = by_number(current)
|
|
739
|
+
added = sorted(number for number in current_by_no if number not in existing_by_no)
|
|
740
|
+
updated = changed_numbers(existing_by_no, current_by_no)
|
|
741
|
+
baseline_only = sorted(number for number in existing_by_no if number not in current_by_no)
|
|
742
|
+
return {
|
|
743
|
+
"baseline_numbers": sorted(existing_by_no),
|
|
744
|
+
"current_numbers": sorted(current_by_no),
|
|
745
|
+
"added_numbers": added,
|
|
746
|
+
"updated_numbers": updated,
|
|
747
|
+
"upsert_numbers": sorted([*added, *updated]),
|
|
748
|
+
"baseline_only_numbers": baseline_only,
|
|
749
|
+
"delete_numbers": [],
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
|
|
753
|
+
def actions_for(deltas: dict[str, Any], provider_fetch: dict[str, Any]) -> list[str]:
|
|
754
|
+
actions: list[str] = []
|
|
755
|
+
local_commit_status = deltas.get("local_commit_status", {})
|
|
756
|
+
if local_commit_status.get("reason") == "missing_baseline_commit":
|
|
757
|
+
actions.append("Local commit delta was skipped because no stored commit baseline was found. Use $memorax-code repo-build for a full rebuild before doing incremental commit updates.")
|
|
758
|
+
if local_commit_status.get("reason") == "baseline_not_ancestor_of_head":
|
|
759
|
+
actions.append("Local commit delta was skipped because the stored baseline is not an ancestor of HEAD. Use $memorax-code repo-build for a full rebuild, or explicitly choose a new baseline before doing incremental commit updates.")
|
|
760
|
+
if deltas["local_commits"]:
|
|
761
|
+
actions.append("Update .repo_memory/resources/commits.md with sections for the new local commits; update PROFILE.md local_head and commit snapshot notes only where needed.")
|
|
762
|
+
if deltas["pull_requests"]["added_numbers"] or deltas["pull_requests"]["updated_numbers"]:
|
|
763
|
+
actions.append("Upsert .repo_memory/resources/prs.md sections by PR/MR number for only the added or changed numbers. Preserve baseline-only and unaffected PR/MR sections; do not delete missing numbers by default.")
|
|
764
|
+
if deltas["issues"]["added_numbers"] or deltas["issues"]["updated_numbers"]:
|
|
765
|
+
actions.append("Upsert .repo_memory/resources/issues.md sections by issue number for only the added or changed numbers. Preserve baseline-only and unaffected issue sections; do not delete missing numbers by default.")
|
|
766
|
+
if provider_fetch.get("attempted") is True and provider_fetch.get("ok") is False:
|
|
767
|
+
actions.append("Provider delta fetch failed; keeping existing PR/issue resources unchanged. Fix provider access and rerun $memorax-code repo-update, or use repo-build for a full provider refresh if needed.")
|
|
768
|
+
if provider_fetch.get("attempted") is False:
|
|
769
|
+
actions.append("Provider delta fetch was skipped; keep PR/issue resources unchanged unless the user asks to login/install provider CLI or force provider refresh.")
|
|
770
|
+
if not actions:
|
|
771
|
+
actions.append("No repo-memory content update is needed.")
|
|
772
|
+
return actions
|
|
773
|
+
|
|
774
|
+
|
|
775
|
+
def notices_for(deltas: dict[str, Any], provider_fetch: dict[str, Any]) -> list[dict[str, Any]]:
|
|
776
|
+
notices: list[dict[str, Any]] = []
|
|
777
|
+
local_commit_status = deltas.get("local_commit_status", {})
|
|
778
|
+
if local_commit_status.get("reason") == "missing_baseline_commit":
|
|
779
|
+
notices.append({
|
|
780
|
+
"level": "warning",
|
|
781
|
+
"title": "Repo Memory Commit Baseline Missing",
|
|
782
|
+
"message": "No stored local commit baseline was found in .repo_memory, so incremental commit detection was skipped.",
|
|
783
|
+
"next_steps": [
|
|
784
|
+
"Run $memorax-code repo-build for a full rebuild before incremental commit updates.",
|
|
785
|
+
],
|
|
786
|
+
"render_as": "assistant_message",
|
|
787
|
+
})
|
|
788
|
+
if local_commit_status.get("reason") == "baseline_not_ancestor_of_head":
|
|
789
|
+
notices.append({
|
|
790
|
+
"level": "warning",
|
|
791
|
+
"title": "Repo Memory Baseline Rewritten",
|
|
792
|
+
"message": "Stored repo-memory commit baseline is not an ancestor of the current HEAD. History was probably rebased or force-pushed, so incremental commit detection was skipped.",
|
|
793
|
+
"next_steps": [
|
|
794
|
+
"Run $memorax-code repo-build for a full rebuild, or explicitly choose a new baseline before incremental commit updates.",
|
|
795
|
+
],
|
|
796
|
+
"render_as": "assistant_message",
|
|
797
|
+
})
|
|
798
|
+
if provider_fetch.get("attempted") is True and provider_fetch.get("ok") is False:
|
|
799
|
+
notices.append({
|
|
800
|
+
"level": "warning",
|
|
801
|
+
"title": "Provider Delta Fetch Failed",
|
|
802
|
+
"message": "Provider delta fetch failed; keeping existing PR/issue resources unchanged. Fix provider access before rerunning provider deltas.",
|
|
803
|
+
"next_steps": [
|
|
804
|
+
"Fix GitHub/GitLab CLI access and rerun $memorax-code repo-update.",
|
|
805
|
+
"Use $memorax-code repo-build for a full provider refresh if a broader rebuild is needed.",
|
|
806
|
+
],
|
|
807
|
+
"render_as": "assistant_message",
|
|
808
|
+
})
|
|
809
|
+
return notices
|
|
810
|
+
|
|
811
|
+
|
|
812
|
+
def parse_args(argv: list[str]) -> argparse.Namespace:
|
|
813
|
+
parser = argparse.ArgumentParser(description="Detect incremental updates for an existing .repo_memory bundle.")
|
|
814
|
+
parser.add_argument("--repo-path", default=".", help="Local git repository path")
|
|
815
|
+
parser.add_argument("--memory-path", help="Existing .repo_memory path; defaults to <repo>/.repo_memory")
|
|
816
|
+
parser.add_argument("--snapshot-ref", default="HEAD", help="Local git ref used for current detection")
|
|
817
|
+
parser.add_argument("--history-mode", choices=sorted(HISTORY_MODES), default=None, help="History evidence policy for this update")
|
|
818
|
+
parser.add_argument("--provider-mode", choices=["auto", "off"], default="auto", help="Whether to fetch latest provider PR/issue facets when provider evidence is ready")
|
|
819
|
+
parser.add_argument("--pr-limit", type=int, default=None, help="Maximum latest PR/MR candidates to compare")
|
|
820
|
+
parser.add_argument("--issue-limit", type=int, default=None, help="Maximum latest issue candidates to compare")
|
|
821
|
+
parser.add_argument("--summary-chars", type=int, default=None, help="Maximum raw provider summary characters when provider facets are fetched")
|
|
822
|
+
parser.add_argument("--pretty", action="store_true", help="Pretty-print JSON")
|
|
823
|
+
args = parser.parse_args(argv)
|
|
824
|
+
args.repo_path = Path(args.repo_path).expanduser().resolve()
|
|
825
|
+
return apply_effective_settings(args, parser)
|
|
826
|
+
|
|
827
|
+
|
|
828
|
+
def main(argv: list[str]) -> int:
|
|
829
|
+
args = parse_args(argv)
|
|
830
|
+
repo = args.repo_path
|
|
831
|
+
memory = resolve_memory(repo, args.memory_path)
|
|
832
|
+
if not memory.exists() or not (memory / "PROFILE.md").exists():
|
|
833
|
+
report = {
|
|
834
|
+
"ok": False,
|
|
835
|
+
"repo_path": str(repo),
|
|
836
|
+
"memory_path": str(memory),
|
|
837
|
+
"builder_helpers": builder_helper_report(),
|
|
838
|
+
"error": "Existing .repo_memory/PROFILE.md is required; run $memorax-code repo-build before repo-update.",
|
|
839
|
+
}
|
|
840
|
+
print(json.dumps(report, ensure_ascii=False, indent=2 if args.pretty else None))
|
|
841
|
+
return 1
|
|
842
|
+
|
|
843
|
+
head = git(repo, ["rev-parse", "--verify", f"{args.snapshot_ref}^{{commit}}"], f"git could not resolve {args.snapshot_ref!r}")
|
|
844
|
+
baseline_sha = commit_baseline(memory, repo, head)
|
|
845
|
+
if args.history_collect["commits"]:
|
|
846
|
+
local_commits, local_commit_status = commit_delta(repo, baseline_sha, head)
|
|
847
|
+
else:
|
|
848
|
+
local_commits, local_commit_status = [], {
|
|
849
|
+
"status": "skipped",
|
|
850
|
+
"reason": "history_disabled_by_policy",
|
|
851
|
+
"message": "Local commit delta detection is disabled by repoHistory.mode.",
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
existing_prs = baseline_items(memory, "prs.md", "pr")
|
|
855
|
+
existing_issues = baseline_items(memory, "issues.md", "issue")
|
|
856
|
+
provider = provider_report(repo, memory, inspect_cli=args.history_collect["provider"])
|
|
857
|
+
if args.history_collect["provider"]:
|
|
858
|
+
provider_fetch, current_prs, current_issues = fetch_provider_items(
|
|
859
|
+
repo,
|
|
860
|
+
provider,
|
|
861
|
+
args.snapshot_ref,
|
|
862
|
+
args.pr_limit,
|
|
863
|
+
args.issue_limit,
|
|
864
|
+
args.summary_chars,
|
|
865
|
+
)
|
|
866
|
+
else:
|
|
867
|
+
reason = (
|
|
868
|
+
"provider-mode=off"
|
|
869
|
+
if args.provider_mode == "off"
|
|
870
|
+
else "history_disabled_by_policy"
|
|
871
|
+
if args.history_mode == "none"
|
|
872
|
+
else "history_provider_disabled_by_policy"
|
|
873
|
+
)
|
|
874
|
+
provider_fetch, current_prs, current_issues = {"attempted": False, "reason": reason}, [], []
|
|
875
|
+
fetched_prs = current_prs if provider_fetch.get("ok") is True else []
|
|
876
|
+
fetched_issues = current_issues if provider_fetch.get("ok") is True else []
|
|
877
|
+
if not current_prs and provider_fetch.get("ok") is not True:
|
|
878
|
+
current_prs = existing_prs
|
|
879
|
+
if not current_issues and provider_fetch.get("ok") is not True:
|
|
880
|
+
current_issues = existing_issues
|
|
881
|
+
|
|
882
|
+
deltas = {
|
|
883
|
+
"local_commits": local_commits,
|
|
884
|
+
"local_commit_status": local_commit_status,
|
|
885
|
+
"pull_requests": delta_summary(existing_prs, current_prs),
|
|
886
|
+
"issues": delta_summary(existing_issues, current_issues),
|
|
887
|
+
}
|
|
888
|
+
if local_commit_status.get("status") == "skipped" and local_commit_status.get("reason"):
|
|
889
|
+
deltas["commit_delta_skipped"] = local_commit_status["reason"]
|
|
890
|
+
report = {
|
|
891
|
+
"ok": True,
|
|
892
|
+
"repo_path": str(repo),
|
|
893
|
+
"memory_path": str(memory),
|
|
894
|
+
"effective_settings": args.effective_settings,
|
|
895
|
+
"builder_helpers": builder_helper_report(),
|
|
896
|
+
"baseline": {
|
|
897
|
+
"local_commit_sha": baseline_sha,
|
|
898
|
+
"pull_request_numbers": deltas["pull_requests"]["baseline_numbers"],
|
|
899
|
+
"issue_numbers": deltas["issues"]["baseline_numbers"],
|
|
900
|
+
},
|
|
901
|
+
"current": {
|
|
902
|
+
"local_head": head,
|
|
903
|
+
"provider": provider,
|
|
904
|
+
"provider_fetch": provider_fetch,
|
|
905
|
+
"provider_items": {
|
|
906
|
+
"pull_requests": fetched_prs,
|
|
907
|
+
"issues": fetched_issues,
|
|
908
|
+
},
|
|
909
|
+
},
|
|
910
|
+
"deltas": deltas,
|
|
911
|
+
"notices": notices_for(deltas, provider_fetch),
|
|
912
|
+
"actions": actions_for(deltas, provider_fetch),
|
|
913
|
+
}
|
|
914
|
+
print(json.dumps(report, ensure_ascii=False, indent=2 if args.pretty else None))
|
|
915
|
+
return 0
|
|
916
|
+
|
|
917
|
+
|
|
918
|
+
if __name__ == "__main__":
|
|
919
|
+
raise SystemExit(main(sys.argv[1:]))
|