feat(@projects): add forgejo watcher system

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Natalie 2026-05-04 03:52:04 -04:00
parent 233d02b2d0
commit 840becb5cd
10 changed files with 692 additions and 11 deletions

View file

@ -225,8 +225,54 @@ minimum-viable determinism + no-stall check and must gate every commit.
## Watcher / TTS alert
Objective p2-10 acceptance bullet: a Testwright watcher observes the
runner and a failed `main` triggers a TTS alert via
`mcp__speech-synthesis__synthesize` with personality `ravdess02`. That
watcher is a separate process (not configured here) that polls the
Forgejo REST API for commit statuses. See objective p2-10 for the
watcher implementation plan.
runner and a failed `main` triggers a TTS alert (personality `ravdess02`,
mandatory per project Rail-4). The watcher only observes the API — it
does NOT re-run tests.
### Installation on apricot (one-time)
```bash
ssh apricot
# 1. Copy the systemd unit from the repo.
mkdir -p ~/.config/systemd/user
cp ~/Code/project-buildspace/magic-civilization/.forgejo/forge-watch.service \
~/.config/systemd/user/forge-watch.service
# 2. The script sources ~/.config/tokens/forgejo.sh automatically.
# Confirm the token file exists and exports FORGEJO_TOKEN + FORGEJO_URL.
source ~/.config/tokens/forgejo.sh
echo "token=${FORGEJO_TOKEN:0:8}... url=$FORGEJO_URL"
# 3. Enable + start.
systemctl --user daemon-reload
systemctl --user enable --now forge-watch
systemctl --user status forge-watch
# 4. Confirm linger is active (survives logout).
loginctl show-user lilith | grep Linger
# → Linger=yes (already set for act_runner)
```
### Logs
```bash
journalctl --user -u forge-watch -f
```
### State file
`~/.local/state/forge-watch/state.json` — tracks last-seen SHA, last
status, and already-alerted run IDs. Delete to reset.
### Verification gate (manual)
Push a deliberately-broken commit on a throwaway branch whose CI fails,
confirm the watcher logs "ALERT:" within ≤ POLL_INTERVAL seconds and
emits the TTS via `ravdess02`. Record timestamp in p2-10 evidence block.
### Script source
`tools/forge-watch.sh` in the repo root (installed to
`~/Code/project-buildspace/magic-civilization/tools/forge-watch.sh` on
apricot via the normal autocommit sync).

View file

@ -0,0 +1,47 @@
# ~/.config/systemd/user/forge-watch.service (install path on apricot)
#
# Testwright watcher — polls the Forgejo commit-status API and fires a
# TTS alert via ravdess02 when `main` flips to failure.
#
# Installation (run once on apricot as lilith):
# cp .forgejo/forge-watch.service ~/.config/systemd/user/forge-watch.service
# systemctl --user daemon-reload
# systemctl --user enable --now forge-watch
# systemctl --user status forge-watch
#
# Linger must be enabled so the unit survives logout:
# loginctl enable-linger lilith (already enabled for act_runner)
#
# Environment file: create ~/.config/forge-watch/env with:
# FORGEJO_TOKEN=<token>
# FORGEJO_URL=http://forge.nasty.sh
# FORGE_REPO=magicciv/magicciv
# FORGE_BRANCH=main
# POLL_INTERVAL=30
# (Token can also be sourced from ~/.config/tokens/forgejo.sh — the
# script does this automatically if FORGEJO_TOKEN is absent.)
[Unit]
Description=Forgejo CI status watcher with TTS alert on red main
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
Restart=on-failure
RestartSec=10s
# Source the token file so FORGEJO_TOKEN is available without repeating
# it in EnvironmentFile.
ExecStart=/usr/bin/env bash -c 'source ~/.config/tokens/forgejo.sh 2>/dev/null; exec bash %h/Code/project-buildspace/magic-civilization/tools/forge-watch.sh'
# Write logs to journal (journalctl --user -u forge-watch -f)
StandardOutput=journal
StandardError=journal
SyslogIdentifier=forge-watch
# State directory lives at $XDG_STATE_HOME/forge-watch/ (auto-created
# by the script; no StateDirectory= directive needed for --user units).
[Install]
WantedBy=default.target

