# Changelog ### 2026-05-15 - **Daily Build retries on transient runner starvation** — today's scheduled run (`25906763857`) failed because the `release / check` job sat in `queued` for ~23 minutes without ever being assigned an `ubuntu-latest` runner; GitHub eventually gave up, dependents were skipped, and the run was marked `failure`. `build.yml` now schedules two follow-up crons at 06:00 and 07:00 UTC (in addition to the original 05:00). The `_release.yml` `check` job is already idempotent — once HEAD equals the latest release's `target_commitish`, `should_build=false` short-circuits the build/release/scoop jobs — so a successful first run turns subsequent retries into ~10-second no-ops. No retry-state file or external coordination needed. ### 2026-05-12 - **`ignore_workflows` opt-out (PR mode)** — sibling of `extra_workflows` for silencing small / shadow-IT workflows that piggyback on a PR. Two independent switches per entry: `status` (drop run from row colour + representative-run pick) and `notifications` (drop run from notification transition tracking). Short form (`- some-check.yml`) ignores both; granular form takes a dict (`file:` plus optional `status:` / `notifications:`, default true each). `PRWorkflowPoller.__init__` parses `cfg_entry["ignore_workflows"]` into two sets via `_parse_ignore_workflows()`; `_poll()` partitions each PR's `group_runs` into `status_runs` + `notif_runs` (matched by workflow-file basename), drives `agg_status` / `representative_run` / snoozed-row aggregate from `status_runs`, and tracks `cur_run_ids` + `notif_agg` for transition detection from `notif_runs`. Stored `_prev_statuses[sub_key]` now tracks the notif aggregate (which may diverge from the displayed row status when the two ignore sets differ). Documented next to `extra_workflows` in `config.template.yaml`. - **Release notes shown in update dialog** — `UpdateDialog` now renders the GitHub release `body` markdown via a `QTextBrowser` placed between the version/link block and the managed-cmd / status block. Source is `UpdateChecker._release_data["body"]` (already cached by `_check_release()`, no extra API call). Uses Qt's built-in CommonMark renderer (`setMarkdown`), dark-theme QSS (`BG_ROW` bg, `FG_TEXT` fg, `FG_LINK` for `a`), 150px fixed height with vertical scroll, external links open in browser. When the release body is empty the widget is hidden and the dialog stays its original 420×260/280 size; when present it grows to 480 wide and adds 170px height. Both install paths (direct Update/Skip + managed-install Copy/Close) show notes. Users can now see what's changing before clicking Update. - **Replaced em-dash with hyphen across all UI-visible strings** — `WorkflowRow._update_labels` status-info line (`Success — run #9638` → `Success - run #9638`), snooze tooltips, `UpdateDialog` window title + completion label, `URLQueryPoller` "Cannot resolve @me" error, `gh_api` rate-limit + network error strings shown in row info lines, tray tooltip (`{APP_NAME} — Success` → `{APP_NAME} - Success`), and missing-deps / missing-libraries dialog titles. Comments and docstrings still use em-dashes. Avoids AI-stylised punctuation in user-facing copy. ### 2026-05-08 - **Richer notification content + multi-event toast aggregation** — single notifications now include PR title, branch (with prefix), `→ target` for PR mode, and the Jira key parsed from the branch — previously the body was just `{workflow_name} • Run #{n}`. Title now embeds workflow name + verb (`✗ Acceptance failed`). Batched toasts (when multiple notifications coalesce inside `notifications.batch_window`) now render a header summary line (`✗ 2 failed • ▶ 1 started`) followed by per-event lines (up to 4, then `… and N more`), sorted failure → new_run → success. Added a `line` field on `_PendingNotification` carrying the single-line summary; `WorkflowPoller._build_notification(notif_type, state, is_pr)` produces `(title, body, line)` from existing `WorkflowState` fields — no extra API calls. Toast-click URL falls back to `state.pr_url` when `state.run_url` isn't set. - **Codebase audit cleanup** — extracted status constants (`ST_*`, `CONCLUSION_MAP`, `_STATUS_PRIORITY`, `_resolve_status`) into a new pure-data `src/status.py` module, eliminating the triple definition that previously lived in `pollers.py`, `icons.py` (as `_ST_*`), and the doc claim that they lived in `main.py`. `pollers.py` re-exports for backward compat so existing call sites (`from pollers import ST_*`) keep working. Reordered `MainWindow._apply_event` to call `row.update()` *before* committing `self._states[key]` — a `row.update()` exception used to leave the state map ahead of the UI. Tightened the `new_run` notification guard in `PRWorkflowPoller._poll` to fire only when a previously-unseen run ID appears (pure GitHub run-history shrinkage no longer spams). Replaced three silent `except Exception: pass` blocks in `pollers.py` (`_fetch_user_open_prs`, extra-workflow `_fetch_branch_runs`) and one in `gh_api._cached_unresolved_fetch` with `print(..., file=sys.stderr)` calls so token / network failures are diagnosable from the console. - **Per-section `include_in_tray_status` + `notifications_enabled` flags** — needed because URL mode surfaces PRs the user doesn't own (e.g. an "Open for Review" inbox); failures there shouldn't turn the tray red and shouldn't notify. Added `pollers.section_flags(cfg_entry)` returning both flags with mode-aware defaults: `include_in_tray_status=true` for every mode; `notifications_enabled=true` for branch/pr/actor, `false` for URL mode. `MainWindow._update_tray()` now filters out states from sections with `include_in_tray_status: false` before calling `_combined_status()`. `WorkflowPoller._fire_notification()` early-returns when the section flag is off, replacing URL mode's previous "never call `_fire_notification`" pattern with a single uniform gate. Documented under "Section flags" in `CLAUDE.md` and on the URL example in `config.template.yaml`. ### 2026-05-07 - **URL mode now fetches CI status** — `URLQueryPoller` previously derived row status from review state only (approved → success, changes-requested → failure, else unknown), so the row icon always read "Unknown" while a build was actually green/red and the subtitle linked to the PR page instead of the latest run. Added `gh_api.fetch_runs_by_sha()` and a per-poller `_fetch_latest_run_for_sha()`; PR detail now caches `head_sha`, the poller fetches `/actions/runs?head_sha=&per_page=1` for each PR, and the resulting `run_id` / `run_url` / `run_number` / `started_at` / `run_updated_at` populate `WorkflowState`. Row status now comes from the CI run; the review badge still surfaces approval state separately. Side effect: URL-section CI failures now escalate the tray colour (previously muted by design). ### 2026-05-06 - **Daily Build no longer self-perpetuates via Scoop manifest bump** — `_release.yml`'s `check` job compared `git rev-parse HEAD` against the latest release's `targetCommitish`, but the `update-scoop` job pushes `bucket/actionsmonitor.json` to `main` after every release. Result: each Daily Build saw "main moved" (the bot's scoop bump from yesterday), built a fresh release, and the cycle repeated forever — releases page showed a new tag every morning even when no human had committed. The `check` step now runs `git diff --name-only LATEST HEAD -- . ':(exclude)bucket/actionsmonitor.json'`; if the only change since the last release is the scoop manifest, `should_build=false` and the run skips cleanly. Manual `workflow_dispatch` of `Release` is unaffected because real commits land outside the exclude path. ### 2026-05-01 - **Relicensed under Business Source License 1.1 (BUSL-1.1)** — replaced the bespoke `WizX20 Free Use License` with the SPDX-listed `BUSL-1.1` (Licensor: WizX20; Change Date: 2030-05-01; Change License: Apache 2.0). The Additional Use Grant preserves the original intent — free for personal, internal, organisational, academic, and free-redistribution use — while explicitly forbidding sale, paid sublicensing, rental, and paid hosted/embedded distribution. NOTICE retention requirement is restated inside the Additional Use Grant. After the Change Date, each release auto-converts to Apache 2.0. Updated `LICENSE`, `NOTICE`, README "License" section, `CONTRIBUTING.md` contributor agreement, `DEVGUIDE.md` winget submission table, and the Scoop manifest's `license` field. - **Split `src/main.py` into `pollers.py` + `widgets.py` + `notifications.py`** — extracted the four `WorkflowPoller` variants (~1,090 lines) into `src/pollers.py` along with the `WorkflowState` / `StatusEvent` dataclasses, status constants, snooze registry, and the cross-poller helpers (`_resolve_status`, `_deep_merge`, `parse_branch_prefix`, `extract_jira_key`, `parse_duration`, `_format_age`, `_worst_status`, `_combined_status`). Pulled `WorkflowRow` + label/badge helpers (~470 lines) into `src/widgets.py` along with the colour palette (`COLOUR`, `STATUS_LABEL`, `BG_*`, `FG_*`). Moved `NotificationManager`, `_PendingNotification`, `_NAMED_SOUNDS`, `_find_linux_default_sound`, `_ensure_focus_vbs`, and the `NOTIF` singleton into `src/notifications.py`. `main.py` shrank from 4,272 → 2,217 lines. **PyInstaller-safe**: same one-way-import pattern as `update.py` — no extracted module imports from `main`. The snooze registry mutations move through new `pollers.add_snooze` / `discard_snooze` / `clear_snooze` helpers so MainWindow no longer touches the lock directly. `notifications.configure(app_name=..., app_ico=..., focus_vbs=..., focus_signal=...)` runs once at module init for the per-process paths. Smoke-tested in source mode. - **Extract update flow into `src/update.py`** — pulled out `UpdateChecker`, `UpdateDialog`, `_detect_install_source`, `_MANAGED_UPGRADE_CMD`, and `_cleanup_stale_mei_dirs`. `main.py` shrank from 4867 → 4272 lines; `update.py` is 687 lines. **PyInstaller-safe**: a naïve `from main import ...` in `update.py` triggered a circular reload at runtime because PyInstaller executes the entry script as both `__main__` and `main`. Fixed by making `update.py` self-contained — it declares module-level placeholders for the constants + widget class it needs and exposes a `configure(**kwargs)` injection function. `main.py` calls `update.configure(app_name=APP_NAME, build_commit=BUILD_COMMIT, is_windows=IS_WINDOWS, fg_text=FG_TEXT, ..., clickable_label_cls=_ClickableLabel)` once at module init, after all those names are defined. Smoke-tested in source mode and as a fresh `--onedir` build. - **Extract GitHub API plumbing into `src/gh_api.py`** — pulled out the HTTP layer, rate-limit gate, ETag conditional-request cache, GraphQL helper, retry wrapper, friendly-error mapper, URL parsers/builders (`parse_workflow_url`, `_build_workflow_url`, `_build_branch_url`, `parse_actor_url`), endpoint fetchers (`fetch_latest_run`, `fetch_pr_runs`, `fetch_actor_runs`, `fetch_github_username`), the username cache and its public `reset_username_cache()` hook, the generic `_prune_cache` helper + `_PR_CACHE_MAX` / `_REVIEW_CACHE_MAX` size caps, and the PR-review aggregation pipeline (`_compile_bot_regex`, `_aggregate_review_status`, `_cached_review_fetch`, `_cached_unresolved_fetch`, `_UNRESOLVED_THREADS_QUERY`). `main.py` shrank from 5340 → 4867 lines; `gh_api.py` is 526 lines. Self-contained — no main imports — so all module-level state (etag cache, rate-limit cooldown, username cache) lives in one place and is shared across pollers via normal module imports rather than `global`. `_reload_pollers` now calls `gh_api.reset_username_cache()` instead of mutating the global directly. `_resolve_status` stays in `main.py` because it depends on the `ST_*` / `CONCLUSION_MAP` constants. Smoke-tested as source and as a fresh `--onedir` build. - **Extract icon rendering into `src/icons.py`** — pulled all PIL drawing code out of `main.py` into a new self-contained module: Lucide-inspired status glyphs (`_draw_lucide_*`), header icons (refresh / update / help), reviewer (user / bot) glyphs, snooze bell, embedded WizX20 mark + decoder, app/tray base icon composition, the `_pil_to_qpixmap` Qt bridge, the `_status_qpixmaps` / `_snooze_qpixmaps` / `_base_icon_cache` / `_wizx20_mark_cache` / `_REVIEWER_ICON_B64` caches, and the `_init_*` helpers. `main.py` shrank from 6486 → 5340 lines; `icons.py` is 1202 lines. icons.py owns its own copy of `COLOUR_BG`, the seven status string literals, and the amber `_FG_LINK` default — the only state shared is the cache dicts which `main` imports directly. `_generate_app_ico` and `_generate_check_glyph` now take the destination `Path` as an argument instead of reading module globals; updated call sites in `main.py`, `src/build.bat`, `src/build.sh`, and `.github/workflows/_build.yml`. The icons import sits below the `Pillow` dep-check block so missing-Pillow still surfaces the friendly Qt dialog rather than a bare `ImportError`. Smoke-tested both as source and as a fresh `--onedir` build — every public icon function still renders and `MainWindow` constructs cleanly. - **Switched from PyInstaller `--onefile` to `--onedir` + zip distribution** — the previous onefile build extracted the full Python runtime (including `python312.dll`, `vcruntime140*`, the `api-ms-win-crt-*` set, and dozens of `.pyd` modules) to `%TEMP%\_MEI\` on every launch. After a self-update Defender was racing the bootstrap and quarantining individual DLLs mid-extraction, leaving the new exe to die with `Failed to load Python DLL '...\python312.dll'. LoadLibrary: The specified module could not be found.` (the classic *transitively-missing-dep* form of that error). Onedir leaves the runtime files on disk in `_internal/` next to the exe permanently, so AV scans them once at install/update time and never has a chance to race the loader. Release assets are now `ActionsMonitor.zip` / `ActionsMonitor-linux.zip` instead of bare binaries, each wrapping a top-level `ActionsMonitor/` (or `ActionsMonitor-linux/`) folder containing the exe plus `_internal/`. CI build (`_build.yml`), local build (`src/build.bat`), Scoop manifest (`bucket/actionsmonitor.json` — adds `extract_dir: "ActionsMonitor"`), and release publishing (`_release.yml`) all flow through the new layout. **Existing installs need a one-time manual reinstall** — auto-update from a pre-2026-05-01 binary won't carry over because the old `UpdateChecker` only knows how to fetch and swap a single `.exe`. Grab the new zip from the release page, unzip, and run `ActionsMonitor.exe` from inside the folder. - **`UpdateChecker` rewritten for the zip + folder swap** — `_apply_release_update` now downloads the release zip to `/.am_update.zip`, verifies size + sha256, extracts to `/.am_update_staging/`, locates the wrapper folder by searching for the exe (robust to layout tweaks), and stores the wrapper path on `_update_path`. `restart_app`'s helper batch/shell now does a four-step swap with rollback at every stage: rename `_internal` → `_internal.old`, rename `` → `.old`, move new exe in, move new `_internal` in. Any failure restores all backups before exiting non-zero. Post-swap the helper deletes the `.old` backups and the staging dir, then runs the same AV-warmup read across the new exe *and* every `*.dll`/`*.pyd` in `_internal/` before launching — onefile only needed to warm the wrapper exe; onedir has the full runtime sitting on disk that AV needs to clear first. Startup leftover-cleanup (`main()`) extended to wipe legacy `.old`/`.update` sidecars, the new `.old` + `_internal.old` backups, `.am_update.zip`, and `.am_update_staging/` so a force-killed helper can't leave the install dir half-swapped. - **`config.template.yaml` lookup falls back to `_MEIPASS`** — onedir bundles `--add-data` files into `_internal/`, which `sys._MEIPASS` points at. `_APP_DIR / "config.template.yaml"` (next to exe) won't exist in onedir installs, so `ConfigManager._write_default()` would fall through to the bare `DEFAULT_CONFIG` and ship users a config without the documented examples / per-mode comments. New `_bundled(name)` helper checks `_APP_DIR` first (preserves user-overridable copy + scoop's `persist` model), then `_MEIPASS`, returning the first match. Used for the template lookup; ready to drop in for any future bundled assets. ### 2026-04-30 - **CI workflow on PRs and `main` pushes** — new `.github/workflows/ci.yml` builds the Windows `.exe` and Linux binary on every pull request and every push to `main`, attaching both as downloadable workflow artifacts. Build logic was extracted from `_release.yml` into a new reusable workflow `.github/workflows/_build.yml` (inputs: `short_sha`, `upload_artifacts`); both `_release.yml` and `ci.yml` now call it, so release builds and PR builds share one definition. Two status badges added to `README.md` (CI on `main`, Daily Build). To block merging on CI failure, enable branch protection in **Settings → Branches → `main`** and require the `build / build-windows` + `build / build-linux` checks — documented in `DEVGUIDE.md` under "CI workflow → Required status checks". - **Release pipeline gated on green CI** — `_release.yml`'s `check` job now runs `gh run list -w ci.yml -b main --status completed -L 1` and aborts when the latest completed CI run on `main` is not `success`. Both Daily Build (cron) and Release (manual dispatch) call into `_release.yml`, so the single gate covers both. If `main` has no completed CI runs yet (e.g. brand-new repo state), the gate is permissive and the release proceeds; once CI has run at least once, a red `main` blocks future releases until it goes green again. - **Add `CONTRIBUTING.md` and `CODE_OF_CONDUCT.md`** — repo now has the two GitHub community-profile files. `CONTRIBUTING.md` covers issue reporting, security disclosure (private security advisory), PR submission, branch + commit conventions, and code-style guard rails specific to this codebase (single-file layout, `_github_api_get` for all API traffic, queue-only thread comms). Cross-links to `DEVGUIDE.md` for build/release and `CLAUDE.md` for architecture. `CODE_OF_CONDUCT.md` is Contributor Covenant 2.1 verbatim, matching the WizX20 house style. `README.md` "Contributing" section now points at `CONTRIBUTING.md` first and references the CoC. - **Enable Dependabot for Python deps and GitHub Actions** — `.github/dependabot.yml` watches `src/requirements.txt` (pip ecosystem) and `.github/workflows/*.yml` (github-actions ecosystem). Weekly cadence (Mondays 06:00 Europe/Amsterdam), 5 open PRs per ecosystem, `chore(scope):` commit prefix matching house style, labels `dependencies` + `python` / `ci` so they sort cleanly alongside the new issue templates. - **Add GitHub issue + PR templates** — under `.github/`: `ISSUE_TEMPLATE/bug_report.yml` and `ISSUE_TEMPLATE/feature_request.yml` are the YAML form schema (structured fields, required pre-flight checkboxes including a token-redaction acknowledgement on bug reports). `ISSUE_TEMPLATE/config.yml` disables blank issues and adds two contact links: GitHub Discussions for questions, private security advisory for vulnerabilities. `PULL_REQUEST_TEMPLATE.md` carries the standard What/Why/Testing/Screenshots/Checklist split, with the checklist enforcing the project-specific rules (`CHANGELOG.md` updated, `config.template.yaml` updated when config changes, `_github_api_get` only, no widget calls from pollers). Templates auto-load on issue / PR creation via the `.github/` paths. ### 2026-04-30 - **PR row layout: target link lifted to title row, subtitles flush-left, window width capped** — the second line (`#4338 EDU-8852-… → acceptance`) was a single label pointing at the branch tree, so clicking the PR/branch slug took you off to the branch instead of the latest run. The arrow / target also drifted around the row depending on width. Reworked into: - `_branch_lbl` carries `#PR branch-slug` → run URL (tooltip "Open latest run on GitHub"), new `_target_lbl` carries the target branch name → branch URL (tooltip "Open branch on GitHub", 12px right padding via `setContentsMargins`). `_branch_url()` returns `run_url` when `s.pr_number` is set, else `branch_url`. - `_target_lbl` lives in a new `_pr_title_widget` HBox alongside `_pr_title_lbl` (PR mode only) so the target sits inline with the PR title — matches the workflows row layout. `_pr_title_widget.setVisible(...)` toggles the whole row in one shot. - Replaced the static `addSpacing(20)` between name and branch with a toggleable `_name_branch_gap` QWidget — hidden in PR mode (where `_name_lbl` is hidden) so the branch slug aligns flush-left with the title above it, instead of inheriting the 20px indent. - New `_TitleLabel(_ClickableLabel)` overrides `sizeHint()` to `max(natural_text_width, 450)` so short titles still occupy a sensible width, while `minimumSizeHint()` (inherited) stays small enough to allow the label to shrink and `wordWrap=True` to wrap when the window narrows. Used for `_name_lbl` and `_pr_title_lbl`. Added with stretch=0 so the layout honors the sizeHint instead of splitting it 50/50 with a trailing stretch. - `MainWindow.setMaximumWidth(720)` plus a `min(..., 720)` clamp in `_restore_all_state` so the window can't grow into a wide expanse of trailing whitespace. - `set_snoozed` re-applies dim styling to `_target_lbl`. Branch/actor-mode rows unaffected — single-label behaviour and tooltip stay as-is. - **Update relaunch no longer dies with `Failed to load Python DLL`** — after a successful swap (`current → .old`, `.update → current`), the helper batch was firing `start "" exe` immediately. On freshly-overwritten files Defender was racing PyInstaller's bootstrap and locking/quarantining `python312.dll` mid-extraction to `_MEI*`, producing `LoadLibrary: The specified module could not be found` and an aborted relaunch. Helper now reads the new exe through (`type "{exe}" > NUL` on Windows, `cat > /dev/null` on Linux) to force AV to scan it once synchronously, then sleeps ~2s for the FS + AV to settle before `start ""` / `nohup`. Both helpers log a `[warming new exe…]` line so future failures show whether the warmup ran. New `_cleanup_stale_mei_dirs()` runs at startup (frozen only) and `shutil.rmtree`s `_MEI*` dirs in the temp dir that aren't this process's `_MEIPASS` and are older than 24h, swallowing every `OSError` so a locked dir can't crash startup. Stale dirs accumulate because `os._exit(0)` in `restart_app` and `taskkill /F /PID` in the helper's force-kill path both skip PyInstaller's atexit cleanup hook; this is the first GC pass. ### 2026-04-29 - **Friendly connection-drop error + transparent retry** — rows used to surface raw urllib3 reprs like `Error: ('Connection aborted.', RemoteDisconnected('Remote end closed connection without response'))` whenever GitHub closed an idle keep-alive socket. New `_friendly_error()` helper maps `requests.ConnectionError` / `Timeout` / `RequestException` to short user-facing strings (`Connection lost — retrying`, `Request timed out — retrying`, `Network error — retrying`); other exceptions fall through to a 120-char-truncated `str(exc)`. All four pollers (branch, PR, actor, URL) plus the username and `@me` resolution paths route through it. `_github_api_get` and `_github_graphql_post` now wrap the actual HTTP call in `_request_with_retry` (1 retry, 0.4s backoff) on `ConnectionError` / `Timeout` / `ChunkedEncodingError`, so a single transient drop is silently absorbed. New `_emit_request_error(exc)` on `WorkflowPoller` collapses the `HTTPError` (→ `HTTP {status}`) vs. generic-Exception split that every poller's `except` block was repeating verbatim. Rate-limit response handling extracted into `_check_rate_limit_response` / `_track_remaining_header` / `_invalidate_username_on_401` shared between REST and GraphQL helpers, removing ~40 lines of duplication. Update download `requests.get(stream=True)` switched from `timeout=120` to `(15, 60)` so a slow proxy stalling mid-download trips the read timeout per chunk instead of pinning the dialog at 100% for the full 120s. ### 2026-04-29 - **Three-target row navigation: title → workflow, status line → run, branch → branch tree** — refining yesterday's two-link change after UX feedback. The status info line (`Success — run #3951 (29 Apr 14:14)`) is now a clickable link pointing at the latest run, and the branch label points at the GitHub branch tree (`/tree/`) instead of the run. New `WorkflowState.branch_url` field, populated by `_build_branch_url(owner, repo, branch)` across branch / actor / PR / URL pollers (active and snoozed code paths). `_info_lbl` switched from plain `QLabel` to `_ClickableLabel` with an amber-on-hover stylesheet via `_link_css`, tooltip "Open latest run on GitHub". Branch label tooltip now "Open branch on GitHub". Branch-mode rows show the branch subtitle whenever `state.branch` is set (no longer gated on `workflow_url + run_url` since `_info_lbl` carries the run link). `_branch_url()` and `_name_url()` helpers fall back through workflow → run → configured URL so a click is never dead. `set_snoozed` re-applies `_link_css` to `_info_lbl` for the dimmed colour so hover affordance is preserved when snoozed. ### 2026-04-29 - **Two-link rows in branch and actor modes** — row title and subtitle now point at distinct URLs, mirroring PR mode. Title (name label) opens the workflow overview filtered by branch (e.g. `…/actions/workflows/foo.yml?query=branch%3Amain`); subtitle (branch label) opens the latest run instance. New `WorkflowState.workflow_url` field, populated by `_build_workflow_url(owner, repo, wf_file, branch)` (also exported for actor mode, which derives `wf_file` from each run's `path`). Branch poller sets it on success + `_emit_error`; actor poller sets it on both the active and snoozed-state branches. `_update_labels` now keeps the workflow-name label visible in actor mode (previously hidden when `head_branch` was truthy) and surfaces a branch subtitle in branch mode whenever both `workflow_url` and `run_url` are available, so every row exposes both click targets. Name-label tooltip flips dynamically: "Open workflow on GitHub" when both URLs are distinct, "Open latest run on GitHub" otherwise. Branch-label tooltip stays "Open latest run on GitHub". Hover/underline affordance from the previous change carries through. ### 2026-04-29 - **Hover affordance + tooltips on row title/subtitle links** — clickable row labels (`_pr_title_lbl`, `_name_lbl`, `_branch_lbl`) now shift to amber (`FG_LINK`) and underline on hover, so the click target is visually discoverable instead of relying on the bare `PointingHandCursor`. New `_link_css()` helper emits `QLabel { color: ; } QLabel:hover { color: #FBBF24; text-decoration: underline; }` per-label, replacing the six raw `setStyleSheet(...)` call sites in row init + `set_snoozed`. Snoozed rows keep the hover affordance with their dimmed base colours so the URL is still discoverable. Tooltips: PR title → "Open PR on GitHub"; workflow name + branch subtitle → "Open latest run on GitHub". Tooltips set once at init; hover styling persists across snooze/dim state changes. ### 2026-04-29 - **Configurable toast notification duration** — new `notifications.duration` config key (`"short"` default, or `"long"`). Wired through `NotificationManager.set_duration()` from `_start_pollers`; `_send` reads the snapshot under the existing `_lock` and threads it to winotify's `duration=` (Windows native ~7s/~25s) and a derived plyer `timeout` of 5/15s on Linux. Invalid values silently coerce to `"short"`. Documented in `config.template.yaml` next to `max_notification_age`. Note for users: Windows respects the OS accessibility "Show notifications for" override if set system-wide, which can extend display time beyond the toast's nominal duration. ### 2026-04-29 - **README logo bumped + winget marked broken** — header WizX20 logo was rendering at `height="60"` which compressed the wordmark on GitHub's repo page (way smaller than the surrounding `# Actions Monitor` H1); raised to `height="140"` so the brand mark reads at glance height. Added a "Currently broken" callout above the `winget install` block pointing users at Scoop / direct download until the winget pipeline is fixed; demoted "(recommended)" from winget to the Scoop section to match. ### 2026-04-29 - **Winget publish disabled by default in release workflows** — winget submission is currently broken (tracked in backlog), so flipped `enable_winget` default from `true` to `false` in both the manual `release.yml` dispatch input and the daily `build.yml` caller. `_release.yml` reusable workflow input default unchanged (callers always pass an explicit value). Re-enable per-run via the workflow_dispatch checkbox once the winget pipeline is fixed. ### 2026-04-29 - **Update helper no longer hangs when previous PID survives** — `UpdateChecker.restart_app` calls `os._exit(0)` after spawning the helper, but on some installs (notably the `C:\Tools\ActionsMonitor` test rig built before this session) the previous process never disappeared from `tasklist`, so the helper batch's `:waitpid` loop ran indefinitely while a visible Windows Terminal tab showed `findstr /C:""`. Helper waitpid loop now bounded: 30 iterations × 2s on Windows, 120 × 0.5s on Linux. On timeout it emits `[waitpid timed out…]` to `am_update.log` and force-kills the pid (`taskkill /F /PID …` / `kill -9 …`) so the existing rename-retry loop can take over even when `os._exit` failed to land. Visibility fix on Windows: `subprocess.Popen` was using `DETACHED_PROCESS | CREATE_NO_WINDOW` together — the combo is undefined and on Win11 with Windows Terminal as default it forces a visible terminal tab. Now uses `CREATE_NO_WINDOW` alone with `STARTUPINFO.wShowWindow = SW_HIDE` for belt-and-braces. ### 2026-04-29 - **Cleaner WizX20 icon at small sizes** — the bolt mark fused into a blob at 16/32px in the taskbar and toast notifications because the rounded-rect chrome consumed too much canvas, leaving the detailed mark in ~10 effective pixels. `_make_base_icon` now branches on size: ≤32px renders a clean amber `>` chevron with no chrome (legible in tray + toast); >32px keeps the full mark + dark rounded rect for the About dialog and Alt-Tab. Tray icons are pre-generated at 32 (was 64) so Windows scales the chevron down crisply rather than re-rendering the small-detail bolt. ### 2026-04-28 - **Repository moved to WizX20 ownership** — repo is now hosted at `github.com/WizX20/ActionsMonitor` (transferred from `summitnl/ActionsMonitor`, GitHub auto-redirects keep old URLs working). All Summit branding has been stripped: logos, copyright, AppUserModelID, and the embedded "S" mark have been replaced with the WizX20 lightning bolt mark; LICENSE and NOTICE attribute to WizX20. The winget package is being renamed `Summit.ActionsMonitor` → `WizX20.ActionsMonitor`; existing winget users must `winget uninstall Summit.ActionsMonitor && winget install WizX20.ActionsMonitor` once the new package clears Microsoft moderation. Scoop bucket URL updated; existing local installs keep working under whatever bucket alias they registered. UpdateChecker now hits `api.github.com/repos/WizX20/ActionsMonitor/releases/latest`. Stale `manifests/s/Summit/` working copy removed — first WizX20 winget submission must be re-bootstrapped manually with `wingetcreate new`. ### 2026-04-28 - **Daily build now publishes to winget** — `Summit.ActionsMonitor` is approved on `microsoft/winget-pkgs`, so `enable_winget` flips to `true` everywhere: default in `_release.yml`, daily `build.yml`, and the manual `release.yml` dispatch input. Daily releases now PR a manifest bump to winget alongside the existing Scoop bump (still gated by `secrets.WINGET_PAT` and `needs.release.result == 'success'`, so a failed build skips both managers). `winget upgrade Summit.ActionsMonitor` will track the daily tag instead of lagging behind the manual release cycle. - **Update download no longer hangs on connection close** — `UpdateChecker._apply_release_update` previously wrapped the download in `with requests.get(...) as dl`, so the function couldn't return until urllib3's `__exit__` had finished tearing down the socket. On Windows with intercepting AV / proxy stacks (NetSkope etc.) that close can stall for minutes after the body has been fully read, leaving the dialog pinned at "Downloading… 100%" even though `ActionsMonitor.update` was already on disk. Switched to an explicit `try / finally` around the response and now run `dl.close()` on a daemon thread joined with a 2 s timeout — the file write is fully done before close starts, the result signal fires immediately, and a stuck socket teardown is left to GC instead of blocking the UI. ### 2026-04-24 - **Notification batch window default lowered from 3s to 1s** — toasts previously felt laggy because `notifications.batch_window` defaulted to 3 seconds: row state updated immediately on the main screen but the toast waited for the batch timer to flush. Dropped the default to 1s in both `DEFAULT_CONFIG` (`src/main.py`) and `config.template.yaml` so bursts still coalesce (a push that triggers multiple workflows lands in the same poll cycle, well within 1s) but single events fire near-immediately. Users wanting zero delay can still set `batch_window: 0`; users wanting wider coalescing can raise it. - **Update dialog no longer stalls at 100%** — `UpdateDialog._do_update` previously scheduled the completion handler via `QTimer.singleShot(0, lambda: self._on_result(ok, msg))` *from the download worker thread*. In PySide6, `QTimer.singleShot` starts the timer in the calling thread's event loop — a plain `threading.Thread` has no loop, so the timer never fired and the dialog stayed pinned at "Downloading… 100%" even though `ActionsMonitor.update` had already been written to disk next to the running exe. Replaced the timer hop with a proper `_result = Signal(bool, str)` that connects in `__init__` and is emitted from the worker, matching the existing `_progress` signal pattern. Qt's queued-connection semantics now deliver `_on_result` on the main thread reliably, which in turn kicks off the 500 ms `restart_app` chain (swap helper + `os._exit`). - **Reviewer icon on APPROVED / CHANGES REQUESTED badges** — PR and URL mode rows now prepend a small Lucide-style glyph inside the review badge indicating whether the winning reviewer is a bot or a human. Classification is regex-driven via a new top-level `bot_pattern` config key (default `"\[bot\]$"`, matches GitHub App suffixes like `github-actions[bot]` / `dependabot[bot]`); invalid patterns silently fall back to the default. Mixed reviewers (bot + human both contributed the winning state) show the human icon — human wins. `_aggregate_review_status` now returns `(status, by_bot)` and `_cached_review_fetch` caches the tuple; both PR and URL pollers thread a compiled regex through on every poll. New `WorkflowState.review_by_bot` field carries the signal to `WorkflowRow`, which renders the icon as a base64 PNG data URI inside the badge QLabel (switched to `Qt.RichText` mode). Icon is colour-matched to the badge foreground (`#4ADE80` green for APPROVED, `#F87171` red for CHANGES REQUESTED) and cached per `(kind, colour, px)` in a module-level dict so each variant renders once. Tooltip flips between "Reviewed by bot" and "Reviewed by human". `commented` / `pending` badges are unchanged (no icon). Badge vertical padding bumped from `0px` to `1px` globally so the taller review pills (and their inline icons, rendered at 11px with `vertical-align: middle`) centre cleanly alongside DRAFT / CONFLICT / UNRESOLVED / STALE / SNOOZED; all badges are 2px taller as a side effect. - **Footer checkbox re-render + layout tweak** — swapped the order of "Minimize to tray on close" and "Always on top" so "Always on top" now sits at the end of the row. Bumped row1 top padding from 10 → 14 px so the checkbox indicator is no longer clipped. Full dark-theme styling for `QCheckBox::indicator` now renders amber-filled checked state with a white check glyph instead of falling back to Qt's minimal native style (which showed just the checkmark without a fill on some Windows builds). The check glyph is generated by PIL at startup (`_generate_check_glyph()` → `_check.png` in `_APP_DIR`, 14x14 supersampled 4x then LANCZOS-downscaled for clean edges) and referenced via QSS `image: url(...)`. Unchecked state shows a stone border on `BG_DARK`, hover lifts the border to amber. The PNG is regenerated every launch and gitignored (`_check.png`) alongside `app.ico`. - **Unresolved review threads badge on PR rows** — PR and URL mode rows now flag PRs that still have unresolved review threads with a red `N UNRESOLVED` badge (dark-red bg `#4A1D1D`, pale-red fg `#FCA5A5`), placed between CONFLICT and the Jira key. Count comes from GitHub's GraphQL API (`pullRequest.reviewThreads(first:100){nodes{isResolved}}`) since the REST Reviews endpoint doesn't expose `isResolved`. A new `_github_graphql_post()` helper mirrors the `_github_api_get` semantics (cooldown gate, 401 cache-bust, 429/secondary-403 + X-RateLimit-Remaining cooldowns, surfacing `errors` as `RuntimeError`); GraphQL responses aren't ETag-cached because the endpoint doesn't honour `If-None-Match`, but results are cached for 120s per PR (shared `_cached_unresolved_fetch()` helper, bounded at `_REVIEW_CACHE_MAX`) so the extra hit is amortised. `WorkflowState.unresolved_threads` carries the count; zero hides the badge. Caches invalidate alongside review/mergeable caches when rows age out (`_remove_sub_key`). PRWorkflowPoller + URLQueryPoller each wire `_fetch_pr_unresolved_threads` / `_fetch_unresolved_threads` next to the existing review-status fetch. Snoozed rows skip the query entirely (still gated by `_is_snoozed`). ### 2026-04-23 - **Merge conflict badge on PR rows** — PR and URL mode rows now flag branches with unresolved merge conflicts with a red "⚠ CONFLICT" badge (dark-red bg `#7F1D1D`, pale-red fg `#FEE2E2`), placed between DRAFT and REVIEW PENDING. Conflict state comes from GitHub's `mergeable_state == "dirty"` signal. URL mode extracts it from the existing `/pulls/{n}` detail call (no extra request). PR mode adds a dedicated `/pulls/{n}` fetch gated behind a 120s TTL cache (`_mergeable_cache`, bounded at `_REVIEW_CACHE_MAX`); GitHub computes `mergeable` lazily so `"unknown"` responses fall back to the previous cached value instead of flapping the badge off. `WorkflowState.has_conflict` carries the flag. Snoozed-row restyle and the PR-mode conflict-less render path both toggle the badge correctly. - **Wrap long titles and branch names in rows** — PR title, workflow name, branch, and status info labels now have `setWordWrap(True)`, and the row's vertical size policy switched from `Fixed` to `Minimum` so wrapped rows can grow in height. Previously a long PR title or a hyphen-heavy branch name (e.g. `EDU-8957-gecombineerde-registrantenexport-voor-proefstuderendagen-per-activiteitengroep`) pushed the right-side poll-rate label and snooze bell off the visible area. Right column stays `Fixed`, so the overflow wraps into the centre column instead of clipping the controls. - **Update helper logging + extended retry window** — the self-update swap helper (`am_update_{pid}.bat` / `.sh` written to `%TEMP%` / `/tmp`) previously ran silently with `CREATE_NO_WINDOW` and all output piped to `DEVNULL`, so when users reported "updates never seem to complete" there was no forensic trail. Both scripts now tee every step to a known log file (`%TEMP%\am_update.log` on Windows, `/tmp/am_update.log` on Linux): startup banner + timestamps, all three paths (current_exe / update_path / old_path), `[waiting for pid to exit]` → `[pid exited, attempting swap]` → per-try rename failures (`[rename current->old failed, try=N]`) → `[renamed current->old, moving new into place]` → `[launching new exe]` → `[done]`. Windows retry window bumped from 10 tries × ~1s (ping -n 2) to 30 tries × ~2s (ping -n 3), giving the swap ~60s instead of ~10s to clear file locks from stragglers like antivirus scanners or Windows Defender's background scan. Both platforms now roll back on mid-swap failure: if `move /y update_path → current_exe` fails (e.g. the new binary is in an unwriteable state after `current_exe → old_path` already succeeded), the batch / shell script restores `old_path → current_exe` before exiting non-zero, so the user is left with a working binary rather than a missing exe. Nothing new ships in the bundle — the helper is still generated at runtime, and the log path is deterministic so post-mortems can grep the file directly without knowing the pid. - **Code review pass — priority fixes** — five targeted improvements from a full-codebase review. (1) Linux build now produces a windowed binary with an embedded icon — `src/build.sh` gained `--windowed` and `--icon "app.ico"`, plus it runs `_generate_app_ico()` before PyInstaller so the icon is present. Previously the Linux binary spawned a terminal and had no taskbar icon. (2) `requirements.txt` pins now have upper bounds (`requests<3.0.0`, `PySide6<6.9.0`, `pyinstaller<7.0.0`, etc.) so a future breaking major doesn't silently break source installs. (3) `_github_api_get` now handles a `304 Not Modified` response when the ETag cache no longer has the matching entry (protocol-violation / race): it retries the request without `If-None-Match` instead of falling through to `resp.json()` on an empty body. (4) `UpdateChecker._apply_release_update` now verifies the downloaded binary's SHA-256 against `asset["digest"]` when GitHub exposes it (`sha256:HEX` format on newer releases); mismatched downloads are deleted and surfaced as a "Checksum mismatch" error so tampered or corrupted binaries can't swap in. Older releases without a digest continue to rely on size-only verification. (5) `MainWindow._apply_event` now gates section re-sorts on whether the active sort's backing field actually changed — `_maybe_resort_section_for_wid` compares `prev.status` / `prev.run_updated_at` / `prev.started_at` against the new state and skips the O(n log n) sort + Qt layout rebuild when nothing sort-relevant moved. New rows and removals still trigger unconditional re-sorts. Most common case (a green row stays green across polls) now costs nothing on the UI thread. - **Rate-limit resilience follow-ups** — three small politeness / recovery wins on top of the ETag + cooldown gate. (1) `_github_api_get` now clears `_cached_github_username` on `401 Unauthorized` before re-raising, so a rotated or revoked GitHub token recovers on the next poll instead of every poller forever spinning on stale `actor=` params until a config hot-reload. (2) `MainWindow._refresh_all()` staggers poller wake-ups via `QTimer.singleShot(i * 75, p.trigger_poll)` instead of hammering `_poll_now` on every poller in the same tick — heavy PR-mode configs no longer fire a synchronized dozen-request burst on manual refresh. (3) `PRWorkflowPoller._fetch_branch_runs()` gained a 30s per-`(wf_file, branch)` TTL cache (`_branch_runs_cache`, bounded at 200 via `_prune_cache`) so the fan-out for open-PR branches missing from the bulk `/actions/workflows/{wf}/runs?actor=…` response doesn't re-issue the same per-branch request across back-to-back polls. The ETag layer already made repeats free on the server side, but this skips the HTTP round-trip entirely while the cached list is fresh. - **ETag conditional requests + rate-limit cooldown gate** — `_github_api_get` now maintains a module-level ETag cache (`_etag_cache`, keyed by `(url, sorted-params)` → `(etag, parsed_json)`, bounded at 300 via `_prune_cache`). Every request sends `If-None-Match` when a prior ETag is cached; on `304 Not Modified` the cached JSON is returned without re-parsing a body — and critically, 304 responses don't count against GitHub's primary rate limit, so polling workloads that mostly see unchanged data are effectively free. A shared cooldown gate (`_rate_limit_until` / `_rate_limit_lock`) is advanced whenever GitHub returns `429`, a secondary-limit `403` (detected by "rate limit" in the body), or `X-RateLimit-Remaining: 0`; wait duration is parsed from `Retry-After` first, falling back to `X-RateLimit-Reset`, then a 60s default. While the gate is active all pollers short-circuit with a `RateLimited` exception — no network hits — so the app backs off globally instead of each poller independently hammering the limit. Callers already catch `Exception`, so the error surfaces as "GitHub rate limited — retry in Xs" on affected rows without additional plumbing. - **Update dialog download progress** — the "Updating…" modal now shows a live progress bar and byte counter (`3.2 / 11.8 MB (27%)`) while the new binary downloads, replacing the previous silent "Updating..." text. `UpdateChecker.apply_update()` takes an optional `progress_cb(bytes_written, expected_size)` that fires per 64KB chunk; `UpdateDialog` wires it through a Qt `Signal` so cross-thread updates queue onto the main thread automatically. Bar starts indeterminate until the first chunk arrives (in case asset metadata omits a size), then switches to a determinate fill. On failure the bar hides and the error message shows in red so the user can retry with Skip. - **Daily build now publishes to Scoop** — consolidated `build.yml` (daily cron) and `release.yml` (manual) onto a shared reusable workflow `_release.yml` (`workflow_call`) so both flows run the same check → build Windows → build Linux → release → (optional) Scoop → (optional) winget pipeline. Scoop and winget are gated by `enable_scoop` / `enable_winget` boolean inputs; their jobs `needs: [release]` but a failure in them does not fail the main release (re-run just the failed manager push from the Actions UI). Daily `build.yml` passes `enable_scoop: true, enable_winget: false, tag_strategy: overwrite` (same-day re-releases delete the existing tag and recreate). Manual `release.yml` exposes both toggles as `workflow_dispatch` inputs (scoop default on, winget default off) and uses `tag_strategy: suffix` (`v.2`, `.3`, … on tag collision). Previously the daily workflow skipped both package managers entirely, so Scoop lagged by a manual release cycle. - **Fix missing PR rows for zero-CI branches** — PR-mode sections dropped rows for branches whose open PRs had no workflow runs yet (typically new drafts that haven't triggered CI, or branches where the primary/extra workflows simply don't run). `_poll()` grouped runs by `head_branch` and a branch with zero runs never entered `by_branch`, so the downstream render loop never saw it. Now every branch in `open_pr_branches` is seeded into `by_branch` (empty list if no runs), PR numbers for zero-run branches are populated from the already-fetched `open_prs` list instead of relying on the `_fetch_prs_for_branch` fallback, and the render path tolerates an empty `group_runs` by emitting `ST_UNKNOWN` with no run fields set. ### 2026-04-22 - **Code review pass** — removed dead Linux focus-script code (`_FOCUS_SH` + `_ensure_focus_sh()` were created but never wired up; plyer can't launch them). Extracted shared `_aggregate_review_status()` + `_cached_review_fetch()` helpers so `PRWorkflowPoller._fetch_pr_review_status()` and `URLQueryPoller._fetch_review_status()` no longer duplicate ~20 lines of cache-TTL + aggregate logic. Added `_PR_CACHE_MAX` / `_REVIEW_CACHE_MAX` (200) with a small `_prune_cache()` helper to stop `_pr_cache` / `_review_cache` growing unbounded on long sessions. Fixed a stale-event race in `_reload_pollers()`: config hot-reload now drains the event queue after stopping old pollers so in-flight events referencing stale `wid`s can't hit the freshly-cleared state. Replaced two O(n) `next(... in _section_content.items())` reverse-lookups in `_apply_event()` with a preserved `_container_to_title` map (built at section creation, cleared in `_destroy_sections`). - **Compact snooze button + trimmed poll label** — snooze bell shrank from 24×24 to 16×16 (`_SNOOZE_ICON_SIZE`) and moved out of its own slot in the left column into a horizontal cluster next to the poll-rate label on the right. Left column is now just the status icon. Poll-rate text dropped the redundant `every ` prefix (`every 45s` → `45s`) and the 70px minimum width is gone — the right cluster sizes to content. Hover, active, and tooltip behaviour unchanged. - **Persist snooze state across restarts** — snoozed rows now survive quit / relaunch. `state.json` gains a top-level `snoozed` dict keyed by a stable workflow identifier (`mode:url` or `url:query`) rather than the positional `wid`, so reordering workflows in `config.yaml` doesn't orphan snoozes. `_toggle_snooze` writes immediately via a new `_save_snoozed_state()` helper (same pattern as `_save_collapse_state`); `_restore_snoozed_state` runs right after `_start_pollers()` to rebuild `_snoozed` + `_snoozed_keys` and apply `set_snoozed(True)` to branch-mode rows that exist synchronously. Dynamic rows (PR/Actor/URL) pick up the snoozed flag during row creation in `_drain_queue`. To make those rows render at all after a cold start — previously the snoozed-skip `continue` emitted nothing — the three multi-row pollers now build a minimal `WorkflowState` from already-fetched bulk data (status from run results in PR/Actor mode, title/draft/target from the search payload in URL mode) and emit that before the continue. No per-PR detail, review, or staleness fetches run. `_reload_pollers` re-maps snooze tuples through the stable key on hot-reloads so they stick even when wids shift. - **Snooze any row + bell icon** — snooze button now shows on every row regardless of status (previously only visible on failed rows). Snoozed rows skip polling entirely: `WorkflowPoller._poll` (branch mode) returns immediately when the row is snoozed, and the PR / Actor / URL pollers skip per-sub_key processing (no PR detail, review, staleness, or event emission) for snoozed keys. New shared `_is_snoozed(wid, sub_key)` helper guards each skip site. Unsnoozing calls `trigger_poll()` on the affected poller so the row refreshes immediately instead of waiting for the next interval. Icon swapped from three stacked Zzz glyphs to a filled Lucide-style bell (`_draw_bell_glyph`) with a diagonal slash overlay for the snoozed state — `_make_snooze_icon(off=True)` draws a bg-coloured gap line first to carve the slash out of the bell, then overlays the foreground stroke. Tooltip now flips between "Snooze — pause polling, dim the row, mute notifications" and "Unsnooze — resume polling and notifications" depending on current state. - **Header Check for updates + Help buttons** — two new icon buttons sit in the header row next to Refresh. The download-arrow "Check for updates" button manually runs `UpdateChecker.check()`; if a new release is available it opens the existing `UpdateDialog`, if the app is already current it surfaces a "You're on the latest version ()" modal, and in source installs it tells the user to `git pull` instead. The question-mark "Open project on GitHub" button opens the repo URL in the default browser. Startup update check now reuses the same `MainWindow._check_for_updates()` method (silent when `manual=False`), so behaviour on launch is unchanged. - **Managed-install update dialog** — when Actions Monitor detects it was installed via Scoop (`\scoop\apps\` in the exe path) or winget (`\WinGet\Packages\`), the update dialog no longer offers to download and swap the binary. Instead it shows the upgrade command for the detected manager (`scoop update actionsmonitor` or `winget upgrade Summit.ActionsMonitor`) with a copy-to-clipboard button. Direct-download installs keep the existing auto-update flow. Prevents metadata drift where the in-app updater overwrites a scoop/winget-managed binary that the next manager-driven update would then silently revert. - **Install via winget and Scoop** — Actions Monitor can now be installed with `winget install Summit.ActionsMonitor` or via the Scoop bucket at `summitnl/ActionsMonitor` (`scoop bucket add summit https://github.com/summitnl/ActionsMonitor && scoop install actionsmonitor`). Both install pipelines strip the Windows Mark-of-the-Web, so SmartScreen no longer prompts on first launch. Direct downloads from the GitHub Releases page still require the usual "Unblock" step because the binary is unsigned. The release workflow auto-bumps `bucket/actionsmonitor.json` on every release and submits a PR to `microsoft/winget-pkgs` via `wingetcreate`. Developer-facing documentation (run-from-source, build, release process) moved from `README.md` into a new `DEVGUIDE.md`. - **URL mode — custom PR inboxes via GitHub Search** — new workflow `mode: "url"` that takes a `query:` string (GitHub issue/PR search syntax, e.g. `"is:pr is:open review-requested:@me"`) and renders each matching pull request as a row in its own section. Hits `GET /search/issues`, filters to PR results only, fetches draft/base-ref/review-status per PR, and caches by `(owner, repo, pr_num)` since queries span repos. `@me` is auto-substituted with the authenticated user's login. Row status is derived from review state (approved → success icon, changes-requested → failure icon, pending/commented → unknown) so URL sections don't trigger noisy tray escalations. Notifications are skipped for this mode. Staleness, Jira, draft, and branch-prefix badges behave the same as PR mode. Implemented via `URLQueryPoller(WorkflowPoller)`; documented with examples + link to GitHub's search docs in `config.template.yaml`. - **Summit brand icon** — replaced the generic amber play-triangle in the app/window/tray/toast-notification icon with the Summit S play-button mark (`#00C890` green). The mark is rasterized from `docs/summit.svg` once and embedded as base64 inside `main.py` (no bundled asset, single-file design preserved), then composited onto the existing warm-dark rounded-rect frame via the cached `_load_summit_mark()` helper. `_generate_app_ico()` now regenerates `app.ico` on every startup so design changes propagate without manual file deletion; tray status-dot overlay is unaffected. - **Deeper snoozed-row dim** — snoozed rows now fade harder so they recede into the background. Labels drop to stone-700/800 (`#57534E`/`#44403C`) from stone-500, accent bar drops to `#3F3B38`, status icon gets 35% opacity via `QGraphicsOpacityEffect`, and all badges (prefix, DRAFT, Jira, review, STALE) override to a uniform muted grey (`#2C2825`/`#57534E`) — amber/yellow/purple/red tones fully fade. SNOOZED badge keeps its normal grey so the state stays visible at a glance. - **Fix auto-update crash & hang** — auto-update swapped the running `.exe` in place (rename to `.old`, move `.update` over the original path) while the process was still alive. PyInstaller's onefile bootloader lazy-loads modules by re-reading `sys.executable` at runtime, so any import after the swap read from the new binary using the old archive offsets and crashed with `zlib.error: Error -3 while decompressing data: incorrect header check` (seen in v2026.04.18). The update dialog would also hang because `sys.exit(0)` doesn't reliably terminate a running Qt event loop. Now `_apply_release_update()` only downloads to `.update` without touching the running exe, and `restart_app()` writes a detached helper script (`.bat` on Windows, `.sh` on Linux) to temp that waits for the PID to exit, swaps the files with retry, launches the new exe, and self-deletes. Current process terminates via `os._exit(0)` to bypass Qt. No extra release asset required — the helper script is generated on the fly. ### 2026-04-21 - **Fix foreign PR leak (round 2)** — previous fix relied on GitHub's `?creator={username}` filter to build the allowlist, but the filter turned out to be loose on the API side: it returned PRs whose `user.login` was someone else entirely (reproduced in summitnl/HippoCampus: `creator=wpaap` returned PR #4134 authored by `boukeversteegh`). Now `_fetch_user_open_prs` re-checks each PR's `user.login` against the authenticated username client-side before adding to `user_pr_numbers`, so the downstream filter drops foreign PRs reliably. - **Minimize to tray on close (optional)** — new footer checkbox "Minimize to tray on close" (default on, persisted in `state.json`). When unchecked, closing the window actually quits the app; when checked, close hides to tray as before. Tray menu "Quit" renamed to "Close". Footer reworked: checkboxes row on top with wider spacing, config hint + open-config link moved to the bottom. - **Fix other users' PRs leaking into PR mode** — when another user opened a PR on a branch that also had the authenticated user's PR (or same branch name), GitHub attached both PR numbers to each run's `pull_requests` field, causing the foreign PR to appear as its own row. Now filters collected PR numbers against `_fetch_user_open_prs` (applies to both the runs-based path and the `_fetch_prs_for_branch` fallback). - **Auto-update: drop git-mode, keep release-mode only** — the source-install update path (`git fetch`/`git pull`) could clobber uncommitted work, ignored the current branch, and confused fetch failures with "up to date". Removed entirely. Auto-update now only runs on frozen binaries (`.exe` / Linux binary) and downloads the matching GitHub Releases asset. Devs running from source update manually via git. Also: verify downloaded byte count against `asset["size"]` to catch truncation, clear cached release data after successful swap, and defer the update check until after the main window is shown (via `QTimer.singleShot(1500, …)`) so startup is no longer blocked by the modal dialog. ### 2026-04-17 - **Codebase quality pass** — fix unbounded `_pr_cache`/`_review_cache` growth (entries now evicted when sub_key is removed), extract `_github_api_get()` helper to deduplicate 8+ identical HTTP request patterns, reuse `_icon_base()` in `_make_refresh_icon`/`_make_snooze_icon`/`_make_base_icon` (was reimplementing same 3 lines), extract `_draw_z_glyph()` helper for snooze icon, data-driven snooze icon style init, skip tray icon `setIcon()` when status unchanged (avoids unnecessary work every 500ms), add Qt `screens()` fallback in `_get_monitor_work_areas()` for Linux multi-monitor support. - **Performance & cleanup pass** — use `requests.Session()` per poller for HTTP keep-alive, cache PR review status with 120s TTL (reduces API calls by ~70% for PR-mode), add `creator=` filter to open PRs fetch (avoids fetching all repo PRs), extract shared `_detect_notification()` and `_remove_sub_key()` to base `WorkflowPoller` class, fix `_check_release` false positive when `target_commitish` is a branch name, move `_MONITORINFO` struct outside per-monitor callback, parse staleness thresholds once per poll from config instead of rebuilding each iteration, remove unused `APP_VERSION` constant and dead `_sep_lbl` widget, document that focus-on-click is Windows-only (Linux `_focus.sh` created but unused by plyer). - **Code review fixes** — race condition fix in GitHub username caching (hold lock through API call), extract `_remove_sub_key()` on `ActorWorkflowPoller` (was inlining removal logic), remove dead `WorkflowState.conclusion` field, make `winotify` dependency Windows-only in `requirements.txt` (was breaking `pip install` on Linux), preserve snooze state across config hot-reloads, use XDG data dirs for Linux notification sounds instead of hardcoded paths, add cross-platform focus signal infrastructure (shell script on Linux alongside VBScript on Windows), and check focus signal on all platforms instead of Windows-only. - **Migrate UI from tkinter to PySide6 (Qt)** — complete rewrite of the UI layer for better performance, native system tray (`QSystemTrayIcon` replaces `pystray`), CSS-like dark theme via QSS stylesheet, built-in tooltips, smooth scrolling via `QScrollArea`, and `QTimer`-based event draining. All backend code (pollers, config, notifications, icon generation) unchanged. Removes `pystray` dependency, adds `PySide6`. - **Window resize performance** — fix sluggish window resizing (especially in .exe builds). Debounce canvas scroll-region recalculation (50ms throttle instead of every Configure event), cache per-row widget lists to avoid repeated `winfo_children()` traversals, share a single right-click context menu across all rows instead of creating one per row, and batch layout updates during section re-sorting. - **Code cleanup** — extract `_emit_error()` helper on `WorkflowPoller` (replaces 13 duplicate error-state blocks), extract `_worst_status()` to deduplicate status aggregation logic, guard `pack_propagate` toggle with try/finally. ### 2026-04-16 - **Auto-hide scrollbar** — the workflow list scrollbar now only appears when there are enough items to scroll. Correctly shows/hides when the list grows or shrinks, including after config hot-reloads. - **Fix run timestamps showing UTC instead of local time** — the time displayed next to run numbers (e.g. "16 Apr 14:30") was parsed without timezone conversion, causing it to be offset from the user's local time. Now properly converts from UTC to the system's local timezone. - **Immediate merged PR removal** — PR-mode rows for merged/closed PRs are now removed on the next poll cycle instead of waiting for the 5-minute stale timeout. The stale timeout is preserved as a fallback for edge cases (API failures, `max_prs` truncation). - **Fix open PR filter bug** — when `_fetch_user_open_prs()` API call failed, the empty-set guard skipped run filtering entirely, causing merged PR rows to persist indefinitely. Now tracks API success separately so the filter works correctly even when the user has zero open PRs. - **Linux system dependency check** — on startup, checks for required system libraries (GTK3, AppIndicator, paplay/aplay). Shows a dismissible warning dialog listing missing packages with install instructions. App continues running regardless. - **Linux sound defaults** — new configs on Linux default to `"default"` sound (freedesktop system sound via paplay) instead of `"whistle"` (Windows-only winotify preset). - **Resilient tray icon** — tray icon initialization wrapped in try/except so the app runs without a tray icon if system libraries are missing (e.g. no AppIndicator on Linux). Window close/minimize now falls back to iconify instead of withdraw when no tray is present, preventing the app from becoming invisible. - **Code review fixes** — comprehensive cleanup pass addressing bugs, inefficiencies, and Linux gaps: - Fix mousewheel scrolling on Linux (was Windows-only `` event; added `Button-4`/`Button-5` bindings) - Fix window vanishing on minimize when tray icon is unavailable (close button now minimizes instead of hiding) - Fix update checker `target_commitish` comparison (could be a branch name, not a SHA) - Fix `StartupManager._exe_cmd()` for frozen builds (was using `__file__` which points to a temp dir) - Guard Windows-only code paths (`_check_focus_signal`, VBS cleanup) to skip on Linux - Remove redundant `_fetch_pr_draft()` API call per PR per poll (data already cached from open PRs fetch) - Cache `_make_base_icon()` result (was regenerated 7× for tray icons) - Remove duplicate `_REPR_PRIORITY` dict (was copy of module-level `_STATUS_PRIORITY`) - Extract shared `_set_row_bg` helper (deduplicate bg-walking in 3 places) - Consolidate `state.json` reads on startup (was read 3× independently) - Fix config watch interval to 5s (was 2s, docs said 5s) - Fix `_restripe_rows()` to stripe per-section instead of globally - Move `_review_cfg` dict to module-level constant (was rebuilt every UI update) - Add platform-aware `UI_FONT` constant (`"DejaVu Sans"` on Linux instead of `"Segoe UI"`) - Try multiple font files for question mark icon on Linux (`DejaVu Sans Bold`, `Liberation Sans Bold`, `FreeSans Bold`) - Remove duplicate `import ctypes` and unused variable in monitor enumeration - Remove dead methods (`_restore_window_state`, `_restore_collapse_state`, `_restore_always_on_top`, `_fetch_pr_draft`) - Remove unused `_SORT_KEYS` class variable - Move `base64`/`io` imports from inside `_build_ui` to module level - Extract `_stale_cfg` dict to module-level `_STALENESS_BADGE_CFG` constant - **Always on top** — checkbox next to "Start with Windows" in the footer. Keeps the window above all other windows. State persisted in `state.json` and restored on startup. - **Snooze rows** — failed workflow rows show a Zzz button below the status icon (with hover highlight). Click to snooze; right-click context menu also available on any row. Snoozed rows are visually dimmed (grey accent bar, muted text) and show a "SNOOZED" badge. Snoozed rows are excluded from the tray icon status aggregation so they won't turn the icon red. Notifications are suppressed for snoozed rows. Snooze auto-clears when a new run starts on that workflow. In-memory only — not persisted across restarts. - **Fix duplicate daily releases** — the daily build's change-detection compared a branch name against a commit SHA, causing it to always detect "changes" and create a release even when nothing had changed. Now resolves `targetCommitish` to a full SHA before comparing. ### 2026-04-15 - **GitHub Actions CI** — added daily build workflow (05:00 UTC) and manual release workflow. Builds Windows `.exe` and Linux binary via PyInstaller, creates date-tagged GitHub Releases (e.g. `v2026.04.15`) with changelog in the release body. Binaries removed from git tracking. - **Release-based auto-updater** — frozen builds now check the GitHub Releases API for updates on startup. Downloads the correct platform binary, swaps the executable in-place, and restarts. Source installs keep the existing git-based update flow. - **Linux support** — fixed `ctypes.wintypes` unconditional import that prevented the app from starting on Linux. Guarded `iconbitmap` and VBScript focus signal to Windows only. Added Linux binary (`ActionsMonitor-linux`) built via WSL/PyInstaller. Added `src/build.sh` for building on Linux or via WSL. Added Linux sound notes to config template. Updated README with Linux instructions. - **Sort controls per section** — each section header now has clickable Status, Updated, and Created sort labels. Click cycles through ascending (▲), descending (▼), and off. Only one sort active globally — activating one clears all others. Sort preference persists in `state.json`. - **Notification click brings window to foreground** — clicking a toast notification body now raises the app window and blinks the relevant row(s) with a brief amber flash animation (3 cycles, 900ms). The "Open workflow" action button keeps its existing behavior. Uses a VBScript signal file mechanism for silent IPC between the notification click and the running app. - **Stale notification suppression** — notifications older than `max_notification_age` (default `"1h"`) are now silently dropped. Prevents a flood of stale toasts after waking from sleep. For `new_run` notifications the run's start time is checked; for `success`/`failure` the run's last update time is used (so long-running jobs that just completed still notify). Configurable under `notifications.max_notification_age` using duration strings (`"30m"`, `"2h"`, etc.) or `0` to disable. ### 2026-04-14 (UI polish) - **Refresh icon button** — replaced the text "Refresh" button in the header with a Lucide-style rotate-cw icon, matching the app's visual language. - **Notification icon** — Windows toast notifications now display the app icon (amber play triangle) instead of a blank square. ### 2026-04-14 (code cleanup) - **Code cleanup** — removed unused imports (`field`, `tkfont`), dead `_prev_conclusion(s)` tracking variables, extracted `_gh_headers()` helper (replaced 9 inline header constructions), extracted `_resolve_status()` helper (replaced 3 duplicated status-mapping blocks), extracted `_cache_pr()` helper (replaced 3 identical PR cache update blocks), consolidated state file I/O into `_load_state()`/`_write_state()`/`_persist_collapsed()` helpers, fixed redundant `config_mgr.get()` call in `_add_poller()`, and skipped `_generate_app_ico()` when `app.ico` already exists. ### 2026-04-14 - **PR staleness badge** — PR-mode rows now show a colour-escalating staleness badge (yellow/orange/red) based on how long since the PR was last updated. Thresholds are configurable with human-friendly durations (`"1d"`, `"3d"`, `"5d"`). New `parse_duration()` utility accepts `"30m"`, `"12h"`, `"2d12h"`, etc. — also used by `pr_stale_after` and `stale_after` which now accept the same format (plain integers still work). - **IN REVIEW badge** — review status now distinguishes between "no reviews" (REVIEW PENDING, amber) and "has review comments but no formal decision" (IN REVIEW, blue). - **Multiple PRs per branch** — PR-mode now shows separate rows when a branch has multiple PRs targeting different branches (e.g. `hotfix/fix-123 → acceptance` and `hotfix/fix-123 → production`). Each row displays its own target branch, PR number, draft status, review status, and build status independently. - **Reliable DRAFT badge** — draft status is now refreshed every poll cycle (previously cached forever). The badge has a new bold amber style for better visibility. - **Open PR discovery** — PR-mode now queries the GitHub Pulls API to find all your open PRs, ensuring that PRs with older CI runs (like long-lived drafts) still appear even when their workflow runs have fallen off the recent runs page. PR numbers, titles, and target branches are now reliably detected for all rows via the Pulls API fallback. - **Closed PR cleanup** — PR-mode now filters out branches whose PRs have been closed or merged, so stale rows from completed work no longer linger in the list. - **Scroll fix** — fixed empty space appearing above content when scrolling up. ### 2026-04-13 - **Refresh button** — click **Refresh** in the header to trigger an immediate re-poll of all workflows without waiting for the next interval. - **PR review status** — PR-mode rows show a colour-coded review badge: green APPROVED, red CHANGES REQUESTED, or amber REVIEW PENDING. Updated each poll cycle. - **PR title display** — PR-mode rows now show the pull request title as the main clickable title (opens the PR), with #number + branch as a subtitle (opens the build run). - **Jira ticket links** — when `jira_base_url` is configured, Jira ticket IDs (e.g. `EDU-1234`) are extracted from branch names and shown as clickable badges on PR and actor-mode rows. Clicking opens the ticket in Jira. - **Fix PR mode false success with multiple workflows** — PR-mode entries now support an `extra_workflows` list that aggregates status across multiple workflow files. The row shows the worst-of status (failure > running > queued > success), so integration tests still running or failing are no longer hidden behind a passing primary workflow. Notifications fire on aggregate status transitions. - **Collapsible categories** — click any section header to collapse/expand its rows. Collapse state persists across restarts via `state.json`. Collapsed sections still contribute to the tray icon status. ### 2026-04-10 - **Visual refresh & UX improvements** — warm dark theme (stone/amber palette), Lucide-inspired status icons rendered with PIL (checkmark, X, loader, clock, ban, skip, question mark), coloured left accent bars on rows, amber section headers, themed scrollbar, and refined spacing throughout. PR rows now show just the PR number and branch name (the workflow name is in the section header). Badges like DRAFT and branch prefix sit on their own line. The app remembers its window position and size across restarts (`state.json`), with multi-monitor–aware clamping so it never restores off-screen. Added PyInstaller support (`build.bat`) to produce a single `.exe` with the icon embedded. ### 2026-04-09 - **Named notification sounds** — new named sound options (`whistle`, `default`, `reminder`, `mail`, `sms`) that play in sync with the Windows notification flyout instead of firing independently. The default sound for new runs is now `whistle`. Custom `.wav` file paths still work as a fallback. - **Custom app icon** — the window, taskbar, and system tray now show a dedicated Actions Monitor icon (play triangle with status dot) instead of the generic Python icon. - **Section headers** — workflows are visually grouped by type: branch-mode workflows under a "Workflows" header, each PR-mode workflow under its own named header with a separator line. - **Actor mode** — new `mode: "actor"` shows all your recent workflow runs across an entire repo (not just one workflow). Supports `filter: "failed"` to show only failed builds. Configure with a GitHub Actions actor URL like `https://github.com/owner/repo/actions?query=actor%3Ausername`. ### 2026-04-03 - **Notification batching** — notifications arriving within a short window (default 3 s) are combined into a single toast and sound, preventing notification spam when many workflows trigger at once. Configurable via `notifications.batch_window` (set to `0` to disable). - **PR mode** — monitor your own pull request builds with `mode: "pr"`. One row per active PR, with branch prefix tags (`hotfix`, `feature`, etc.), PR numbers, and a DRAFT indicator. Stale rows auto-remove after a configurable timeout. - **PR notification overrides** — new `notifications.pr` config subsection lets you override notification defaults for PR-mode workflows (e.g. disable success notifications for PRs only). - **Auto-update check** — the app checks for updates on startup via git and offers to pull + restart automatically.