@memorax/memorax-code 0.1.11 → 0.1.12
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 +7 -4
- package/bin/memorax-code-setup.mjs +136 -34
- package/bin/memorax-code-trae.mjs +4 -0
- package/docs/configuration.md +106 -52
- package/docs/troubleshooting.md +45 -6
- package/lib/memorax-code-backend/dist/clients/claude/transcript-turn.js +2 -2
- package/lib/memorax-code-backend/dist/clients/codex/rollout-turn.js +1 -1
- package/lib/memorax-code-backend/dist/clients/trae/lifecycle.js +53 -0
- package/lib/memorax-code-backend/dist/clients/trae/memory-hook-runtime.js +302 -0
- package/lib/memorax-code-backend/dist/clients/trae/turn-id.js +16 -0
- package/lib/memorax-code-backend/dist/config/memorax-code.js +14 -1
- package/lib/memorax-code-backend/dist/entrypoints/backend-cli.js +22 -4
- package/lib/memorax-code-backend/dist/lifecycle/active-clients.js +3 -0
- package/lib/memorax-code-backend/dist/lifecycle/client-plugin-removal.js +14 -2
- package/lib/memorax-code-backend/dist/lifecycle/client-selection.js +5 -3
- package/lib/memorax-code-backend/dist/lifecycle/orchestrator.js +62 -9
- package/lib/memorax-code-backend/dist/memory/hook-command.js +34 -2
- 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/trace/config.js +12 -1
- package/lib/memorax-code-backend/dist/trace/context.js +26 -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/skills/memorax-code/SKILL.md +3 -1
- package/lib/memorax-code-claude-adapter/skills/memorax-code/references/memorax-add.md +16 -3
- package/lib/memorax-code-claude-adapter/skills/memorax-code/references/memorax-search.md +15 -2
- 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/package.json +1 -1
- package/lib/memorax-code-claude-marketplace/plugins/memorax-code-claude-adapter/skills/memorax-code/SKILL.md +3 -1
- package/lib/memorax-code-claude-marketplace/plugins/memorax-code-claude-adapter/skills/memorax-code/references/memorax-add.md +16 -3
- package/lib/memorax-code-claude-marketplace/plugins/memorax-code-claude-adapter/skills/memorax-code/references/memorax-search.md +15 -2
- package/lib/memorax-code-codebuddy-adapter/.codebuddy-plugin/plugin.json +1 -1
- package/lib/memorax-code-codebuddy-adapter/package.json +1 -1
- package/lib/memorax-code-codebuddy-adapter/skills/memorax-code/SKILL.md +3 -1
- package/lib/memorax-code-codebuddy-adapter/skills/memorax-code/references/memorax-add.md +16 -3
- package/lib/memorax-code-codebuddy-adapter/skills/memorax-code/references/memorax-search.md +15 -2
- 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/skills/memorax-code/SKILL.md +3 -1
- package/lib/memorax-code-codex-adapter/skills/memorax-code/references/memorax-add.md +16 -3
- package/lib/memorax-code-codex-adapter/skills/memorax-code/references/memorax-search.md +15 -2
- package/lib/memorax-code-dsh-adapter/package.json +1 -1
- package/lib/memorax-code-dsh-adapter/skills/memorax-code/SKILL.md +3 -1
- package/lib/memorax-code-dsh-adapter/skills/memorax-code/references/memorax-add.md +16 -3
- package/lib/memorax-code-dsh-adapter/skills/memorax-code/references/memorax-search.md +15 -2
- package/lib/memorax-code-opencode-adapter/package.json +1 -1
- package/lib/memorax-code-opencode-adapter/skills/memorax-code/SKILL.md +3 -1
- package/lib/memorax-code-opencode-adapter/skills/memorax-code/references/memorax-add.md +16 -3
- package/lib/memorax-code-opencode-adapter/skills/memorax-code/references/memorax-search.md +15 -2
- package/lib/memorax-code-trae-adapter/hooks/runtime-hook.mjs +340 -0
- package/lib/memorax-code-trae-adapter/package.json +9 -0
- package/lib/memorax-code-trae-adapter/skills/memorax-code/SKILL.md +87 -0
- package/lib/memorax-code-trae-adapter/skills/memorax-code/agents/claude.yaml +10 -0
- package/lib/memorax-code-trae-adapter/skills/memorax-code/agents/openai.yaml +7 -0
- package/lib/memorax-code-trae-adapter/skills/memorax-code/defaults.json +12 -0
- package/lib/memorax-code-trae-adapter/skills/memorax-code/references/memorax-add.md +101 -0
- package/lib/memorax-code-trae-adapter/skills/memorax-code/references/memorax-search.md +106 -0
- package/lib/memorax-code-trae-adapter/skills/memorax-code/references/personal-read.md +46 -0
- package/lib/memorax-code-trae-adapter/skills/memorax-code/references/personal-write.md +120 -0
- package/lib/memorax-code-trae-adapter/skills/memorax-code/references/repo-build.md +319 -0
- package/lib/memorax-code-trae-adapter/skills/memorax-code/references/repo-read.md +103 -0
- package/lib/memorax-code-trae-adapter/skills/memorax-code/references/repo-templates.md +390 -0
- package/lib/memorax-code-trae-adapter/skills/memorax-code/references/repo-update.md +127 -0
- package/lib/memorax-code-trae-adapter/skills/memorax-code/scripts/collect_all.py +579 -0
- package/lib/memorax-code-trae-adapter/skills/memorax-code/scripts/detect_updates.py +919 -0
- package/lib/memorax-code-trae-adapter/skills/memorax-code/scripts/git_commit_facets.py +222 -0
- package/lib/memorax-code-trae-adapter/skills/memorax-code/scripts/github_resource_facets.py +512 -0
- package/lib/memorax-code-trae-adapter/skills/memorax-code/scripts/gitlab_resource_facets.py +517 -0
- package/lib/memorax-code-trae-adapter/skills/memorax-code/scripts/prepare_repo_memory.py +411 -0
- package/lib/memorax-code-trae-adapter/skills/memorax-code/scripts/user_profile_memory.py +528 -0
- package/lib/memorax-code-trae-adapter/skills/memorax-code/scripts/validate_memory.py +248 -0
- package/lib/memorax-code-trae-adapter/src/adapter-paths.mjs +69 -0
- package/lib/memorax-code-trae-adapter/src/cli.mjs +59 -0
- package/lib/memorax-code-trae-adapter/src/config.mjs +630 -0
- package/lib/memorax-code-trae-adapter/src/runtime-observation.mjs +55 -0
- package/lib/run-entrypoint.mjs +6 -0
- package/package.json +5 -3
|
@@ -0,0 +1,517 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Extract GitLab merge request/issue resource facets with Python stdlib + glab CLI."""
|
|
3
|
+
|
|
4
|
+
import argparse
|
|
5
|
+
import json
|
|
6
|
+
import re
|
|
7
|
+
import shutil
|
|
8
|
+
import subprocess
|
|
9
|
+
import sys
|
|
10
|
+
import time
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any, Iterable, Optional
|
|
13
|
+
from urllib.parse import quote, urlencode
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
DEFAULT_GLAB_RETRIES = 3
|
|
17
|
+
DEFAULT_GLAB_RETRY_DELAY_MS = 1000
|
|
18
|
+
TRANSIENT_GLAB_ERROR = re.compile(
|
|
19
|
+
r"EOF|timeout|timed out|connection reset|connection refused|temporarily unavailable|"
|
|
20
|
+
r"TLS handshake timeout|502|503|504|rate limit|too many requests",
|
|
21
|
+
re.IGNORECASE,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def is_transient_glab_error(stderr: str, stdout: str = "") -> bool:
|
|
26
|
+
return TRANSIENT_GLAB_ERROR.search(f"{stderr}\n{stdout}") is not None
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def run_glab_api(path: str, hostname: str = "", retries: int = DEFAULT_GLAB_RETRIES, retry_delay_ms: int = DEFAULT_GLAB_RETRY_DELAY_MS) -> Any:
|
|
30
|
+
if shutil.which("glab") is None:
|
|
31
|
+
raise SystemExit("GitLab CLI 'glab' is required. Install it and run: glab auth login")
|
|
32
|
+
args = ["api"]
|
|
33
|
+
if hostname:
|
|
34
|
+
args.extend(["--hostname", hostname])
|
|
35
|
+
args.append(path)
|
|
36
|
+
attempts = max(1, retries + 1)
|
|
37
|
+
last_result: Optional[subprocess.CompletedProcess[str]] = None
|
|
38
|
+
for attempt in range(1, attempts + 1):
|
|
39
|
+
result = subprocess.run(["glab", *args], text=True, capture_output=True)
|
|
40
|
+
last_result = result
|
|
41
|
+
if result.returncode == 0:
|
|
42
|
+
text = result.stdout.strip()
|
|
43
|
+
return json.loads(text) if text else None
|
|
44
|
+
if attempt < attempts and is_transient_glab_error(result.stderr, result.stdout):
|
|
45
|
+
delay = max(0, retry_delay_ms) / 1000 * (2 ** (attempt - 1))
|
|
46
|
+
print(
|
|
47
|
+
f"glab api {path} failed with transient error; retrying "
|
|
48
|
+
f"{attempt}/{retries} after {delay:.1f}s: {result.stderr.strip()}",
|
|
49
|
+
file=sys.stderr,
|
|
50
|
+
)
|
|
51
|
+
time.sleep(delay)
|
|
52
|
+
continue
|
|
53
|
+
break
|
|
54
|
+
assert last_result is not None
|
|
55
|
+
raise SystemExit(f"glab api {path} failed:\n{last_result.stderr.strip()}")
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def auth_status_failure(result: subprocess.CompletedProcess[str], login_hint: str) -> str:
|
|
59
|
+
details: list[str] = []
|
|
60
|
+
stderr = result.stderr.strip()
|
|
61
|
+
stdout = result.stdout.strip()
|
|
62
|
+
if stderr:
|
|
63
|
+
details.append(f"stderr: {stderr}")
|
|
64
|
+
if stdout:
|
|
65
|
+
details.append(f"stdout: {stdout}")
|
|
66
|
+
detail = "\n".join(details) if details else "glab auth status returned no diagnostic output"
|
|
67
|
+
return f"glab auth check failed with exit code {result.returncode}. Run: {login_hint}\n{detail}"
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def login_hint(hostname: str) -> str:
|
|
71
|
+
return f"glab auth login --hostname {hostname}" if hostname and hostname != "gitlab.com" else "glab auth login"
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def assert_glab_ready(hostname: str) -> None:
|
|
75
|
+
if shutil.which("glab") is None:
|
|
76
|
+
raise SystemExit("GitLab CLI 'glab' is required. Install it and run: glab auth login")
|
|
77
|
+
args = ["auth", "status"]
|
|
78
|
+
if hostname:
|
|
79
|
+
args.extend(["--hostname", hostname])
|
|
80
|
+
attempts = max(1, DEFAULT_GLAB_RETRIES + 1)
|
|
81
|
+
last_result: Optional[subprocess.CompletedProcess[str]] = None
|
|
82
|
+
for attempt in range(1, attempts + 1):
|
|
83
|
+
result = subprocess.run(["glab", *args], text=True, capture_output=True)
|
|
84
|
+
last_result = result
|
|
85
|
+
if result.returncode == 0:
|
|
86
|
+
return
|
|
87
|
+
if attempt < attempts and is_transient_glab_error(result.stderr, result.stdout):
|
|
88
|
+
delay = max(0, DEFAULT_GLAB_RETRY_DELAY_MS) / 1000 * (2 ** (attempt - 1))
|
|
89
|
+
print(
|
|
90
|
+
f"glab auth status failed with transient error; retrying "
|
|
91
|
+
f"{attempt}/{DEFAULT_GLAB_RETRIES} after {delay:.1f}s: {result.stderr.strip()}",
|
|
92
|
+
file=sys.stderr,
|
|
93
|
+
)
|
|
94
|
+
time.sleep(delay)
|
|
95
|
+
continue
|
|
96
|
+
break
|
|
97
|
+
assert last_result is not None
|
|
98
|
+
raise SystemExit(auth_status_failure(last_result, login_hint(hostname)))
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def run_git(repo_path: Path, args: list[str]) -> subprocess.CompletedProcess[str]:
|
|
102
|
+
if shutil.which("git") is None:
|
|
103
|
+
raise SystemExit("git is required for MR snapshot filtering")
|
|
104
|
+
return subprocess.run(["git", "-C", str(repo_path), *args], text=True, capture_output=True)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def resolve_snapshot_sha(repo_path: Path, snapshot_ref: str) -> str:
|
|
108
|
+
result = run_git(repo_path, ["rev-parse", "--verify", f"{snapshot_ref}^{{commit}}"])
|
|
109
|
+
if result.returncode != 0:
|
|
110
|
+
raise SystemExit(f"git could not resolve snapshot ref {snapshot_ref!r} in {repo_path}:\n{result.stderr.strip()}")
|
|
111
|
+
return result.stdout.strip()
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def is_git_ancestor(repo_path: Path, ancestor_sha: str, descendant_sha: str) -> bool:
|
|
115
|
+
result = run_git(repo_path, ["merge-base", "--is-ancestor", ancestor_sha, descendant_sha])
|
|
116
|
+
if result.returncode == 0:
|
|
117
|
+
return True
|
|
118
|
+
if result.returncode == 1:
|
|
119
|
+
return False
|
|
120
|
+
stderr = result.stderr.strip()
|
|
121
|
+
normalized_stderr = stderr.lower()
|
|
122
|
+
if (
|
|
123
|
+
"not a valid commit" in normalized_stderr
|
|
124
|
+
or "no such commit" in normalized_stderr
|
|
125
|
+
or "not a valid object name" in normalized_stderr
|
|
126
|
+
):
|
|
127
|
+
return False
|
|
128
|
+
raise SystemExit(
|
|
129
|
+
f"git could not compare {ancestor_sha} with snapshot {descendant_sha} in {repo_path}:\n{stderr}"
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def api_path(repo: str, endpoint: str, params: Optional[dict[str, Any]] = None) -> str:
|
|
134
|
+
encoded_repo = quote(repo, safe="")
|
|
135
|
+
query = urlencode(params or {})
|
|
136
|
+
path = f"projects/{encoded_repo}/{endpoint}"
|
|
137
|
+
return f"{path}?{query}" if query else path
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def state_param(state: str) -> str:
|
|
141
|
+
return {"open": "opened", "closed": "closed"}.get(state, state)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def normalize_state(value: Any) -> str:
|
|
145
|
+
raw = str(value or "").strip().lower()
|
|
146
|
+
if raw == "opened":
|
|
147
|
+
return "OPEN"
|
|
148
|
+
if raw == "merged":
|
|
149
|
+
return "MERGED"
|
|
150
|
+
if raw == "closed":
|
|
151
|
+
return "CLOSED"
|
|
152
|
+
return raw.upper() if raw else ""
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def unique_strings(values: Iterable[str]) -> list[str]:
|
|
156
|
+
seen: set[str] = set()
|
|
157
|
+
out: list[str] = []
|
|
158
|
+
for value in values:
|
|
159
|
+
if value and value not in seen:
|
|
160
|
+
seen.add(value)
|
|
161
|
+
out.append(value)
|
|
162
|
+
return out
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def unique_numbers(values: Iterable[int]) -> list[int]:
|
|
166
|
+
seen: set[int] = set()
|
|
167
|
+
out: list[int] = []
|
|
168
|
+
for value in values:
|
|
169
|
+
if isinstance(value, int) and value not in seen:
|
|
170
|
+
seen.add(value)
|
|
171
|
+
out.append(value)
|
|
172
|
+
return out
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def text_field(value: Any, *keys: str) -> str:
|
|
176
|
+
if isinstance(value, str):
|
|
177
|
+
return value.strip()
|
|
178
|
+
if not isinstance(value, dict):
|
|
179
|
+
return ""
|
|
180
|
+
for key in keys:
|
|
181
|
+
item = value.get(key)
|
|
182
|
+
if isinstance(item, str) and item.strip():
|
|
183
|
+
return item.strip()
|
|
184
|
+
return ""
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def int_field(value: Any, key: str) -> Optional[int]:
|
|
188
|
+
if not isinstance(value, dict):
|
|
189
|
+
return None
|
|
190
|
+
item = value.get(key)
|
|
191
|
+
if isinstance(item, int):
|
|
192
|
+
return item
|
|
193
|
+
if isinstance(item, str) and item.isdigit():
|
|
194
|
+
return int(item)
|
|
195
|
+
return None
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def bounded_summary(parts: Iterable[str], max_chars: int) -> str:
|
|
199
|
+
text = "\n\n".join(part.strip() for part in parts if isinstance(part, str) and part.strip())
|
|
200
|
+
return text if len(text) <= max_chars else text[: max_chars - 3] + "..."
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def extract_symbols(text: str) -> list[str]:
|
|
204
|
+
symbols: list[str] = []
|
|
205
|
+
for match in re.finditer(r"\b[$A-Z_a-z][$\w]*\b", text):
|
|
206
|
+
value = match.group(0)
|
|
207
|
+
looks_symbolic = (
|
|
208
|
+
re.search(r"[a-z][A-Z]", value) is not None
|
|
209
|
+
or re.match(r"^[A-Z0-9_]{2,}$", value) is not None
|
|
210
|
+
or "_" in value
|
|
211
|
+
or "$" in value
|
|
212
|
+
)
|
|
213
|
+
if looks_symbolic:
|
|
214
|
+
symbols.append(value)
|
|
215
|
+
return unique_strings(symbols)
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def linked_issue_numbers(text: str) -> list[int]:
|
|
219
|
+
numbers = [int(match.group(1)) for match in re.finditer(r"\b(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)\s+#(\d+)", text, re.IGNORECASE)]
|
|
220
|
+
return unique_numbers(numbers)
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def author_login(value: Any) -> str:
|
|
224
|
+
return text_field(value, "username", "login", "name")
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def label_names(value: Any) -> list[str]:
|
|
228
|
+
if not isinstance(value, list):
|
|
229
|
+
return []
|
|
230
|
+
return unique_strings(text_field(item, "name") if isinstance(item, dict) else str(item).strip() for item in value)
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def change_paths(value: Any) -> list[str]:
|
|
234
|
+
if not isinstance(value, dict):
|
|
235
|
+
return []
|
|
236
|
+
changes = value.get("changes")
|
|
237
|
+
if not isinstance(changes, list):
|
|
238
|
+
return []
|
|
239
|
+
paths: list[str] = []
|
|
240
|
+
for item in changes:
|
|
241
|
+
if isinstance(item, dict):
|
|
242
|
+
path = text_field(item, "new_path", "old_path")
|
|
243
|
+
if path:
|
|
244
|
+
paths.append(path)
|
|
245
|
+
return unique_strings(paths)
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def mr_to_facet(repo: str, detail: dict[str, Any], changes: dict[str, Any], summary_chars: int) -> dict[str, Any]:
|
|
249
|
+
number = int_field(detail, "iid") or 0
|
|
250
|
+
title = text_field(detail, "title") or f"MR !{number}"
|
|
251
|
+
description = text_field(detail, "description")
|
|
252
|
+
files = change_paths(changes)
|
|
253
|
+
summary = bounded_summary([description], summary_chars) or title
|
|
254
|
+
base_ref = text_field(detail, "target_branch")
|
|
255
|
+
head_ref = text_field(detail, "source_branch")
|
|
256
|
+
branch_label = f"{base_ref} <- {head_ref}" if base_ref or head_ref else ""
|
|
257
|
+
issues = linked_issue_numbers(description)
|
|
258
|
+
merge_sha = text_field(detail, "merge_commit_sha", "squash_commit_sha")
|
|
259
|
+
evidence = unique_strings(
|
|
260
|
+
[
|
|
261
|
+
f"MR !{number}: {title}",
|
|
262
|
+
f"MR !{number} changed {', '.join(files)}" if files else "",
|
|
263
|
+
f"MR !{number} closes issue {', '.join(f'#{issue}' for issue in issues)}" if issues else "",
|
|
264
|
+
]
|
|
265
|
+
)
|
|
266
|
+
return {
|
|
267
|
+
"facetId": f"mr.{number}",
|
|
268
|
+
"sourceType": "pr",
|
|
269
|
+
"provider": "gitlab",
|
|
270
|
+
"repo": repo,
|
|
271
|
+
"title": title,
|
|
272
|
+
"summary": summary,
|
|
273
|
+
"url": text_field(detail, "web_url", "url"),
|
|
274
|
+
"state": normalize_state(detail.get("state")),
|
|
275
|
+
"createdAt": text_field(detail, "created_at"),
|
|
276
|
+
"updatedAt": text_field(detail, "updated_at"),
|
|
277
|
+
"closedAt": text_field(detail, "closed_at"),
|
|
278
|
+
"mergedAt": text_field(detail, "merged_at"),
|
|
279
|
+
"author": author_login(detail.get("author")),
|
|
280
|
+
"isDraft": bool(detail.get("draft") or detail.get("work_in_progress")),
|
|
281
|
+
"base_ref": base_ref,
|
|
282
|
+
"head_ref": head_ref,
|
|
283
|
+
"head_repo": repo,
|
|
284
|
+
"branch_label": branch_label,
|
|
285
|
+
"changed_files": int_field(detail, "changes_count") or len(files),
|
|
286
|
+
"additions": 0,
|
|
287
|
+
"deletions": 0,
|
|
288
|
+
"commit_headlines": [],
|
|
289
|
+
"review_decision": "",
|
|
290
|
+
"review_states": [],
|
|
291
|
+
"commits": [merge_sha] if merge_sha else [],
|
|
292
|
+
"merge_commit": merge_sha,
|
|
293
|
+
"prs": [number] if number else [],
|
|
294
|
+
"issues": issues,
|
|
295
|
+
"files": files,
|
|
296
|
+
"symbols": extract_symbols("\n".join([title, summary, *files])),
|
|
297
|
+
"evidence": evidence,
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def issue_to_facet(repo: str, detail: dict[str, Any], summary_chars: int) -> dict[str, Any]:
|
|
302
|
+
number = int_field(detail, "iid") or 0
|
|
303
|
+
title = text_field(detail, "title") or f"Issue #{number}"
|
|
304
|
+
description = text_field(detail, "description")
|
|
305
|
+
labels = label_names(detail.get("labels"))
|
|
306
|
+
labels_text = f"Labels: {', '.join(labels)}" if labels else ""
|
|
307
|
+
summary = bounded_summary([description, labels_text], summary_chars) or title
|
|
308
|
+
evidence = unique_strings(
|
|
309
|
+
[
|
|
310
|
+
f"issue #{number}: {title}",
|
|
311
|
+
f"issue #{number} labels: {', '.join(labels)}" if labels else "",
|
|
312
|
+
]
|
|
313
|
+
)
|
|
314
|
+
return {
|
|
315
|
+
"facetId": f"issue.{number}",
|
|
316
|
+
"sourceType": "issue",
|
|
317
|
+
"provider": "gitlab",
|
|
318
|
+
"repo": repo,
|
|
319
|
+
"title": title,
|
|
320
|
+
"summary": summary,
|
|
321
|
+
"url": text_field(detail, "web_url", "url"),
|
|
322
|
+
"state": normalize_state(detail.get("state")),
|
|
323
|
+
"updatedAt": text_field(detail, "updated_at"),
|
|
324
|
+
"labels": labels,
|
|
325
|
+
"commits": [],
|
|
326
|
+
"prs": [],
|
|
327
|
+
"issues": [number] if number else [],
|
|
328
|
+
"files": [],
|
|
329
|
+
"symbols": extract_symbols("\n".join([title, summary, *labels])),
|
|
330
|
+
"evidence": evidence,
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def mr_is_in_snapshot(repo_path: Path, detail: dict[str, Any], snapshot_sha: str) -> bool:
|
|
335
|
+
if not snapshot_sha:
|
|
336
|
+
return True
|
|
337
|
+
merge_sha = text_field(detail, "merge_commit_sha", "squash_commit_sha")
|
|
338
|
+
return bool(merge_sha) and is_git_ancestor(repo_path, merge_sha, snapshot_sha)
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def append_mr_facet(
|
|
342
|
+
facets: list[dict[str, Any]],
|
|
343
|
+
repo: str,
|
|
344
|
+
item: dict[str, Any],
|
|
345
|
+
hostname: str,
|
|
346
|
+
summary_chars: int,
|
|
347
|
+
retries: int,
|
|
348
|
+
retry_delay_ms: int,
|
|
349
|
+
) -> None:
|
|
350
|
+
number = int_field(item, "iid")
|
|
351
|
+
if not number:
|
|
352
|
+
return
|
|
353
|
+
changes = run_glab_api(api_path(repo, f"merge_requests/{number}/changes"), hostname, retries, retry_delay_ms)
|
|
354
|
+
facets.append(mr_to_facet(repo, item, changes if isinstance(changes, dict) else {}, summary_chars))
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def fetch_mr_facets(
|
|
358
|
+
repo: str,
|
|
359
|
+
repo_path: Path,
|
|
360
|
+
hostname: str,
|
|
361
|
+
api_state: str,
|
|
362
|
+
snapshot_sha: str,
|
|
363
|
+
limit: int,
|
|
364
|
+
summary_chars: int,
|
|
365
|
+
retries: int,
|
|
366
|
+
retry_delay_ms: int,
|
|
367
|
+
) -> list[dict[str, Any]]:
|
|
368
|
+
if snapshot_sha and api_state == "opened":
|
|
369
|
+
return []
|
|
370
|
+
|
|
371
|
+
facets: list[dict[str, Any]] = []
|
|
372
|
+
|
|
373
|
+
if not snapshot_sha:
|
|
374
|
+
merge_requests = run_glab_api(
|
|
375
|
+
api_path(repo, "merge_requests", {"state": api_state, "per_page": limit, "order_by": "updated_at", "sort": "desc"}),
|
|
376
|
+
hostname,
|
|
377
|
+
retries,
|
|
378
|
+
retry_delay_ms,
|
|
379
|
+
)
|
|
380
|
+
if not isinstance(merge_requests, list):
|
|
381
|
+
raise SystemExit("glab merge request list response was not a JSON list")
|
|
382
|
+
for item in merge_requests:
|
|
383
|
+
if isinstance(item, dict):
|
|
384
|
+
append_mr_facet(facets, repo, item, hostname, summary_chars, retries, retry_delay_ms)
|
|
385
|
+
return facets[:limit]
|
|
386
|
+
|
|
387
|
+
page = 1
|
|
388
|
+
while len(facets) < limit:
|
|
389
|
+
merge_requests = run_glab_api(
|
|
390
|
+
api_path(
|
|
391
|
+
repo,
|
|
392
|
+
"merge_requests",
|
|
393
|
+
{"state": api_state, "per_page": limit, "order_by": "updated_at", "sort": "desc", "page": page},
|
|
394
|
+
),
|
|
395
|
+
hostname,
|
|
396
|
+
retries,
|
|
397
|
+
retry_delay_ms,
|
|
398
|
+
)
|
|
399
|
+
if not isinstance(merge_requests, list):
|
|
400
|
+
raise SystemExit("glab merge request list response was not a JSON list")
|
|
401
|
+
if not merge_requests:
|
|
402
|
+
break
|
|
403
|
+
|
|
404
|
+
for item in merge_requests:
|
|
405
|
+
if not isinstance(item, dict) or not mr_is_in_snapshot(repo_path, item, snapshot_sha):
|
|
406
|
+
continue
|
|
407
|
+
append_mr_facet(facets, repo, item, hostname, summary_chars, retries, retry_delay_ms)
|
|
408
|
+
if len(facets) >= limit:
|
|
409
|
+
break
|
|
410
|
+
|
|
411
|
+
if len(facets) >= limit or len(merge_requests) < limit:
|
|
412
|
+
break
|
|
413
|
+
page += 1
|
|
414
|
+
|
|
415
|
+
return facets[:limit]
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
def fetch_facets(
|
|
419
|
+
repo: str,
|
|
420
|
+
repo_path: Path,
|
|
421
|
+
hostname: str,
|
|
422
|
+
snapshot_ref: str,
|
|
423
|
+
pr_limit: int,
|
|
424
|
+
issue_limit: int,
|
|
425
|
+
state: str,
|
|
426
|
+
include: set[str],
|
|
427
|
+
summary_chars: int,
|
|
428
|
+
retries: int,
|
|
429
|
+
retry_delay_ms: int,
|
|
430
|
+
) -> list[dict[str, Any]]:
|
|
431
|
+
assert_glab_ready(hostname)
|
|
432
|
+
api_state = state_param(state)
|
|
433
|
+
snapshot_sha = resolve_snapshot_sha(repo_path, snapshot_ref) if snapshot_ref and "prs" in include else ""
|
|
434
|
+
facets: list[dict[str, Any]] = []
|
|
435
|
+
|
|
436
|
+
if "prs" in include:
|
|
437
|
+
facets.extend(fetch_mr_facets(repo, repo_path, hostname, api_state, snapshot_sha, pr_limit, summary_chars, retries, retry_delay_ms))
|
|
438
|
+
|
|
439
|
+
if "issues" in include:
|
|
440
|
+
issues = run_glab_api(
|
|
441
|
+
api_path(repo, "issues", {"state": api_state, "per_page": issue_limit, "order_by": "updated_at", "sort": "desc"}),
|
|
442
|
+
hostname,
|
|
443
|
+
retries,
|
|
444
|
+
retry_delay_ms,
|
|
445
|
+
)
|
|
446
|
+
if not isinstance(issues, list):
|
|
447
|
+
raise SystemExit("glab issue list response was not a JSON list")
|
|
448
|
+
facets.extend(issue_to_facet(repo, item, summary_chars) for item in issues if isinstance(item, dict))
|
|
449
|
+
|
|
450
|
+
return facets
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
def parse_args(argv: list[str]) -> argparse.Namespace:
|
|
454
|
+
parser = argparse.ArgumentParser(description="Extract GitLab MR/issue SourceFacet JSON using glab CLI.")
|
|
455
|
+
parser.add_argument("--repo", required=True, help="GitLab repository in group/project or group/subgroup/project form")
|
|
456
|
+
parser.add_argument("--hostname", default="", help="GitLab hostname for auth/API commands; defaults to glab's current host")
|
|
457
|
+
parser.add_argument("--repo-path", default=".", help="Local git repository path used for MR snapshot filtering")
|
|
458
|
+
parser.add_argument(
|
|
459
|
+
"--snapshot-ref",
|
|
460
|
+
default="",
|
|
461
|
+
help="Optional local git ref; MRs are kept only when their merge commit is an ancestor of this ref",
|
|
462
|
+
)
|
|
463
|
+
parser.add_argument("--pr-limit", type=int, default=None, help="Maximum retained MRs after snapshot filtering")
|
|
464
|
+
parser.add_argument("--issue-limit", type=int, default=None, help="Maximum retained provider issues")
|
|
465
|
+
parser.add_argument("--state", choices=["all", "open", "closed"], default="all")
|
|
466
|
+
parser.add_argument("--include", default="prs,issues", help="Comma-separated: prs,issues")
|
|
467
|
+
parser.add_argument("--glab-retries", type=int, default=DEFAULT_GLAB_RETRIES, help="Retries for transient glab CLI failures")
|
|
468
|
+
parser.add_argument("--glab-retry-delay-ms", type=int, default=DEFAULT_GLAB_RETRY_DELAY_MS, help="Initial retry delay for transient glab CLI failures")
|
|
469
|
+
parser.add_argument("--summary-chars", type=int, default=4000)
|
|
470
|
+
parser.add_argument("--pretty", action="store_true", help="Pretty-print JSON")
|
|
471
|
+
parser.add_argument("--out", help="Write JSON to this file instead of stdout")
|
|
472
|
+
args = parser.parse_args(argv)
|
|
473
|
+
if "/" not in args.repo:
|
|
474
|
+
parser.error("--repo must be group/project or group/subgroup/project")
|
|
475
|
+
include = {part.strip() for part in args.include.split(",") if part.strip()}
|
|
476
|
+
if not include or include - {"prs", "issues"}:
|
|
477
|
+
parser.error("--include must contain prs, issues, or prs,issues")
|
|
478
|
+
args.include = include
|
|
479
|
+
args.pr_limit = args.pr_limit if args.pr_limit is not None else 30
|
|
480
|
+
args.issue_limit = args.issue_limit if args.issue_limit is not None else 30
|
|
481
|
+
if args.pr_limit < 1 or args.pr_limit > 100:
|
|
482
|
+
parser.error("--pr-limit must be from 1 to 100")
|
|
483
|
+
if args.issue_limit < 1 or args.issue_limit > 100:
|
|
484
|
+
parser.error("--issue-limit must be from 1 to 100")
|
|
485
|
+
if args.glab_retries < 0 or args.glab_retries > 10:
|
|
486
|
+
parser.error("--glab-retries must be from 0 to 10")
|
|
487
|
+
if args.glab_retry_delay_ms < 0:
|
|
488
|
+
parser.error("--glab-retry-delay-ms must be non-negative")
|
|
489
|
+
args.repo_path = Path(args.repo_path).expanduser().resolve()
|
|
490
|
+
return args
|
|
491
|
+
|
|
492
|
+
|
|
493
|
+
def main(argv: list[str]) -> int:
|
|
494
|
+
args = parse_args(argv)
|
|
495
|
+
facets = fetch_facets(
|
|
496
|
+
args.repo,
|
|
497
|
+
args.repo_path,
|
|
498
|
+
args.hostname,
|
|
499
|
+
args.snapshot_ref,
|
|
500
|
+
args.pr_limit,
|
|
501
|
+
args.issue_limit,
|
|
502
|
+
args.state,
|
|
503
|
+
args.include,
|
|
504
|
+
args.summary_chars,
|
|
505
|
+
args.glab_retries,
|
|
506
|
+
args.glab_retry_delay_ms,
|
|
507
|
+
)
|
|
508
|
+
text = json.dumps(facets, ensure_ascii=False, indent=2 if args.pretty else None)
|
|
509
|
+
if args.out:
|
|
510
|
+
Path(args.out).write_text(text + "\n", encoding="utf-8")
|
|
511
|
+
else:
|
|
512
|
+
print(text)
|
|
513
|
+
return 0
|
|
514
|
+
|
|
515
|
+
|
|
516
|
+
if __name__ == "__main__":
|
|
517
|
+
raise SystemExit(main(sys.argv[1:]))
|