@memorax/memorax-code 0.1.9 → 0.1.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (101) hide show
  1. package/README.md +10 -0
  2. package/bin/memorax-code-codebuddy.mjs +4 -0
  3. package/bin/memorax-code-setup.mjs +182 -65
  4. package/bin/memorax-code.mjs +74 -45
  5. package/docs/configuration.md +85 -22
  6. package/docs/troubleshooting.md +72 -5
  7. package/lib/automatic-update.mjs +233 -0
  8. package/lib/memorax-code-adapter-common/src/automatic-update-state.d.mts +24 -0
  9. package/lib/memorax-code-adapter-common/src/automatic-update-state.mjs +73 -0
  10. package/lib/memorax-code-adapter-common/src/clients/codebuddy-command.mjs +82 -0
  11. package/lib/memorax-code-adapter-common/src/config-utils.d.mts +2 -0
  12. package/lib/memorax-code-adapter-common/src/config-utils.mjs +11 -0
  13. package/lib/memorax-code-adapter-common/src/hooks/client-hook-launcher.mjs +3 -0
  14. package/lib/memorax-code-adapter-common/src/hooks/ensure-backend-runner.mjs +45 -10
  15. package/lib/memorax-code-adapter-common/src/repo-memory/repo-memory-job-worker.mjs +88 -3
  16. package/lib/memorax-code-adapter-common/src/windows-cli-invocation.mjs +11 -1
  17. package/lib/memorax-code-backend/dist/app/backend-server.js +2 -2
  18. package/lib/memorax-code-backend/dist/app/memory-observability.js +2 -2
  19. package/lib/memorax-code-backend/dist/clients/codebuddy/jsonl-history.js +208 -0
  20. package/lib/memorax-code-backend/dist/clients/codebuddy/lifecycle.js +44 -0
  21. package/lib/memorax-code-backend/dist/clients/codebuddy/memory-hook-runtime.js +250 -0
  22. package/lib/memorax-code-backend/dist/clients/codebuddy/turn-id.js +16 -0
  23. package/lib/memorax-code-backend/dist/clients/codex/plugin-install.js +129 -7
  24. package/lib/memorax-code-backend/dist/config/memorax-code.js +14 -1
  25. package/lib/memorax-code-backend/dist/entrypoints/backend-cli.js +43 -6
  26. package/lib/memorax-code-backend/dist/lifecycle/active-clients.js +3 -0
  27. package/lib/memorax-code-backend/dist/lifecycle/automatic-update-scheduler.js +133 -0
  28. package/lib/memorax-code-backend/dist/lifecycle/client-plugin-removal.js +14 -2
  29. package/lib/memorax-code-backend/dist/lifecycle/client-selection.js +6 -4
  30. package/lib/memorax-code-backend/dist/lifecycle/orchestrator.js +48 -9
  31. package/lib/memorax-code-backend/dist/memory/cli.js +4 -2
  32. package/lib/memorax-code-backend/dist/memory/hook-command.js +31 -1
  33. package/lib/memorax-code-backend/dist/memory/reminder-trace-recorder.js +5 -1
  34. package/lib/memorax-code-backend/dist/memory/service.js +12 -0
  35. package/lib/memorax-code-backend/dist/shared/windows-cli-invocation.js +10 -1
  36. package/lib/memorax-code-backend/dist/trace/config.js +12 -0
  37. package/lib/memorax-code-backend/dist/trace/context.js +21 -1
  38. package/lib/memorax-code-backend/dist/trace/store.js +12 -1
  39. package/lib/memorax-code-backend/package.json +1 -1
  40. package/lib/memorax-code-claude-adapter/.claude-plugin/plugin.json +1 -1
  41. package/lib/memorax-code-claude-adapter/hooks/runtime-shell.json +1 -1
  42. package/lib/memorax-code-claude-adapter/package.json +1 -1
  43. package/lib/memorax-code-claude-adapter/runtime-hooks/ensure-backend.mjs +2 -0
  44. package/lib/memorax-code-claude-adapter/src/plugin-install.mjs +3 -0
  45. package/lib/memorax-code-claude-marketplace/plugins/memorax-code-claude-adapter/.claude-plugin/plugin.json +1 -1
  46. package/lib/memorax-code-claude-marketplace/plugins/memorax-code-claude-adapter/hooks/runtime-shell.json +1 -1
  47. package/lib/memorax-code-claude-marketplace/plugins/memorax-code-claude-adapter/memorax-code-adapter-common/src/automatic-update-state.d.mts +24 -0
  48. package/lib/memorax-code-claude-marketplace/plugins/memorax-code-claude-adapter/memorax-code-adapter-common/src/automatic-update-state.mjs +73 -0
  49. package/lib/memorax-code-claude-marketplace/plugins/memorax-code-claude-adapter/memorax-code-adapter-common/src/clients/codebuddy-command.mjs +82 -0
  50. package/lib/memorax-code-claude-marketplace/plugins/memorax-code-claude-adapter/memorax-code-adapter-common/src/config-utils.d.mts +2 -0
  51. package/lib/memorax-code-claude-marketplace/plugins/memorax-code-claude-adapter/memorax-code-adapter-common/src/config-utils.mjs +11 -0
  52. package/lib/memorax-code-claude-marketplace/plugins/memorax-code-claude-adapter/memorax-code-adapter-common/src/hooks/client-hook-launcher.mjs +3 -0
  53. package/lib/memorax-code-claude-marketplace/plugins/memorax-code-claude-adapter/memorax-code-adapter-common/src/hooks/ensure-backend-runner.mjs +45 -10
  54. 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
  55. package/lib/memorax-code-claude-marketplace/plugins/memorax-code-claude-adapter/memorax-code-adapter-common/src/windows-cli-invocation.mjs +11 -1
  56. package/lib/memorax-code-claude-marketplace/plugins/memorax-code-claude-adapter/package.json +1 -1
  57. package/lib/memorax-code-claude-marketplace/plugins/memorax-code-claude-adapter/runtime-hooks/ensure-backend.mjs +2 -0
  58. package/lib/memorax-code-claude-marketplace/plugins/memorax-code-claude-adapter/src/plugin-install.mjs +3 -0
  59. package/lib/memorax-code-codebuddy-adapter/.codebuddy-plugin/plugin.json +7 -0
  60. package/lib/memorax-code-codebuddy-adapter/hooks/common-runtime.mjs +13 -0
  61. package/lib/memorax-code-codebuddy-adapter/hooks/hooks.json +38 -0
  62. package/lib/memorax-code-codebuddy-adapter/hooks/repo-memory-job.mjs +40 -0
  63. package/lib/memorax-code-codebuddy-adapter/hooks/runtime-hook.mjs +289 -0
  64. package/lib/memorax-code-codebuddy-adapter/package.json +9 -0
  65. package/lib/memorax-code-codebuddy-adapter/skills/memorax-code/SKILL.md +85 -0
  66. package/lib/memorax-code-codebuddy-adapter/skills/memorax-code/agents/claude.yaml +10 -0
  67. package/lib/memorax-code-codebuddy-adapter/skills/memorax-code/agents/openai.yaml +7 -0
  68. package/lib/memorax-code-codebuddy-adapter/skills/memorax-code/defaults.json +12 -0
  69. package/lib/memorax-code-codebuddy-adapter/skills/memorax-code/references/memorax-add.md +88 -0
  70. package/lib/memorax-code-codebuddy-adapter/skills/memorax-code/references/memorax-search.md +93 -0
  71. package/lib/memorax-code-codebuddy-adapter/skills/memorax-code/references/personal-read.md +46 -0
  72. package/lib/memorax-code-codebuddy-adapter/skills/memorax-code/references/personal-write.md +120 -0
  73. package/lib/memorax-code-codebuddy-adapter/skills/memorax-code/references/repo-build.md +319 -0
  74. package/lib/memorax-code-codebuddy-adapter/skills/memorax-code/references/repo-read.md +103 -0
  75. package/lib/memorax-code-codebuddy-adapter/skills/memorax-code/references/repo-templates.md +390 -0
  76. package/lib/memorax-code-codebuddy-adapter/skills/memorax-code/references/repo-update.md +127 -0
  77. package/lib/memorax-code-codebuddy-adapter/skills/memorax-code/scripts/collect_all.py +579 -0
  78. package/lib/memorax-code-codebuddy-adapter/skills/memorax-code/scripts/detect_updates.py +919 -0
  79. package/lib/memorax-code-codebuddy-adapter/skills/memorax-code/scripts/git_commit_facets.py +222 -0
  80. package/lib/memorax-code-codebuddy-adapter/skills/memorax-code/scripts/github_resource_facets.py +512 -0
  81. package/lib/memorax-code-codebuddy-adapter/skills/memorax-code/scripts/gitlab_resource_facets.py +517 -0
  82. package/lib/memorax-code-codebuddy-adapter/skills/memorax-code/scripts/prepare_repo_memory.py +411 -0
  83. package/lib/memorax-code-codebuddy-adapter/skills/memorax-code/scripts/user_profile_memory.py +528 -0
  84. package/lib/memorax-code-codebuddy-adapter/skills/memorax-code/scripts/validate_memory.py +248 -0
  85. package/lib/memorax-code-codebuddy-adapter/src/cli.mjs +58 -0
  86. package/lib/memorax-code-codebuddy-adapter/src/config.mjs +340 -0
  87. package/lib/memorax-code-codebuddy-adapter/src/hook-manifest.mjs +47 -0
  88. package/lib/memorax-code-codebuddy-adapter/src/runtime-observation.mjs +65 -0
  89. package/lib/memorax-code-codex-adapter/.codex-plugin/plugin.json +1 -1
  90. package/lib/memorax-code-codex-adapter/hooks/runtime-shell.json +1 -1
  91. package/lib/memorax-code-codex-adapter/package.json +1 -1
  92. package/lib/memorax-code-codex-adapter/runtime-hooks/ensure-backend.mjs +4 -1
  93. package/lib/memorax-code-dsh-adapter/package.json +2 -1
  94. package/lib/memorax-code-dsh-adapter/src/profile-lifecycle.mjs +1 -0
  95. package/lib/memorax-code-opencode-adapter/package.json +1 -1
  96. package/lib/npm-invocation.mjs +51 -8
  97. package/lib/resolve-codebuddy-command.mjs +123 -0
  98. package/lib/run-entrypoint.mjs +25 -1
  99. package/lib/windows-cli-invocation.mjs +11 -1
  100. package/lib/windows-user-path.mjs +218 -0
  101. 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:]))