willow-bot 0.1.1__py3-none-any.whl
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.
- willow_bot/__init__.py +3 -0
- willow_bot/cli.py +21 -0
- willow_bot/deposits.py +137 -0
- willow_bot/steward/__init__.py +1 -0
- willow_bot/steward/config.py +45 -0
- willow_bot/steward/fleet.py +120 -0
- willow_bot/steward/heartbeat.py +100 -0
- willow_bot/steward/inbox.py +121 -0
- willow_bot/steward/mcp_client.py +118 -0
- willow_bot/steward/merge.py +197 -0
- willow_bot/steward/pass2_checkout.py +69 -0
- willow_bot/steward/pass2_ci_status.py +51 -0
- willow_bot/steward/pass2_comment.py +83 -0
- willow_bot/steward/scan.py +27 -0
- willow_bot/steward/tick.py +168 -0
- willow_bot-0.1.1.dist-info/METADATA +47 -0
- willow_bot-0.1.1.dist-info/RECORD +20 -0
- willow_bot-0.1.1.dist-info/WHEEL +4 -0
- willow_bot-0.1.1.dist-info/entry_points.txt +3 -0
- willow_bot-0.1.1.dist-info/licenses/LICENSE +202 -0
willow_bot/__init__.py
ADDED
willow_bot/cli.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""Console entrypoints: willow-bot (webhook) and willow-bot-steward."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import os
|
|
5
|
+
import sys
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def webhook_main() -> None:
|
|
9
|
+
"""Run the FastAPI webhook via uvicorn (reads BOT_HOST / BOT_PORT)."""
|
|
10
|
+
import uvicorn
|
|
11
|
+
|
|
12
|
+
host = os.environ.get("BOT_HOST", "127.0.0.1")
|
|
13
|
+
port = int(os.environ.get("BOT_PORT", "9000"))
|
|
14
|
+
# Root-level bot:app kept for systemd compatibility during prove.
|
|
15
|
+
uvicorn.run("bot:app", host=host, port=port, factory=False)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def steward_main(argv: list[str] | None = None) -> int:
|
|
19
|
+
from willow_bot.steward.tick import main
|
|
20
|
+
|
|
21
|
+
return main(argv if argv is not None else sys.argv[1:])
|
willow_bot/deposits.py
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
"""Propose-only CI / PR deposits (§12 middle row).
|
|
2
|
+
|
|
3
|
+
Always append a local draft JSONL under ``$WILLOW_HOME/willow-bot/deposits/``.
|
|
4
|
+
Optionally mirror into willow-mcp ``store_put`` when MCP is configured — never seals.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import logging
|
|
10
|
+
import os
|
|
11
|
+
from datetime import datetime, timezone
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
log = logging.getLogger("willow-bot.deposits")
|
|
16
|
+
|
|
17
|
+
COLLECTION = "willow_bot_ci_deposits"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _willow_home() -> Path:
|
|
21
|
+
default = Path.home() / "sean-data-vault" / "willow-operator-box"
|
|
22
|
+
return Path(os.environ.get("WILLOW_HOME", default))
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def deposits_dir() -> Path:
|
|
26
|
+
return _willow_home() / "willow-bot" / "deposits"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def deposits_jsonl() -> Path:
|
|
30
|
+
return deposits_dir() / "ci_outcomes.jsonl"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _now() -> str:
|
|
34
|
+
return datetime.now(timezone.utc).isoformat()
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def ci_outcome_record(
|
|
38
|
+
*,
|
|
39
|
+
repo: str,
|
|
40
|
+
head_sha: str | None,
|
|
41
|
+
check_run_id: Any,
|
|
42
|
+
check_name: str | None,
|
|
43
|
+
conclusion: str | None,
|
|
44
|
+
status: str | None = None,
|
|
45
|
+
pr_number: int | None = None,
|
|
46
|
+
html_url: str | None = None,
|
|
47
|
+
sender_type: str = "",
|
|
48
|
+
received_at: str | None = None,
|
|
49
|
+
) -> dict[str, Any]:
|
|
50
|
+
"""Draft claim: how CI went for repo@sha (pass and fail both recorded)."""
|
|
51
|
+
return {
|
|
52
|
+
"kind": "ci_outcome",
|
|
53
|
+
"lane": "draft",
|
|
54
|
+
"deposited_by": "willows-bot",
|
|
55
|
+
"actor_type": sender_type or "unknown",
|
|
56
|
+
"repo": repo,
|
|
57
|
+
"head_sha": head_sha or "",
|
|
58
|
+
"check_run_id": check_run_id,
|
|
59
|
+
"check_name": check_name or "",
|
|
60
|
+
"conclusion": conclusion or "",
|
|
61
|
+
"status": status or "",
|
|
62
|
+
"pr_number": pr_number,
|
|
63
|
+
"html_url": html_url or "",
|
|
64
|
+
"received_at": received_at or _now(),
|
|
65
|
+
"deposited_at": _now(),
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def record_id_for(rec: dict[str, Any]) -> str:
|
|
70
|
+
repo = (rec.get("repo") or "unknown").replace("/", "__")
|
|
71
|
+
sha = (rec.get("head_sha") or "nosha")[:12]
|
|
72
|
+
cid = rec.get("check_run_id") or "0"
|
|
73
|
+
return f"ci-{repo}-{sha}-{cid}"
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def append_local(rec: dict[str, Any]) -> Path:
|
|
77
|
+
path = deposits_jsonl()
|
|
78
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
79
|
+
with path.open("a", encoding="utf-8") as fh:
|
|
80
|
+
fh.write(json.dumps(rec, separators=(",", ":")) + "\n")
|
|
81
|
+
return path
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def deposit_ci_outcome(rec: dict[str, Any]) -> dict[str, Any]:
|
|
85
|
+
"""Write local draft; optionally MCP store_put. Returns status receipt."""
|
|
86
|
+
path = append_local(rec)
|
|
87
|
+
out: dict[str, Any] = {
|
|
88
|
+
"local": str(path),
|
|
89
|
+
"record_id": record_id_for(rec),
|
|
90
|
+
"mcp": {"status": "skipped", "detail": "WILLOW_BOT_MCP not enabled"},
|
|
91
|
+
}
|
|
92
|
+
if os.environ.get("WILLOW_BOT_MCP", "").strip().lower() not in ("1", "true", "yes"):
|
|
93
|
+
return out
|
|
94
|
+
try:
|
|
95
|
+
from willow_bot.steward import mcp_client
|
|
96
|
+
|
|
97
|
+
app_id = os.environ.get("WILLOW_BOT_MCP_APP_ID", "willow").strip() or "willow"
|
|
98
|
+
result = mcp_client.call(
|
|
99
|
+
"store_put",
|
|
100
|
+
{
|
|
101
|
+
"app_id": app_id,
|
|
102
|
+
"collection": COLLECTION,
|
|
103
|
+
"record": rec,
|
|
104
|
+
"record_id": record_id_for(rec),
|
|
105
|
+
"deviation": 0,
|
|
106
|
+
},
|
|
107
|
+
)
|
|
108
|
+
out["mcp"] = {"status": "ok", "result": result}
|
|
109
|
+
except Exception as exc: # noqa: BLE001 — honest absence, never silent success
|
|
110
|
+
log.warning("mcp store_put failed: %s", exc)
|
|
111
|
+
out["mcp"] = {"status": "could-not-run", "detail": str(exc)}
|
|
112
|
+
return out
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def deposit_from_check_run_payload(payload: dict) -> dict[str, Any] | None:
|
|
116
|
+
"""Build + deposit from a completed check_run webhook payload."""
|
|
117
|
+
if payload.get("action") != "completed":
|
|
118
|
+
return None
|
|
119
|
+
check = payload.get("check_run") or {}
|
|
120
|
+
repo = (payload.get("repository") or {}).get("full_name") or ""
|
|
121
|
+
if not repo:
|
|
122
|
+
return None
|
|
123
|
+
prs = check.get("pull_requests") or []
|
|
124
|
+
pr_number = prs[0].get("number") if prs else None
|
|
125
|
+
sender_type = str((payload.get("sender") or {}).get("type") or "")
|
|
126
|
+
rec = ci_outcome_record(
|
|
127
|
+
repo=repo,
|
|
128
|
+
head_sha=check.get("head_sha"),
|
|
129
|
+
check_run_id=check.get("id"),
|
|
130
|
+
check_name=check.get("name"),
|
|
131
|
+
conclusion=check.get("conclusion"),
|
|
132
|
+
status=check.get("status"),
|
|
133
|
+
pr_number=pr_number,
|
|
134
|
+
html_url=check.get("html_url"),
|
|
135
|
+
sender_type=sender_type,
|
|
136
|
+
)
|
|
137
|
+
return deposit_ci_outcome(rec)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Deterministic PR/CI steward (never Grove 3B Loki)."""
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""Shared env for steward PR watch (deterministic gh/CI — never Grove 3B watcher)."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import os
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def _truthy(name: str, default: str = "0") -> bool:
|
|
9
|
+
return os.environ.get(name, default).strip().lower() in ("1", "true", "yes")
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def willow_home() -> Path:
|
|
13
|
+
default = Path.home() / "sean-data-vault" / "willow-operator-box"
|
|
14
|
+
return Path(os.environ.get("WILLOW_HOME", default))
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def webhook_inbox_dir() -> Path:
|
|
18
|
+
return willow_home() / "upstream_steward" / "webhook_inbox"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def state_path() -> Path:
|
|
22
|
+
vault = os.environ.get("WILLOW_VAULT_BOX", str(willow_home()))
|
|
23
|
+
return Path(
|
|
24
|
+
os.environ.get(
|
|
25
|
+
"WILLOW_BOT_STEWARD_STATE",
|
|
26
|
+
os.environ.get("LOKI_PR_WATCH_STATE", f"{vault}/loki_pr_watch_state.json"),
|
|
27
|
+
)
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def watcher_call_enabled() -> bool:
|
|
32
|
+
"""PR watch must not call Ollama/Cerebras. Opt-in is reserved and currently rejected."""
|
|
33
|
+
return _truthy("WILLOW_BOT_STEWARD_CALL_WATCHER") or _truthy("LOKI_PR_WATCH_CALL_WATCHER")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def watcher_url() -> str:
|
|
37
|
+
"""Documented for willow-bot loki.watcher only; steward does not HTTP here."""
|
|
38
|
+
return os.environ.get("LOKI_WATCHER_URL", "http://localhost:11434/v1").strip()
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def host_sync_enabled() -> bool:
|
|
42
|
+
"""Optional merge→pull/pip -e. Default on (legacy loki_pr_watch behaviour)."""
|
|
43
|
+
if "WILLOW_BOT_STEWARD_HOST_SYNC" in os.environ:
|
|
44
|
+
return _truthy("WILLOW_BOT_STEWARD_HOST_SYNC", "1")
|
|
45
|
+
return True
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"""Fleet scope for Loki PR watch — all operator GitHub orgs (+ optional user account)."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import subprocess
|
|
7
|
+
from functools import lru_cache
|
|
8
|
+
from typing import Iterator
|
|
9
|
+
|
|
10
|
+
# Used only when ``gh api /user/memberships/orgs`` is unavailable.
|
|
11
|
+
_DEFAULT_ORGS = (
|
|
12
|
+
"almanac-data",
|
|
13
|
+
"Die-Namic-Systems",
|
|
14
|
+
"forge-play",
|
|
15
|
+
"homestead-affairs",
|
|
16
|
+
"hornbook-knowledge",
|
|
17
|
+
"terpsi-programs",
|
|
18
|
+
"willow-memory",
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
DEFAULT_USER = "rudi193-cmd"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def gh_api(path: str):
|
|
25
|
+
return json.loads(subprocess.check_output(["gh", "api", path], text=True))
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@lru_cache(maxsize=1)
|
|
29
|
+
def fleet_orgs() -> tuple[str, ...]:
|
|
30
|
+
"""Org logins to scan. Override with LOKI_PR_WATCH_ORGS (comma-separated)."""
|
|
31
|
+
raw = os.environ.get("LOKI_PR_WATCH_ORGS", "").strip()
|
|
32
|
+
if raw:
|
|
33
|
+
return tuple(o.strip() for o in raw.split(",") if o.strip())
|
|
34
|
+
try:
|
|
35
|
+
members = gh_api("/user/memberships/orgs?per_page=100")
|
|
36
|
+
logins = [m["organization"]["login"] for m in members if m.get("organization")]
|
|
37
|
+
if logins:
|
|
38
|
+
return tuple(sorted(logins))
|
|
39
|
+
except (subprocess.CalledProcessError, KeyError, TypeError, json.JSONDecodeError):
|
|
40
|
+
pass
|
|
41
|
+
return _DEFAULT_ORGS
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def fleet_user() -> str:
|
|
45
|
+
return os.environ.get("LOKI_PR_WATCH_USER", DEFAULT_USER).strip() or DEFAULT_USER
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def repos_for_org(org: str) -> list[str]:
|
|
49
|
+
names: list[str] = []
|
|
50
|
+
page = 1
|
|
51
|
+
while True:
|
|
52
|
+
try:
|
|
53
|
+
batch = gh_api(f"/orgs/{org}/repos?per_page=100&page={page}")
|
|
54
|
+
except subprocess.CalledProcessError:
|
|
55
|
+
break
|
|
56
|
+
if not batch:
|
|
57
|
+
break
|
|
58
|
+
names.extend(r["full_name"] for r in batch)
|
|
59
|
+
if len(batch) < 100:
|
|
60
|
+
break
|
|
61
|
+
page += 1
|
|
62
|
+
return names
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def repos_for_user(user: str) -> list[str]:
|
|
66
|
+
names: list[str] = []
|
|
67
|
+
page = 1
|
|
68
|
+
while True:
|
|
69
|
+
try:
|
|
70
|
+
batch = gh_api(
|
|
71
|
+
f"/users/{user}/repos?per_page=100&page={page}&type=owner"
|
|
72
|
+
)
|
|
73
|
+
except subprocess.CalledProcessError:
|
|
74
|
+
break
|
|
75
|
+
if not batch:
|
|
76
|
+
break
|
|
77
|
+
names.extend(r["full_name"] for r in batch)
|
|
78
|
+
if len(batch) < 100:
|
|
79
|
+
break
|
|
80
|
+
page += 1
|
|
81
|
+
return names
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def fleet_repos() -> list[str]:
|
|
85
|
+
names: set[str] = set()
|
|
86
|
+
for org in fleet_orgs():
|
|
87
|
+
names.update(repos_for_org(org))
|
|
88
|
+
if os.environ.get("LOKI_PR_WATCH_SKIP_USER", "").strip() not in ("1", "true", "yes"):
|
|
89
|
+
names.update(repos_for_user(fleet_user()))
|
|
90
|
+
return sorted(names)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def iter_open_pulls() -> Iterator[tuple[str, int, str, str]]:
|
|
94
|
+
"""Yield (full_name, number, title, html_url) for every open PR in fleet scope."""
|
|
95
|
+
for repo in fleet_repos():
|
|
96
|
+
try:
|
|
97
|
+
pulls = gh_api(f"repos/{repo}/pulls?state=open&per_page=50")
|
|
98
|
+
except subprocess.CalledProcessError:
|
|
99
|
+
continue
|
|
100
|
+
for p in pulls:
|
|
101
|
+
yield (
|
|
102
|
+
repo,
|
|
103
|
+
int(p["number"]),
|
|
104
|
+
str(p.get("title") or ""),
|
|
105
|
+
str(p.get("html_url") or ""),
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def repo_matches_filters(repo: str, num: int, filters: list[str]) -> bool:
|
|
110
|
+
if not filters:
|
|
111
|
+
return True
|
|
112
|
+
key = f"{repo}#{num}"
|
|
113
|
+
for f in filters:
|
|
114
|
+
if f == key or f in key:
|
|
115
|
+
return True
|
|
116
|
+
if "/" not in f and repo.startswith(f"{f}/"):
|
|
117
|
+
return True
|
|
118
|
+
if f == repo or repo.startswith(f):
|
|
119
|
+
return True
|
|
120
|
+
return False
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""Curated willow-mcp tool heartbeat for the steward (prove phase).
|
|
2
|
+
|
|
3
|
+
Calls platform verbs over MCP; does NOT own commitment dew (Kart
|
|
4
|
+
``CommitmentProactiveHook`` stays until proven). Honest absence when MCP off.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import os
|
|
10
|
+
from datetime import datetime, timezone
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from willow_bot.steward.config import willow_home
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
# Read-only / status verbs — no dew publish, no seal.
|
|
18
|
+
DEFAULT_CURATED: list[tuple[str, dict[str, Any]]] = [
|
|
19
|
+
("fleet_health", {}),
|
|
20
|
+
("commitment_surface", {}),
|
|
21
|
+
("human_required_list", {}),
|
|
22
|
+
("diagnostic_summary", {}),
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _app_id() -> str:
|
|
27
|
+
return os.environ.get("WILLOW_BOT_MCP_APP_ID", "willow").strip() or "willow"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _receipts_path() -> Path:
|
|
31
|
+
return willow_home() / "willow-bot" / "steward_heartbeat.jsonl"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def curated_calls() -> list[tuple[str, dict[str, Any]]]:
|
|
35
|
+
raw = os.environ.get("WILLOW_BOT_STEWARD_TOOLS", "").strip()
|
|
36
|
+
if not raw:
|
|
37
|
+
app = _app_id()
|
|
38
|
+
return [(name, {**args, "app_id": app}) for name, args in DEFAULT_CURATED]
|
|
39
|
+
# comma-separated tool names; each gets app_id only
|
|
40
|
+
app = _app_id()
|
|
41
|
+
return [(n.strip(), {"app_id": app}) for n in raw.split(",") if n.strip()]
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def run_heartbeat(*, enable_mcp: bool | None = None) -> dict[str, Any]:
|
|
45
|
+
"""Run one curated pass. Returns a receipt dict (also appended to JSONL)."""
|
|
46
|
+
if enable_mcp is None:
|
|
47
|
+
enable_mcp = os.environ.get("WILLOW_BOT_MCP", "").strip().lower() in (
|
|
48
|
+
"1",
|
|
49
|
+
"true",
|
|
50
|
+
"yes",
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
receipt: dict[str, Any] = {
|
|
54
|
+
"event": "steward_heartbeat",
|
|
55
|
+
"at": datetime.now(timezone.utc).isoformat(),
|
|
56
|
+
"tools": [],
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if not enable_mcp:
|
|
60
|
+
receipt["status"] = "absent"
|
|
61
|
+
receipt["detail"] = "WILLOW_BOT_MCP not enabled — no tool calls"
|
|
62
|
+
_append(receipt)
|
|
63
|
+
print(json.dumps(receipt), flush=True)
|
|
64
|
+
return receipt
|
|
65
|
+
|
|
66
|
+
try:
|
|
67
|
+
from willow_bot.steward import mcp_client
|
|
68
|
+
except ImportError as exc:
|
|
69
|
+
receipt["status"] = "could-not-run"
|
|
70
|
+
receipt["detail"] = f"mcp package missing: {exc}"
|
|
71
|
+
_append(receipt)
|
|
72
|
+
print(json.dumps(receipt), flush=True)
|
|
73
|
+
return receipt
|
|
74
|
+
|
|
75
|
+
receipt["status"] = "ok"
|
|
76
|
+
for name, args in curated_calls():
|
|
77
|
+
entry: dict[str, Any] = {"tool": name, "args_keys": sorted(args)}
|
|
78
|
+
try:
|
|
79
|
+
result = mcp_client.call(name, args)
|
|
80
|
+
entry["outcome"] = "ok"
|
|
81
|
+
# Keep receipt small — summarize
|
|
82
|
+
if isinstance(result, dict):
|
|
83
|
+
entry["result_keys"] = sorted(result.keys())[:20]
|
|
84
|
+
else:
|
|
85
|
+
entry["result_preview"] = str(result)[:240]
|
|
86
|
+
except Exception as exc: # noqa: BLE001
|
|
87
|
+
entry["outcome"] = "could-not-run"
|
|
88
|
+
entry["detail"] = str(exc)[:400]
|
|
89
|
+
receipt["tools"].append(entry)
|
|
90
|
+
|
|
91
|
+
_append(receipt)
|
|
92
|
+
print(json.dumps(receipt), flush=True)
|
|
93
|
+
return receipt
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _append(receipt: dict[str, Any]) -> None:
|
|
97
|
+
path = _receipts_path()
|
|
98
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
99
|
+
with path.open("a", encoding="utf-8") as fh:
|
|
100
|
+
fh.write(json.dumps(receipt, separators=(",", ":")) + "\n")
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Merge willow-bot fleet_bridge PR webhook inbox into loki_pr_watch state.
|
|
3
|
+
|
|
4
|
+
Reads ~/.willow/upstream_steward/webhook_inbox/*.json (WILLOW_HOME), records
|
|
5
|
+
consumed work_ids, emits one JSON line per newly seen pull_request event.
|
|
6
|
+
|
|
7
|
+
PR watch never calls LOKI_WATCHER_URL / Ollama; Grove Loki (3B) is separate.
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import sys
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
from willow_bot.steward.config import (
|
|
16
|
+
watcher_call_enabled,
|
|
17
|
+
watcher_url,
|
|
18
|
+
webhook_inbox_dir,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
_MAX_CONSUMED = 5000
|
|
22
|
+
_MAX_SIGNALS = 100
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _load_state(path: Path) -> dict:
|
|
26
|
+
if path.is_file() and path.read_text().strip():
|
|
27
|
+
return json.loads(path.read_text())
|
|
28
|
+
return {"seen": [], "open": [], "merged_synced": []}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _emit(obj: dict) -> None:
|
|
32
|
+
print(json.dumps(obj), flush=True)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _repo_pr(item: dict) -> str:
|
|
36
|
+
repo = item.get("repo") or ""
|
|
37
|
+
num = item.get("number")
|
|
38
|
+
if repo and num is not None:
|
|
39
|
+
return f"{repo}#{num}"
|
|
40
|
+
return ""
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def ingest(state_path: Path) -> int:
|
|
44
|
+
if watcher_call_enabled():
|
|
45
|
+
_emit(
|
|
46
|
+
{
|
|
47
|
+
"event": "error",
|
|
48
|
+
"detail": (
|
|
49
|
+
"WILLOW_BOT_STEWARD_CALL_WATCHER / LOKI_PR_WATCH_CALL_WATCHER is set but not "
|
|
50
|
+
"supported; willow-bot runs loki.watcher separately "
|
|
51
|
+
f"(LOKI_WATCHER_URL={watcher_url()!r} is ignored here)."
|
|
52
|
+
),
|
|
53
|
+
}
|
|
54
|
+
)
|
|
55
|
+
return 1
|
|
56
|
+
|
|
57
|
+
inbox = webhook_inbox_dir()
|
|
58
|
+
state = _load_state(state_path)
|
|
59
|
+
state.setdefault("seen", [])
|
|
60
|
+
state.setdefault("open", [])
|
|
61
|
+
state.setdefault("merged_synced", [])
|
|
62
|
+
consumed: set[str] = set(state.get("inbox_consumed") or [])
|
|
63
|
+
signals: list[dict] = list(state.get("webhook_signals") or [])
|
|
64
|
+
|
|
65
|
+
if not inbox.is_dir():
|
|
66
|
+
state_path.parent.mkdir(parents=True, exist_ok=True)
|
|
67
|
+
state["inbox_consumed"] = sorted(consumed)
|
|
68
|
+
state["webhook_signals"] = signals[-_MAX_SIGNALS:]
|
|
69
|
+
state_path.write_text(json.dumps(state, indent=2) + "\n")
|
|
70
|
+
return 0
|
|
71
|
+
|
|
72
|
+
for path in sorted(inbox.glob("*.json")):
|
|
73
|
+
try:
|
|
74
|
+
item = json.loads(path.read_text(encoding="utf-8"))
|
|
75
|
+
except (json.JSONDecodeError, OSError):
|
|
76
|
+
continue
|
|
77
|
+
wid = item.get("work_id")
|
|
78
|
+
if not wid or wid in consumed:
|
|
79
|
+
continue
|
|
80
|
+
if item.get("kind") != "pull_request":
|
|
81
|
+
continue
|
|
82
|
+
|
|
83
|
+
consumed.add(wid)
|
|
84
|
+
key = _repo_pr(item)
|
|
85
|
+
if not key:
|
|
86
|
+
continue
|
|
87
|
+
|
|
88
|
+
ev = {
|
|
89
|
+
"event": "webhook_pr",
|
|
90
|
+
"repo_pr": key,
|
|
91
|
+
"action": item.get("action"),
|
|
92
|
+
"title": item.get("title") or "",
|
|
93
|
+
"url": item.get("html_url") or "",
|
|
94
|
+
"received_at": item.get("received_at"),
|
|
95
|
+
"work_id": wid,
|
|
96
|
+
"merged": item.get("merged"),
|
|
97
|
+
"pr_state": item.get("state"),
|
|
98
|
+
"source": item.get("source") or "willow-bot",
|
|
99
|
+
}
|
|
100
|
+
_emit(ev)
|
|
101
|
+
signals.append({k: v for k, v in ev.items() if k != "event"})
|
|
102
|
+
|
|
103
|
+
if len(consumed) > _MAX_CONSUMED:
|
|
104
|
+
consumed = set(sorted(consumed)[-_MAX_CONSUMED:])
|
|
105
|
+
|
|
106
|
+
state["inbox_consumed"] = sorted(consumed)
|
|
107
|
+
state["webhook_signals"] = signals[-_MAX_SIGNALS:]
|
|
108
|
+
state_path.parent.mkdir(parents=True, exist_ok=True)
|
|
109
|
+
state_path.write_text(json.dumps(state, indent=2) + "\n")
|
|
110
|
+
return 0
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def main() -> int:
|
|
114
|
+
if len(sys.argv) != 2:
|
|
115
|
+
print("usage: willow-bot-steward inbox <state.json>", file=sys.stderr)
|
|
116
|
+
return 2
|
|
117
|
+
return ingest(Path(sys.argv[1]))
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
if __name__ == "__main__":
|
|
121
|
+
sys.exit(main())
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"""Thin MCP stdio client for steward prove-phase tool calls.
|
|
2
|
+
|
|
3
|
+
Re-points the lifecycle pattern from ratatosk.mcp_client (Rule 11) — same env
|
|
4
|
+
forwarding lesson, no dependency on the ratatosk package.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import asyncio
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
import shlex
|
|
12
|
+
import sys
|
|
13
|
+
import threading
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
_mcp_session = None
|
|
17
|
+
_mcp_loop: asyncio.AbstractEventLoop | None = None
|
|
18
|
+
_mcp_stop_event: asyncio.Event | None = None
|
|
19
|
+
_mcp_thread: threading.Thread | None = None
|
|
20
|
+
_mcp_argv: list[str] | None = None
|
|
21
|
+
|
|
22
|
+
_ENV_PREFIXES = ("WILLOW_", "WILLOW_BOT_", "PG")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def default_mcp_argv() -> list[str]:
|
|
26
|
+
override = os.environ.get("WILLOW_BOT_MCP_COMMAND", "").strip()
|
|
27
|
+
if override:
|
|
28
|
+
return shlex.split(override)
|
|
29
|
+
module = os.environ.get("WILLOW_BOT_MCP_MODULE", "willow_mcp")
|
|
30
|
+
return [sys.executable, "-m", module]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def server_env() -> dict[str, str]:
|
|
34
|
+
from mcp.client.stdio import get_default_environment
|
|
35
|
+
|
|
36
|
+
if os.environ.get("WILLOW_BOT_MCP_INHERIT_ENV", "").strip().lower() in (
|
|
37
|
+
"1",
|
|
38
|
+
"true",
|
|
39
|
+
"yes",
|
|
40
|
+
):
|
|
41
|
+
return dict(os.environ)
|
|
42
|
+
env = dict(get_default_environment())
|
|
43
|
+
env.update({k: v for k, v in os.environ.items() if k.startswith(_ENV_PREFIXES)})
|
|
44
|
+
return env
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _call_sync(coro):
|
|
48
|
+
assert _mcp_loop is not None
|
|
49
|
+
return asyncio.run_coroutine_threadsafe(coro, _mcp_loop).result(timeout=90)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
async def _lifecycle(argv: list[str], ready: threading.Event) -> None:
|
|
53
|
+
global _mcp_session, _mcp_stop_event
|
|
54
|
+
from mcp import ClientSession
|
|
55
|
+
from mcp.client.stdio import StdioServerParameters, stdio_client
|
|
56
|
+
|
|
57
|
+
stop = asyncio.Event()
|
|
58
|
+
_mcp_stop_event = stop
|
|
59
|
+
params = StdioServerParameters(command=argv[0], args=argv[1:], env=server_env())
|
|
60
|
+
async with stdio_client(params) as (read, write):
|
|
61
|
+
async with ClientSession(read, write) as session:
|
|
62
|
+
await session.initialize()
|
|
63
|
+
_mcp_session = session
|
|
64
|
+
ready.set()
|
|
65
|
+
await stop.wait()
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def start(argv: list[str] | None = None) -> None:
|
|
69
|
+
global _mcp_loop, _mcp_thread, _mcp_argv
|
|
70
|
+
if _mcp_session is not None:
|
|
71
|
+
return
|
|
72
|
+
argv = argv or default_mcp_argv()
|
|
73
|
+
_mcp_argv = argv
|
|
74
|
+
loop = asyncio.new_event_loop()
|
|
75
|
+
_mcp_loop = loop
|
|
76
|
+
ready = threading.Event()
|
|
77
|
+
_mcp_thread = threading.Thread(
|
|
78
|
+
target=lambda: loop.run_until_complete(_lifecycle(argv, ready)),
|
|
79
|
+
daemon=True,
|
|
80
|
+
name="willow-bot-mcp",
|
|
81
|
+
)
|
|
82
|
+
_mcp_thread.start()
|
|
83
|
+
if not ready.wait(timeout=90):
|
|
84
|
+
raise RuntimeError("MCP server did not initialize within 90s")
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def shutdown() -> None:
|
|
88
|
+
global _mcp_session, _mcp_loop, _mcp_thread, _mcp_stop_event
|
|
89
|
+
if _mcp_stop_event is not None and _mcp_loop is not None:
|
|
90
|
+
_mcp_loop.call_soon_threadsafe(_mcp_stop_event.set)
|
|
91
|
+
if _mcp_thread is not None:
|
|
92
|
+
_mcp_thread.join(timeout=10)
|
|
93
|
+
_mcp_session = None
|
|
94
|
+
_mcp_loop = None
|
|
95
|
+
_mcp_thread = None
|
|
96
|
+
_mcp_stop_event = None
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def call(name: str, inputs: dict[str, Any]) -> Any:
|
|
100
|
+
start()
|
|
101
|
+
assert _mcp_session is not None
|
|
102
|
+
result = _call_sync(_mcp_session.call_tool(name, inputs))
|
|
103
|
+
# SDK 1.x isError / 2.x is_error
|
|
104
|
+
is_err = getattr(result, "isError", None)
|
|
105
|
+
if is_err is None:
|
|
106
|
+
is_err = getattr(result, "is_error", False)
|
|
107
|
+
texts = []
|
|
108
|
+
for block in getattr(result, "content", None) or []:
|
|
109
|
+
t = getattr(block, "text", None)
|
|
110
|
+
if t is not None:
|
|
111
|
+
texts.append(t)
|
|
112
|
+
payload = "\n".join(texts) if texts else str(result)
|
|
113
|
+
if is_err:
|
|
114
|
+
raise RuntimeError(payload)
|
|
115
|
+
try:
|
|
116
|
+
return json.loads(payload)
|
|
117
|
+
except (json.JSONDecodeError, TypeError):
|
|
118
|
+
return payload
|