brahman-buddy 0.2.6__tar.gz
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.
- brahman_buddy-0.2.6/LICENSE +21 -0
- brahman_buddy-0.2.6/PKG-INFO +10 -0
- brahman_buddy-0.2.6/pyproject.toml +28 -0
- brahman_buddy-0.2.6/pyproject.toml.orig +31 -0
- brahman_buddy-0.2.6/src/work_brahman/__init__.py +1 -0
- brahman_buddy-0.2.6/src/work_brahman/__main__.py +7 -0
- brahman_buddy-0.2.6/src/work_brahman/asks.py +125 -0
- brahman_buddy-0.2.6/src/work_brahman/bitable.py +251 -0
- brahman_buddy-0.2.6/src/work_brahman/bot.py +375 -0
- brahman_buddy-0.2.6/src/work_brahman/channel.py +101 -0
- brahman_buddy-0.2.6/src/work_brahman/cli.py +647 -0
- brahman_buddy-0.2.6/src/work_brahman/configure.py +282 -0
- brahman_buddy-0.2.6/src/work_brahman/duties/__init__.py +11 -0
- brahman_buddy-0.2.6/src/work_brahman/duties/code_review/DUTY.md +75 -0
- brahman_buddy-0.2.6/src/work_brahman/duties/code_review/__init__.py +266 -0
- brahman_buddy-0.2.6/src/work_brahman/duties/code_review/forge.py +255 -0
- brahman_buddy-0.2.6/src/work_brahman/duties/code_review/rebase.py +125 -0
- brahman_buddy-0.2.6/src/work_brahman/duties/threads/DUTY.md +67 -0
- brahman_buddy-0.2.6/src/work_brahman/duties/threads/__init__.py +155 -0
- brahman_buddy-0.2.6/src/work_brahman/engine.py +170 -0
- brahman_buddy-0.2.6/src/work_brahman/families/__init__.py +18 -0
- brahman_buddy-0.2.6/src/work_brahman/families/long_story/FAMILY.md +70 -0
- brahman_buddy-0.2.6/src/work_brahman/families/long_story/__init__.py +91 -0
- brahman_buddy-0.2.6/src/work_brahman/families/monthly_report/FAMILY.md +49 -0
- brahman_buddy-0.2.6/src/work_brahman/families/monthly_report/__init__.py +208 -0
- brahman_buddy-0.2.6/src/work_brahman/families/short_story/FAMILY.md +78 -0
- brahman_buddy-0.2.6/src/work_brahman/families/short_story/__init__.py +79 -0
- brahman_buddy-0.2.6/src/work_brahman/families/upstream_mr/FAMILY.md +62 -0
- brahman_buddy-0.2.6/src/work_brahman/families/upstream_mr/__init__.py +109 -0
- brahman_buddy-0.2.6/src/work_brahman/feishu.py +247 -0
- brahman_buddy-0.2.6/src/work_brahman/install.py +519 -0
- brahman_buddy-0.2.6/src/work_brahman/long_stories.py +502 -0
- brahman_buddy-0.2.6/src/work_brahman/meter.py +58 -0
- brahman_buddy-0.2.6/src/work_brahman/portable.py +118 -0
- brahman_buddy-0.2.6/src/work_brahman/procs.py +52 -0
- brahman_buddy-0.2.6/src/work_brahman/py.typed +0 -0
- brahman_buddy-0.2.6/src/work_brahman/runs.py +194 -0
- brahman_buddy-0.2.6/src/work_brahman/scheduler.py +252 -0
- brahman_buddy-0.2.6/src/work_brahman/short_stories.py +651 -0
- brahman_buddy-0.2.6/src/work_brahman/skills/pr-review/SKILL.md +38 -0
- brahman_buddy-0.2.6/src/work_brahman/skills/work-and-ask/SKILL.md +77 -0
- brahman_buddy-0.2.6/src/work_brahman/static/index.html +630 -0
- brahman_buddy-0.2.6/src/work_brahman/static/media/brahman-01-evolution.jpg +0 -0
- brahman_buddy-0.2.6/src/work_brahman/static/media/brahman-01-evolution.mp4 +0 -0
- brahman_buddy-0.2.6/src/work_brahman/static/media/brahman-02-system.jpg +0 -0
- brahman_buddy-0.2.6/src/work_brahman/static/media/brahman-02-system.mp4 +0 -0
- brahman_buddy-0.2.6/src/work_brahman/static/tokens.css +45 -0
- brahman_buddy-0.2.6/src/work_brahman/telegram.py +402 -0
- brahman_buddy-0.2.6/src/work_brahman/text.py +811 -0
- brahman_buddy-0.2.6/src/work_brahman/topics.py +86 -0
- brahman_buddy-0.2.6/src/work_brahman/web.py +402 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Cong Zhang
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: brahman-buddy
|
|
3
|
+
Version: 0.2.6
|
|
4
|
+
Summary: A personal work buddy: Telegram, Lark and Feishu chats driving Claude Code, with scheduled jobs, short and long stories, and portable skills
|
|
5
|
+
Author: Cong Zhang
|
|
6
|
+
Author-email: Cong Zhang <13283869+congzhangzh@users.noreply.github.com>
|
|
7
|
+
License-Expression: MIT
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Requires-Dist: lark-oapi>=1.7.5
|
|
10
|
+
Requires-Python: >=3.12
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "brahman-buddy"
|
|
3
|
+
version = "0.2.6"
|
|
4
|
+
license = "MIT"
|
|
5
|
+
license-files = ["LICENSE"]
|
|
6
|
+
description = "A personal work buddy: Telegram, Lark and Feishu chats driving Claude Code, with scheduled jobs, short and long stories, and portable skills"
|
|
7
|
+
requires-python = ">=3.12"
|
|
8
|
+
dependencies = ["lark-oapi>=1.7.5"]
|
|
9
|
+
|
|
10
|
+
[[project.authors]]
|
|
11
|
+
name = "Cong Zhang"
|
|
12
|
+
email = "13283869+congzhangzh@users.noreply.github.com"
|
|
13
|
+
|
|
14
|
+
[project.scripts]
|
|
15
|
+
buddy = "work_brahman.cli:main"
|
|
16
|
+
|
|
17
|
+
[tool.uv.build-backend]
|
|
18
|
+
module-name = "work_brahman"
|
|
19
|
+
|
|
20
|
+
[tool.pytest.ini_options]
|
|
21
|
+
testpaths = ["tests"]
|
|
22
|
+
|
|
23
|
+
[build-system]
|
|
24
|
+
requires = ["uv_build>=0.11.6,<0.12.0"]
|
|
25
|
+
build-backend = "uv_build"
|
|
26
|
+
|
|
27
|
+
[dependency-groups]
|
|
28
|
+
dev = ["pytest>=9.1.1"]
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "brahman-buddy"
|
|
3
|
+
version = "0.2.6"
|
|
4
|
+
license = "MIT"
|
|
5
|
+
license-files = ["LICENSE"]
|
|
6
|
+
description = "A personal work buddy: Telegram, Lark and Feishu chats driving Claude Code, with scheduled jobs, short and long stories, and portable skills"
|
|
7
|
+
authors = [
|
|
8
|
+
{ name = "Cong Zhang", email = "13283869+congzhangzh@users.noreply.github.com" }
|
|
9
|
+
]
|
|
10
|
+
requires-python = ">=3.12"
|
|
11
|
+
dependencies = [
|
|
12
|
+
"lark-oapi>=1.7.5",
|
|
13
|
+
]
|
|
14
|
+
|
|
15
|
+
[project.scripts]
|
|
16
|
+
buddy = "work_brahman.cli:main"
|
|
17
|
+
|
|
18
|
+
[tool.uv.build-backend]
|
|
19
|
+
module-name = "work_brahman" # the code keeps its name; the package on PyPI is brahman-buddy
|
|
20
|
+
|
|
21
|
+
[build-system]
|
|
22
|
+
requires = ["uv_build>=0.11.6,<0.12.0"]
|
|
23
|
+
build-backend = "uv_build"
|
|
24
|
+
|
|
25
|
+
[dependency-groups]
|
|
26
|
+
dev = [
|
|
27
|
+
"pytest>=9.1.1",
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
[tool.pytest.ini_options]
|
|
31
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""work_brahman: a small, extensible work buddy (Feishu/Lark + Claude Code)."""
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
"""Asks: a question with buttons, sent to every chat of one person; the first answer wins.
|
|
2
|
+
|
|
3
|
+
A job or duty runs as a child process that ends long before anyone answers, while the
|
|
4
|
+
answer arrives in `buddy serve`. So an ask is a small file both can read:
|
|
5
|
+
|
|
6
|
+
$XDG_STATE_HOME/work_brahman/asks/<ask id>.json
|
|
7
|
+
|
|
8
|
+
The child posts the card to each linked chat and writes what to do for each choice
|
|
9
|
+
(`then`: a job or duty to run). When someone presses a button in any app, serve marks the
|
|
10
|
+
first answer, changes every copy to "answered on Telegram: …", and starts what the choice
|
|
11
|
+
asked for. A later press on another copy is told it was already answered. STOP closes
|
|
12
|
+
every ask still open.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
import os
|
|
17
|
+
import secrets
|
|
18
|
+
import threading
|
|
19
|
+
import time
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
|
|
22
|
+
from .channel import Channel
|
|
23
|
+
from .text import app, t
|
|
24
|
+
|
|
25
|
+
_lock = threading.Lock() # one answer at a time, in serve
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def create(state: Path, channels: list[Channel], chats: list[str], markdown: str, *,
|
|
29
|
+
title: str, options: list[list[str]], then: dict | None = None, run: str = "",
|
|
30
|
+
failed: bool = False, note: str = "") -> dict:
|
|
31
|
+
"""Post the question to every chat. options: [[key, label], ...]; then: key -> [kind, name]."""
|
|
32
|
+
ask = {"id": f"{time.strftime('%m%d%H%M')}{secrets.token_hex(2)}", "run": run, "title": title,
|
|
33
|
+
"markdown": markdown, "options": options, "then": then or {}, "failed": failed,
|
|
34
|
+
"note": note, "asked": time.strftime("%Y-%m-%dT%H:%M:%S%z"), "copies": {},
|
|
35
|
+
"answer": None}
|
|
36
|
+
for chat in chats:
|
|
37
|
+
ch = next((c for c in channels if c.owns(chat)), None)
|
|
38
|
+
handle = ch.ask(chat, markdown, title=title, options=options, ask_id=ask["id"],
|
|
39
|
+
failed=failed, note=note) if ch else None
|
|
40
|
+
if handle:
|
|
41
|
+
ask["copies"][chat] = handle
|
|
42
|
+
_save(state, ask)
|
|
43
|
+
return ask
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def answer(state: Path, channels: list[Channel], ask_id: str, choice: str, where: str,
|
|
47
|
+
who: str) -> tuple[str, dict | None]:
|
|
48
|
+
"""Record the first answer and settle every copy. Returns (what to tell the presser,
|
|
49
|
+
the ask if this answer won, else None)."""
|
|
50
|
+
with _lock:
|
|
51
|
+
ask = load(state, ask_id)
|
|
52
|
+
if ask is None:
|
|
53
|
+
return t("ask.unknown"), None
|
|
54
|
+
if ask["answer"]:
|
|
55
|
+
a = ask["answer"]
|
|
56
|
+
return t("ask.already", said=_said(a)), None
|
|
57
|
+
label = dict(map(tuple, ask["options"])).get(choice)
|
|
58
|
+
if label is None:
|
|
59
|
+
return t("ask.bad_choice"), None
|
|
60
|
+
ask["answer"] = {"choice": choice, "label": label, "where": where, "who": who,
|
|
61
|
+
"at": time.strftime("%Y-%m-%dT%H:%M:%S%z")}
|
|
62
|
+
_save(state, ask)
|
|
63
|
+
_settle_all(channels, ask, t("ask.settled", where=app(where), label=label))
|
|
64
|
+
return f"✓ {label}", ask
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def stop_all(state: Path, channels: list[Channel], where: str, who: str) -> int:
|
|
68
|
+
"""Close every ask still open, as STOP does. Returns how many."""
|
|
69
|
+
closed = []
|
|
70
|
+
with _lock:
|
|
71
|
+
for f in sorted((state / "asks").glob("*.json")):
|
|
72
|
+
if time.time() - f.stat().st_mtime > 14 * 86400: # Lark can't edit it any more
|
|
73
|
+
continue
|
|
74
|
+
ask = load(state, f.stem)
|
|
75
|
+
if ask and not ask["answer"]:
|
|
76
|
+
ask["answer"] = {"choice": "", "label": t("ask.stopped_label"), "where": where, "who": who,
|
|
77
|
+
"at": time.strftime("%Y-%m-%dT%H:%M:%S%z"), "stopped": True}
|
|
78
|
+
_save(state, ask)
|
|
79
|
+
closed.append(ask)
|
|
80
|
+
for ask in closed:
|
|
81
|
+
_settle_all(channels, ask, t("ask.stopped_line", where=app(where)))
|
|
82
|
+
return len(closed)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def still_open(state: Path, title: str) -> dict | None:
|
|
86
|
+
"""The unanswered ask with this title, if there is one: the same question is asked once,
|
|
87
|
+
and its card stands until it is answered."""
|
|
88
|
+
for f in sorted((state / "asks").glob("*.json"), reverse=True):
|
|
89
|
+
if time.time() - f.stat().st_mtime > 14 * 86400: # as in stop_all: past editing
|
|
90
|
+
continue
|
|
91
|
+
ask = load(state, f.stem)
|
|
92
|
+
if ask and not ask["answer"] and ask["title"] == title:
|
|
93
|
+
return ask
|
|
94
|
+
return None
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def load(state: Path, ask_id: str) -> dict | None:
|
|
98
|
+
if not ask_id.isalnum(): # it came from a button press: never a path
|
|
99
|
+
return None
|
|
100
|
+
try:
|
|
101
|
+
return json.loads((state / "asks" / f"{ask_id}.json").read_text())
|
|
102
|
+
except (OSError, ValueError):
|
|
103
|
+
return None
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _said(a: dict) -> str:
|
|
107
|
+
return (t("ask.stopped_from", where=app(a["where"])) if a.get("stopped")
|
|
108
|
+
else t("ask.answered_on", where=app(a["where"]), label=a["label"]))
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _settle_all(channels: list[Channel], ask: dict, line: str) -> None:
|
|
112
|
+
"""Every copy loses its buttons and says how the question was settled."""
|
|
113
|
+
for chat, handle in ask["copies"].items():
|
|
114
|
+
ch = next((c for c in channels if c.owns(chat)), None)
|
|
115
|
+
if ch:
|
|
116
|
+
ch.settle(chat, handle, ask["markdown"], title=ask["title"], line=line,
|
|
117
|
+
failed=ask["failed"], note=ask["note"])
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _save(state: Path, ask: dict) -> None:
|
|
121
|
+
d = state / "asks"
|
|
122
|
+
d.mkdir(parents=True, exist_ok=True)
|
|
123
|
+
tmp = d / f"{ask['id']}.tmp"
|
|
124
|
+
tmp.write_text(json.dumps(ask, indent=1, ensure_ascii=False))
|
|
125
|
+
os.replace(tmp, d / f"{ask['id']}.json")
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
"""A Lark/Feishu Base (Bitable) as a workbench: the buddy's runs, asks, PRs, threads,
|
|
2
|
+
workflows and short stories as tables, for Lark's own charts and dashboards.
|
|
3
|
+
|
|
4
|
+
One way only, buddy -> Base. Every minute (and on `buddy bitable`) the rows are rebuilt
|
|
5
|
+
from the files the buddy already writes (run records, asks, code-review.json,
|
|
6
|
+
threads.json), and only rows that changed are sent: new ones created, changed ones
|
|
7
|
+
updated, by a key per row. What was sent is remembered in state/bitable.json:
|
|
8
|
+
|
|
9
|
+
{"app": token, "url": ..., "tables": {name: table id}, "rows": {name: {key: [record id, hash]}}}
|
|
10
|
+
|
|
11
|
+
On the first sync the buddy creates the Base in its own space and makes the person in
|
|
12
|
+
[bitable] share_with a full-access collaborator. Needs the app scopes bitable:app and
|
|
13
|
+
docs:permission.member:create. Rows never leave the tenant; the Base is as private as
|
|
14
|
+
any document the bot owns.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
import hashlib
|
|
18
|
+
import json
|
|
19
|
+
import logging
|
|
20
|
+
import threading
|
|
21
|
+
import time
|
|
22
|
+
from datetime import UTC, datetime
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
|
|
25
|
+
import lark_oapi as lark
|
|
26
|
+
|
|
27
|
+
from . import short_stories as shorts
|
|
28
|
+
from .runs import read as read_run
|
|
29
|
+
|
|
30
|
+
log = logging.getLogger("buddy.bitable")
|
|
31
|
+
|
|
32
|
+
STAMP = "%Y-%m-%dT%H:%M:%S%z"
|
|
33
|
+
TEXT, NUMBER, SELECT, DATETIME, URL = 1, 2, 3, 5, 15
|
|
34
|
+
# table -> [(field, type)], the first field is the row's key (the primary field)
|
|
35
|
+
TABLES = {
|
|
36
|
+
"Runs": [("Run", TEXT), ("Workflow", TEXT), ("Kind", SELECT), ("Status", SELECT),
|
|
37
|
+
("Started", DATETIME), ("Seconds", NUMBER), ("USD (list)", NUMBER), ("Tokens", NUMBER),
|
|
38
|
+
("Denied", NUMBER), ("Trigger", TEXT), ("Result", TEXT), ("Version", TEXT)],
|
|
39
|
+
"Asks": [("Ask", TEXT), ("Title", TEXT), ("Question", TEXT), ("Options", TEXT),
|
|
40
|
+
("Answer", TEXT), ("Where", SELECT), ("Asked", DATETIME), ("Answered", DATETIME),
|
|
41
|
+
("Minutes to answer", NUMBER)],
|
|
42
|
+
"PRs": [("PR", TEXT), ("Link", URL), ("Repo", TEXT), ("Title", TEXT), ("CI", SELECT),
|
|
43
|
+
("Review", SELECT), ("Merge", SELECT), ("Last comment", DATETIME)],
|
|
44
|
+
"Threads": [("Thread", TEXT), ("Link", URL), ("Kind", SELECT), ("Repo", TEXT),
|
|
45
|
+
("Title", TEXT), ("Turn", SELECT), ("Last by", TEXT), ("At", DATETIME),
|
|
46
|
+
("Mine", NUMBER)],
|
|
47
|
+
"Workflows": [("Workflow", TEXT), ("Schedule", TEXT), ("Runs (3 days)", NUMBER),
|
|
48
|
+
("Failed (3 days)", NUMBER), ("USD (3 days)", NUMBER),
|
|
49
|
+
("Tokens (3 days)", NUMBER), ("Last run", DATETIME), ("Last status", SELECT)],
|
|
50
|
+
# Short stories (short_stories.py): sessions' pipeline-bound work, one row per story
|
|
51
|
+
"Short stories": [("Story", TEXT), ("Title", TEXT), ("Status", SELECT), ("Who", TEXT),
|
|
52
|
+
("Started", DATETIME), ("Ended", DATETIME), ("Minutes", NUMBER),
|
|
53
|
+
("Marks done", NUMBER), ("Marks", NUMBER), ("Not planned", NUMBER),
|
|
54
|
+
("Replies", NUMBER), ("Stop from", SELECT), ("Last event", TEXT)],
|
|
55
|
+
}
|
|
56
|
+
FAILED = ("failed", "timeout", "interrupted")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _ms(stamp: str | None) -> int | None:
|
|
60
|
+
if not stamp:
|
|
61
|
+
return None
|
|
62
|
+
for fmt in (STAMP, "%Y-%m-%dT%H:%M:%SZ"): # ours carry an offset; GitHub's end in Z (UTC)
|
|
63
|
+
try:
|
|
64
|
+
d = datetime.strptime(stamp, fmt)
|
|
65
|
+
return int((d if d.tzinfo else d.replace(tzinfo=UTC)).timestamp() * 1000)
|
|
66
|
+
except ValueError:
|
|
67
|
+
continue
|
|
68
|
+
return None
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _read(path: Path) -> dict:
|
|
72
|
+
try:
|
|
73
|
+
return json.loads(path.read_text())
|
|
74
|
+
except (OSError, ValueError):
|
|
75
|
+
return {}
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def rows(state: Path, cfg: dict, days: int = 30) -> dict[str, dict[str, dict]]:
|
|
79
|
+
"""Every table's rows, {table: {key: fields}}, from the buddy's own files."""
|
|
80
|
+
out = {name: {} for name in TABLES}
|
|
81
|
+
runs = []
|
|
82
|
+
for d in sorted((state / "runs").glob("*"))[-days:]:
|
|
83
|
+
runs += [read_run(r) for r in (_read(f) for f in d.glob("*.json")) if r.get("id")]
|
|
84
|
+
for r in runs:
|
|
85
|
+
f = r.get("fitness") or {}
|
|
86
|
+
out["Runs"][r["id"]] = {
|
|
87
|
+
"Workflow": f"{r['kind']}:{r['name']}", "Kind": r["kind"], "Status": r["status"],
|
|
88
|
+
"Started": _ms(r.get("started")), "Seconds": f.get("seconds"),
|
|
89
|
+
"USD (list)": round(f.get("usd_list") or 0, 4),
|
|
90
|
+
"Tokens": sum(f.get(k) or 0 for k in ("tokens_in", "tokens_cached", "tokens_out")),
|
|
91
|
+
"Denied": f.get("denied") or 0, "Trigger": r.get("trigger", ""),
|
|
92
|
+
"Result": str(r.get("result") or r.get("error") or "")[:500], "Version": r.get("version", "")}
|
|
93
|
+
for f in (state / "asks").glob("*.json"):
|
|
94
|
+
a = _read(f)
|
|
95
|
+
if not a.get("id"):
|
|
96
|
+
continue
|
|
97
|
+
ans = a.get("answer") or {}
|
|
98
|
+
asked, answered = _ms(a.get("asked")), _ms(ans.get("at"))
|
|
99
|
+
out["Asks"][a["id"]] = {
|
|
100
|
+
"Title": a.get("title", ""), "Question": a.get("markdown", "")[:1000],
|
|
101
|
+
"Options": " / ".join(label for _, label in a.get("options", [])),
|
|
102
|
+
"Answer": ans.get("label", ""), "Where": ans.get("where", "") or "open",
|
|
103
|
+
"Asked": asked, "Answered": answered,
|
|
104
|
+
"Minutes to answer": round((answered - asked) / 60000, 1) if asked and answered else None}
|
|
105
|
+
for pr in _read(state / "code-review.json").get("prs", {}).values():
|
|
106
|
+
talk = pr.get("talk") or []
|
|
107
|
+
out["PRs"][pr["key"]] = {
|
|
108
|
+
"Link": {"text": pr["url"], "link": pr["url"]}, "Repo": pr["repo"], "Title": pr["title"],
|
|
109
|
+
"CI": pr["ci"], "Review": pr["review"], "Merge": pr["merge"],
|
|
110
|
+
"Last comment": _ms(talk[-1]["at"]) if talk else None}
|
|
111
|
+
for url, t in _read(state / "threads.json").get("items", {}).items():
|
|
112
|
+
out["Threads"][f"{t['repo']}#{t['number']}"] = {
|
|
113
|
+
"Link": {"text": url, "link": url}, "Kind": t["kind"], "Repo": t["repo"],
|
|
114
|
+
"Title": t["title"], "Turn": t["turn"], "Last by": t.get("last") or "",
|
|
115
|
+
"At": _ms(t.get("at")), "Mine": t.get("mine") or 0}
|
|
116
|
+
since = time.time() - 3 * 86400
|
|
117
|
+
for kind, section in (("job", "jobs"), ("duty", "duties")):
|
|
118
|
+
for name, t in cfg.get(section, {}).items():
|
|
119
|
+
rs = [r for r in runs if r["kind"] == kind and r["name"] == name]
|
|
120
|
+
recent = [r for r in rs if (_ms(r.get("started")) or 0) / 1000 >= since]
|
|
121
|
+
last = max(rs, key=lambda r: r.get("started", ""), default=None)
|
|
122
|
+
fit = [r.get("fitness") or {} for r in recent]
|
|
123
|
+
out["Workflows"][f"{kind}:{name}"] = {
|
|
124
|
+
"Schedule": t.get("schedule", "not scheduled"), "Runs (3 days)": len(recent),
|
|
125
|
+
"Failed (3 days)": sum(r["status"] in FAILED for r in recent),
|
|
126
|
+
"USD (3 days)": round(sum(f.get("usd_list") or 0 for f in fit), 4),
|
|
127
|
+
"Tokens (3 days)": sum(f.get(k) or 0 for f in fit for k in ("tokens_in", "tokens_cached", "tokens_out")),
|
|
128
|
+
"Last run": _ms(last.get("started")) if last else None,
|
|
129
|
+
"Last status": last["status"] if last else "never"}
|
|
130
|
+
# A family is a workflow too, and its stories are its runs (the web's level 1 does the same).
|
|
131
|
+
now = int(time.time() * 1000)
|
|
132
|
+
for s in shorts.all_stories(state):
|
|
133
|
+
status = "stopped" if s["status"] == "failed" and s.get("stop") else s["status"]
|
|
134
|
+
started, ended = _ms(s["started"]), _ms(s["ended"])
|
|
135
|
+
out["Short stories"][f"#{s['id']}"] = {
|
|
136
|
+
"Title": s["title"], "Status": status, "Who": s.get("who") or "", "Started": started,
|
|
137
|
+
"Ended": ended, "Minutes": round(((ended or now) - started) / 60000, 1) if started else None,
|
|
138
|
+
"Marks done": sum(m["status"] in ("done", "skipped") for m in s["marks"]),
|
|
139
|
+
"Marks": len(s["marks"]), "Not planned": sum(not m.get("planned", True) for m in s["marks"]),
|
|
140
|
+
"Replies": len(s.get("replies", [])), "Stop from": (s.get("stop") or {}).get("where"),
|
|
141
|
+
"Last event": s["events"][-1]["text"][:500] if s["events"] else ""}
|
|
142
|
+
recent = [r for r in out["Short stories"].values() if (r.get("Started") or 0) / 1000 >= since]
|
|
143
|
+
last = max(out["Short stories"].values(), key=lambda r: r.get("Started") or 0, default=None)
|
|
144
|
+
out["Workflows"]["family:short-story"] = {
|
|
145
|
+
"Schedule": "started by sessions", "Runs (3 days)": len(recent),
|
|
146
|
+
"Failed (3 days)": sum(r["Status"] in ("failed", "stopped") for r in recent),
|
|
147
|
+
"USD (3 days)": 0, "Tokens (3 days)": 0, # the buddy only keeps the board: no agent of its own
|
|
148
|
+
"Last run": last.get("Started") if last else None,
|
|
149
|
+
"Last status": last["Status"] if last else "never"}
|
|
150
|
+
# Lark rejects null values; an empty cell is simply left out
|
|
151
|
+
return {t: {k: {f: v for f, v in fields.items() if v is not None} for k, fields in rs.items()}
|
|
152
|
+
for t, rs in out.items()}
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
class Bitable:
|
|
156
|
+
def __init__(self, app_id: str, app_secret: str, domain, state: Path, cfg: dict):
|
|
157
|
+
self.api = (lark.Client.builder().app_id(app_id).app_secret(app_secret).domain(domain)
|
|
158
|
+
.log_level(lark.LogLevel.WARNING).build())
|
|
159
|
+
self.state, self.cfg = state, cfg
|
|
160
|
+
self.share_with = cfg.get("bitable", {}).get("share_with", "")
|
|
161
|
+
self.path = state / "bitable.json"
|
|
162
|
+
self.lock = threading.Lock()
|
|
163
|
+
|
|
164
|
+
def start(self, every: int = 60) -> None:
|
|
165
|
+
def loop():
|
|
166
|
+
while True:
|
|
167
|
+
try:
|
|
168
|
+
n = self.sync()
|
|
169
|
+
if n:
|
|
170
|
+
log.info("bitable: %d rows sent", n)
|
|
171
|
+
except Exception:
|
|
172
|
+
log.exception("bitable sync failed")
|
|
173
|
+
time.sleep(every)
|
|
174
|
+
threading.Thread(target=loop, name="bitable", daemon=True).start()
|
|
175
|
+
|
|
176
|
+
def sync(self) -> int:
|
|
177
|
+
"""Create the Base on the first call, then send what changed. Returns rows sent."""
|
|
178
|
+
with self.lock:
|
|
179
|
+
st = _read(self.path) or {"rows": {}}
|
|
180
|
+
if not st.get("app"):
|
|
181
|
+
self._create(st)
|
|
182
|
+
self._save(st)
|
|
183
|
+
for name in TABLES.keys() - st["tables"].keys(): # added since the Base was made
|
|
184
|
+
self._table(st, name)
|
|
185
|
+
self._save(st)
|
|
186
|
+
sent = 0
|
|
187
|
+
for table, want in rows(self.state, self.cfg).items():
|
|
188
|
+
have = st["rows"].setdefault(table, {})
|
|
189
|
+
new, changed = [], []
|
|
190
|
+
for key, fields in want.items():
|
|
191
|
+
fields = {TABLES[table][0][0]: key, **fields}
|
|
192
|
+
h = hashlib.sha1(json.dumps(fields, sort_keys=True).encode()).hexdigest()[:12]
|
|
193
|
+
if key not in have:
|
|
194
|
+
new.append((key, fields, h))
|
|
195
|
+
elif have[key][1] != h:
|
|
196
|
+
changed.append((key, fields, h))
|
|
197
|
+
tid = st["tables"][table]
|
|
198
|
+
for i in range(0, len(new), 500):
|
|
199
|
+
part = new[i:i + 500]
|
|
200
|
+
made = self._call("POST", f"/apps/{st['app']}/tables/{tid}/records/batch_create",
|
|
201
|
+
{"records": [{"fields": f} for _, f, _ in part]})["records"]
|
|
202
|
+
for (key, _, h), rec in zip(part, made):
|
|
203
|
+
have[key] = [rec["record_id"], h]
|
|
204
|
+
for i in range(0, len(changed), 500):
|
|
205
|
+
part = changed[i:i + 500]
|
|
206
|
+
self._call("POST", f"/apps/{st['app']}/tables/{tid}/records/batch_update",
|
|
207
|
+
{"records": [{"record_id": have[k][0], "fields": f} for k, f, _ in part]})
|
|
208
|
+
for key, _, h in part:
|
|
209
|
+
have[key][1] = h
|
|
210
|
+
sent += len(new) + len(changed)
|
|
211
|
+
self._save(st)
|
|
212
|
+
return sent
|
|
213
|
+
|
|
214
|
+
def url(self) -> str:
|
|
215
|
+
return _read(self.path).get("url", "")
|
|
216
|
+
|
|
217
|
+
def _create(self, st: dict) -> None:
|
|
218
|
+
app = self._call("POST", "/apps", {"name": "brahman workbench"})["app"]
|
|
219
|
+
st.update(app=app["app_token"], url=app.get("url", ""), tables={}, rows={})
|
|
220
|
+
for name in TABLES:
|
|
221
|
+
self._table(st, name)
|
|
222
|
+
if app.get("default_table_id"): # the empty table every new Base starts with
|
|
223
|
+
self._call("DELETE", f"/apps/{app['app_token']}/tables/{app['default_table_id']}", None)
|
|
224
|
+
if self.share_with:
|
|
225
|
+
self._call("POST", f"/permissions/{app['app_token']}/members?type=bitable&need_notification=true",
|
|
226
|
+
{"member_type": "openid", "member_id": self.share_with, "perm": "full_access"},
|
|
227
|
+
base="/open-apis/drive/v1")
|
|
228
|
+
log.info("bitable: created %s", st["url"])
|
|
229
|
+
|
|
230
|
+
def _table(self, st: dict, name: str) -> None:
|
|
231
|
+
t = self._call("POST", f"/apps/{st['app']}/tables", {"table": {
|
|
232
|
+
"name": name, "default_view_name": "All",
|
|
233
|
+
"fields": [{"field_name": f, "type": kind} for f, kind in TABLES[name]]}})
|
|
234
|
+
st["tables"][name] = t["table_id"]
|
|
235
|
+
log.info("bitable: table %s added", name)
|
|
236
|
+
|
|
237
|
+
def _call(self, method: str, path: str, body, base: str = "/open-apis/bitable/v1") -> dict:
|
|
238
|
+
req = (lark.BaseRequest.builder().http_method(getattr(lark.HttpMethod, method))
|
|
239
|
+
.uri(base + path).token_types({lark.AccessTokenType.TENANT}))
|
|
240
|
+
if body is not None:
|
|
241
|
+
req = req.body(body)
|
|
242
|
+
resp = self.api.request(req.build())
|
|
243
|
+
d = json.loads(resp.raw.content or b"{}")
|
|
244
|
+
if d.get("code"):
|
|
245
|
+
raise RuntimeError(f"lark {method} {path.split('?')[0]}: {d.get('code')} {d.get('msg')}")
|
|
246
|
+
return d.get("data") or {}
|
|
247
|
+
|
|
248
|
+
def _save(self, st: dict) -> None:
|
|
249
|
+
tmp = self.path.with_suffix(".tmp")
|
|
250
|
+
tmp.write_text(json.dumps(st, ensure_ascii=False))
|
|
251
|
+
tmp.replace(self.path)
|