@memorax/memorax-code 0.1.9 → 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/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
package/lib/memorax-code-codebuddy-adapter/skills/memorax-code/scripts/github_resource_facets.py
ADDED
|
@@ -0,0 +1,512 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Extract GitHub PR/issue resource facets with only Python stdlib + gh CLI."""
|
|
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 time
|
|
13
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Any, Iterable
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
PR_LIST_FIELDS = "number,title,state,url,updatedAt,baseRefName,headRefName,headRepository,headRepositoryOwner,isDraft"
|
|
19
|
+
PR_VIEW_FIELDS = "number,title,body,state,url,updatedAt,createdAt,closedAt,mergedAt,author,comments,reviews,latestReviews,reviewDecision,files,commits,closingIssuesReferences,mergeCommit,baseRefName,headRefName,headRepository,headRepositoryOwner,isDraft,additions,deletions,changedFiles"
|
|
20
|
+
ISSUE_LIST_FIELDS = "number,title,state,url,updatedAt,labels"
|
|
21
|
+
ISSUE_VIEW_FIELDS = "number,title,body,state,url,updatedAt,labels,comments"
|
|
22
|
+
DEFAULT_GH_RETRIES = 3
|
|
23
|
+
DEFAULT_GH_RETRY_DELAY_MS = 1000
|
|
24
|
+
TRANSIENT_GH_ERROR = re.compile(
|
|
25
|
+
r"EOF|timeout|timed out|connection reset|connection refused|temporarily unavailable|"
|
|
26
|
+
r"TLS handshake timeout|502|503|504|rate limit|secondary rate limit|abuse detection",
|
|
27
|
+
re.IGNORECASE,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def is_transient_gh_error(stderr: str, stdout: str = "") -> bool:
|
|
32
|
+
return TRANSIENT_GH_ERROR.search(f"{stderr}\n{stdout}") is not None
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def run_gh(args: list[str], retries: int = DEFAULT_GH_RETRIES, retry_delay_ms: int = DEFAULT_GH_RETRY_DELAY_MS) -> Any:
|
|
36
|
+
if shutil.which("gh") is None:
|
|
37
|
+
raise SystemExit("GitHub CLI 'gh' is required. Install it and run: gh auth login")
|
|
38
|
+
attempts = max(1, retries + 1)
|
|
39
|
+
last_result: subprocess.CompletedProcess[str] | None = None
|
|
40
|
+
for attempt in range(1, attempts + 1):
|
|
41
|
+
result = subprocess.run(["gh", *args], text=True, capture_output=True)
|
|
42
|
+
last_result = result
|
|
43
|
+
if result.returncode == 0:
|
|
44
|
+
text = result.stdout.strip()
|
|
45
|
+
return json.loads(text) if text else None
|
|
46
|
+
if attempt < attempts and is_transient_gh_error(result.stderr, result.stdout):
|
|
47
|
+
delay = max(0, retry_delay_ms) / 1000 * (2 ** (attempt - 1))
|
|
48
|
+
print(
|
|
49
|
+
f"gh {' '.join(args)} failed with transient error; retrying "
|
|
50
|
+
f"{attempt}/{retries} after {delay:.1f}s: {result.stderr.strip()}",
|
|
51
|
+
file=sys.stderr,
|
|
52
|
+
)
|
|
53
|
+
time.sleep(delay)
|
|
54
|
+
continue
|
|
55
|
+
break
|
|
56
|
+
assert last_result is not None
|
|
57
|
+
raise SystemExit(f"gh {' '.join(args)} failed:\n{last_result.stderr.strip()}")
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def auth_status_failure(result: subprocess.CompletedProcess[str], login_hint: str) -> str:
|
|
61
|
+
details: list[str] = []
|
|
62
|
+
stderr = result.stderr.strip()
|
|
63
|
+
stdout = result.stdout.strip()
|
|
64
|
+
if stderr:
|
|
65
|
+
details.append(f"stderr: {stderr}")
|
|
66
|
+
if stdout:
|
|
67
|
+
details.append(f"stdout: {stdout}")
|
|
68
|
+
detail = "\n".join(details) if details else "gh auth status returned no diagnostic output"
|
|
69
|
+
return f"gh auth check failed with exit code {result.returncode}. Run: {login_hint}\n{detail}"
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def run_git(repo_path: Path, args: list[str]) -> subprocess.CompletedProcess[str]:
|
|
73
|
+
if shutil.which("git") is None:
|
|
74
|
+
raise SystemExit("git is required for PR snapshot filtering")
|
|
75
|
+
return subprocess.run(["git", "-C", str(repo_path), *args], text=True, capture_output=True)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def resolve_snapshot_sha(repo_path: Path, snapshot_ref: str) -> str:
|
|
79
|
+
result = run_git(repo_path, ["rev-parse", "--verify", f"{snapshot_ref}^{{commit}}"])
|
|
80
|
+
if result.returncode != 0:
|
|
81
|
+
raise SystemExit(f"git could not resolve snapshot ref {snapshot_ref!r} in {repo_path}:\n{result.stderr.strip()}")
|
|
82
|
+
return result.stdout.strip()
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def is_git_ancestor(repo_path: Path, ancestor_sha: str, descendant_sha: str) -> bool:
|
|
86
|
+
result = run_git(repo_path, ["merge-base", "--is-ancestor", ancestor_sha, descendant_sha])
|
|
87
|
+
if result.returncode == 0:
|
|
88
|
+
return True
|
|
89
|
+
if result.returncode == 1:
|
|
90
|
+
return False
|
|
91
|
+
stderr = result.stderr.strip()
|
|
92
|
+
normalized_stderr = stderr.lower()
|
|
93
|
+
if (
|
|
94
|
+
"not a valid commit" in normalized_stderr
|
|
95
|
+
or "no such commit" in normalized_stderr
|
|
96
|
+
or "not a valid object name" in normalized_stderr
|
|
97
|
+
):
|
|
98
|
+
return False
|
|
99
|
+
raise SystemExit(
|
|
100
|
+
f"git could not compare {ancestor_sha} with snapshot {descendant_sha} in {repo_path}:\n{stderr}"
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def login_hint(hostname: str) -> str:
|
|
105
|
+
return f"gh auth login --hostname {hostname}" if hostname and hostname != "github.com" else "gh auth login"
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def repo_selector(repo: str, hostname: str) -> str:
|
|
109
|
+
if hostname and hostname != "github.com" and not repo.startswith(f"{hostname}/"):
|
|
110
|
+
return f"{hostname}/{repo}"
|
|
111
|
+
return repo
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def assert_gh_ready(hostname: str) -> None:
|
|
115
|
+
if shutil.which("gh") is None:
|
|
116
|
+
raise SystemExit("GitHub CLI 'gh' is required. Install it and run: gh auth login")
|
|
117
|
+
args = ["auth", "status"]
|
|
118
|
+
if hostname:
|
|
119
|
+
args.extend(["--hostname", hostname])
|
|
120
|
+
attempts = max(1, DEFAULT_GH_RETRIES + 1)
|
|
121
|
+
last_result: subprocess.CompletedProcess[str] | None = None
|
|
122
|
+
for attempt in range(1, attempts + 1):
|
|
123
|
+
result = subprocess.run(["gh", *args], text=True, capture_output=True)
|
|
124
|
+
last_result = result
|
|
125
|
+
if result.returncode == 0:
|
|
126
|
+
return
|
|
127
|
+
if attempt < attempts and is_transient_gh_error(result.stderr, result.stdout):
|
|
128
|
+
delay = max(0, DEFAULT_GH_RETRY_DELAY_MS) / 1000 * (2 ** (attempt - 1))
|
|
129
|
+
print(
|
|
130
|
+
f"gh auth status failed with transient error; retrying "
|
|
131
|
+
f"{attempt}/{DEFAULT_GH_RETRIES} after {delay:.1f}s: {result.stderr.strip()}",
|
|
132
|
+
file=sys.stderr,
|
|
133
|
+
)
|
|
134
|
+
time.sleep(delay)
|
|
135
|
+
continue
|
|
136
|
+
break
|
|
137
|
+
assert last_result is not None
|
|
138
|
+
raise SystemExit(auth_status_failure(last_result, login_hint(hostname)))
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def unique_strings(values: Iterable[str]) -> list[str]:
|
|
142
|
+
seen: set[str] = set()
|
|
143
|
+
out: list[str] = []
|
|
144
|
+
for value in values:
|
|
145
|
+
if value and value not in seen:
|
|
146
|
+
seen.add(value)
|
|
147
|
+
out.append(value)
|
|
148
|
+
return out
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def unique_numbers(values: Iterable[int]) -> list[int]:
|
|
152
|
+
seen: set[int] = set()
|
|
153
|
+
out: list[int] = []
|
|
154
|
+
for value in values:
|
|
155
|
+
if isinstance(value, int) and value not in seen:
|
|
156
|
+
seen.add(value)
|
|
157
|
+
out.append(value)
|
|
158
|
+
return out
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def text_field(value: Any, *keys: str) -> str:
|
|
162
|
+
if isinstance(value, str):
|
|
163
|
+
return value.strip()
|
|
164
|
+
if not isinstance(value, dict):
|
|
165
|
+
return ""
|
|
166
|
+
for key in keys:
|
|
167
|
+
item = value.get(key)
|
|
168
|
+
if isinstance(item, str) and item.strip():
|
|
169
|
+
return item.strip()
|
|
170
|
+
return ""
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def int_field(value: Any, key: str = "number") -> int | None:
|
|
174
|
+
if isinstance(value, int):
|
|
175
|
+
return value
|
|
176
|
+
if isinstance(value, dict) and isinstance(value.get(key), int):
|
|
177
|
+
return value[key]
|
|
178
|
+
return None
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def bounded_summary(parts: Iterable[str], max_chars: int) -> str:
|
|
182
|
+
text = "\n\n".join(part.strip() for part in parts if isinstance(part, str) and part.strip())
|
|
183
|
+
return text if len(text) <= max_chars else text[: max_chars - 3] + "..."
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def extract_symbols(text: str) -> list[str]:
|
|
187
|
+
symbols: list[str] = []
|
|
188
|
+
for match in re.finditer(r"\b[$A-Z_a-z][$\w]*\b", text):
|
|
189
|
+
value = match.group(0)
|
|
190
|
+
looks_symbolic = (
|
|
191
|
+
re.search(r"[a-z][A-Z]", value) is not None
|
|
192
|
+
or re.match(r"^[A-Z0-9_]{2,}$", value) is not None
|
|
193
|
+
or "_" in value
|
|
194
|
+
or "$" in value
|
|
195
|
+
)
|
|
196
|
+
if looks_symbolic:
|
|
197
|
+
symbols.append(value)
|
|
198
|
+
return unique_strings(symbols)
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def commit_sha(value: Any) -> str:
|
|
202
|
+
if isinstance(value, dict) and isinstance(value.get("commit"), dict):
|
|
203
|
+
return text_field(value["commit"], "oid", "sha", "abbreviatedOid")
|
|
204
|
+
return text_field(value, "oid", "sha", "abbreviatedOid")
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def commit_headline(value: Any) -> str:
|
|
208
|
+
if isinstance(value, dict) and isinstance(value.get("commit"), dict):
|
|
209
|
+
return text_field(value["commit"], "messageHeadline", "message", "title")
|
|
210
|
+
return text_field(value, "messageHeadline", "message", "title")
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def author_login(value: Any) -> str:
|
|
214
|
+
return text_field(value, "login", "name")
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def review_state(value: Any) -> str:
|
|
218
|
+
return text_field(value, "state")
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def file_path(value: Any) -> str:
|
|
222
|
+
return text_field(value, "path", "filename")
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def label_name(value: Any) -> str:
|
|
226
|
+
return text_field(value, "name")
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def repo_name(value: Any) -> str:
|
|
230
|
+
if isinstance(value, str):
|
|
231
|
+
return value.strip()
|
|
232
|
+
if not isinstance(value, dict):
|
|
233
|
+
return ""
|
|
234
|
+
owner = value.get("owner")
|
|
235
|
+
owner_login = text_field(owner, "login") if isinstance(owner, dict) else ""
|
|
236
|
+
name = text_field(value, "name", "nameWithOwner")
|
|
237
|
+
name_with_owner = text_field(value, "nameWithOwner")
|
|
238
|
+
if name_with_owner and "/" in name_with_owner:
|
|
239
|
+
return name_with_owner
|
|
240
|
+
return f"{owner_login}/{name}" if owner_login and name else name
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def body_text(value: Any) -> str:
|
|
244
|
+
return text_field(value, "body", "title", "name")
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def pr_detail_to_facet(repo: str, detail: dict[str, Any], summary_chars: int) -> dict[str, Any]:
|
|
248
|
+
number = int_field(detail) or 0
|
|
249
|
+
title = text_field(detail, "title") or f"PR #{number}"
|
|
250
|
+
body = text_field(detail, "body")
|
|
251
|
+
comments = [body_text(item) for item in detail.get("comments", []) if body_text(item)]
|
|
252
|
+
reviews = [body_text(item) for item in detail.get("reviews", []) if body_text(item)]
|
|
253
|
+
commits = unique_strings(commit_sha(item) for item in detail.get("commits", []) if commit_sha(item))
|
|
254
|
+
commit_headlines = unique_strings(commit_headline(item) for item in detail.get("commits", []) if commit_headline(item))
|
|
255
|
+
files = unique_strings(file_path(item) for item in detail.get("files", []) if file_path(item))
|
|
256
|
+
review_states = unique_strings(review_state(item) for item in detail.get("latestReviews", []) if review_state(item))
|
|
257
|
+
issues = unique_numbers(
|
|
258
|
+
number for number in (int_field(item) for item in detail.get("closingIssuesReferences", [])) if number is not None
|
|
259
|
+
)
|
|
260
|
+
summary = bounded_summary([body, *comments, *reviews], summary_chars) or title
|
|
261
|
+
base_ref = text_field(detail, "baseRefName")
|
|
262
|
+
head_ref = text_field(detail, "headRefName")
|
|
263
|
+
head_repo = repo_name(detail.get("headRepository")) or repo
|
|
264
|
+
branch_label = f"{base_ref} <- {head_ref}" if base_ref or head_ref else ""
|
|
265
|
+
evidence = unique_strings(
|
|
266
|
+
[
|
|
267
|
+
f"PR #{number}: {title}",
|
|
268
|
+
f"PR #{number} changed {', '.join(files)}" if files else "",
|
|
269
|
+
f"PR #{number} closes issue {', '.join(f'#{issue}' for issue in issues)}" if issues else "",
|
|
270
|
+
]
|
|
271
|
+
)
|
|
272
|
+
return {
|
|
273
|
+
"facetId": f"pr.{number}",
|
|
274
|
+
"sourceType": "pr",
|
|
275
|
+
"repo": repo,
|
|
276
|
+
"title": title,
|
|
277
|
+
"summary": summary,
|
|
278
|
+
"url": text_field(detail, "url"),
|
|
279
|
+
"state": text_field(detail, "state"),
|
|
280
|
+
"createdAt": text_field(detail, "createdAt"),
|
|
281
|
+
"updatedAt": text_field(detail, "updatedAt"),
|
|
282
|
+
"closedAt": text_field(detail, "closedAt"),
|
|
283
|
+
"mergedAt": text_field(detail, "mergedAt"),
|
|
284
|
+
"author": author_login(detail.get("author")),
|
|
285
|
+
"isDraft": bool(detail.get("isDraft")),
|
|
286
|
+
"base_ref": base_ref,
|
|
287
|
+
"head_ref": head_ref,
|
|
288
|
+
"head_repo": head_repo,
|
|
289
|
+
"branch_label": branch_label,
|
|
290
|
+
"changed_files": detail.get("changedFiles") or len(files),
|
|
291
|
+
"additions": detail.get("additions") or 0,
|
|
292
|
+
"deletions": detail.get("deletions") or 0,
|
|
293
|
+
"commit_headlines": commit_headlines,
|
|
294
|
+
"review_decision": text_field(detail, "reviewDecision"),
|
|
295
|
+
"review_states": review_states,
|
|
296
|
+
"commits": commits,
|
|
297
|
+
"prs": [number] if number else [],
|
|
298
|
+
"issues": issues,
|
|
299
|
+
"files": files,
|
|
300
|
+
"symbols": extract_symbols("\n".join([title, summary, *files])),
|
|
301
|
+
"evidence": evidence,
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def issue_detail_to_facet(repo: str, detail: dict[str, Any], summary_chars: int) -> dict[str, Any]:
|
|
306
|
+
number = int_field(detail) or 0
|
|
307
|
+
title = text_field(detail, "title") or f"Issue #{number}"
|
|
308
|
+
body = text_field(detail, "body")
|
|
309
|
+
comments = [body_text(item) for item in detail.get("comments", []) if body_text(item)]
|
|
310
|
+
labels = [label_name(item) for item in detail.get("labels", []) if label_name(item)]
|
|
311
|
+
labels_text = f"Labels: {', '.join(labels)}" if labels else ""
|
|
312
|
+
summary = bounded_summary([body, *comments, labels_text], summary_chars) or title
|
|
313
|
+
evidence = unique_strings(
|
|
314
|
+
[
|
|
315
|
+
f"issue #{number}: {title}",
|
|
316
|
+
f"issue #{number} labels: {', '.join(labels)}" if labels else "",
|
|
317
|
+
]
|
|
318
|
+
)
|
|
319
|
+
return {
|
|
320
|
+
"facetId": f"issue.{number}",
|
|
321
|
+
"sourceType": "issue",
|
|
322
|
+
"repo": repo,
|
|
323
|
+
"title": title,
|
|
324
|
+
"summary": summary,
|
|
325
|
+
"url": text_field(detail, "url"),
|
|
326
|
+
"state": text_field(detail, "state"),
|
|
327
|
+
"updatedAt": text_field(detail, "updatedAt"),
|
|
328
|
+
"labels": labels,
|
|
329
|
+
"commits": [],
|
|
330
|
+
"prs": [],
|
|
331
|
+
"issues": [number] if number else [],
|
|
332
|
+
"files": [],
|
|
333
|
+
"symbols": extract_symbols("\n".join([title, summary, *labels])),
|
|
334
|
+
"evidence": evidence,
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def gh_list(kind: str, repo: str, limit: int, state: str, retries: int, retry_delay_ms: int) -> list[dict[str, Any]]:
|
|
339
|
+
fields = PR_LIST_FIELDS if kind == "pr" else ISSUE_LIST_FIELDS
|
|
340
|
+
return run_gh([kind, "list", "--repo", repo, "--limit", str(limit), "--state", state, "--json", fields], retries, retry_delay_ms)
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def gh_view(kind: str, repo: str, number: int, retries: int, retry_delay_ms: int) -> dict[str, Any]:
|
|
344
|
+
fields = PR_VIEW_FIELDS if kind == "pr" else ISSUE_VIEW_FIELDS
|
|
345
|
+
return run_gh([kind, "view", str(number), "--repo", repo, "--json", fields], retries, retry_delay_ms)
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def pr_is_in_snapshot(repo_path: Path, detail: dict[str, Any], snapshot_sha: str) -> bool:
|
|
349
|
+
if not snapshot_sha:
|
|
350
|
+
return True
|
|
351
|
+
merge_sha = commit_sha(detail.get("mergeCommit"))
|
|
352
|
+
return bool(merge_sha) and is_git_ancestor(repo_path, merge_sha, snapshot_sha)
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
def fetch_pr_facets(
|
|
356
|
+
query_repo: str,
|
|
357
|
+
facet_repo: str,
|
|
358
|
+
repo_path: Path,
|
|
359
|
+
snapshot_sha: str,
|
|
360
|
+
limit: int,
|
|
361
|
+
state: str,
|
|
362
|
+
concurrency: int,
|
|
363
|
+
summary_chars: int,
|
|
364
|
+
retries: int,
|
|
365
|
+
retry_delay_ms: int,
|
|
366
|
+
) -> list[dict[str, Any]]:
|
|
367
|
+
if snapshot_sha and state == "open":
|
|
368
|
+
return []
|
|
369
|
+
|
|
370
|
+
facets: list[dict[str, Any]] = []
|
|
371
|
+
seen_numbers: set[int] = set()
|
|
372
|
+
candidate_limit = max(1, limit)
|
|
373
|
+
previous_candidate_count = -1
|
|
374
|
+
|
|
375
|
+
while len(facets) < limit:
|
|
376
|
+
candidates = gh_list("pr", query_repo, candidate_limit, state, retries, retry_delay_ms)
|
|
377
|
+
if not isinstance(candidates, list):
|
|
378
|
+
raise SystemExit("gh PR list response was not a JSON list")
|
|
379
|
+
|
|
380
|
+
candidate_count = len(candidates)
|
|
381
|
+
numbers: list[int] = []
|
|
382
|
+
for item in candidates:
|
|
383
|
+
number = int_field(item)
|
|
384
|
+
if number is not None and number not in seen_numbers:
|
|
385
|
+
seen_numbers.add(number)
|
|
386
|
+
numbers.append(number)
|
|
387
|
+
|
|
388
|
+
def one(number: int) -> dict[str, Any] | None:
|
|
389
|
+
detail = gh_view("pr", query_repo, number, retries, retry_delay_ms)
|
|
390
|
+
if snapshot_sha and not pr_is_in_snapshot(repo_path, detail, snapshot_sha):
|
|
391
|
+
return None
|
|
392
|
+
return pr_detail_to_facet(facet_repo, detail, summary_chars)
|
|
393
|
+
|
|
394
|
+
if numbers:
|
|
395
|
+
with ThreadPoolExecutor(max_workers=max(1, concurrency)) as pool:
|
|
396
|
+
for facet in pool.map(one, numbers):
|
|
397
|
+
if facet:
|
|
398
|
+
facets.append(facet)
|
|
399
|
+
if len(facets) >= limit:
|
|
400
|
+
break
|
|
401
|
+
|
|
402
|
+
if len(facets) >= limit or candidate_count < candidate_limit or candidate_count == previous_candidate_count:
|
|
403
|
+
break
|
|
404
|
+
previous_candidate_count = candidate_count
|
|
405
|
+
candidate_limit *= 2
|
|
406
|
+
|
|
407
|
+
return facets[:limit]
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
def fetch_facets(
|
|
411
|
+
repo: str,
|
|
412
|
+
repo_path: Path,
|
|
413
|
+
hostname: str,
|
|
414
|
+
snapshot_ref: str,
|
|
415
|
+
pr_limit: int,
|
|
416
|
+
issue_limit: int,
|
|
417
|
+
state: str,
|
|
418
|
+
include: set[str],
|
|
419
|
+
concurrency: int,
|
|
420
|
+
summary_chars: int,
|
|
421
|
+
retries: int,
|
|
422
|
+
retry_delay_ms: int,
|
|
423
|
+
) -> list[dict[str, Any]]:
|
|
424
|
+
assert_gh_ready(hostname)
|
|
425
|
+
selected_repo = repo_selector(repo, hostname)
|
|
426
|
+
snapshot_sha = resolve_snapshot_sha(repo_path, snapshot_ref) if snapshot_ref and "prs" in include else ""
|
|
427
|
+
facets: list[dict[str, Any]] = []
|
|
428
|
+
jobs: list[tuple[str, int]] = []
|
|
429
|
+
if "prs" in include:
|
|
430
|
+
facets.extend(fetch_pr_facets(selected_repo, repo, repo_path, snapshot_sha, pr_limit, state, concurrency, summary_chars, retries, retry_delay_ms))
|
|
431
|
+
if "issues" in include:
|
|
432
|
+
jobs.extend(("issue", item["number"]) for item in gh_list("issue", selected_repo, issue_limit, state, retries, retry_delay_ms))
|
|
433
|
+
|
|
434
|
+
def one(job: tuple[str, int]) -> dict[str, Any] | None:
|
|
435
|
+
kind, number = job
|
|
436
|
+
detail = gh_view(kind, selected_repo, number, retries, retry_delay_ms)
|
|
437
|
+
return issue_detail_to_facet(repo, detail, summary_chars)
|
|
438
|
+
|
|
439
|
+
with ThreadPoolExecutor(max_workers=max(1, concurrency)) as pool:
|
|
440
|
+
facets.extend(facet for facet in pool.map(one, jobs) if facet)
|
|
441
|
+
return facets
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
def parse_args(argv: list[str]) -> argparse.Namespace:
|
|
445
|
+
parser = argparse.ArgumentParser(description="Extract GitHub PR/issue SourceFacet JSON using gh CLI.")
|
|
446
|
+
parser.add_argument("--repo", required=True, help="GitHub repository in owner/name form")
|
|
447
|
+
parser.add_argument("--hostname", default="", help="GitHub hostname for auth/API commands; defaults to gh's current host")
|
|
448
|
+
parser.add_argument("--repo-path", default=".", help="Local git repository path used for PR snapshot filtering")
|
|
449
|
+
parser.add_argument(
|
|
450
|
+
"--snapshot-ref",
|
|
451
|
+
default="",
|
|
452
|
+
help="Optional local git ref; PRs are kept only when their merge commit is an ancestor of this ref",
|
|
453
|
+
)
|
|
454
|
+
parser.add_argument("--pr-limit", type=int, default=None, help="Maximum retained PRs after snapshot filtering")
|
|
455
|
+
parser.add_argument("--issue-limit", type=int, default=None, help="Maximum retained provider issues")
|
|
456
|
+
parser.add_argument("--state", choices=["all", "open", "closed"], default="all")
|
|
457
|
+
parser.add_argument("--include", default="prs,issues", help="Comma-separated: prs,issues")
|
|
458
|
+
parser.add_argument("--concurrency", type=int, default=3)
|
|
459
|
+
parser.add_argument("--gh-retries", type=int, default=DEFAULT_GH_RETRIES, help="Retries for transient gh CLI failures")
|
|
460
|
+
parser.add_argument("--gh-retry-delay-ms", type=int, default=DEFAULT_GH_RETRY_DELAY_MS, help="Initial retry delay for transient gh CLI failures")
|
|
461
|
+
parser.add_argument("--summary-chars", type=int, default=4000)
|
|
462
|
+
parser.add_argument("--pretty", action="store_true", help="Pretty-print JSON")
|
|
463
|
+
parser.add_argument("--out", help="Write JSON to this file instead of stdout")
|
|
464
|
+
args = parser.parse_args(argv)
|
|
465
|
+
if not re.match(r"^[^/]+/[^/]+$", args.repo):
|
|
466
|
+
parser.error("--repo must be owner/name")
|
|
467
|
+
include = {part.strip() for part in args.include.split(",") if part.strip()}
|
|
468
|
+
if not include or include - {"prs", "issues"}:
|
|
469
|
+
parser.error("--include must contain prs, issues, or prs,issues")
|
|
470
|
+
args.include = include
|
|
471
|
+
args.pr_limit = args.pr_limit if args.pr_limit is not None else 30
|
|
472
|
+
args.issue_limit = args.issue_limit if args.issue_limit is not None else 30
|
|
473
|
+
if args.pr_limit < 1 or args.pr_limit > 100:
|
|
474
|
+
parser.error("--pr-limit must be from 1 to 100")
|
|
475
|
+
if args.issue_limit < 1 or args.issue_limit > 100:
|
|
476
|
+
parser.error("--issue-limit must be from 1 to 100")
|
|
477
|
+
if args.concurrency < 1 or args.concurrency > 10:
|
|
478
|
+
parser.error("--concurrency must be from 1 to 10")
|
|
479
|
+
if args.gh_retries < 0 or args.gh_retries > 10:
|
|
480
|
+
parser.error("--gh-retries must be from 0 to 10")
|
|
481
|
+
if args.gh_retry_delay_ms < 0:
|
|
482
|
+
parser.error("--gh-retry-delay-ms must be non-negative")
|
|
483
|
+
args.repo_path = Path(args.repo_path).expanduser().resolve()
|
|
484
|
+
return args
|
|
485
|
+
|
|
486
|
+
|
|
487
|
+
def main(argv: list[str]) -> int:
|
|
488
|
+
args = parse_args(argv)
|
|
489
|
+
facets = fetch_facets(
|
|
490
|
+
args.repo,
|
|
491
|
+
args.repo_path,
|
|
492
|
+
args.hostname,
|
|
493
|
+
args.snapshot_ref,
|
|
494
|
+
args.pr_limit,
|
|
495
|
+
args.issue_limit,
|
|
496
|
+
args.state,
|
|
497
|
+
args.include,
|
|
498
|
+
args.concurrency,
|
|
499
|
+
args.summary_chars,
|
|
500
|
+
args.gh_retries,
|
|
501
|
+
args.gh_retry_delay_ms,
|
|
502
|
+
)
|
|
503
|
+
text = json.dumps(facets, ensure_ascii=False, indent=2 if args.pretty else None)
|
|
504
|
+
if args.out:
|
|
505
|
+
Path(args.out).write_text(text + "\n", encoding="utf-8")
|
|
506
|
+
else:
|
|
507
|
+
print(text)
|
|
508
|
+
return 0
|
|
509
|
+
|
|
510
|
+
|
|
511
|
+
if __name__ == "__main__":
|
|
512
|
+
raise SystemExit(main(sys.argv[1:]))
|