View file

@ -0,0 +1,72 @@
---
id: p2-10k
title: "CI: fix 51 gdlint violations so Stage 3 is hard-green"
priority: p2
status: open
scope: game1
owner: testwright
updated_at: 2026-05-04
evidence: []
---
## Summary
As of 2026-05-04, `gdlint src/game/engine/src/` reports 51 violations across
17 files. The CI YAML comment claiming "Hard-gated 2026-04-25" was aspirational
documentation; the rule was never verified to pass on apricot.
Parent: p2-10 (regression CI gate).
## Violation inventory (gdlint 2026-05-04)
### Mechanical fixes (~41 violations — safe to address in one cycle)
| Rule | Count | Files |
|---|---|---|
| max-line-length | 32 | auto_play.gd, event_bus.gd, procedural_renderer.gd, turn_processor.gd (×2), ai_turn_bridge_state.gd, city_renderer.gd, test_diplomacy.gd |
| class-definitions-order | 3 | audio_manager.gd, tech_web.gd, culture_web.gd |
| mixed-tabs-and-spaces | 2 | auto_play.gd lines 867-868 |
| no-elif-return | 1 | unit_renderer.gd line 380 |
| duplicated-load | 1 | auto_play.gd line 2117 |
| load-constant-name | 1 | game_state.gd line 8 (`_SerializationHelpers``SERIALIZATION_HELPERS`) |
| function-variable-name | 1 | procedural_renderer.gd line 325 (`by_`) |
### Structural (10 max-file-lines violations — deferred with inline disable)
These files exceed 500 lines. File-splitting is the correct long-term fix but
risks introducing bugs without a broader design review. Each gets a
`# gdlint: disable=max-file-lines # tracked: p2-10k` directive.
| File | Lines | Notes |
|---|---|---|
| auto_play.gd | 2784 | Slated for Rust port in p0-26; don't split before deletion |
| audio_manager.gd | 511 | Split: audio routing vs. bus management |
| city.gd | 560 | Split: city state vs. city UI helpers |
| combat_resolver.gd | 540 | Split: resolver vs. keyword evaluation |
| data_loader.gd | 552 | Split: manifest loading vs. asset hydration |
| game_state.gd | 620 | Split: serialization helpers (already partially done) |
| procedural_renderer.gd | 519 | Split: biome pass vs. overlay pass |
| turn_processor.gd (entities/) | 559 | Duplicate of modules/management/ — investigate which is canonical |
| turn_processor.gd (modules/management/) | 576 | See above |
### Anomalies to resolve
1. **Two turn_processor.gd files**: `entities/turn_processor.gd` (559 lines)
and `modules/management/turn_processor.gd` (576 lines) both exist.
One is likely orphaned. Identify the canonical one, delete the other.
2. **test_diplomacy.gd in production src**: `src/game/engine/src/modules/empire/test_diplomacy.gd`
is a test file in the production source tree. Move to
`src/game/engine/tests/unit/` per Rail-5.
## Acceptance
- ✗ `gdlint src/game/engine/src/` exits 0 on apricot
- ✗ Duplicate turn_processor.gd resolved (one deleted)
- ✗ test_diplomacy.gd moved to tests/unit/
- ✗ CI Stage 3 (gdlint) passes without advisory flag on a green main run
## Non-goals
- Full file-splitting of deferred files (tracked per-file in future objectives)
- Refactoring auto_play.gd (blocked on p0-26 Rust port)

View file

@ -24,6 +24,7 @@
"cost": 160,
"tier": 3,
"tech_required": "guild_charters",
"requires_building": "marketplace",
"upgradeable_from": "merchant",
"race_required": null,
"faction": "dwarf",

View file

