feat: log corrected Today recap time (Closes #587)
CI / lint (pull_request) Successful in 1m25s
CI / build-release (pull_request) Successful in 6s
CI / release-candidate (pull_request) Has been skipped

This commit is contained in:
timmy committed 2026-08-11 19:40:45 +00:00
1 parent c170240057
commit 697f72da09
9 files changed
+518 -10

No files matched your search

+29
View File
@@ -214,6 +214,35 @@ async def fetch(path: str) -> Any:
return r.json()
def issue_time_target(identity: str) -> tuple[str, str]:
"""Return the canonical repository and issue number encoded by an identity."""
match = re.fullmatch(
r"(?:issue|pull):([A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+):([1-9][0-9]*):",
identity,
)
if match is None:
raise ValueError("time log target is invalid")
return match.groups()
async def log_issue_time(identity: str, seconds: int) -> None:
"""Log time to the canonical issue endpoint encoded by a dashboard identity."""
repository, number = issue_time_target(identity)
if not isinstance(seconds, int) or isinstance(seconds, bool) or not 1 <= seconds <= 86400:
raise ValueError("time log duration is invalid")
response = await _get_client().post(
f"/api/v1/repos/{repository}/issues/{number}/times",
headers={**_auth(), "Content-Type": "application/json"},
json={"time": seconds},
)
response.raise_for_status()
def time_log_failure_is_retryable(error: Exception) -> bool:
"""Return true only when failure proves no successful response was lost."""
return isinstance(error, (ValueError, GiteaOverloadedError, httpx.ConnectError, httpx.HTTPStatusError))
async def fetch_text(path: str, max_bytes: int) -> tuple[str, bool]:
chunks: list[bytes] = []
size = 0
+71
View File
@@ -426,6 +426,10 @@ class TodayRecap(BaseModel):
items: list[TodayRecapItem] = Field(min_length=1, max_length=20)
class TodayRecapTimeLog(TodayRecap):
log_identities: list[str] = Field(min_length=1, max_length=20)
class LaterOperation(BaseModel):
operation_id: str = Field(min_length=1, max_length=100)
action: Literal["defer", "restore"]
@@ -1916,6 +1920,73 @@ async def save_today_recap(payload: TodayRecap, response: Response):
return recap
@app.post("/api/v1/today/recaps/log-time")
async def save_today_recap_and_log_time(payload: TodayRecapTimeLog, response: Response):
login = await _confirmed_login()
if len(set(payload.log_identities)) != len(payload.log_identities):
raise HTTPException(status_code=422, detail="time log targets must be unique")
requested_items = {item.identity: item for item in payload.items}
selected = []
try:
for identity in payload.log_identities:
gitea_proxy.issue_time_target(identity)
requested = requested_items.get(identity)
if requested is None:
raise ValueError("time log target must match the recap")
if requested.actual_minutes <= 0:
raise ValueError("only non-zero recap time can be logged")
selected.append(requested)
recap = await asyncio.to_thread(
_today_store().save_recap,
login,
payload.session_id,
[item.model_dump() for item in payload.items],
)
saved_items = {item["identity"]: item for item in recap["items"]}
for requested in selected:
saved = saved_items.get(requested.identity)
if saved is None or saved["actual_minutes"] != requested.actual_minutes:
raise ValueError("time log target must match the saved recap")
except ValueError as error:
raise HTTPException(status_code=422, detail=str(error))
except (OSError, sqlite3.Error):
raise HTTPException(status_code=503, detail="Today recap could not be saved", headers={"Retry-After": "1"})
results = []
for item in selected:
try:
state = await asyncio.to_thread(
_today_store().begin_time_log, login, payload.session_id, item.identity, item.actual_minutes
)
if state == "claimed":
try:
await gitea_proxy.log_issue_time(item.identity, item.actual_minutes * 60)
except Exception as error:
if gitea_proxy.time_log_failure_is_retryable(error):
await asyncio.to_thread(
_today_store().finish_time_log,
login, payload.session_id, item.identity, succeeded=False,
)
status = "retry"
else:
status = "verify"
results.append({"identity": item.identity, "status": status})
continue
await asyncio.to_thread(
_today_store().finish_time_log,
login, payload.session_id, item.identity, succeeded=True,
)
state = "logged"
results.append({
"identity": item.identity,
"status": "logged" if state == "logged" else ("verify" if state == "pending" else "retry"),
})
except (ValueError, OSError, sqlite3.Error):
results.append({"identity": item.identity, "status": "retry"})
response.headers["Cache-Control"] = "no-store"
return {**recap, "time_logs": results}
@app.get("/api/v1/later")
async def get_later_plan():
login = await _confirmed_login()
+48
View File
@@ -82,6 +82,18 @@ class TodayStore:
"CREATE INDEX IF NOT EXISTS today_recaps_recent "
"ON today_recaps(login, created_at DESC)"
)
connection.execute(
"""
CREATE TABLE IF NOT EXISTS today_time_logs (
login TEXT NOT NULL,
session_id TEXT NOT NULL,
identity TEXT NOT NULL,
actual_minutes INTEGER NOT NULL,
status TEXT NOT NULL,
PRIMARY KEY (login, session_id, identity)
)
"""
)
connection.commit()
return connection
@@ -214,6 +226,42 @@ class TodayStore:
).fetchall()
return [self._recap_snapshot(*row) for row in rows]
def begin_time_log(self, login: str, session_id: str, identity: str, actual_minutes: int) -> str:
"""Claim one recap item for upstream logging, returning its current state."""
login = self._normalize_login(login)
with self._connect() as connection:
connection.execute("BEGIN IMMEDIATE")
row = connection.execute(
"SELECT actual_minutes, status FROM today_time_logs "
"WHERE login = ? AND session_id = ? AND identity = ?",
(login, session_id, identity),
).fetchone()
if row is None:
connection.execute(
"INSERT INTO today_time_logs(login, session_id, identity, actual_minutes, status) "
"VALUES (?, ?, ?, ?, 'pending')",
(login, session_id, identity, actual_minutes),
)
return "claimed"
if row[0] != actual_minutes:
raise ValueError("logged recap time cannot be changed")
if row[1] == "failed":
connection.execute(
"UPDATE today_time_logs SET status = 'pending' "
"WHERE login = ? AND session_id = ? AND identity = ?",
(login, session_id, identity),
)
return "claimed"
return row[1]
def finish_time_log(self, login: str, session_id: str, identity: str, *, succeeded: bool) -> None:
with self._connect() as connection:
connection.execute(
"UPDATE today_time_logs SET status = ? "
"WHERE login = ? AND session_id = ? AND identity = ? AND status = 'pending'",
("logged" if succeeded else "failed", self._normalize_login(login), session_id, identity),
)
def apply(
self,
login: str,