{
  "sha": "2c1375781a689830911d01ea25ec65b2329909f3",
  "message": "Improve runtime resilience and flightwatch fallback (#202)\n\n* Add ADSB.lol fallback for rare-aircraft scans\n\n* Keep flight observation attribution accurate on fallback\n\n* Add DCA and PNS airport aliases\n\n* Avoid pytest collection exit in companion tests\n\n* Test rare-aircraft provider fallback\n\n* Run flightwatch regression tests before deployment\n\n* Add pull request regression gate for runtime fixes\n\n* Let pull request regression checks run beside scheduled deploys",
  "author": {
    "login": "nnneeeooo-nnneeeooo",
    "name": "",
    "email": ""
  },
  "committer": {
    "login": "web-flow",
    "name": "",
    "email": ""
  },
  "created_at": "2026-09-19T00:51:58Z",
  "files": [
    {
      "filename": ".github/workflows/hourly.yml",
      "previous_filename": "",
      "status": "modified",
      "additions": 37,
      "deletions": 1,
      "changes": 38
    },
    {
      "filename": "config/airport_iata_aliases.json",
      "previous_filename": "",
      "status": "modified",
      "additions": 2,
      "deletions": 0,
      "changes": 2
    },
    {
      "filename": "pipeline/flightnews.py",
      "previous_filename": "",
      "status": "modified",
      "additions": 5,
      "deletions": 2,
      "changes": 7
    },
    {
      "filename": "pipeline/flightwatch.py",
      "previous_filename": "",
      "status": "modified",
      "additions": 35,
      "deletions": 15,
      "changes": 50
    },
    {
      "filename": "tests/test_companion.py",
      "previous_filename": "",
      "status": "modified",
      "additions": 2,
      "deletions": 1,
      "changes": 3
    },
    {
      "filename": "tests/test_flightwatch.py",
      "previous_filename": "",
      "status": "modified",
      "additions": 34,
      "deletions": 0,
      "changes": 34
    }
  ],
  "diff": "@@ -4,17 +4,48 @@ on:\\n   schedule:\\n     - cron: \"1 * * * *\" # hourly; 16:01 UTC is 00:01 Asia/Taipei\\n   workflow_dispatch:\\n+  pull_request:\\n+    branches: [main]\\n+    paths:\\n+      - \".github/workflows/hourly.yml\"\\n+      - \"pipeline/adsb.py\"\\n+      - \"pipeline/flightwatch.py\"\\n+      - \"pipeline/flightnews.py\"\\n+      - \"pipeline/airport_codes.py\"\\n+      - \"config/airport_iata_aliases.json\"\\n+      - \"tests/test_companion.py\"\\n+      - \"tests/test_flightwatch.py\"\\n+      - \"tests/test_airport_codes.py\"\\n   push:\\n     branches: [main]\\n     paths-ignore:\\n       - \"data/**\" # bot data commits must not retrigger the workflow\\n \\n concurrency:\\n-  group: hourly\\n+  group: ${{ github.event_name == 'pull_request' && format('hourly-pr-{0}', github.event.pull_request.number) || 'hourly' }}\\n   cancel-in-progress: false\\n \\n jobs:\\n+  regression:\\n+    if: github.event_name == 'pull_request'\\n+    runs-on: ubuntu-latest\\n+    permissions:\\n+      contents: read\\n+    steps:\\n+      - uses: actions/checkout@v5\\n+      - uses: actions/setup-python@v6\\n+        with:\\n+          python-version: \"3.12\"\\n+          cache: pip\\n+      - run: pip install -r requirements.txt\\n+      - name: Run pipeline regression tests\\n+        run: |\\n+          python tests/test_companion.py\\n+          python tests/test_flightwatch.py\\n+          python tests/test_airport_codes.py\\n+\\n   update:\\n+    if: github.event_name != 'pull_request'\\n     runs-on: ubuntu-latest\\n     permissions:\\n       contents: write # commits data/ back to the repo\\n@@ -40,6 +71,11 @@ jobs:\\n       - name: Test summary boundary guard\\n         run: python tests/test_summary_guard.py\\n \\n+      - name: Test source enrichment and flightwatch resilience\\n+        run: |\\n+          python tests/test_companion.py\\n+          python tests/test_flightwatch.py\\n+\\n       - name: Test headline priority and subject scope\\n         run: python tests/test_headline_rotation.py\\n \\n@@ -7,6 +7,8 @@\\n     \"Chicago O'Hare\": \"ORD\",\\n     \"O'Hare\": \"ORD\",\\n     \"芝加哥奧黑爾\": \"ORD\",\\n+    \"Ronald Reagan Washington National Airport\": \"DCA\",\\n+    \"Pensacola International Airport\": \"PNS\",\\n     \"Philadelphia International Airport\": \"PHL\",\\n     \"Northeast Philadelphia Airport\": \"PNE\",\\n     \"Atlantic City Bader Field\": \"AIY\",\\n@@ -128,12 +128,15 @@ def assemble_source(event: dict) -> str:\\n     operator = _operator_details(event)\\n     arrival = event.get(\"arrival\") or {}\\n     rar = event.get(\"rarity\") or {}\\n+    primary_provider = str(event.get(\"primaryProvider\") or \"Airplanes.live\")\\n+    secondary_provider = (\"ADSB.lol\"\\n+                          if primary_provider != \"ADSB.lol\" else \"未使用獨立第二來源\")\\n     lines = [\\n         '<SOURCE type=\"flight_observation\">',\\n         f\"事件 ID：{event.get('eventId')}\",\\n         \"資料類型：公開 ADS-B 觀測\",\\n-        \"主要資料來源：Airplanes.live\",\\n-        \"第二資料來源：ADSB.lol\",\\n+        f\"主要資料來源：{primary_provider}\",\\n+        f\"第二資料來源：{secondary_provider}\",\\n         f\"交叉確認狀態：{event.get('crossCheck')}\",\\n         f\"機場 ICAO：{airport.get('icao')}\",\\n         f\"機場名稱：{airport.get('name_zh')}\",\\n@@ -438,17 +438,31 @@ def main() -> None:\\n         \"duplicate_events_skipped\", \"secondary_confirmations\",\\n         \"conflicting_sources\", \"queued_news_events\")}\\n \\n-    # ONE wide-area scan per run; failure leaves every file untouched.\\n+    # Prefer Airplanes.live, but fail over to ADSB.lol for the wide-area\\n+    # scan. A single provider outage must not silently blind the monitor.\\n+    # If both providers fail, preserve every state file: unknown sky is\\n+    # never interpreted as an empty sky.\\n+    scan_provider = \"Airplanes.live\"\\n     stats[\"provider_requests\"] += 1\\n     try:\\n         raw_rows = adsb.airplanes_live_point(CENTER_LAT, CENTER_LON, RADIUS_NM)\\n-    except adsb.ProviderDown as exc:\\n+    except adsb.ProviderDown as primary_exc:\\n         stats[\"provider_failures\"] += 1\\n-        print(f\"flightwatch: primary provider unavailable ({exc}); \"\\n-              \"state untouched - an API failure is NOT an empty sky\")\\n-        print(\"flightwatch stats: \"\\n-              + \" \".join(f\"{k}={v}\" for k, v in stats.items()))\\n-        return\\n+        print(f\"flightwatch: Airplanes.live unavailable ({primary_exc}); \"\\n+              \"trying ADSB.lol wide-area fallback\")\\n+        stats[\"provider_requests\"] += 1\\n+        try:\\n+            raw_rows = adsb.adsb_lol_point(CENTER_LAT, CENTER_LON, RADIUS_NM)\\n+            scan_provider = \"ADSB.lol\"\\n+            print(\"flightwatch: ADSB.lol wide-area fallback active\")\\n+        except adsb.ProviderDown as fallback_exc:\\n+            stats[\"provider_failures\"] += 1\\n+            print(f\"flightwatch: both scan providers unavailable \"\\n+                  f\"(Airplanes.live: {primary_exc}; ADSB.lol: {fallback_exc}); \"\\n+                  \"state untouched - API failure is NOT an empty sky\")\\n+            print(\"flightwatch stats: \"\\n+                  + \" \".join(f\"{k}={v}\" for k, v in stats.items()))\\n+            return\\n     stats[\"aircraft_returned\"] = len(raw_rows)\\n \\n     state = _load_state()\\n@@ -553,14 +567,19 @@ def main() -> None:\\n             continue\\n         stats[\"rarity_candidates\"] += 1\\n \\n-        # Secondary source ONLY now, for this one candidate.\\n-        stats[\"provider_requests\"] += 1\\n-        try:\\n-            secondary = adsb.adsb_lol_icao(ac[\"hex\"])\\n-            cross = cross_check(ac, secondary)\\n-        except adsb.ProviderDown:\\n-            stats[\"provider_failures\"] += 1\\n-            cross = \"secondary_unavailable\"\\n+        # Cross-check only when ADSB.lol is independent of the wide-area\\n+        # scan. If ADSB.lol already rescued the scan, do not pretend that a\\n+        # second request to the same provider is independent confirmation.\\n+        if scan_provider == \"ADSB.lol\":\\n+            cross = \"confirmed_by_primary_only\"\\n+        else:\\n+            stats[\"provider_requests\"] += 1\\n+            try:\\n+                secondary = adsb.adsb_lol_icao(ac[\"hex\"])\\n+                cross = cross_check(ac, secondary)\\n+            except adsb.ProviderDown:\\n+                stats[\"provider_failures\"] += 1\\n+                cross = \"secondary_unavailable\"\\n         if cross == \"confirmed_by_two_sources\":\\n             stats[\"secondary_confirmations\"] += 1\\n         elif cross == \"conflicting_sources\":\\n@@ -581,6 +600,7 @@ def main() -> None:\\n             \"notBeforeUtc\": iso(now + timedelta(\\n                 minutes=PUBLICATION_DELAY_MIN)),\\n             \"bootstrap\": bootstrap,\\n+            \"primaryProvider\": scan_provider,\\n             \"crossCheck\": cross,\\n             \"airport\": {k: airport[k] for k in\\n                         (\"icao\", \"iata\", \"name_zh\", \"name_en\")},\\n@@ -166,4 +166,5 @@ def get(self, url, **kwargs):\\n print(f\"\\n{CHECKS} checks passed, {FAILED} failed\"\\n       if not FAILED else f\"\\n{CHECKS - FAILED}/{CHECKS} passed, \"\\n       f\"{FAILED} FAILED\")\\n-sys.exit(1 if FAILED else 0)\\n+if __name__ == \"__main__\":\\n+    sys.exit(1 if FAILED else 0)\\n@@ -509,6 +509,39 @@ def test_flight_no_candidates_no_ai():\\n     print(\"test_flight_no_candidates_no_ai: done\")\\n \\n \\n+def test_scan_provider_fallback():\\n+    _reset_data()\\n+    original_primary = adsb.airplanes_live_point\\n+    original_fallback = adsb.adsb_lol_point\\n+    original_enabled = os.environ.get(\"FLIGHT_TRACKING_ENABLED\")\\n+    calls = {\"primary\": 0, \"fallback\": 0}\\n+\\n+    def primary(*_args, **_kwargs):\\n+        calls[\"primary\"] += 1\\n+        raise adsb.ProviderDown(\"HTTP 403\")\\n+\\n+    def fallback(*_args, **_kwargs):\\n+        calls[\"fallback\"] += 1\\n+        return []\\n+\\n+    adsb.airplanes_live_point = primary\\n+    adsb.adsb_lol_point = fallback\\n+    os.environ[\"FLIGHT_TRACKING_ENABLED\"] = \"true\"\\n+    try:\\n+        flightwatch.main()\\n+    finally:\\n+        adsb.airplanes_live_point = original_primary\\n+        adsb.adsb_lol_point = original_fallback\\n+        if original_enabled is None:\\n+            os.environ.pop(\"FLIGHT_TRACKING_ENABLED\", None)\\n+        else:\\n+            os.environ[\"FLIGHT_TRACKING_ENABLED\"] = original_enabled\\n+\\n+    check(calls == {\"primary\": 1, \"fallback\": 1},\\n+          \"Airplanes.live failure triggers exactly one ADSB.lol wide-area fallback\")\\n+    print(\"test_scan_provider_fallback: done\")\\n+\\n+\\n def test_flight_bad_quote_and_reject():\\n     _reset_data()\\n     flightnews.save_queue([json.loads(json.dumps(FLIGHT_EVENT))])\\n@@ -550,6 +583,7 @@ def main():\\n         test_source_assembly_privacy,\\n         test_flight_events_auto_publish,\\n         test_flight_fallback_history,\\n+        test_scan_provider_fallback,\\n         test_flight_gate_rejects_conflicts_and_publishes_reliable_risk,\\n         test_flight_no_candidates_no_ai,\\n         test_flight_bad_quote_and_reject,"
}