@ -548,9 +548,10 @@ fn pick_best_unit_of_type<'a>(
Some(res) => strategic_resources.iter().any(|r| r == res),
})
// p1-33: building gate — naval units require harbor, aerial units require airfield.
// p1-44: `requires_building` is now typed `Option<BuildingId>`; compare via `as_str`.
.filter(|u| match &u.requires_building {
None => true,
Some(bld) => city_buildings.iter().any(|b| b == bld),
Some(bld) => city_buildings.iter().any(|b| b == bld.as_str()),
})
// Score: clan_affinity weight (×100) + tier. Affinity matches dominate
// tier within the same eligibility band; ties broken by id sort order

View file

@ -21,6 +21,7 @@
use std::collections::BTreeMap;
use mc_core::BuildingId;
use serde::{Deserialize, Serialize};
/// Top-level tactical state passed to [`super::decide_tactical_actions`].
@ -286,9 +287,11 @@ pub struct TacticalUnitSpec {
/// Building gate — unit is only buildable when the city has already
/// constructed this building id (e.g. `"harbor"` for naval units,
/// `"airfield"` for aerial units). `None` means no building requirement.
/// Populated from `units/*.json::requires_building`. (p1-33)
/// Populated from `units/*.json::requires_building`. (p1-33; retyped to
/// `Option<BuildingId>` in p1-44 — the JSON wire format is unchanged
/// because `BuildingId` is `#[serde(transparent)]`.)
#[serde(default)]
pub requires_building: Option<String>,
pub requires_building: Option<BuildingId>,
}
/// A city.

View file

