mirror of
https://github.com/ALLFATHER-BV/wadamesh.git
synced 2026-10-23 10:42:26 +00:00
665 lines
25 KiB
Python
665 lines
25 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
wadamesh beta report service.
|
|
|
|
Why this exists
|
|
---------------
|
|
A beta is promoted to stable on a hunch today: it has been out for a while,
|
|
nobody shouted, so it ships. There are 13 board images and most of them are in
|
|
hands we never hear from, so "nobody shouted" mostly means "nobody with that
|
|
board was listening". This service turns that into a checklist: testers report
|
|
from the device, per board, per tag, and the promote decision reads off a grid.
|
|
|
|
Why it is a service at all, rather than the device talking to GitHub
|
|
--------------------------------------------------------------------
|
|
Two reasons, both hard:
|
|
|
|
* The device cannot do TLS. mbedTLS wants ~30 KB of internal heap for a
|
|
handshake and about 5 KB survives Wi-Fi association on the 2 MB boards,
|
|
which is the same reason map tiles come through our own proxy over plain
|
|
HTTP. So the device can only reach a host we control, unencrypted.
|
|
* A GitHub token in a GPL firmware image is a token everybody has. The
|
|
write credential has to live somewhere the public cannot read, which is
|
|
here.
|
|
|
|
So: device POSTs plain HTTP to firmware.wadamesh.com/report/..., this service
|
|
holds the token, and GitHub sees a well-formed matrix instead of raw traffic.
|
|
|
|
SQLite is the database; GitHub is a view
|
|
----------------------------------------
|
|
Reports land in SQLite and stay there. The per-tag tracking issue is rendered
|
|
from SQLite and rewritten in place, so it can be regenerated at any time and a
|
|
GitHub outage loses nothing. Do not read state back out of the issue body.
|
|
|
|
Bug reports are deliberately NOT here. Those go straight to GitHub as a
|
|
prefilled issue form, opened from a QR the device draws, and are submitted by
|
|
the reporter's own account: correct attribution, GitHub's own spam controls,
|
|
and no token needed. This service only handles the structured test reports and
|
|
the opt-in install ping, which have to be one tap on the device to be worth
|
|
anything.
|
|
|
|
Endpoints
|
|
---------
|
|
POST /report/test a tester's verdict for one board on one tag
|
|
POST /report/ping opt-in "this device is running <tag>" (denominators)
|
|
GET /report/matrix.json[?tag=beta_84] the grid, for the site and the device
|
|
GET /report/health
|
|
|
|
Run
|
|
---
|
|
pip install flask requests
|
|
python3 report-service.py # listens on 127.0.0.1:5006
|
|
|
|
Environment
|
|
-----------
|
|
WADA_REPORTS_DB sqlite path (default /opt/wadamesh-reports/reports.db)
|
|
WADA_FW_ROOT firmware tree (default /srv/wadamesh/firmware)
|
|
WADA_GH_TOKEN GitHub token with issues:write on the repo. Absent = the
|
|
service runs normally and simply never publishes.
|
|
DELIBERATELY UNSET (Kaj's call, 2026-09-23): the matrix
|
|
lives on wadamesh.com/beta only, so no write credential
|
|
exists for this anywhere. /report/health reporting
|
|
publishes:false is the expected state, not a fault. The
|
|
publishing code below stays in place because turning it
|
|
on later is then one line in /etc/wadamesh-reports.env.
|
|
WADA_GH_REPO owner/name (default ALLFATHER-BV/wadamesh)
|
|
WADA_REPORTS_PORT listen port (default 5006)
|
|
"""
|
|
|
|
import json
|
|
import os
|
|
import re
|
|
import sqlite3
|
|
import threading
|
|
import time
|
|
from collections import defaultdict
|
|
|
|
from flask import Flask, jsonify, request
|
|
|
|
try:
|
|
import requests
|
|
except ImportError: # publishing is optional
|
|
requests = None
|
|
|
|
DB_PATH = os.environ.get("WADA_REPORTS_DB", "/opt/wadamesh-reports/reports.db")
|
|
FW_ROOT = os.environ.get("WADA_FW_ROOT", "/srv/wadamesh/firmware")
|
|
GH_TOKEN = os.environ.get("WADA_GH_TOKEN", "").strip()
|
|
GH_REPO = os.environ.get("WADA_GH_REPO", "ALLFATHER-BV/wadamesh")
|
|
PORT = int(os.environ.get("WADA_REPORTS_PORT", "5006"))
|
|
|
|
# What a tester can say they exercised. Keep these stable: they are stored raw
|
|
# and the firmware sends the same keys.
|
|
AREAS = ("radio", "map", "link", "input", "storage")
|
|
AREA_LABEL = {
|
|
"radio": "radio and messaging",
|
|
"map": "map and GPS",
|
|
"link": "phone-app link",
|
|
"input": "keyboard and input",
|
|
"storage": "storage and SD",
|
|
}
|
|
RAN = ("boot", "day", "week")
|
|
RAN_LABEL = {"boot": "ran it for minutes", "day": "ran it for about a day",
|
|
"week": "ran it for a week or more"}
|
|
|
|
# Boards with no web-flasher manifest to take a display name from.
|
|
NAME_FALLBACK = {
|
|
"tdeck-pro-v1-0": "LilyGo T-Deck Pro V1.0",
|
|
"tdeck-pro-v1-1": "LilyGo T-Deck Pro V1.1",
|
|
"tdisplay-p4-lr2021": "LilyGo T-Display P4 (AMOLED, LR2021)",
|
|
"tdisplay-p4-lcd": "LilyGo T-Display P4 (LCD, SX1262)",
|
|
"tdisplay-p4-lcd-lr2021": "LilyGo T-Display P4 (LCD, LR2021)",
|
|
"tanmatsu": "Tanmatsu",
|
|
}
|
|
|
|
RE_TAG = re.compile(r"^beta_\d{1,4}$")
|
|
RE_BOARD = re.compile(r"^[a-z0-9][a-z0-9-]{1,31}$")
|
|
RE_RID = re.compile(r"^[0-9a-f]{16}$")
|
|
|
|
app = Flask(__name__)
|
|
|
|
# ---------------------------------------------------------------- storage ----
|
|
|
|
_db_lock = threading.Lock()
|
|
_local = threading.local()
|
|
|
|
|
|
def db():
|
|
"""One connection per thread. WAL so the publisher can read while a report writes."""
|
|
if not hasattr(_local, "conn"):
|
|
os.makedirs(os.path.dirname(DB_PATH) or ".", exist_ok=True)
|
|
conn = sqlite3.connect(DB_PATH, timeout=10)
|
|
conn.row_factory = sqlite3.Row
|
|
conn.execute("PRAGMA journal_mode=WAL")
|
|
conn.execute("PRAGMA synchronous=NORMAL")
|
|
_local.conn = conn
|
|
return _local.conn
|
|
|
|
|
|
def init_db():
|
|
conn = db()
|
|
conn.executescript(
|
|
"""
|
|
-- One row per (device, tag): a tester who changes their mind updates
|
|
-- their own verdict instead of stacking a second one.
|
|
CREATE TABLE IF NOT EXISTS reports (
|
|
rid TEXT NOT NULL,
|
|
tag TEXT NOT NULL,
|
|
board TEXT NOT NULL,
|
|
status TEXT NOT NULL, -- works | issues
|
|
areas TEXT NOT NULL DEFAULT '',
|
|
ran TEXT NOT NULL DEFAULT 'boot',
|
|
note TEXT NOT NULL DEFAULT '',
|
|
hw TEXT NOT NULL DEFAULT '{}',
|
|
created INTEGER NOT NULL,
|
|
updated INTEGER NOT NULL,
|
|
PRIMARY KEY (rid, tag)
|
|
);
|
|
CREATE INDEX IF NOT EXISTS reports_tag ON reports(tag);
|
|
|
|
-- Opt-in install counts. A device runs exactly one build, so last
|
|
-- write wins and there is no history to mine.
|
|
CREATE TABLE IF NOT EXISTS pings (
|
|
rid TEXT PRIMARY KEY,
|
|
tag TEXT NOT NULL,
|
|
board TEXT NOT NULL,
|
|
last_seen INTEGER NOT NULL
|
|
);
|
|
CREATE INDEX IF NOT EXISTS pings_tag ON pings(tag);
|
|
|
|
CREATE TABLE IF NOT EXISTS state (k TEXT PRIMARY KEY, v TEXT NOT NULL);
|
|
"""
|
|
)
|
|
conn.commit()
|
|
|
|
|
|
def state_get(k, default=None):
|
|
row = db().execute("SELECT v FROM state WHERE k=?", (k,)).fetchone()
|
|
return row["v"] if row else default
|
|
|
|
|
|
def state_set(k, v):
|
|
db().execute("INSERT INTO state(k,v) VALUES(?,?) ON CONFLICT(k) DO UPDATE SET v=excluded.v",
|
|
(k, str(v)))
|
|
db().commit()
|
|
|
|
|
|
# ------------------------------------------------------------- validation ----
|
|
|
|
def release_boards(tag):
|
|
"""Board ids we actually shipped for this tag, from the bins on disk.
|
|
|
|
Authoritative and self-maintaining: a board added mid-cycle appears here the
|
|
moment its bin is rsynced, with no list to keep in step.
|
|
|
|
Returns None ONLY when there is no releases tree at all, which happens when
|
|
the service runs off the box (local testing) and means "cannot check". An
|
|
unknown tag with the tree present returns an empty set, which is a rejection:
|
|
otherwise anything could open a matrix for a release that does not exist.
|
|
"""
|
|
for channel in ("BETA", "TOUCH"):
|
|
d = os.path.join(FW_ROOT, "releases", channel, tag)
|
|
if os.path.isdir(d):
|
|
out = set()
|
|
for fn in os.listdir(d):
|
|
m = re.match(r"^wadamesh-(.+)-merged\.bin$", fn)
|
|
if m:
|
|
out.add(m.group(1))
|
|
if out:
|
|
return out
|
|
if os.path.isdir(os.path.join(FW_ROOT, "releases")):
|
|
return set()
|
|
return None
|
|
|
|
|
|
def board_name(board, tag=None):
|
|
"""Human name, taken from the web-flasher manifest so there is one list."""
|
|
for feed in ("latest-beta", "latest"):
|
|
p = os.path.join(FW_ROOT, feed, "manifest-%s.json" % board)
|
|
try:
|
|
with open(p) as f:
|
|
n = json.load(f).get("name", "")
|
|
n = n.replace("wadamesh - ", "").replace(" (beta)", "").strip()
|
|
if n:
|
|
return n
|
|
except (OSError, ValueError):
|
|
continue
|
|
return NAME_FALLBACK.get(board, board)
|
|
|
|
|
|
def clean_note(s):
|
|
"""One line, bounded, and nothing that can break a markdown table."""
|
|
s = "".join(ch for ch in str(s or "") if ch == " " or (ch.isprintable() and ch != "|"))
|
|
s = re.sub(r"\s+", " ", s).strip()
|
|
return s[:280]
|
|
|
|
|
|
# Per-IP bucket. Not security, just friction: the endpoint is public by
|
|
# necessity and the worst case is junk rows, which are one DELETE away.
|
|
_ip_hits = defaultdict(list)
|
|
_ip_lock = threading.Lock()
|
|
|
|
|
|
def rate_ok(ip, limit=30, window=3600):
|
|
now = time.time()
|
|
with _ip_lock:
|
|
hits = [t for t in _ip_hits[ip] if now - t < window]
|
|
hits.append(now)
|
|
_ip_hits[ip] = hits
|
|
return len(hits) <= limit
|
|
|
|
|
|
def client_ip():
|
|
fwd = request.headers.get("X-Forwarded-For", "")
|
|
return fwd.split(",")[0].strip() or request.remote_addr or "?"
|
|
|
|
|
|
def bad(msg, code=400):
|
|
return jsonify({"ok": False, "error": msg}), code
|
|
|
|
|
|
# ---------------------------------------------------------------- intake ----
|
|
|
|
@app.post("/report/test")
|
|
def post_test():
|
|
if not rate_ok(client_ip()):
|
|
return bad("slow down", 429)
|
|
try:
|
|
body = request.get_json(force=True, silent=False) or {}
|
|
except Exception:
|
|
return bad("not json")
|
|
|
|
tag = str(body.get("tag", ""))
|
|
board = str(body.get("board", "")).replace("wadamesh-", "")
|
|
rid = str(body.get("rid", "")).lower()
|
|
status = str(body.get("status", ""))
|
|
ran = str(body.get("ran", "boot"))
|
|
|
|
if not RE_TAG.match(tag):
|
|
return bad("bad tag")
|
|
if not RE_BOARD.match(board):
|
|
return bad("bad board")
|
|
if not RE_RID.match(rid):
|
|
return bad("bad rid")
|
|
if status not in ("works", "issues"):
|
|
return bad("bad status")
|
|
if ran not in RAN:
|
|
ran = "boot"
|
|
|
|
shipped = release_boards(tag)
|
|
if shipped is not None:
|
|
if not shipped:
|
|
return bad("no such release")
|
|
if board not in shipped:
|
|
return bad("board not in this release")
|
|
|
|
areas = [a for a in (body.get("areas") or []) if a in AREAS]
|
|
note = clean_note(body.get("note"))
|
|
hw = body.get("hw") if isinstance(body.get("hw"), dict) else {}
|
|
hw = {k: hw[k] for k in list(hw)[:12]} # bounded, whatever it holds
|
|
|
|
now = int(time.time())
|
|
with _db_lock:
|
|
conn = db()
|
|
prev = conn.execute("SELECT updated FROM reports WHERE rid=? AND tag=?",
|
|
(rid, tag)).fetchone()
|
|
if prev and now - prev["updated"] < 30:
|
|
return bad("slow down", 429)
|
|
conn.execute(
|
|
"""INSERT INTO reports(rid,tag,board,status,areas,ran,note,hw,created,updated)
|
|
VALUES(?,?,?,?,?,?,?,?,?,?)
|
|
ON CONFLICT(rid,tag) DO UPDATE SET
|
|
board=excluded.board, status=excluded.status, areas=excluded.areas,
|
|
ran=excluded.ran, note=excluded.note, hw=excluded.hw,
|
|
updated=excluded.updated""",
|
|
(rid, tag, board, status, ",".join(areas), ran, note,
|
|
json.dumps(hw)[:1000], now, now),
|
|
)
|
|
conn.commit()
|
|
mark_dirty(tag)
|
|
return jsonify({"ok": True, "matrix": "https://wadamesh.com/beta.html"})
|
|
|
|
|
|
@app.post("/report/ping")
|
|
def post_ping():
|
|
"""Opt-in install count. Deliberately holds no history and no report text."""
|
|
if not rate_ok(client_ip(), limit=60):
|
|
return bad("slow down", 429)
|
|
try:
|
|
body = request.get_json(force=True, silent=True) or {}
|
|
except Exception:
|
|
return bad("not json")
|
|
tag = str(body.get("tag", ""))
|
|
board = str(body.get("board", "")).replace("wadamesh-", "")
|
|
rid = str(body.get("rid", "")).lower()
|
|
if not RE_TAG.match(tag) or not RE_BOARD.match(board) or not RE_RID.match(rid):
|
|
return bad("bad ping")
|
|
shipped = release_boards(tag)
|
|
if shipped is not None and board not in shipped:
|
|
return bad("no such release")
|
|
with _db_lock:
|
|
db().execute(
|
|
"""INSERT INTO pings(rid,tag,board,last_seen) VALUES(?,?,?,?)
|
|
ON CONFLICT(rid) DO UPDATE SET
|
|
tag=excluded.tag, board=excluded.board, last_seen=excluded.last_seen""",
|
|
(rid, tag, board, int(time.time())),
|
|
)
|
|
db().commit()
|
|
return jsonify({"ok": True})
|
|
|
|
|
|
# ---------------------------------------------------------------- matrix ----
|
|
|
|
PING_WINDOW = 21 * 86400 # a device silent for three weeks is not "on" a tag
|
|
|
|
|
|
def stable_tag():
|
|
"""The tag the stable channel is serving, from the feed the devices read."""
|
|
try:
|
|
with open(os.path.join(FW_ROOT, "latest", "version.json")) as f:
|
|
t = json.load(f).get("tag", "")
|
|
if RE_TAG.match(t):
|
|
return t
|
|
except (OSError, ValueError):
|
|
pass
|
|
return None
|
|
|
|
|
|
def tag_n(tag):
|
|
try:
|
|
return int(tag.split("_")[1])
|
|
except (IndexError, ValueError):
|
|
return -1
|
|
|
|
|
|
def tags_since_stable(limit=12):
|
|
"""Every beta published above the current stable, newest first.
|
|
|
|
This is the set a promote decision spans. They are kept SEPARATE on purpose:
|
|
a report is about one build, so a green on beta_85 vouches for beta_85 and
|
|
for nothing that came after it. Merging them would quietly let an old tester
|
|
sign off code they never ran.
|
|
"""
|
|
floor = tag_n(stable_tag() or "")
|
|
out = []
|
|
try:
|
|
for name in os.listdir(os.path.join(FW_ROOT, "releases", "BETA")):
|
|
if RE_TAG.match(name) and tag_n(name) > floor:
|
|
out.append(name)
|
|
except OSError:
|
|
pass
|
|
# Anything reported on but no longer on disk still belongs in the list.
|
|
for r in db().execute("SELECT DISTINCT tag FROM reports").fetchall():
|
|
if RE_TAG.match(r["tag"]) and tag_n(r["tag"]) > floor and r["tag"] not in out:
|
|
out.append(r["tag"])
|
|
out.sort(key=tag_n, reverse=True)
|
|
return out[:limit]
|
|
|
|
|
|
def current_beta():
|
|
"""Highest beta_N with bins on disk; falls back to the newest tag reported."""
|
|
d = os.path.join(FW_ROOT, "releases", "BETA")
|
|
best = None
|
|
try:
|
|
for name in os.listdir(d):
|
|
if RE_TAG.match(name):
|
|
n = int(name.split("_")[1])
|
|
if best is None or n > best[0]:
|
|
best = (n, name)
|
|
except OSError:
|
|
pass
|
|
if best:
|
|
return best[1]
|
|
row = db().execute("SELECT tag FROM reports ORDER BY updated DESC LIMIT 1").fetchone()
|
|
return row["tag"] if row else None
|
|
|
|
|
|
def build_matrix(tag):
|
|
conn = db()
|
|
rows = conn.execute("SELECT * FROM reports WHERE tag=? ORDER BY updated", (tag,)).fetchall()
|
|
cutoff = int(time.time()) - PING_WINDOW
|
|
pings = conn.execute(
|
|
"SELECT board, COUNT(*) c FROM pings WHERE tag=? AND last_seen>? GROUP BY board",
|
|
(tag, cutoff)).fetchall()
|
|
ping_by_board = {r["board"]: r["c"] for r in pings}
|
|
|
|
boards = release_boards(tag) or set()
|
|
boards |= {r["board"] for r in rows} | set(ping_by_board)
|
|
|
|
out = []
|
|
for b in sorted(boards):
|
|
mine = [r for r in rows if r["board"] == b]
|
|
works = [r for r in mine if r["status"] == "works"]
|
|
issues = [r for r in mine if r["status"] == "issues"]
|
|
# "Green" wants two independent devices that actually lived with the
|
|
# build. One report, or a report from someone who only watched it boot,
|
|
# is worth recording and is not worth promoting on.
|
|
soaked = [r for r in works if r["ran"] in ("day", "week")]
|
|
if issues:
|
|
status = "red"
|
|
elif len(soaked) >= 2:
|
|
status = "green"
|
|
elif works:
|
|
status = "amber"
|
|
else:
|
|
status = "none"
|
|
covered = sorted({a for r in works for a in (r["areas"] or "").split(",") if a})
|
|
out.append({
|
|
"board": b,
|
|
"name": board_name(b, tag),
|
|
"status": status,
|
|
"works": len(works),
|
|
"issues": len(issues),
|
|
"soaked": len(soaked),
|
|
"devices": ping_by_board.get(b, 0),
|
|
"areas": covered,
|
|
"missing_areas": [a for a in AREAS if a not in covered],
|
|
"last": max([r["updated"] for r in mine], default=0),
|
|
"notes": [
|
|
{"status": r["status"], "ran": r["ran"], "note": r["note"], "at": r["updated"]}
|
|
for r in mine if r["note"]
|
|
][-6:],
|
|
})
|
|
|
|
red = [b for b in out if b["status"] == "red"]
|
|
# Which boards are OUT THERE, so that silence on one of them means untested
|
|
# rather than unowned. The install count would be the obvious signal, but it
|
|
# is opt-in and most people will leave it off, which would let one green
|
|
# board declare a whole release ready. So a board also counts as owned once
|
|
# anybody has ever reported on it, on any build: reports are not opt-in, and
|
|
# somebody who reported on beta_85 still has that board when beta_86 lands.
|
|
owned = {r["board"] for r in conn.execute(
|
|
"SELECT DISTINCT board FROM reports").fetchall()}
|
|
owned |= {b for b in ping_by_board}
|
|
blocking = [b for b in out
|
|
if b["status"] in ("amber", "none") and (b["devices"] > 0 or b["board"] in owned)]
|
|
return {
|
|
"tag": tag,
|
|
"generated": int(time.time()),
|
|
"issue": state_get("issue:" + tag),
|
|
"stable": stable_tag(),
|
|
"tags": tag_summaries(),
|
|
"boards": out,
|
|
"summary": {
|
|
"green": sum(1 for b in out if b["status"] == "green"),
|
|
"amber": sum(1 for b in out if b["status"] == "amber"),
|
|
"red": len(red),
|
|
"none": sum(1 for b in out if b["status"] == "none"),
|
|
"reports": len(rows),
|
|
"devices": sum(ping_by_board.values()),
|
|
# No reports is not the same as a clean bill of health: with nothing
|
|
# in the table "no reds and nothing blocking" is trivially true, and
|
|
# that is exactly the habit this is meant to replace. At least one
|
|
# board has to have been vouched for.
|
|
"promote_ready": not red and not blocking and any(b["status"] == "green" for b in out),
|
|
"owned": sorted(owned),
|
|
"blocked_by": [b["board"] for b in red] + [b["board"] for b in blocking],
|
|
},
|
|
}
|
|
|
|
|
|
def tag_summaries():
|
|
"""One row per beta above stable, for the selector. Counts only."""
|
|
conn = db()
|
|
cutoff = int(time.time()) - PING_WINDOW
|
|
rows = []
|
|
for t in tags_since_stable():
|
|
reps = conn.execute("SELECT board, status, ran FROM reports WHERE tag=?", (t,)).fetchall()
|
|
devs = conn.execute("SELECT COUNT(DISTINCT rid) c FROM pings WHERE tag=? AND last_seen>?",
|
|
(t, cutoff)).fetchone()["c"]
|
|
by_board = defaultdict(list)
|
|
for r in reps:
|
|
by_board[r["board"]].append(r)
|
|
green = red = amber = 0
|
|
for b, mine in by_board.items():
|
|
if any(r["status"] == "issues" for r in mine):
|
|
red += 1
|
|
elif sum(1 for r in mine if r["status"] == "works" and r["ran"] in ("day", "week")) >= 2:
|
|
green += 1
|
|
else:
|
|
amber += 1
|
|
rows.append({"tag": t, "green": green, "amber": amber, "red": red,
|
|
"reports": len(reps), "devices": devs})
|
|
return rows
|
|
|
|
|
|
_matrix_cache = {}
|
|
|
|
|
|
@app.get("/report/matrix.json")
|
|
def get_matrix():
|
|
tag = request.args.get("tag") or current_beta()
|
|
if not tag:
|
|
return jsonify({"ok": False, "error": "no tag"}), 404
|
|
if not RE_TAG.match(tag):
|
|
return bad("bad tag")
|
|
hit = _matrix_cache.get(tag)
|
|
if hit and time.time() - hit[0] < 15:
|
|
data = hit[1]
|
|
else:
|
|
data = build_matrix(tag)
|
|
_matrix_cache[tag] = (time.time(), data)
|
|
resp = jsonify(data)
|
|
resp.headers["Access-Control-Allow-Origin"] = "*"
|
|
resp.headers["Cache-Control"] = "public, max-age=30"
|
|
return resp
|
|
|
|
|
|
@app.get("/report/health")
|
|
def health():
|
|
n = db().execute("SELECT COUNT(*) c FROM reports").fetchone()["c"]
|
|
return jsonify({"ok": True, "reports": n, "publishes": bool(GH_TOKEN and requests)})
|
|
|
|
|
|
# ------------------------------------------------------------- publishing ----
|
|
|
|
_dirty = set()
|
|
_dirty_lock = threading.Lock()
|
|
|
|
|
|
def mark_dirty(tag):
|
|
with _dirty_lock:
|
|
_dirty.add(tag)
|
|
|
|
|
|
def render_issue(m):
|
|
"""The tracking issue body. Rendered from SQLite every time, never parsed back."""
|
|
s = m["summary"]
|
|
L = []
|
|
L.append("Test reports for **%s**, sent from the device by the people running it." % m["tag"])
|
|
L.append("")
|
|
L.append("This body is rewritten automatically as reports arrive. Do not edit it by hand; "
|
|
"comments are read, the table is not.")
|
|
L.append("")
|
|
L.append("**To report:** on the device, Settings, About, \"Send a test report\". "
|
|
"To report a bug instead, use the QR on that page, which opens a prefilled issue form.")
|
|
L.append("")
|
|
L.append("| Board | State | Works | Problems | Running it | Untested areas |")
|
|
L.append("|---|---|---|---|---|---|")
|
|
word = {"green": "green", "amber": "needs a second report",
|
|
"red": "problem reported", "none": "no reports"}
|
|
for b in m["boards"]:
|
|
missing = ", ".join(AREA_LABEL[a] for a in b["missing_areas"]) or "none"
|
|
if b["status"] == "none":
|
|
missing = "everything"
|
|
L.append("| %s | %s | %d | %d | %s | %s |" % (
|
|
b["name"], word[b["status"]], b["works"], b["issues"],
|
|
(str(b["devices"]) if b["devices"] else "unknown"), missing))
|
|
L.append("")
|
|
if s["promote_ready"]:
|
|
L.append("**Ready to promote:** no problems outstanding, and every board anyone is "
|
|
"known to be running has been vouched for by two testers who lived with it.")
|
|
else:
|
|
blocked = ", ".join(s["blocked_by"]) or "nothing"
|
|
L.append("**Not ready to promote.** Waiting on: %s." % blocked)
|
|
L.append("")
|
|
L.append("Green needs two separate devices whose owners ran the build for a day or more. "
|
|
"A board nobody has ever reported on cannot go green and does not block the "
|
|
"promote; a board somebody has reported on before is known to be out there, so "
|
|
"silence on it now blocks until somebody vouches for this build.")
|
|
notes = [(b, n) for b in m["boards"] for n in b["notes"]]
|
|
if notes:
|
|
L.append("")
|
|
L.append("<details><summary>What testers wrote (%d)</summary>" % len(notes))
|
|
L.append("")
|
|
for b, n in sorted(notes, key=lambda x: -x[1]["at"]):
|
|
L.append("- **%s**, %s, %s: %s" % (
|
|
b["name"], "works" if n["status"] == "works" else "problem",
|
|
RAN_LABEL.get(n["ran"], n["ran"]), n["note"]))
|
|
L.append("")
|
|
L.append("</details>")
|
|
L.append("")
|
|
L.append("_Updated %s UTC._" % time.strftime("%Y-%m-%d %H:%M", time.gmtime(m["generated"])))
|
|
return "\n".join(L)
|
|
|
|
|
|
def gh(method, path, payload=None):
|
|
r = requests.request(
|
|
method, "https://api.github.com/repos/%s%s" % (GH_REPO, path),
|
|
headers={"Authorization": "Bearer " + GH_TOKEN,
|
|
"Accept": "application/vnd.github+json",
|
|
"X-GitHub-Api-Version": "2022-11-28",
|
|
"User-Agent": "wadamesh-reports"},
|
|
json=payload, timeout=20)
|
|
r.raise_for_status()
|
|
return r.json()
|
|
|
|
|
|
def publish(tag):
|
|
if not (GH_TOKEN and requests):
|
|
return
|
|
m = build_matrix(tag)
|
|
body = render_issue(m)
|
|
num = state_get("issue:" + tag)
|
|
if num:
|
|
gh("PATCH", "/issues/%s" % num, {"body": body})
|
|
else:
|
|
issue = gh("POST", "/issues", {
|
|
"title": "Test matrix: %s" % tag,
|
|
"body": body,
|
|
"labels": ["test-matrix"],
|
|
})
|
|
state_set("issue:" + tag, issue["number"])
|
|
|
|
|
|
def publisher():
|
|
"""Debounced: a burst of reports on one tag costs one API write, not one each."""
|
|
while True:
|
|
time.sleep(30)
|
|
with _dirty_lock:
|
|
tags = list(_dirty)
|
|
_dirty.clear()
|
|
for t in tags:
|
|
try:
|
|
publish(t)
|
|
except Exception as e: # never lose a report to GitHub
|
|
print("[reports] publish %s failed: %s" % (t, e), flush=True)
|
|
mark_dirty(t)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
init_db()
|
|
threading.Thread(target=publisher, daemon=True).start()
|
|
print("[reports] db=%s fw=%s github=%s" %
|
|
(DB_PATH, FW_ROOT, GH_REPO if GH_TOKEN else "disabled (no token)"), flush=True)
|
|
app.run(host="127.0.0.1", port=PORT, threaded=True)
|