bingo-light 2.1.1 → 2.1.3
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.en.md +20 -7
- package/README.md +209 -126
- package/bingo-light +385 -11
- package/bingo_core/__init__.py +3 -1
- package/bingo_core/dep.py +1012 -0
- package/bingo_core/dep_fork.py +268 -0
- package/bingo_core/dep_npm.py +113 -0
- package/bingo_core/dep_pip.py +178 -0
- package/bingo_core/repo.py +795 -8
- package/bingo_core/setup.py +73 -17
- package/bingo_core/state.py +1 -1
- package/bingo_core/team.py +170 -0
- package/completions/bingo-light.bash +26 -3
- package/completions/bingo-light.fish +46 -1
- package/completions/bingo-light.zsh +38 -2
- package/mcp-server.py +346 -6
- package/package.json +1 -1
package/bingo_core/repo.py
CHANGED
|
@@ -8,6 +8,7 @@ import json
|
|
|
8
8
|
import os
|
|
9
9
|
import re
|
|
10
10
|
import shlex
|
|
11
|
+
import shutil
|
|
11
12
|
import subprocess
|
|
12
13
|
import tempfile
|
|
13
14
|
from datetime import datetime, timezone
|
|
@@ -33,6 +34,7 @@ from bingo_core.models import ConflictInfo
|
|
|
33
34
|
from bingo_core.git import Git
|
|
34
35
|
from bingo_core.config import Config
|
|
35
36
|
from bingo_core.state import State
|
|
37
|
+
from bingo_core.team import TeamState
|
|
36
38
|
|
|
37
39
|
|
|
38
40
|
class Repo:
|
|
@@ -43,6 +45,7 @@ class Repo:
|
|
|
43
45
|
self.git = Git(self.path)
|
|
44
46
|
self.config = Config(self.path)
|
|
45
47
|
self.state = State(self.path)
|
|
48
|
+
self.team = TeamState(self.path, git=self.git)
|
|
46
49
|
|
|
47
50
|
# -- Internal helpers --
|
|
48
51
|
|
|
@@ -212,6 +215,200 @@ class Repo:
|
|
|
212
215
|
pass
|
|
213
216
|
return ""
|
|
214
217
|
|
|
218
|
+
_PATCH_SUBJECT_RE = re.compile(
|
|
219
|
+
r"^\[bl\]\s+([a-zA-Z0-9][a-zA-Z0-9_-]*)\s*:\s*(.*)$"
|
|
220
|
+
)
|
|
221
|
+
_MESSAGE_MAX = 2048
|
|
222
|
+
|
|
223
|
+
def _build_patch_intent(self) -> dict:
|
|
224
|
+
"""Assemble patch-intent context for a rebase in progress.
|
|
225
|
+
|
|
226
|
+
Returns a dict with: name, subject, message, message_truncated,
|
|
227
|
+
original_sha, original_diff, diff_truncated, meta, stack_position.
|
|
228
|
+
|
|
229
|
+
All fields fall back to empty/None rather than raising. This is
|
|
230
|
+
defensive context-gathering for AI consumption, not a validator.
|
|
231
|
+
"""
|
|
232
|
+
result = {
|
|
233
|
+
"name": "",
|
|
234
|
+
"subject": "",
|
|
235
|
+
"message": "",
|
|
236
|
+
"message_truncated": False,
|
|
237
|
+
"original_sha": None,
|
|
238
|
+
"original_diff": None,
|
|
239
|
+
"diff_truncated": False,
|
|
240
|
+
"meta": None,
|
|
241
|
+
"stack_position": None,
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
msg_file = os.path.join(self.path, ".git", "rebase-merge", "message")
|
|
245
|
+
sha_file = os.path.join(self.path, ".git", "rebase-merge", "stopped-sha")
|
|
246
|
+
|
|
247
|
+
try:
|
|
248
|
+
with open(msg_file) as f:
|
|
249
|
+
raw_msg = f.read()
|
|
250
|
+
except (IOError, OSError):
|
|
251
|
+
return result
|
|
252
|
+
|
|
253
|
+
if len(raw_msg) > self._MESSAGE_MAX:
|
|
254
|
+
result["message"] = raw_msg[: self._MESSAGE_MAX]
|
|
255
|
+
result["message_truncated"] = True
|
|
256
|
+
else:
|
|
257
|
+
result["message"] = raw_msg
|
|
258
|
+
|
|
259
|
+
first_line = raw_msg.split("\n", 1)[0]
|
|
260
|
+
m = self._PATCH_SUBJECT_RE.match(first_line)
|
|
261
|
+
if m:
|
|
262
|
+
result["name"] = m.group(1)
|
|
263
|
+
result["subject"] = m.group(2).strip()
|
|
264
|
+
|
|
265
|
+
try:
|
|
266
|
+
with open(sha_file) as f:
|
|
267
|
+
sha = f.read().strip()
|
|
268
|
+
if sha:
|
|
269
|
+
result["original_sha"] = sha
|
|
270
|
+
try:
|
|
271
|
+
diff = self.git.run("show", "--format=", sha)
|
|
272
|
+
except GitError:
|
|
273
|
+
diff = ""
|
|
274
|
+
if diff:
|
|
275
|
+
if len(diff) > MAX_DIFF_SIZE:
|
|
276
|
+
result["original_diff"] = diff[:MAX_DIFF_SIZE]
|
|
277
|
+
result["diff_truncated"] = True
|
|
278
|
+
else:
|
|
279
|
+
result["original_diff"] = diff
|
|
280
|
+
except (IOError, OSError):
|
|
281
|
+
pass
|
|
282
|
+
|
|
283
|
+
if result["name"]:
|
|
284
|
+
try:
|
|
285
|
+
result["meta"] = self.state.patch_meta_get(result["name"])
|
|
286
|
+
except Exception:
|
|
287
|
+
result["meta"] = None
|
|
288
|
+
|
|
289
|
+
try:
|
|
290
|
+
c = self._load()
|
|
291
|
+
base = self._patches_base(c)
|
|
292
|
+
if base:
|
|
293
|
+
try:
|
|
294
|
+
log_output = self.git.run(
|
|
295
|
+
"rev-list", "--reverse",
|
|
296
|
+
f"{base}..{c['patches_branch']}"
|
|
297
|
+
)
|
|
298
|
+
except GitError:
|
|
299
|
+
log_output = ""
|
|
300
|
+
shas = log_output.splitlines()
|
|
301
|
+
subjects = []
|
|
302
|
+
for s in shas:
|
|
303
|
+
try:
|
|
304
|
+
subj = self.git.run(
|
|
305
|
+
"log", "-1", "--format=%s", s
|
|
306
|
+
).strip()
|
|
307
|
+
except GitError:
|
|
308
|
+
subj = ""
|
|
309
|
+
subjects.append(subj)
|
|
310
|
+
for idx, subj in enumerate(subjects, start=1):
|
|
311
|
+
m2 = self._PATCH_SUBJECT_RE.match(subj)
|
|
312
|
+
if m2 and m2.group(1) == result["name"]:
|
|
313
|
+
result["stack_position"] = {
|
|
314
|
+
"index": idx,
|
|
315
|
+
"total": len(subjects),
|
|
316
|
+
}
|
|
317
|
+
break
|
|
318
|
+
except Exception:
|
|
319
|
+
pass
|
|
320
|
+
|
|
321
|
+
return result
|
|
322
|
+
|
|
323
|
+
def _auto_dep_apply(self) -> Optional[dict]:
|
|
324
|
+
"""Auto-apply dependency patches after a successful sync.
|
|
325
|
+
|
|
326
|
+
Returns dep apply result dict, or None if no dep patches configured.
|
|
327
|
+
"""
|
|
328
|
+
dep_dir = os.path.join(self.path, ".bingo-deps")
|
|
329
|
+
if not os.path.isdir(dep_dir):
|
|
330
|
+
return None
|
|
331
|
+
try:
|
|
332
|
+
from bingo_core.dep import DepManager
|
|
333
|
+
dm = DepManager(self.path)
|
|
334
|
+
return dm.apply()
|
|
335
|
+
except Exception as e:
|
|
336
|
+
import sys as _sys
|
|
337
|
+
print(f"warning: auto dep-apply failed: {e}", file=_sys.stderr)
|
|
338
|
+
return {"ok": False, "warning": f"dep apply failed: {e}"}
|
|
339
|
+
|
|
340
|
+
# Lock file basenames that should be auto-resolved during sync
|
|
341
|
+
_LOCK_FILES = {"package-lock.json", "yarn.lock", "pnpm-lock.yaml"}
|
|
342
|
+
|
|
343
|
+
# Lock file -> package manager command
|
|
344
|
+
_LOCK_MANAGERS = {
|
|
345
|
+
"package-lock.json": ["npm", "install", "--package-lock-only"],
|
|
346
|
+
"yarn.lock": ["yarn", "install", "--mode", "update-lockfile"],
|
|
347
|
+
"pnpm-lock.yaml": ["pnpm", "install", "--lockfile-only"],
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
# Verification command templates by file extension.
|
|
351
|
+
# Each value is (template, kind). {path} is replaced with shlex.quote(file).
|
|
352
|
+
# Templates pass the path as argv[1] so nested quoting is not a concern.
|
|
353
|
+
_VERIFY_HINTS_BY_EXT = {
|
|
354
|
+
".py": ("python3 -m py_compile {path}", "syntax"),
|
|
355
|
+
".json": ("python3 -c 'import json,sys; json.load(open(sys.argv[1]))' {path}", "parse"),
|
|
356
|
+
".yml": ("python3 -c 'import yaml,sys; yaml.safe_load(open(sys.argv[1]))' {path}", "parse"),
|
|
357
|
+
".yaml": ("python3 -c 'import yaml,sys; yaml.safe_load(open(sys.argv[1]))' {path}", "parse"),
|
|
358
|
+
".toml": ("python3 -c 'import tomllib,sys; tomllib.load(open(sys.argv[1],\"rb\"))' {path}", "parse"),
|
|
359
|
+
".sh": ("bash -n {path}", "syntax"),
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
def _verify_hints_for(self, files: List[str]) -> List[dict]:
|
|
363
|
+
"""Generate per-file verification commands by extension.
|
|
364
|
+
|
|
365
|
+
Returns a list of dicts: {"file": str, "command": str, "kind": str}.
|
|
366
|
+
Files with unknown extensions are silently skipped. Paths are passed
|
|
367
|
+
through shlex.quote to stay shell-safe.
|
|
368
|
+
"""
|
|
369
|
+
hints: List[dict] = []
|
|
370
|
+
for f in files:
|
|
371
|
+
_, ext = os.path.splitext(f)
|
|
372
|
+
entry = self._VERIFY_HINTS_BY_EXT.get(ext.lower())
|
|
373
|
+
if entry is None:
|
|
374
|
+
continue
|
|
375
|
+
template, kind = entry
|
|
376
|
+
command = template.format(path=shlex.quote(f))
|
|
377
|
+
hints.append({"file": f, "command": command, "kind": kind})
|
|
378
|
+
return hints
|
|
379
|
+
|
|
380
|
+
def _resolve_lock_files(self, unresolved: List[str]) -> List[str]:
|
|
381
|
+
"""Auto-resolve lock file conflicts by accepting theirs + regenerating.
|
|
382
|
+
|
|
383
|
+
Returns the list of still-unresolved files (lock files removed).
|
|
384
|
+
"""
|
|
385
|
+
lock_files = [f for f in unresolved if os.path.basename(f) in self._LOCK_FILES]
|
|
386
|
+
if not lock_files:
|
|
387
|
+
return unresolved
|
|
388
|
+
|
|
389
|
+
for lf in lock_files:
|
|
390
|
+
# Accept upstream version
|
|
391
|
+
self.git.run_ok("checkout", "--theirs", "--", lf)
|
|
392
|
+
self.git.run_ok("add", lf)
|
|
393
|
+
|
|
394
|
+
# Try to regenerate via package manager
|
|
395
|
+
basename = os.path.basename(lf)
|
|
396
|
+
mgr_cmd = self._LOCK_MANAGERS.get(basename)
|
|
397
|
+
lock_dir = os.path.dirname(os.path.join(self.path, lf)) or self.path
|
|
398
|
+
if (mgr_cmd and shutil.which(mgr_cmd[0])
|
|
399
|
+
and os.path.isfile(os.path.join(lock_dir, "package.json"))):
|
|
400
|
+
try:
|
|
401
|
+
subprocess.run(
|
|
402
|
+
mgr_cmd, cwd=lock_dir,
|
|
403
|
+
capture_output=True, text=True, timeout=120,
|
|
404
|
+
)
|
|
405
|
+
# Re-add after regeneration
|
|
406
|
+
self.git.run_ok("add", lf)
|
|
407
|
+
except (subprocess.TimeoutExpired, OSError):
|
|
408
|
+
pass # Keep the theirs version
|
|
409
|
+
|
|
410
|
+
return [f for f in unresolved if f not in lock_files]
|
|
411
|
+
|
|
215
412
|
def _build_conflict_result(
|
|
216
413
|
self,
|
|
217
414
|
conflicted_files: List[str],
|
|
@@ -607,9 +804,12 @@ class Repo:
|
|
|
607
804
|
"reason": reason,
|
|
608
805
|
}
|
|
609
806
|
|
|
610
|
-
def doctor(self) -> dict:
|
|
807
|
+
def doctor(self, report: bool = False) -> dict:
|
|
611
808
|
"""Run health checks on the repository.
|
|
612
809
|
|
|
810
|
+
Args:
|
|
811
|
+
report: If True, include extended checks (team locks, expiry, deps).
|
|
812
|
+
|
|
613
813
|
Returns {"ok": True/False, "issues": N, "checks": [...]}
|
|
614
814
|
"""
|
|
615
815
|
c = self._load()
|
|
@@ -735,6 +935,66 @@ class Repo:
|
|
|
735
935
|
else:
|
|
736
936
|
_check("config", "fail", "missing")
|
|
737
937
|
|
|
938
|
+
# Extended checks (--report mode)
|
|
939
|
+
if report:
|
|
940
|
+
# Stale locks
|
|
941
|
+
locks = self.team.list_locks()
|
|
942
|
+
if locks:
|
|
943
|
+
now = datetime.now(timezone.utc)
|
|
944
|
+
for lock in locks:
|
|
945
|
+
locked_at = lock.get("locked_at", "")
|
|
946
|
+
if locked_at:
|
|
947
|
+
try:
|
|
948
|
+
lock_dt = datetime.strptime(
|
|
949
|
+
locked_at, "%Y-%m-%dT%H:%M:%SZ"
|
|
950
|
+
).replace(tzinfo=timezone.utc)
|
|
951
|
+
days = (now - lock_dt).days
|
|
952
|
+
if days > 7:
|
|
953
|
+
_check(
|
|
954
|
+
f"stale_lock:{lock['patch']}",
|
|
955
|
+
"warn",
|
|
956
|
+
f"locked by {lock['owner']} for {days}d",
|
|
957
|
+
)
|
|
958
|
+
except ValueError:
|
|
959
|
+
pass
|
|
960
|
+
if not any(c_item["name"].startswith("stale_lock:") for c_item in checks):
|
|
961
|
+
_check("team_locks", "pass", f"{len(locks)} active lock(s)")
|
|
962
|
+
else:
|
|
963
|
+
_check("team_locks", "pass", "no locks")
|
|
964
|
+
|
|
965
|
+
# Expired patches
|
|
966
|
+
try:
|
|
967
|
+
expire_result = self.patch_expire()
|
|
968
|
+
n_expired = len(expire_result.get("expired", []))
|
|
969
|
+
n_expiring = len(expire_result.get("expiring_soon", []))
|
|
970
|
+
if n_expired > 0:
|
|
971
|
+
_check("expired_patches", "warn", f"{n_expired} expired patch(es)")
|
|
972
|
+
elif n_expiring > 0:
|
|
973
|
+
_check("expiring_patches", "warn", f"{n_expiring} expiring soon")
|
|
974
|
+
else:
|
|
975
|
+
_check("patch_expiry", "pass", "none expired")
|
|
976
|
+
except Exception:
|
|
977
|
+
pass
|
|
978
|
+
|
|
979
|
+
# Dependency patches health
|
|
980
|
+
dep_dir = os.path.join(self.path, ".bingo-deps")
|
|
981
|
+
if os.path.isdir(dep_dir):
|
|
982
|
+
try:
|
|
983
|
+
from bingo_core.dep import DepManager
|
|
984
|
+
dm = DepManager(self.path)
|
|
985
|
+
dep_status = dm.status()
|
|
986
|
+
if dep_status.get("ok"):
|
|
987
|
+
total = dep_status.get("total_patches", 0)
|
|
988
|
+
healthy = dep_status.get("healthy", 0)
|
|
989
|
+
if healthy == total:
|
|
990
|
+
_check("dep_patches", "pass", f"{total} patch(es) healthy")
|
|
991
|
+
else:
|
|
992
|
+
_check("dep_patches", "warn", f"{total - healthy}/{total} need attention")
|
|
993
|
+
else:
|
|
994
|
+
_check("dep_patches", "warn", dep_status.get("error", "unknown"))
|
|
995
|
+
except Exception:
|
|
996
|
+
pass
|
|
997
|
+
|
|
738
998
|
return {"ok": issues == 0, "issues": issues, "checks": checks}
|
|
739
999
|
|
|
740
1000
|
def diff(self) -> dict:
|
|
@@ -857,7 +1117,9 @@ class Repo:
|
|
|
857
1117
|
def conflict_analyze(self) -> dict:
|
|
858
1118
|
"""Analyze current rebase conflicts.
|
|
859
1119
|
|
|
860
|
-
Returns structured info about each conflicted file
|
|
1120
|
+
Returns structured info about each conflicted file plus
|
|
1121
|
+
patch-intent context and per-file verification hints when
|
|
1122
|
+
a rebase is in progress.
|
|
861
1123
|
"""
|
|
862
1124
|
self._ensure_git_repo()
|
|
863
1125
|
|
|
@@ -871,11 +1133,19 @@ class Repo:
|
|
|
871
1133
|
current_patch = self._current_rebase_patch()
|
|
872
1134
|
conflicts = [self._extract_conflict(f) for f in conflicted]
|
|
873
1135
|
|
|
1136
|
+
patch_intent = self._build_patch_intent()
|
|
1137
|
+
verify = {
|
|
1138
|
+
"test_command": self.config.get("test.command") or None,
|
|
1139
|
+
"file_hints": self._verify_hints_for(conflicted),
|
|
1140
|
+
}
|
|
1141
|
+
|
|
874
1142
|
return {
|
|
875
1143
|
"ok": True,
|
|
876
1144
|
"in_rebase": True,
|
|
877
1145
|
"current_patch": current_patch,
|
|
878
1146
|
"conflicts": [c.to_dict() for c in conflicts],
|
|
1147
|
+
"patch_intent": patch_intent,
|
|
1148
|
+
"verify": verify,
|
|
879
1149
|
"resolution_steps": [
|
|
880
1150
|
"1. Read ours (upstream) and theirs (your patch) for each conflict",
|
|
881
1151
|
"2. Write the merged file content (include both changes where possible)",
|
|
@@ -886,7 +1156,9 @@ class Repo:
|
|
|
886
1156
|
],
|
|
887
1157
|
}
|
|
888
1158
|
|
|
889
|
-
def conflict_resolve(
|
|
1159
|
+
def conflict_resolve(
|
|
1160
|
+
self, file_path: str, content: str = "", verify: bool = False
|
|
1161
|
+
) -> dict:
|
|
890
1162
|
"""Resolve a single conflicted file and continue rebase if possible.
|
|
891
1163
|
|
|
892
1164
|
Args:
|
|
@@ -983,12 +1255,35 @@ class Repo:
|
|
|
983
1255
|
"sync_complete": False,
|
|
984
1256
|
}
|
|
985
1257
|
# Rebase fully complete
|
|
986
|
-
|
|
1258
|
+
result_dict = {
|
|
987
1259
|
"ok": True,
|
|
988
1260
|
"resolved": rel_path,
|
|
989
1261
|
"rebase_continued": True,
|
|
990
1262
|
"sync_complete": True,
|
|
991
1263
|
}
|
|
1264
|
+
if verify:
|
|
1265
|
+
test_cmd = self.config.get("test.command")
|
|
1266
|
+
if not test_cmd:
|
|
1267
|
+
result_dict["verify_result"] = {
|
|
1268
|
+
"skipped": True,
|
|
1269
|
+
"reason": "no test.command configured",
|
|
1270
|
+
}
|
|
1271
|
+
else:
|
|
1272
|
+
try:
|
|
1273
|
+
t = self.test()
|
|
1274
|
+
vr = {
|
|
1275
|
+
"test": t.get("test", "fail"),
|
|
1276
|
+
"command": t.get("command", test_cmd),
|
|
1277
|
+
}
|
|
1278
|
+
if t.get("output"):
|
|
1279
|
+
vr["output"] = t["output"]
|
|
1280
|
+
result_dict["verify_result"] = vr
|
|
1281
|
+
except BingoError as e:
|
|
1282
|
+
result_dict["verify_result"] = {
|
|
1283
|
+
"skipped": True,
|
|
1284
|
+
"reason": str(e),
|
|
1285
|
+
}
|
|
1286
|
+
return result_dict
|
|
992
1287
|
|
|
993
1288
|
# rebase --continue failed -- check why
|
|
994
1289
|
new_unmerged = self.git.ls_files_unmerged()
|
|
@@ -1187,15 +1482,22 @@ class Repo:
|
|
|
1187
1482
|
"test_error": str(e),
|
|
1188
1483
|
}
|
|
1189
1484
|
|
|
1190
|
-
|
|
1485
|
+
result = {
|
|
1191
1486
|
"ok": True,
|
|
1192
1487
|
"synced": True,
|
|
1193
1488
|
"behind_before": behind,
|
|
1194
1489
|
"patches_rebased": patch_count,
|
|
1195
1490
|
}
|
|
1491
|
+
dep = self._auto_dep_apply()
|
|
1492
|
+
if dep is not None:
|
|
1493
|
+
result["dep_apply"] = dep
|
|
1494
|
+
return result
|
|
1196
1495
|
|
|
1197
1496
|
# Rebase failed -- check if rerere auto-resolved
|
|
1198
1497
|
unresolved = self.git.ls_files_unmerged()
|
|
1498
|
+
# Auto-resolve lock file conflicts (package-lock.json, yarn.lock, etc.)
|
|
1499
|
+
if unresolved:
|
|
1500
|
+
unresolved = self._resolve_lock_files(unresolved)
|
|
1199
1501
|
if not unresolved:
|
|
1200
1502
|
# rerere resolved everything -- try to continue
|
|
1201
1503
|
rerere_ok = True
|
|
@@ -1233,13 +1535,17 @@ class Repo:
|
|
|
1233
1535
|
"rerere_resolved": True,
|
|
1234
1536
|
},
|
|
1235
1537
|
)
|
|
1236
|
-
|
|
1538
|
+
result = {
|
|
1237
1539
|
"ok": True,
|
|
1238
1540
|
"synced": True,
|
|
1239
1541
|
"behind_before": behind,
|
|
1240
1542
|
"patches_rebased": patch_count,
|
|
1241
1543
|
"rerere_resolved": True,
|
|
1242
1544
|
}
|
|
1545
|
+
dep = self._auto_dep_apply()
|
|
1546
|
+
if dep is not None:
|
|
1547
|
+
result["dep_apply"] = dep
|
|
1548
|
+
return result
|
|
1243
1549
|
|
|
1244
1550
|
# Rollback tracking branch
|
|
1245
1551
|
self.git.run_ok("branch", "-f", c["tracking_branch"], saved_tracking)
|
|
@@ -1349,13 +1655,17 @@ class Repo:
|
|
|
1349
1655
|
# Clean rebase
|
|
1350
1656
|
self.state.clear_circuit_breaker()
|
|
1351
1657
|
self._record_sync(c, behind, saved_tracking)
|
|
1352
|
-
|
|
1658
|
+
result = {
|
|
1353
1659
|
"ok": True,
|
|
1354
1660
|
"action": "synced",
|
|
1355
1661
|
"behind_before": behind,
|
|
1356
1662
|
"patches_rebased": patch_count,
|
|
1357
1663
|
"conflicts_resolved": 0,
|
|
1358
1664
|
}
|
|
1665
|
+
dep = self._auto_dep_apply()
|
|
1666
|
+
if dep is not None:
|
|
1667
|
+
result["dep_apply"] = dep
|
|
1668
|
+
return result
|
|
1359
1669
|
|
|
1360
1670
|
# Enter conflict resolution loop
|
|
1361
1671
|
conflicts_resolved = 0
|
|
@@ -1391,6 +1701,21 @@ class Repo:
|
|
|
1391
1701
|
if not unresolved:
|
|
1392
1702
|
continue
|
|
1393
1703
|
|
|
1704
|
+
# Auto-resolve lock file conflicts before reporting
|
|
1705
|
+
unresolved = self._resolve_lock_files(unresolved)
|
|
1706
|
+
if not unresolved:
|
|
1707
|
+
# Lock files were the only conflicts — try to continue
|
|
1708
|
+
env = os.environ.copy()
|
|
1709
|
+
env["GIT_EDITOR"] = "true"
|
|
1710
|
+
cont_result = subprocess.run(
|
|
1711
|
+
["git", "rebase", "--continue"],
|
|
1712
|
+
cwd=self.path,
|
|
1713
|
+
capture_output=True, text=True, env=env,
|
|
1714
|
+
)
|
|
1715
|
+
if cont_result.returncode == 0:
|
|
1716
|
+
conflicts_resolved += 1
|
|
1717
|
+
continue
|
|
1718
|
+
|
|
1394
1719
|
# Real unresolved conflicts -- report and stop
|
|
1395
1720
|
self.git.run_ok("branch", "-f", c["tracking_branch"], saved_tracking)
|
|
1396
1721
|
|
|
@@ -1415,13 +1740,17 @@ class Repo:
|
|
|
1415
1740
|
# If we get here, all conflicts were auto-resolved by rerere
|
|
1416
1741
|
self.state.clear_circuit_breaker()
|
|
1417
1742
|
self._record_sync(c, behind, saved_tracking)
|
|
1418
|
-
|
|
1743
|
+
result = {
|
|
1419
1744
|
"ok": True,
|
|
1420
1745
|
"action": "synced_with_rerere",
|
|
1421
1746
|
"behind_before": behind,
|
|
1422
1747
|
"patches_rebased": patch_count,
|
|
1423
1748
|
"conflicts_auto_resolved": conflicts_resolved,
|
|
1424
1749
|
}
|
|
1750
|
+
dep = self._auto_dep_apply()
|
|
1751
|
+
if dep is not None:
|
|
1752
|
+
result["dep_apply"] = dep
|
|
1753
|
+
return result
|
|
1425
1754
|
|
|
1426
1755
|
def undo(self) -> dict:
|
|
1427
1756
|
"""Undo the last sync operation.
|
|
@@ -1664,6 +1993,15 @@ class Repo:
|
|
|
1664
1993
|
if m:
|
|
1665
1994
|
pname = m.group(1)
|
|
1666
1995
|
|
|
1996
|
+
# Lock enforcement — check by parsed name or by target
|
|
1997
|
+
lock_name = pname or target
|
|
1998
|
+
if lock_name and self.team.is_locked_by_other(lock_name):
|
|
1999
|
+
lock = self.team.get_lock(lock_name)
|
|
2000
|
+
raise BingoError(
|
|
2001
|
+
f"Patch '{lock_name}' is locked by {lock['owner']}. "
|
|
2002
|
+
"They must unlock it first."
|
|
2003
|
+
)
|
|
2004
|
+
|
|
1667
2005
|
if self.git.current_branch() != c["patches_branch"]:
|
|
1668
2006
|
self.git.run("checkout", c["patches_branch"])
|
|
1669
2007
|
|
|
@@ -1695,6 +2033,17 @@ class Repo:
|
|
|
1695
2033
|
)
|
|
1696
2034
|
hash_val = self._resolve_patch(c, target)
|
|
1697
2035
|
|
|
2036
|
+
# Lock enforcement — check by parsed name or by target
|
|
2037
|
+
subject_chk = self.git.run("log", "-1", "--format=%s", hash_val)
|
|
2038
|
+
m_chk = re.match(r"^\[bl\] ([^:]+):", subject_chk)
|
|
2039
|
+
lock_name_chk = m_chk.group(1) if m_chk else target
|
|
2040
|
+
if lock_name_chk and self.team.is_locked_by_other(lock_name_chk):
|
|
2041
|
+
lock = self.team.get_lock(lock_name_chk)
|
|
2042
|
+
raise BingoError(
|
|
2043
|
+
f"Patch '{lock_name_chk}' is locked by {lock['owner']}. "
|
|
2044
|
+
"They must unlock it first."
|
|
2045
|
+
)
|
|
2046
|
+
|
|
1698
2047
|
has_staged = not self.git.run_ok("diff", "--cached", "--quiet")
|
|
1699
2048
|
if not has_staged:
|
|
1700
2049
|
raise BingoError(
|
|
@@ -2048,6 +2397,444 @@ class Repo:
|
|
|
2048
2397
|
self.state.patch_meta_set(target, key, value)
|
|
2049
2398
|
return {"ok": True, "patch": target, "set": key, "value": value}
|
|
2050
2399
|
|
|
2400
|
+
# -- Team / Locking --
|
|
2401
|
+
|
|
2402
|
+
def patch_lock(self, name: str, reason: str = "") -> dict:
|
|
2403
|
+
"""Lock a patch for exclusive editing.
|
|
2404
|
+
|
|
2405
|
+
Returns {"ok": True, "patch": ..., "owner": ..., "locked_at": ...}
|
|
2406
|
+
"""
|
|
2407
|
+
c = self._load()
|
|
2408
|
+
# Verify patch exists
|
|
2409
|
+
self._resolve_patch(c, name)
|
|
2410
|
+
return self.team.lock(name, reason=reason)
|
|
2411
|
+
|
|
2412
|
+
def patch_unlock(self, name: str, force: bool = False) -> dict:
|
|
2413
|
+
"""Unlock a patch.
|
|
2414
|
+
|
|
2415
|
+
Returns {"ok": True, "patch": ..., "owner": ...}
|
|
2416
|
+
"""
|
|
2417
|
+
c = self._load()
|
|
2418
|
+
self._resolve_patch(c, name)
|
|
2419
|
+
return self.team.unlock(name, force=force)
|
|
2420
|
+
|
|
2421
|
+
# -- Smart Patch Management --
|
|
2422
|
+
|
|
2423
|
+
def patch_check(self, name: str = "") -> dict:
|
|
2424
|
+
"""Check if patches are still needed (obsolescence detection).
|
|
2425
|
+
|
|
2426
|
+
For each patch, checks whether upstream now contains equivalent changes.
|
|
2427
|
+
Heuristic: apply patch diff to current upstream — if it produces no change,
|
|
2428
|
+
the patch is obsolete.
|
|
2429
|
+
|
|
2430
|
+
Returns {"ok": True, "patches": [{"name", "status", "reason"}]}
|
|
2431
|
+
"""
|
|
2432
|
+
c = self._load()
|
|
2433
|
+
base = self._patches_base(c)
|
|
2434
|
+
if not base:
|
|
2435
|
+
return {"ok": True, "patches": [], "count": 0}
|
|
2436
|
+
|
|
2437
|
+
patches = self.git.log_patches(base, c["patches_branch"])
|
|
2438
|
+
if not patches:
|
|
2439
|
+
return {"ok": True, "patches": [], "count": 0}
|
|
2440
|
+
|
|
2441
|
+
# If a specific name given, filter
|
|
2442
|
+
if name:
|
|
2443
|
+
patches = [p for p in patches if p.name == name]
|
|
2444
|
+
if not patches:
|
|
2445
|
+
raise BingoError(f"Patch '{name}' not found.")
|
|
2446
|
+
|
|
2447
|
+
# Get current upstream tip
|
|
2448
|
+
tracking = c.get("tracking_branch", DEFAULT_TRACKING)
|
|
2449
|
+
upstream_head = self.git.rev_parse(tracking)
|
|
2450
|
+
if not upstream_head:
|
|
2451
|
+
return {
|
|
2452
|
+
"ok": True,
|
|
2453
|
+
"patches": [
|
|
2454
|
+
{"name": p.name, "status": "unknown", "reason": "No upstream tracking branch"}
|
|
2455
|
+
for p in patches
|
|
2456
|
+
],
|
|
2457
|
+
"count": len(patches),
|
|
2458
|
+
}
|
|
2459
|
+
|
|
2460
|
+
results = []
|
|
2461
|
+
for p in patches:
|
|
2462
|
+
try:
|
|
2463
|
+
# Get the files this patch touches
|
|
2464
|
+
diff_output = self.git.run(
|
|
2465
|
+
"diff", "--name-only", f"{p.hash}^", p.hash, check=False
|
|
2466
|
+
)
|
|
2467
|
+
patch_files = [f for f in diff_output.splitlines() if f.strip()]
|
|
2468
|
+
|
|
2469
|
+
if not patch_files:
|
|
2470
|
+
results.append({"name": p.name, "status": "active", "reason": "No files changed"})
|
|
2471
|
+
continue
|
|
2472
|
+
|
|
2473
|
+
# Check if upstream already contains equivalent changes
|
|
2474
|
+
# If it applies but produces no diff, the changes are already upstream
|
|
2475
|
+
try:
|
|
2476
|
+
# Check if the patch's changes already exist at upstream
|
|
2477
|
+
all_match = True
|
|
2478
|
+
for pf in patch_files:
|
|
2479
|
+
# Get file content at patch commit
|
|
2480
|
+
try:
|
|
2481
|
+
content_at_patch = self.git.run(
|
|
2482
|
+
"show", f"{p.hash}:{pf}", check=False
|
|
2483
|
+
)
|
|
2484
|
+
except GitError:
|
|
2485
|
+
content_at_patch = ""
|
|
2486
|
+
|
|
2487
|
+
# Get file content at upstream
|
|
2488
|
+
try:
|
|
2489
|
+
content_at_upstream = self.git.run(
|
|
2490
|
+
"show", f"{tracking}:{pf}", check=False
|
|
2491
|
+
)
|
|
2492
|
+
except GitError:
|
|
2493
|
+
content_at_upstream = ""
|
|
2494
|
+
|
|
2495
|
+
# If upstream already has the same content as post-patch,
|
|
2496
|
+
# this patch is obsolete for this file
|
|
2497
|
+
if content_at_upstream != content_at_patch:
|
|
2498
|
+
all_match = False
|
|
2499
|
+
break
|
|
2500
|
+
|
|
2501
|
+
if all_match:
|
|
2502
|
+
results.append({
|
|
2503
|
+
"name": p.name,
|
|
2504
|
+
"status": "obsolete",
|
|
2505
|
+
"reason": "Upstream contains equivalent changes",
|
|
2506
|
+
})
|
|
2507
|
+
else:
|
|
2508
|
+
# Check if upstream changed same files (potential conflict)
|
|
2509
|
+
upstream_changed = self.git.run(
|
|
2510
|
+
"diff", "--name-only", base, tracking, "--", *patch_files,
|
|
2511
|
+
check=False,
|
|
2512
|
+
)
|
|
2513
|
+
if upstream_changed.strip():
|
|
2514
|
+
results.append({
|
|
2515
|
+
"name": p.name,
|
|
2516
|
+
"status": "active",
|
|
2517
|
+
"reason": "Upstream also modified these files — review recommended",
|
|
2518
|
+
})
|
|
2519
|
+
else:
|
|
2520
|
+
results.append({
|
|
2521
|
+
"name": p.name,
|
|
2522
|
+
"status": "active",
|
|
2523
|
+
"reason": "Patch still applies unique changes",
|
|
2524
|
+
})
|
|
2525
|
+
except GitError:
|
|
2526
|
+
results.append({"name": p.name, "status": "active", "reason": "Could not compare"})
|
|
2527
|
+
|
|
2528
|
+
except GitError:
|
|
2529
|
+
results.append({"name": p.name, "status": "unknown", "reason": "Error analyzing patch"})
|
|
2530
|
+
|
|
2531
|
+
return {"ok": True, "patches": results, "count": len(results)}
|
|
2532
|
+
|
|
2533
|
+
def patch_upstream(self, name: str) -> dict:
|
|
2534
|
+
"""Export a patch as a clean PR-ready diff for upstream submission.
|
|
2535
|
+
|
|
2536
|
+
Strips [bl] prefix and git metadata — produces a clean diff + description.
|
|
2537
|
+
|
|
2538
|
+
Returns {"ok": True, "patch": ..., "diff": ..., "description": ..., "files": [...]}
|
|
2539
|
+
"""
|
|
2540
|
+
c = self._load()
|
|
2541
|
+
hash_val = self._resolve_patch(c, name)
|
|
2542
|
+
|
|
2543
|
+
# Get commit subject and strip [bl] prefix
|
|
2544
|
+
subject = self.git.run("log", "-1", "--format=%s", hash_val)
|
|
2545
|
+
description = subject
|
|
2546
|
+
m = re.match(r"^\[bl\] [^:]+:\s*(.*)", subject)
|
|
2547
|
+
if m:
|
|
2548
|
+
description = m.group(1)
|
|
2549
|
+
|
|
2550
|
+
# Get commit body (if any)
|
|
2551
|
+
body = self.git.run("log", "-1", "--format=%b", hash_val, check=False).strip()
|
|
2552
|
+
if body:
|
|
2553
|
+
description = f"{description}\n\n{body}"
|
|
2554
|
+
|
|
2555
|
+
# Generate clean diff
|
|
2556
|
+
diff = self.git.run("diff", f"{hash_val}^", hash_val, check=False)
|
|
2557
|
+
|
|
2558
|
+
# Get file list
|
|
2559
|
+
files_output = self.git.run(
|
|
2560
|
+
"diff", "--name-only", f"{hash_val}^", hash_val, check=False
|
|
2561
|
+
)
|
|
2562
|
+
files = [f for f in files_output.splitlines() if f.strip()]
|
|
2563
|
+
|
|
2564
|
+
# Get stats
|
|
2565
|
+
stat = self.git.run(
|
|
2566
|
+
"diff", "--stat", f"{hash_val}^", hash_val, check=False
|
|
2567
|
+
).strip()
|
|
2568
|
+
|
|
2569
|
+
return {
|
|
2570
|
+
"ok": True,
|
|
2571
|
+
"patch": name,
|
|
2572
|
+
"diff": diff,
|
|
2573
|
+
"description": description,
|
|
2574
|
+
"files": files,
|
|
2575
|
+
"stats": stat,
|
|
2576
|
+
}
|
|
2577
|
+
|
|
2578
|
+
def patch_expire(self) -> dict:
|
|
2579
|
+
"""List patches that have passed or are approaching their expiry date.
|
|
2580
|
+
|
|
2581
|
+
Returns {"ok": True, "expired": [...], "expiring_soon": [...], "active": [...]}
|
|
2582
|
+
"""
|
|
2583
|
+
c = self._load()
|
|
2584
|
+
base = self._patches_base(c)
|
|
2585
|
+
if not base:
|
|
2586
|
+
return {"ok": True, "expired": [], "expiring_soon": [], "active": [], "count": 0}
|
|
2587
|
+
|
|
2588
|
+
patches = self.git.log_patches(base, c["patches_branch"])
|
|
2589
|
+
now = datetime.now(timezone.utc)
|
|
2590
|
+
expired = []
|
|
2591
|
+
expiring_soon = []
|
|
2592
|
+
active = []
|
|
2593
|
+
|
|
2594
|
+
for p in patches:
|
|
2595
|
+
meta = self.state.patch_meta_get(p.name)
|
|
2596
|
+
expires_str = meta.get("expires")
|
|
2597
|
+
if not expires_str:
|
|
2598
|
+
active.append({"name": p.name, "expires": None, "status": "no_expiry"})
|
|
2599
|
+
continue
|
|
2600
|
+
|
|
2601
|
+
try:
|
|
2602
|
+
expires_dt = datetime.strptime(expires_str, "%Y-%m-%d").replace(
|
|
2603
|
+
tzinfo=timezone.utc
|
|
2604
|
+
)
|
|
2605
|
+
except ValueError:
|
|
2606
|
+
try:
|
|
2607
|
+
expires_dt = datetime.strptime(
|
|
2608
|
+
expires_str, "%Y-%m-%dT%H:%M:%SZ"
|
|
2609
|
+
).replace(tzinfo=timezone.utc)
|
|
2610
|
+
except ValueError:
|
|
2611
|
+
active.append({"name": p.name, "expires": expires_str, "status": "invalid_date"})
|
|
2612
|
+
continue
|
|
2613
|
+
|
|
2614
|
+
days_left = (expires_dt - now).days
|
|
2615
|
+
entry = {"name": p.name, "expires": expires_str, "days_left": days_left}
|
|
2616
|
+
|
|
2617
|
+
if days_left < 0:
|
|
2618
|
+
entry["status"] = "expired"
|
|
2619
|
+
expired.append(entry)
|
|
2620
|
+
elif days_left <= 7:
|
|
2621
|
+
entry["status"] = "expiring_soon"
|
|
2622
|
+
expiring_soon.append(entry)
|
|
2623
|
+
else:
|
|
2624
|
+
entry["status"] = "active"
|
|
2625
|
+
active.append(entry)
|
|
2626
|
+
|
|
2627
|
+
return {
|
|
2628
|
+
"ok": True,
|
|
2629
|
+
"expired": expired,
|
|
2630
|
+
"expiring_soon": expiring_soon,
|
|
2631
|
+
"active": active,
|
|
2632
|
+
"count": len(expired) + len(expiring_soon),
|
|
2633
|
+
}
|
|
2634
|
+
|
|
2635
|
+
def patch_stats(self) -> dict:
|
|
2636
|
+
"""Get health metrics for all patches.
|
|
2637
|
+
|
|
2638
|
+
Returns {"ok": True, "patches": [{"name", "age_days", "files", "insertions",
|
|
2639
|
+
"deletions", "sync_conflicts"}]}
|
|
2640
|
+
"""
|
|
2641
|
+
c = self._load()
|
|
2642
|
+
base = self._patches_base(c)
|
|
2643
|
+
if not base:
|
|
2644
|
+
return {"ok": True, "patches": [], "count": 0}
|
|
2645
|
+
|
|
2646
|
+
patches = self.git.log_patches(base, c["patches_branch"])
|
|
2647
|
+
now = datetime.now(timezone.utc)
|
|
2648
|
+
|
|
2649
|
+
# Load sync history for conflict frequency analysis
|
|
2650
|
+
sync_history = self.state.get_sync_history()
|
|
2651
|
+
syncs = sync_history.get("syncs", [])
|
|
2652
|
+
|
|
2653
|
+
results = []
|
|
2654
|
+
for p in patches:
|
|
2655
|
+
meta = self.state.patch_meta_get(p.name)
|
|
2656
|
+
|
|
2657
|
+
# Compute age
|
|
2658
|
+
created_str = meta.get("created", "")
|
|
2659
|
+
age_days = -1
|
|
2660
|
+
if created_str:
|
|
2661
|
+
try:
|
|
2662
|
+
created_dt = datetime.strptime(
|
|
2663
|
+
created_str, "%Y-%m-%dT%H:%M:%SZ"
|
|
2664
|
+
).replace(tzinfo=timezone.utc)
|
|
2665
|
+
age_days = (now - created_dt).days
|
|
2666
|
+
except ValueError:
|
|
2667
|
+
pass
|
|
2668
|
+
|
|
2669
|
+
# Count sync conflicts — look for syncs where this patch name
|
|
2670
|
+
# appeared and the sync had issues
|
|
2671
|
+
sync_count = 0
|
|
2672
|
+
for sync in syncs:
|
|
2673
|
+
for sp in sync.get("patches", []):
|
|
2674
|
+
if sp.get("name") == p.name:
|
|
2675
|
+
sync_count += 1
|
|
2676
|
+
|
|
2677
|
+
# Lock info
|
|
2678
|
+
lock = self.team.get_lock(p.name)
|
|
2679
|
+
|
|
2680
|
+
entry = {
|
|
2681
|
+
"name": p.name,
|
|
2682
|
+
"age_days": age_days,
|
|
2683
|
+
"files": p.files,
|
|
2684
|
+
"insertions": p.insertions,
|
|
2685
|
+
"deletions": p.deletions,
|
|
2686
|
+
"status": meta.get("status", "permanent"),
|
|
2687
|
+
"owner": meta.get("owner", ""),
|
|
2688
|
+
"locked_by": lock["owner"] if lock else "",
|
|
2689
|
+
"syncs_survived": sync_count,
|
|
2690
|
+
}
|
|
2691
|
+
results.append(entry)
|
|
2692
|
+
|
|
2693
|
+
return {"ok": True, "patches": results, "count": len(results)}
|
|
2694
|
+
|
|
2695
|
+
# -- Report --
|
|
2696
|
+
|
|
2697
|
+
def report(self) -> dict:
|
|
2698
|
+
"""Generate a comprehensive markdown health report.
|
|
2699
|
+
|
|
2700
|
+
Aggregates status, patches, stats, expiry, locks, history, and deps.
|
|
2701
|
+
|
|
2702
|
+
Returns {"ok": True, "report": "<markdown>", "summary": {...}}
|
|
2703
|
+
"""
|
|
2704
|
+
now = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
2705
|
+
lines = ["# Fork Health Report", f"Generated: {now}", ""]
|
|
2706
|
+
|
|
2707
|
+
alerts = []
|
|
2708
|
+
patch_count = 0
|
|
2709
|
+
behind = 0
|
|
2710
|
+
|
|
2711
|
+
# Overview
|
|
2712
|
+
try:
|
|
2713
|
+
st = self.status()
|
|
2714
|
+
behind = st.get("behind", 0)
|
|
2715
|
+
patch_count = st.get("patch_count", 0)
|
|
2716
|
+
upstream = st.get("upstream_url", "?")
|
|
2717
|
+
branch = st.get("upstream_branch", "?")
|
|
2718
|
+
action = st.get("recommended_action", "?")
|
|
2719
|
+
lines.append("## Overview")
|
|
2720
|
+
lines.append(f"- Upstream: {upstream} ({branch})")
|
|
2721
|
+
lines.append(f"- Behind: {behind} commit(s)")
|
|
2722
|
+
lines.append(f"- Patches: {patch_count}")
|
|
2723
|
+
lines.append(f"- Recommended action: {action}")
|
|
2724
|
+
last_sync = st.get("last_sync", "")
|
|
2725
|
+
if last_sync:
|
|
2726
|
+
lines.append(f"- Last sync: {last_sync}")
|
|
2727
|
+
lines.append("")
|
|
2728
|
+
except Exception as e:
|
|
2729
|
+
lines.append(f"## Overview\n- Error: {e}\n")
|
|
2730
|
+
|
|
2731
|
+
# Patch Stack
|
|
2732
|
+
try:
|
|
2733
|
+
stats = self.patch_stats()
|
|
2734
|
+
stat_patches = stats.get("patches", [])
|
|
2735
|
+
if stat_patches:
|
|
2736
|
+
lines.append("## Patch Stack")
|
|
2737
|
+
lines.append("| # | Name | Age | Size | Syncs | Status | Owner |")
|
|
2738
|
+
lines.append("|---|------|-----|------|-------|--------|-------|")
|
|
2739
|
+
for i, p in enumerate(stat_patches, 1):
|
|
2740
|
+
age = f"{p['age_days']}d" if p.get("age_days", -1) >= 0 else "?"
|
|
2741
|
+
size = f"+{p.get('insertions', 0)}/-{p.get('deletions', 0)}"
|
|
2742
|
+
syncs = str(p.get("syncs_survived", 0))
|
|
2743
|
+
status = p.get("status", "")
|
|
2744
|
+
owner = p.get("owner", "") or p.get("locked_by", "") or "-"
|
|
2745
|
+
lines.append(f"| {i} | {p['name']} | {age} | {size} | {syncs} | {status} | {owner} |")
|
|
2746
|
+
lines.append("")
|
|
2747
|
+
except Exception:
|
|
2748
|
+
pass
|
|
2749
|
+
|
|
2750
|
+
# Expiry
|
|
2751
|
+
try:
|
|
2752
|
+
expire = self.patch_expire()
|
|
2753
|
+
expired = expire.get("expired", [])
|
|
2754
|
+
expiring = expire.get("expiring_soon", [])
|
|
2755
|
+
for e in expired:
|
|
2756
|
+
alerts.append(f"[EXPIRED] patch \"{e['name']}\" expired {e['expires']}")
|
|
2757
|
+
for e in expiring:
|
|
2758
|
+
alerts.append(f"[EXPIRING] patch \"{e['name']}\" expires {e['expires']} ({e['days_left']}d left)")
|
|
2759
|
+
except Exception:
|
|
2760
|
+
pass
|
|
2761
|
+
|
|
2762
|
+
# Team locks
|
|
2763
|
+
try:
|
|
2764
|
+
locks = self.team.list_locks()
|
|
2765
|
+
if locks:
|
|
2766
|
+
now_dt = datetime.now(timezone.utc)
|
|
2767
|
+
for lock in locks:
|
|
2768
|
+
locked_at = lock.get("locked_at", "")
|
|
2769
|
+
if locked_at:
|
|
2770
|
+
try:
|
|
2771
|
+
lock_dt = datetime.strptime(
|
|
2772
|
+
locked_at, "%Y-%m-%dT%H:%M:%SZ"
|
|
2773
|
+
).replace(tzinfo=timezone.utc)
|
|
2774
|
+
days = (now_dt - lock_dt).days
|
|
2775
|
+
if days > 7:
|
|
2776
|
+
alerts.append(
|
|
2777
|
+
f"[STALE LOCK] patch \"{lock['patch']}\" "
|
|
2778
|
+
f"locked by {lock['owner']} for {days}d"
|
|
2779
|
+
)
|
|
2780
|
+
except ValueError:
|
|
2781
|
+
pass
|
|
2782
|
+
except Exception:
|
|
2783
|
+
pass
|
|
2784
|
+
|
|
2785
|
+
# Alerts
|
|
2786
|
+
if alerts:
|
|
2787
|
+
lines.append("## Alerts")
|
|
2788
|
+
for a in alerts:
|
|
2789
|
+
lines.append(f"- {a}")
|
|
2790
|
+
lines.append("")
|
|
2791
|
+
|
|
2792
|
+
# Sync History (last 5)
|
|
2793
|
+
try:
|
|
2794
|
+
history = self.state.get_sync_history()
|
|
2795
|
+
syncs = history.get("syncs", [])
|
|
2796
|
+
if syncs:
|
|
2797
|
+
lines.append("## Sync History (last 5)")
|
|
2798
|
+
for sync in syncs[-5:]:
|
|
2799
|
+
ts = sync.get("timestamp", "?")
|
|
2800
|
+
n = sync.get("upstream_commits_integrated", 0)
|
|
2801
|
+
p_count = len(sync.get("patches", []))
|
|
2802
|
+
lines.append(f"- {ts}: {n} upstream commit(s), {p_count} patch(es)")
|
|
2803
|
+
lines.append("")
|
|
2804
|
+
except Exception:
|
|
2805
|
+
pass
|
|
2806
|
+
|
|
2807
|
+
# Dependencies
|
|
2808
|
+
try:
|
|
2809
|
+
dep_dir = os.path.join(self.path, ".bingo-deps")
|
|
2810
|
+
if os.path.isdir(dep_dir):
|
|
2811
|
+
from bingo_core.dep import DepManager
|
|
2812
|
+
dm = DepManager(self.path)
|
|
2813
|
+
dep_list = dm.list_patches()
|
|
2814
|
+
dep_pkgs = dep_list.get("packages", [])
|
|
2815
|
+
if dep_pkgs:
|
|
2816
|
+
lines.append("## Dependencies")
|
|
2817
|
+
for pkg in dep_pkgs:
|
|
2818
|
+
pname = pkg.get("name", "?")
|
|
2819
|
+
ver = pkg.get("version", "?")
|
|
2820
|
+
n_patches = len(pkg.get("patches", []))
|
|
2821
|
+
lines.append(f"- {pname}@{ver}: {n_patches} patch(es)")
|
|
2822
|
+
lines.append("")
|
|
2823
|
+
except Exception:
|
|
2824
|
+
pass
|
|
2825
|
+
|
|
2826
|
+
report_text = "\n".join(lines)
|
|
2827
|
+
|
|
2828
|
+
return {
|
|
2829
|
+
"ok": True,
|
|
2830
|
+
"report": report_text,
|
|
2831
|
+
"summary": {
|
|
2832
|
+
"patches": patch_count,
|
|
2833
|
+
"behind": behind,
|
|
2834
|
+
"alerts": len(alerts),
|
|
2835
|
+
},
|
|
2836
|
+
}
|
|
2837
|
+
|
|
2051
2838
|
# -- Config --
|
|
2052
2839
|
|
|
2053
2840
|
def config_get(self, key: str) -> dict:
|