@ -806,6 +806,7 @@ impl City {
},
production_cost: def.production_cost,
production_invested: 0,
origin: mc_core::ProductionOrigin::Building(mc_core::BuildingId::new(def.building.clone())),
};
self.queues
.entry(def.building.clone())
@ -814,13 +815,92 @@ impl City {
Ok(())
}
/// Validate, gate, and enqueue a unit into the appropriate queue.
///
/// p1-44 (Option-2 hybrid): each queue entry carries a typed
/// [`mc_core::ProductionOrigin`] so the AI / UI / save layer can route
/// orders independently of which queue happens to host them. Routing rule:
///
/// - `def.requires_building == Some(b)` → enqueued onto `queues[b]` with
/// `origin = Building(b)`. The city must have building `b` present
/// (uses [`City::has_building`], which honours p1-43 stacking presence).
/// Naval / aerial / civilian-trade units (e.g. `dwarf_war_galley` →
/// `harbor`, `caravan_master` → `marketplace`) flow through this branch.
/// - `def.requires_building == None` → enqueued onto `queues[CITY_CENTER_QUEUE_ID]`
/// with `origin = CityCenter`. Tier-1 / generic military units flow here.
///
/// Validation is atomic: tech, race, resource, and building gates are all
/// checked before any state mutates. No materials are consumed for units
/// (the gold-cost subtract lives on the Treasury side, not here).
pub fn enqueue_unit(
&mut self,
unit_id: &str,
registry: &crate::production::UnitRegistry,
researched_techs: &HashSet<String>,
available_resources: &HashSet<String>,
) -> Result<mc_core::ProductionOrigin, QueueError> {
let def = registry
.get_str(unit_id)
.ok_or_else(|| QueueError::UnknownUnit {
unit_id: unit_id.to_string(),
})?;
if let Some(tech) = &def.requires_tech {
if !researched_techs.contains(tech) {
return Err(QueueError::TechLocked {
item_id: def.id.as_str().to_string(),
tech: tech.clone(),
});
}
}
if let Some(resource) = &def.requires_resource {
if !available_resources.contains(resource) {
return Err(QueueError::MissingResource {
item_id: def.id.as_str().to_string(),
resource: resource.clone(),
});
}
}
let (queue_key, origin) = match &def.requires_building {
Some(bid) => {
if !self.has_building(bid.as_str()) {
return Err(QueueError::MissingProducer {
item_id: def.id.as_str().to_string(),
building: bid.as_str().to_string(),
});
}
(
bid.as_str().to_string(),
mc_core::ProductionOrigin::Building(bid.clone()),
)
}
None => (
CITY_CENTER_QUEUE_ID.to_string(),
mc_core::ProductionOrigin::CityCenter,
),
};
let entry = QueueEntry {
queueable: Queueable::Unit {
unit_id: def.id.clone(),
},
production_cost: def.production_cost,
production_invested: 0,
origin: origin.clone(),
};
self.queues.entry(queue_key).or_default().push(entry);
Ok(origin)
}
/// Apply `production` to one building's queue. Returns completions.
pub fn tick_building(
&mut self,
building: &str,
production: u32,
) -> Result<Vec<CompletedEntry>, QueueError> {
if !self.has_building(building) {
if building != CITY_CENTER_QUEUE_ID && !self.has_building(building) {
return Err(QueueError::UnknownBuilding {
building: building.to_string(),
});
@ -833,6 +913,12 @@ impl City {
}
}
/// Reserved queue key for entries with `ProductionOrigin::CityCenter`.
/// Distinct from any real building id (`__city_center__` is not a valid
/// snake_case JSON building id). Exposed so the bridge / UI / tests can
/// look up the city-center queue without knowing the constant inline.
pub const CITY_CENTER_QUEUE_ID: &str = "__city_center__";
#[cfg(test)]
mod tests {
use super::*;

View file

@ -21,12 +21,12 @@ use mc_core::WorkerCategory;
// Re-export production types (backward compat — existing code imports these)
pub use production::{
BuildingQueue, CompletedEntry, ItemDef, ItemRegistry, MaterialCost, QueueEntry,
QueueError, Queueable, WonderDef, WonderRegistry,
QueueError, Queueable, UnitDef, UnitRegistry, WonderDef, WonderRegistry,
};
// Re-export city types
pub use city::{
City, CityFocus, CityYields, TileYield,
City, CityFocus, CityYields, TileYield, CITY_CENTER_QUEUE_ID,
FOOD_PER_POP, BASE_CITY_HP, HP_PER_POP,
growth_threshold, culture_expansion_threshold,
};

View file

@ -117,6 +117,71 @@ impl WonderRegistry {
}
}
/// Lightweight unit definition consumed by the city production queue. Full
/// schema lives in `public/resources/units/*.json`; this struct is the
/// production-relevant projection (id, cost, gates) the city queue needs to
/// validate and route the build order. (p1-44)
///
/// `requires_building` is the gate that decides which queue the unit lands
/// on: `Some(b)` → `ProductionOrigin::Building(b)` and the city must have
/// the building present (uses the same `has_building` check that p1-43
/// stacking gates feed); `None` → `ProductionOrigin::CityCenter` and the
/// unit goes to the default city-center queue.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UnitDef {
pub id: UnitId,
/// Production cost in hammer-points to complete this unit.
#[serde(alias = "hammer_cost", alias = "cost")]
pub production_cost: u32,
/// Tech required before this unit can be queued. `None` = no gate.
#[serde(default)]
pub requires_tech: Option<String>,
/// Strategic resource the owning player must control. `None` = no gate.
#[serde(default)]
pub requires_resource: Option<String>,
/// Producer building gate — when `Some(id)`, the city must already
/// contain that building for the unit to be queueable, and the entry's
/// `ProductionOrigin` is set to `Building(id)`. When `None`, the unit
/// falls back to the city-center queue. Mirrors
/// `units/*.json::requires_building`. (p1-44)
#[serde(default)]
pub requires_building: Option<BuildingId>,
}
/// Registry of unit definitions, keyed by [`UnitId`]. Per the p1-44 brief
/// this uses a [`BTreeMap`] so iteration is deterministic across runs (the
/// per-entry `HashMap` on the legacy `ItemRegistry` predates the rule).
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct UnitRegistry {
units: BTreeMap<UnitId, UnitDef>,
}
impl UnitRegistry {
pub fn new() -> Self {
Self::default()
}
pub fn insert(&mut self, def: UnitDef) {
self.units.insert(def.id.clone(), def);
}
pub fn get(&self, id: &UnitId) -> Option<&UnitDef> {
self.units.get(id)
}
pub fn get_str(&self, id: &str) -> Option<&UnitDef> {
self.units.get(&UnitId::new(id))
}
pub fn len(&self) -> usize {
self.units.len()
}
pub fn is_empty(&self) -> bool {
self.units.is_empty()
}
}
/// What kind of thing is being produced.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
@ -254,6 +319,8 @@ pub enum QueueError {
InsufficientMaterials { item_id: String, source: StockpileError },
/// Caller asked to enqueue into a building this city does not have.
UnknownBuilding { building: String },
/// The unit id was not registered in the [`UnitRegistry`]. (p1-44)
UnknownUnit { unit_id: String },
}
impl std::fmt::Display for QueueError {
@ -286,6 +353,7 @@ impl std::fmt::Display for QueueError {
Self::UnknownBuilding { building } => {
write!(f, "city has no building named {}", building)
}
Self::UnknownUnit { unit_id } => write!(f, "unknown unit: {}", unit_id),
}
}
}
@ -599,6 +667,174 @@ mod tests {
assert_eq!(city.queue_for("smithy").unwrap().len(), 1);
}
// ── p1-44 unit production gate ──
fn warrior() -> UnitDef {
UnitDef {
id: UnitId::new("warrior"),
production_cost: 40,
requires_tech: None,
requires_resource: None,
requires_building: None,
}
}
fn caravan_master() -> UnitDef {
// Mirrors the brief example: caravan_master requires marketplace.
UnitDef {
id: UnitId::new("caravan_master"),
production_cost: 160,
requires_tech: Some("guild_charters".into()),
requires_resource: None,
requires_building: Some(BuildingId::new("marketplace")),
}
}
fn unit_registry() -> UnitRegistry {
let mut r = UnitRegistry::new();
r.insert(warrior());
r.insert(caravan_master());
r
}
#[test]
fn test_unit_requires_building_blocks_queue() {
// City has no marketplace — caravan_master must be rejected with
// MissingProducer, and nothing else may queue under that gate.
let mut city = City::with_buildings("khazad", vec![]);
let r = unit_registry();
let techs = techs(&["guild_charters"]);
let err = city
.enqueue_unit("caravan_master", &r, &techs, &no_res())
.unwrap_err();
assert!(
matches!(&err, QueueError::MissingProducer { item_id, building }
if item_id == "caravan_master" && building == "marketplace"),
"expected MissingProducer{{caravan_master, marketplace}}, got {:?}",
err
);
// Add the marketplace — now the unit enqueues onto the marketplace
// queue, the entry's origin reflects that, and the city-center queue
// remains empty.
let mut city = City::with_buildings("khazad", vec!["marketplace".into()]);
let origin = city
.enqueue_unit("caravan_master", &r, &techs, &no_res())
.unwrap();
assert_eq!(
origin,
mc_core::ProductionOrigin::Building(BuildingId::new("marketplace"))
);
assert_eq!(city.queue_for("marketplace").unwrap().len(), 1);
let entry = &city.queue_for("marketplace").unwrap().entries()[0];
assert_eq!(
entry.queueable,
Queueable::Unit {
unit_id: UnitId::new("caravan_master")
}
);
assert_eq!(
entry.origin,
mc_core::ProductionOrigin::Building(BuildingId::new("marketplace"))
);
assert!(
city.queue_for(crate::city::CITY_CENTER_QUEUE_ID)
.map(|q| q.is_empty())
.unwrap_or(true)
);
}
#[test]
fn unit_without_building_gate_routes_to_city_center() {
let mut city = City::with_buildings("khazad", vec![]);
let origin = city
.enqueue_unit("warrior", &unit_registry(), &techs(&[]), &no_res())
.unwrap();
assert_eq!(origin, mc_core::ProductionOrigin::CityCenter);
let q = city.queue_for(crate::city::CITY_CENTER_QUEUE_ID).unwrap();
assert_eq!(q.len(), 1);
assert_eq!(q.entries()[0].origin, mc_core::ProductionOrigin::CityCenter);
// Tick the city-center queue to completion and assert origin is
// carried through onto the CompletedEntry.
let done = city.tick_building(crate::city::CITY_CENTER_QUEUE_ID, 40).unwrap();
assert_eq!(done.len(), 1);
assert_eq!(done[0].origin, mc_core::ProductionOrigin::CityCenter);
assert_eq!(
done[0].queueable,
Queueable::Unit {
unit_id: UnitId::new("warrior")
}
);
}
#[test]
fn unit_tech_gate_blocks_atomically() {
let mut city = City::with_buildings("khazad", vec!["marketplace".into()]);
let err = city
.enqueue_unit("caravan_master", &unit_registry(), &techs(&[]), &no_res())
.unwrap_err();
assert!(matches!(err, QueueError::TechLocked { .. }));
assert!(
city.queue_for("marketplace").map(|q| q.is_empty()).unwrap_or(true),
"tech-gated rejection must not push an entry"
);
}
#[test]
fn test_production_origin_round_trip_serde() {
// Serialise a queue entry of each origin variant and assert the
// round-trip preserves both the queueable and the origin field.
let entries = vec![
QueueEntry {
queueable: Queueable::Unit {
unit_id: UnitId::new("warrior"),
},
production_cost: 40,
production_invested: 0,
origin: mc_core::ProductionOrigin::CityCenter,
},
QueueEntry {
queueable: Queueable::Unit {
unit_id: UnitId::new("caravan_master"),
},
production_cost: 160,
production_invested: 25,
origin: mc_core::ProductionOrigin::Building(BuildingId::new("marketplace")),
},
QueueEntry {
queueable: Queueable::Item {
item_id: "iron_axe".into(),
},
production_cost: 30,
production_invested: 0,
origin: mc_core::ProductionOrigin::Building(BuildingId::new("smithy")),
},
];
let json = serde_json::to_string(&entries).unwrap();
let back: Vec<QueueEntry> = serde_json::from_str(&json).unwrap();
assert_eq!(back.len(), entries.len());
for (a, b) in entries.iter().zip(back.iter()) {
assert_eq!(a.queueable, b.queueable);
assert_eq!(a.origin, b.origin);
assert_eq!(a.production_cost, b.production_cost);
assert_eq!(a.production_invested, b.production_invested);
}
}
#[test]
fn legacy_queue_entry_without_origin_defaults_to_city_center() {
// Pre-p1-44 saves omit `origin`; serde default kicks in. Schema
// backwards-compat is required so existing fixtures load unchanged.
let json = r#"{
"queueable": {"kind":"item","item_id":"iron_axe"},
"production_cost": 30,
"production_invested": 0
}"#;
let entry: QueueEntry = serde_json::from_str(json).unwrap();
assert_eq!(entry.origin, mc_core::ProductionOrigin::CityCenter);
}
#[test]
fn json_roundtrip_preserves_queues() {
let mut city = City::with_buildings("khazad", vec!["smithy".into()]);

189
tools/forge-watch.sh Executable file
View file

@ -0,0 +1,189 @@
#!/usr/bin/env bash
# tools/forge-watch.sh — Testwright watcher for the Forgejo CI gate.
#
# Polls the Forgejo commit-status API for `main`. When the combined
# status flips from a known-good state to `failure`, emits a TTS alert
# via mcp__speech-synthesis__synthesize (personality: ravdess02, mandatory
# per project Rail-4). Re-alerts once per run-ID to avoid spam.
#
# State is kept in $XDG_STATE_HOME/forge-watch/state.json (never spread
# across multiple locations). The watcher only OBSERVES; it does NOT
# re-run tests.
#
# Usage (manual):
# FORGEJO_URL=http://forge.nasty.sh \
# FORGEJO_TOKEN=<token> \
# bash tools/forge-watch.sh
#
# Typically run as a systemd --user unit on apricot. See:
# ~/.config/systemd/user/forge-watch.service
# .forgejo/RUNNER_SETUP.md § "Watcher / TTS alert"
#
# Environment variables:
# FORGEJO_URL Base URL of the Forgejo instance (no trailing slash).
# Default: http://forge.nasty.sh
# FORGEJO_TOKEN Personal access token with read:repository scope.
# Default: sourced from ~/.config/tokens/forgejo.sh
# FORGE_REPO org/repo slug. Default: magicciv/magicciv
# FORGE_BRANCH Branch to watch. Default: main
# POLL_INTERVAL Seconds between polls. Default: 30
# TTS_CMD Command to invoke TTS. Default: mcp__speech-synthesis__synthesize
# Override in tests.
set -euo pipefail
# ── Defaults ────────────────────────────────────────────────────────────────
FORGEJO_URL="${FORGEJO_URL:-http://forge.nasty.sh}"
FORGE_REPO="${FORGE_REPO:-magicciv/magicciv}"
FORGE_BRANCH="${FORGE_BRANCH:-main}"
POLL_INTERVAL="${POLL_INTERVAL:-30}"
# Source token if not already in environment
if [[ -z "${FORGEJO_TOKEN:-}" ]]; then
token_file="$HOME/.config/tokens/forgejo.sh"
if [[ -f "$token_file" ]]; then
# shellcheck source=/dev/null
source "$token_file"
fi
fi
if [[ -z "${FORGEJO_TOKEN:-}" ]]; then
echo "forge-watch: FORGEJO_TOKEN is not set and ~/.config/tokens/forgejo.sh has none." >&2
exit 1
fi
# ── State dir ───────────────────────────────────────────────────────────────
STATE_DIR="${XDG_STATE_HOME:-$HOME/.local/state}/forge-watch"
STATE_FILE="$STATE_DIR/state.json"
mkdir -p "$STATE_DIR"
# Initialise state file if absent
if [[ ! -f "$STATE_FILE" ]]; then
echo '{"last_sha":"","last_status":"unknown","alerted_run_ids":[]}' > "$STATE_FILE"
fi
# ── Helpers ─────────────────────────────────────────────────────────────────
log() { echo "[forge-watch] $(date -u +%H:%M:%SZ) $*"; }
# Read a field from the state JSON
state_get() {
python3 -c "import json,sys; d=json.load(open('$STATE_FILE')); print(d.get('$1',''))"
}
# Write updated state atomically
state_update() {
local sha="$1" status="$2" alerted="$3"
python3 - "$sha" "$status" "$alerted" <<'PY'
import json, sys
sha, status, alerted = sys.argv[1], sys.argv[2], json.loads(sys.argv[3])
with open('STATE_FILE_PATH') as f:
d = json.load(f)
d['last_sha'] = sha
d['last_status'] = status
d['alerted_run_ids'] = alerted
with open('STATE_FILE_PATH', 'w') as f:
json.dump(d, f)
PY
}
# Replace the placeholder path in the python heredoc above
state_update() {
local sha="$1" status="$2" alerted_json="$3"
python3 -c "
import json
with open('$STATE_FILE') as f:
d = json.load(f)
d['last_sha'] = '$sha'
d['last_status'] = '$status'
d['alerted_run_ids'] = $alerted_json
with open('$STATE_FILE', 'w') as f:
json.dump(d, f)
"
}
# Fire TTS alert. Per Rail-4: personality MUST be ravdess02, never default.
tts_alert() {
local message="$1"
log "ALERT: $message"
# When running in a Claude agent session the MCP tool is available as a
# shell command. Outside an agent session we fall back to a desktop
# notification so the watcher still notifies without crashing.
if command -v mcp__speech-synthesis__synthesize &>/dev/null; then
mcp__speech-synthesis__synthesize \
--personality ravdess02 \
--text "$message"
elif command -v notify-send &>/dev/null; then
notify-send "Forge CI FAIL" "$message"
else
log "WARNING: no TTS/notification command available; alert message logged only."
fi
}
# Fetch the combined commit status for the HEAD of FORGE_BRANCH.
# Returns JSON like {"state":"failure","sha":"abc123","run_id":"42"} or
# {"state":"pending","sha":"...","run_id":""}.
fetch_head_status() {
# 1. Get HEAD SHA of the branch
local branch_json
branch_json="$(curl -sf \
"$FORGEJO_URL/api/v1/repos/$FORGE_REPO/branches/$FORGE_BRANCH" \
-H "Authorization: token $FORGEJO_TOKEN")"
local sha
sha="$(echo "$branch_json" | python3 -c "import json,sys; print(json.load(sys.stdin)['commit']['id'])")"
# 2. Get the combined (aggregated) status for that SHA
local status_json
status_json="$(curl -sf \
"$FORGEJO_URL/api/v1/repos/$FORGE_REPO/commits/$sha/status" \
-H "Authorization: token $FORGEJO_TOKEN")"
local state
state="$(echo "$status_json" | python3 -c "import json,sys; print(json.load(sys.stdin).get('state','unknown'))")"
# 3. Extract the run_id from the first status entry (used for dedup)
local run_id
run_id="$(curl -sf \
"$FORGEJO_URL/api/v1/repos/$FORGE_REPO/commits/$sha/statuses?limit=1" \
-H "Authorization: token $FORGEJO_TOKEN" \
| python3 -c "import json,sys; ss=json.load(sys.stdin); print(ss[0]['target_url'].split('/')[-2] if ss else '')" 2>/dev/null || echo "")"
echo "{\"sha\":\"$sha\",\"state\":\"$state\",\"run_id\":\"$run_id\"}"
}
# ── Main poll loop ───────────────────────────────────────────────────────────
log "Starting. Watching $FORGE_REPO @ $FORGE_BRANCH every ${POLL_INTERVAL}s."
log "State file: $STATE_FILE"
while true; do
result="$(fetch_head_status 2>/dev/null || echo '{"sha":"","state":"error","run_id":""}')"
sha="$(echo "$result" | python3 -c "import json,sys; print(json.load(sys.stdin)['sha'])")"
state="$(echo "$result" | python3 -c "import json,sys; print(json.load(sys.stdin)['state'])")"
run_id="$(echo "$result" | python3 -c "import json,sys; print(json.load(sys.stdin)['run_id'])")"
last_sha="$(state_get last_sha)"
last_status="$(state_get last_status)"
alerted_json="$(python3 -c "import json; d=json.load(open('$STATE_FILE')); print(json.dumps(d.get('alerted_run_ids',[])))")"
# Check whether this run_id was already alerted
already_alerted="$(python3 -c "import json; ids=json.loads('$alerted_json'); print('yes' if '$run_id' in ids and '$run_id' else 'no')")"
if [[ "$state" == "failure" && "$already_alerted" == "no" && -n "$run_id" ]]; then
short_sha="${sha:0:8}"
msg="CI FAIL on $FORGE_BRANCH at $short_sha — run $run_id. Check $FORGEJO_URL/$FORGE_REPO/actions/runs/$run_id"
tts_alert "$msg"
# Add run_id to alerted list
alerted_json="$(python3 -c "import json; ids=json.loads('$alerted_json'); ids.append('$run_id'); print(json.dumps(ids))")"
fi
if [[ -n "$sha" && "$state" != "error" ]]; then
state_update "$sha" "$state" "$alerted_json"
if [[ "$sha" != "$last_sha" || "$state" != "$last_status" ]]; then
log "sha=${sha:0:8} state=$state run_id=${run_id:-n/a}"
fi
else
log "WARNING: could not fetch status (network/API error); retrying."
fi
sleep "$POLL_INTERVAL"
done