diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a28a2f6..6a1406c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,5 +1,8 @@ name: CI +permissions: + contents: read + on: push: branches: [main] @@ -16,17 +19,69 @@ jobs: with: enable-cache: true + - name: Install just + uses: extractions/setup-just@dd310ad5a97d8e7b41793f8ef055398d51ad4de6 # v2 + - name: Set up Python run: uv python install 3.12 - name: Install dependencies run: uv sync --all-extras --dev - - name: Lint (ruff) - run: uv run ruff check . + - name: Install extension test tools + run: scripts/install-gnome-test-requirements.sh + + - name: Lint + run: just lint + + - name: Static analysis + run: just static - - name: Static analysis (mypy) - run: uv run mypy src + - name: Tests + run: just test - - name: Tests (pytest + coverage) - run: uv run pytest + - name: Package extension + run: just package-extension + + - name: Upload extension package + uses: actions/upload-artifact@v4 + with: + name: gnome-shell-extension + path: dist/et@seb4stien.github.com.shell-extension.zip + + gnome-shell: + name: GNOME Shell ${{ matrix.shell }} + strategy: + fail-fast: false + matrix: + include: + - os: ubuntu-24.04 + shell: "46" + - os: ubuntu-26.04 + shell: "50" + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + + - name: Install just + uses: extractions/setup-just@dd310ad5a97d8e7b41793f8ef055398d51ad4de6 # v2 + + - name: Install GNOME Shell test dependencies + run: scripts/install-gnome-test-requirements.sh + + - name: Verify scripts + run: shellcheck scripts/*.sh scripts/lib/*.sh + + - name: Run real Shell lifecycle test + env: + CI: "true" + ET_DIAGNOSTICS_DIR: ${{ github.workspace }}/artifacts/gnome-${{ matrix.shell }} + run: just test-extension-live --strict --expected-shell-major ${{ matrix.shell }} + + - name: Upload Shell diagnostics + if: failure() + uses: actions/upload-artifact@v4 + with: + name: gnome-shell-${{ matrix.shell }}-diagnostics + path: artifacts/gnome-${{ matrix.shell }} + if-no-files-found: ignore diff --git a/.gitignore b/.gitignore index edc2a61..a9984d6 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ .venv/ __pycache__/ dist/ +gschemas.compiled .coverage *.egg-info/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 7dd1f28..bfc1043 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -3,18 +3,17 @@ repos: hooks: - id: ruff name: ruff check - entry: uv run ruff check + entry: just lint language: system - types: [python] + pass_filenames: false - id: mypy - name: mypy - entry: uv run mypy src + name: static analysis + entry: just static language: system - types: [python] pass_filenames: false - id: pytest name: pytest - entry: uv run pytest + entry: just test language: system pass_filenames: false always_run: true diff --git a/CHANGELOG.md b/CHANGELOG.md index 8c61a0d..dba1dec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,57 @@ All notable changes to `et` are documented here. Format loosely follows ## [Unreleased] ### Added +- `et config`: an interactive wizard to create or update + `~/.config/et/config.yaml` (the `jira` block and the `workspaces` list), + pre-filling existing values and testing Jira credentials once entered. + +## [1.0.0-rc.1] + +Release candidate for `v1.0.0`, built from `feat/native-wayland` (PR #9). + +### Changed +- **Breaking:** `et jira start` now takes the Jira issue key as a + positional argument (`et jira start KEY`) instead of the `-k`/`--key` + option; the option has been removed. + +### Fixed +- Workspace creation, deletion, reset, and reordering now roll back reversible + config and GNOME settings changes when a later step fails, and report when + extension state may still be partially applied. +- Jira worklogs that succeed before a counter-reset failure are now reported + as partial successes with a safe counter-only recovery command, preventing + accidental duplicate worklogs on retry. +- Jira issue and transition parsing now handles malformed nested response + fields without leaking raw `AttributeError` exceptions. +- Config updates are now written atomically with restrictive permissions. +- CI now restricts token permissions and pins the third-party `setup-just` + action to an immutable commit. +- `scripts/install-gnome-shell-extension.sh` now *copies* the `et` GNOME + Shell extension into `~/.local/share/gnome-shell/extensions/` by default + instead of symlinking it, so it keeps working across workspace + re-provisioning that happens on a timeline independent of the GNOME + session (previously, if the symlink's target didn't exist yet at the + exact moment GNOME Shell scanned extensions at startup, the extension was + silently skipped and never loaded until an unrelated further restart). + Pass `--dev`/`--symlink` to opt back into symlinking, e.g. when actively + editing `extension.js`. The "not yet scanned" fallback message now also + queries the running Shell over D-Bus to distinguish "never scanned" from + "scanned but errored". + +### Added +- A companion `et` GNOME Shell extension + (`gnome-extension/et@seb4stien.github.com`, installed via + `scripts/install-gnome-shell-extension.sh` / `just install-requirements`) + exposing a D-Bus service used to detect the active workspace on Wayland + sessions and to own persistent per-workspace counters. Managed dynamic + workspaces now start and pause automatically as the active workspace + changes, pause while the session is locked, and resume after unlock. +- Configurable, ticket-system-agnostic workspace labels, reference estimates, + and live counters in both the GNOME top panel and workspace switcher. A + libadwaita preferences window controls each displayed value and location; + only the counter is shown by default. +- D-Bus operations for the CLI to prepare, read, reset, remove, update, and + atomically remap workspace counters without editing extension state directly. - `et jira start -k KEY` (`--key`): start a task from a specific Jira issue key directly, skipping the active-issues picker (failing if `KEY` is already linked to a workspace). Follows the same steps as the @@ -30,12 +81,12 @@ All notable changes to `et` are documented here. Format loosely follows the active workspace (or a different issue via `-j`/`--jira`), prompting for the message if not given as an argument. - `et jira log-time [Xh]`: log a manually-specified duration (e.g. `2h`, - `1.5h`) to the linked Jira issue instead of reading the Tracker timer's + `1.5h`) to the linked Jira issue instead of reading the workspace counter's elapsed time. - `et jira log-time --all`: log every workspace with a linked Jira issue (not just the active one) in a single run, each to its own issue, without switching GNOME workspaces. Workspaces with too little tracked time, no - Tracker timer, or a failing Jira call are skipped and reported instead of + prepared counter, or a failing Jira call are skipped and reported instead of aborting the rest. - `et jira status [in-progress|blocked]`: move the linked Jira issue directly to "In Progress" or "Blocked". With no argument, shows the @@ -61,11 +112,16 @@ All notable changes to `et` are documented here. Format loosely follows issues in the Jira web UI. - `et ws organize`: reorder dynamic workspaces by editing their order in `$EDITOR` (static workspaces stay pinned). Shows a before/after summary - (old/new index, name, linked Jira issue, Tracker time) and asks for - confirmation before applying. Tracker timers follow their workspace to + (old/new index, name, linked Jira issue, counter time) and asks for + confirmation before applying. Counters follow their workspace to its new slot. ### Changed +- The bundled `et` extension replaces the third-party Tracker extension. + Existing Tracker values are intentionally not imported; newly prepared + workspaces start at zero. +- Jira issue reads now include the original estimate so the CLI can provide it + to the generic extension display alongside the workspace label. - Config gains `jira.project_key` and `jira.board_id`. - Jira issue references (`et jira log-time`, `et jira complete`) are now clickable OSC 8 terminal hyperlinks, like `et jira start` already was. @@ -74,16 +130,15 @@ All notable changes to `et` are documented here. Format loosely follows - `et jira complete` now actually deletes the freed GNOME workspace instead of leaving an empty slot, and prompts for confirmation before freeing the workspace and closing the linked Jira issue. -- `et jira start` (task creation) now cleans up stale Tracker timers left - behind by previously deleted workspaces. +- `et jira start` resets a reused workspace's extension-owned counter. - `et ws delete` shrinks GNOME's workspace count instead of leaving a bare - trailing slot, and no longer orphans the Tracker timer when deleting the + trailing slot, and no longer orphans the counter when deleting the last remaining workspace. - Workspace names can now be up to 30 characters (up from a shorter limit). - Dropped the `max_workspaces` config option; `et` now requires a fixed (non-dynamic) set of GNOME workspaces and manages within it directly. -- README now recommends the Workspace Switcher Manager GNOME extension for - showing workspace names on-screen. +- README now documents the built-in panel/switcher display and extension + preferences. ### Fixed - Rejected Jira credentials are now reported as such instead of as an diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..e621f2b --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,82 @@ +# Contributing + +This document covers the development workflow for `et`: setting up a dev +environment, running tests/lint/static analysis, and testing the bundled +GNOME Shell extension. For user-facing setup and usage, see the +[README](./README.md); for internal design rationale, see +[docs/ARCHITECTURE.md](./docs/ARCHITECTURE.md). + +## Setting up a development environment + +This project uses [uv](https://docs.astral.sh/uv/) and exposes common tasks +through a [`Justfile`](./Justfile): + +```bash +just install-requirements # system tools + uv sync + git hooks + GNOME extension +``` + +This installs the GNOME test tooling, runs `uv sync --all-extras --dev`, +installs the [`prek`](https://github.com/j178/prek) (pre-commit) git hook, +and installs/enables the bundled GNOME Shell extension (see +[The `et` GNOME Shell extension](./README.md#the-et-gnome-shell-extension) +in the README). + +`prek` runs linting, static validation, Python tests, and the fast GJS unit +tests on every commit. It does not launch a real GNOME Shell. + +## Common tasks + +```bash +just install-requirements # system tools + uv sync + hooks + GNOME extension +just dev # uv run et --help +just lint # ruff + ShellCheck +just static # mypy + extension metadata/schema validation +just test # pytest + coverage + deterministic GJS tests +just test-integ # Python integration + real headless Shell test +just test-extension # isolated interactive nested Shell +just test-extension-live # isolated automated real-Shell lifecycle test +just package-extension # build and validate only the extension ZIP +just ops # build the Python package and extension ZIP +``` + +Before opening a PR, make sure `just lint`, `just static`, and `just test` +all pass (this is exactly what `prek`/CI check). + +`just ops` writes the extensions.gnome.org-ready archive to +`dist/et@seb4stien.github.com.shell-extension.zip`. The package contains +only the extension runtime, preferences, schema source, stylesheet, +metadata, and license; generated schemas and repository tooling are +excluded. + +## Testing the GNOME Shell extension + +The extension has three test layers: + +- `just test` runs the Python suite and deterministic GJS unit tests for + the extension's persisted counter store and display formatting. These + tests do not start GNOME Shell or access the host's GNOME settings. +- `just test-extension-live` starts a real headless GNOME Shell and + exercises the complete D-Bus lifecycle, including timing, remapping, + persistence, and disable/re-enable cleanup. It uses a disposable HOME + and XDG tree. +- `just test-extension` starts an interactive nested Wayland Shell for + visual checks. The extension, dconf database, caches, runtime directory, + and enabled extension list all live under a temporary directory that is + deleted on exit; nothing is installed into the host user's extension + directory. A working user systemd session is required so every helper + daemon is reaped. + +GitHub Actions runs the real-Shell lifecycle test on Ubuntu 24.04/GNOME 46 +and Ubuntu 26.04/GNOME 50, matching `metadata.json`. + +For full-desktop visual compatibility checks, use disposable GNOME Boxes or +QEMU snapshots for both Ubuntu releases: + +1. Build the test artifact with `just package-extension`. +2. Restore a clean VM snapshot and copy + `dist/et@seb4stien.github.com.shell-extension.zip` into the VM. +3. Install the ZIP with `gnome-extensions install --force `, log out + and back in, then enable the extension. +4. Check preferences, the top-panel display, workspace-switcher labels, + workspace changes, lock/unlock counter pausing, and disable/re-enable. +5. Revert the VM snapshot after the test. diff --git a/Justfile b/Justfile index 764a657..7a4820e 100644 --- a/Justfile +++ b/Justfile @@ -1,21 +1,42 @@ +[private] +default: + @just --list --unsorted + install-requirements: + scripts/install-gnome-test-requirements.sh uv sync --all-extras --dev uv run prek install + scripts/install-gnome-shell-extension.sh dev: uv run et --help lint: uv run ruff check . + shellcheck scripts/*.sh scripts/lib/*.sh static: uv run mypy src + python3 scripts/check-gnome-shell-extension.py gnome-extension/et@seb4stien.github.com + glib-compile-schemas --strict --dry-run gnome-extension/et@seb4stien.github.com/schemas test: uv run pytest + gjs -m tests/gnome-extension/test-extension.js test-integ: uv run pytest tests/integration --no-cov + scripts/check-gnome-shell-extension-live.sh + +test-extension: + scripts/test-gnome-shell-extension.sh + +test-extension-live *ARGS: + scripts/check-gnome-shell-extension-live.sh {{ARGS}} + +package-extension: + scripts/package-gnome-shell-extension.sh ops: uv build + just package-extension diff --git a/README.md b/README.md index 0272afd..a789c6c 100644 --- a/README.md +++ b/README.md @@ -1,138 +1,162 @@ # et `et` is a small command-line tool for **tracking effort** and **managing your -Ubuntu/GNOME workspaces**. It renames GNOME workspaces, drives the -[Tracker](https://extensions.gnome.org/extension/3212/tracker/) GNOME Shell -extension's per-workspace timers, and can link workspaces to Jira issues to -log time against them. +Ubuntu/GNOME workspaces**. It renames GNOME workspaces, automatically tracks +time spent on managed workspaces through its own GNOME Shell extension, and +can link workspaces to Jira issues to log time against them. ## Features -- **`et`** (no subcommand) / **`et info`** — in a non-`static` workspace, - shows the Jira issue linked to it plus its tracked time; otherwise shows - this help. -- **`et ws rename`** — rename the active workspace (or all of them from config). -- **`et ws delete`** — delete the active (free) workspace, shifting later ones left. -- **`et jira [start|create|log-time|complete|comment|status]`** — a friendlier, - task-centric layer that creates a workspace + Tracker timer for a task - (picked straight from your active Jira issues, optionally moving it to "In - Progress"), interactively creates new Jira issues (optionally pre-filled - from a GitHub issue/PR URL), completes a task by logging its tracked time - to Jira and optionally freeing the slot and moving the issue to "Done", - adds a comment to the linked issue, and moves the linked issue through its - workflow (directly to "In Progress"/"Blocked", or interactively picked from - a numbered list of the team's workflow statuses). +- **`et jira [start|create|log-time|complete|comment|status]`**: interact and align your workspaces with Jira from the command line: + - `create`: wrapper to create Jira issue from the command line (can take GitHub PR as input) + - `start`: let you select and assigned task, create a workspace associated to it, and track the time you spend on it. + - `log-time`: log your current progress (typically to do it on a daily-basis) + - `complete`: log the time spent, and move the issue to Done. + - `comment`: add a comment to the ticket. + - `status`: show and let you update the status. +- **`et`** (no subcommand) / **`et info`**: show the Jira issue linked to the workspace (if any). - **`et git create-branch`** (alias **`et git cb`**) — create (and switch to) a git branch named after the current task's Jira issue, following Canonical's `type/scope-short-description-jirakey` branch naming convention. +- **`et ws rename`** — rename the active workspace (or all of them from config). +- **`et ws delete`** — delete the active (free) workspace, shifting later ones left. -## Requirements +## Getting started -`et` shells out to standard GNOME/Ubuntu tooling, which must be available on -`PATH`: +New to `et`? Here's the fastest path to a working setup on Ubuntu 24.04+ +(adapt package names if you're on a different distribution): -- [`gsettings`](https://manpages.ubuntu.com/manpages/en/man1/gsettings.1.html) - — read/write GNOME workspace names and the Tracker extension's timers. -- [`wmctrl`](https://manpages.ubuntu.com/manpages/en/man1/wmctrl.1.html) - — detect the active workspace (`sudo apt install wmctrl`). -- [`gnome-extensions`](https://manpages.ubuntu.com/manpages/en/man1/gnome-extensions.1.html) - — reload the Tracker extension around timer writes. -- The **Tracker** GNOME Shell extension (`tracker@aliakseiz.github.com`), - installed and enabled, for `et jira`'s timer functionality. -- [`gh`](https://cli.github.com/) — installed and authenticated, only - needed for `et jira create `'s summary/description prefill. +1. **Install the system packages `et` needs:** -Python **3.12+** is required. + ```bash + sudo apt update + sudo apt install libglib2.0-bin gnome-shell + ``` -### Recommended: show workspace names in the switcher + - `libglib2.0-bin` provides `gsettings`/`gdbus` (workspace renaming and + talking to the GNOME Shell extension). + - `gnome-shell` provides `gnome-extensions` (install/enable the + extension) — already present on any GNOME desktop. + - Optionally, [`gh`](https://cli.github.com/) (`sudo apt install gh`, + then `gh auth login`) if you want `et jira create ` to + pre-fill from a GitHub issue/PR. -Since `et` names your workspaces after tasks/Jira issues, it helps to see -those names in GNOME's workspace switcher popup. Install the -[**Workspace Switcher Manager**](https://extensions.gnome.org/extension/4788/workspace-switcher-manager/) -extension (`workspace-switcher-manager@G-dH.github.com`), then configure it -to display the workspace name (it shows only the index/app name by default): + Python **3.12+** is required — Ubuntu 24.04+ ships it by default + (`python3 --version` to check). +2. **Install [uv](https://docs.astral.sh/uv/):** -```bash -S=org.gnome.shell.extensions.workspace-switcher-manager -gsettings set $S active-show-ws-name true # show the name on the active workspace -gsettings set $S inactive-show-ws-name true # ...and on the others -gsettings set $S active-show-app-name false # drop the focused-app name -gsettings set $S inactive-show-app-name false -gsettings set $S popup-width-scale 200 # widen the popup so names fit -``` + ```bash + curl -LsSf https://astral.sh/uv/install.sh | sh + ``` -The workspace index stays visible (`active-show-ws-index`, on by default). -The rest is personal taste — you can also tweak the popup position -(`horizontal`/`vertical`), corner radius (`popup-radius-scale`), on-screen -time (`on-screen-time`), and font size (`font-scale`) from the extension's -preferences. +3. **Clone the repo and install the GNOME Shell extension:** -## Installation + ```bash + git clone && cd et + scripts/install-gnome-shell-extension.sh + ``` -This project uses [uv](https://docs.astral.sh/uv/): +4. **Log out and back in** so GNOME Shell picks up the newly installed + extension. +5. **Create your config file interactively:** -```bash -uv sync # create the virtualenv and install et + dependencies -uv run et --help # run without installing globally -``` + ```bash + uv run et config + ``` -To install the `et` entry point onto your `PATH`: + Walks you through the `jira` block and your `workspaces` list — see + [Configuration](#configuration) for the file format it writes. +6. **Try it out:** -```bash -uv tool install . -``` + ```bash + uv run et --help + uv run et ws rename focus + uv run et jira start + ``` -## Usage +7. **Install globally**, so you can run `et` directly instead of + `uv run et`: + + ```bash + uv tool install . + ``` + +Want to contribute code instead? See [CONTRIBUTING.md](./CONTRIBUTING.md). + +## Requirements + +`et` shells out to standard GNOME/Ubuntu tooling, which must be available +on `PATH`: + +- [`gsettings`](https://manpages.ubuntu.com/manpages/en/man1/gsettings.1.html) + — read/write GNOME workspace names and settings. +- [`gdbus`](https://manpages.ubuntu.com/manpages/en/man1/gdbus.1.html) + — communicate with the `et` GNOME Shell extension. +- [`gnome-extensions`](https://manpages.ubuntu.com/manpages/en/man1/gnome-extensions.1.html) + — install, enable, and configure the bundled extension. +- The bundled **et Workspace Timer** GNOME Shell extension + (`et@seb4stien.github.com`), installed and enabled. It supports the GNOME + Shell releases used by Ubuntu 24.04 and Ubuntu 26.04 (GNOME 46 and 50). +- [`gh`](https://cli.github.com/) — installed and authenticated, only + needed for `et jira create `'s summary/description prefill. + +Python **3.12+** is required. [`just`](https://github.com/casey/just) is +only needed for the `Justfile`-based development workflow — see +[CONTRIBUTING.md](./CONTRIBUTING.md). + +### The `et` GNOME Shell extension + +Since GNOME Shell doesn't expose the active workspace over D-Bus by +default, this repo ships a companion extension, +[`gnome-extension/et@seb4stien.github.com`](gnome-extension/et@seb4stien.github.com), +that reports it directly. It also owns the per-workspace counters: when an +`et`-managed `dynamic` workspace is active, its counter runs automatically; +switching away or locking the session pauses it, and returning or +unlocking resumes it. Ordinary keyboard/mouse inactivity still counts. See +[docs/ARCHITECTURE.md](./docs/ARCHITECTURE.md) for how the extension and +its display preferences work internally. + +Install and enable it with: ```bash -et --help +scripts/install-gnome-shell-extension.sh ``` -Run bare (no subcommand), or `et info` explicitly, from a non-`static` -workspace to see its linked Jira issue and tracked time at a glance: +`gnome-extensions enable` may report the extension "does not exist" the +first time it's installed — this is expected, since GNOME Shell only scans +`~/.local/share/gnome-shell/extensions` for new UUIDs at startup; the script +detects this, registers it as enabled directly via `gsettings`, and checks +the running Shell over D-Bus to tell you whether it has no record of the +extension yet (needs a full restart) or already scanned it but hit an +error. Either way, log out and back in afterwards so GNOME Shell picks it +up. + +Open its preferences with: ```bash -et # same output as `et jira log-time` would act on, without logging anything -et info # explicit, named equivalent of the above +gnome-extensions prefs et@seb4stien.github.com ``` -From a `static` workspace, or one that isn't part of the managed pool, -both fall back to the usual help text. +## CLI features -### Workspaces +### Workspaces features ```bash et ws rename focus # rename the active workspace to "focus" et ws rename --all # rename workspaces 0..n-1 from the config's "workspaces" list et ws delete # delete the active workspace, shifting later ones left -et ws delete --force # same, even if still linked to a Jira issue (tracker is lost) +et ws delete --force # same, even if still linked to a Jira issue (counter is lost) ``` -`et ws delete` frees a workspace slot. It only works on a -"free" workspace — not `static`, and not linked to a Jira issue (run `et -jira complete` first if it still is). Every non-`static` workspace after the -deleted one (and its Tracker timer) shifts one slot to the left to close the -gap, then the now-empty trailing slot is reclaimed by decrementing GNOME's -workspace count (`num-workspaces`). The exception is when the -highest-numbered workspace is `static` — shrinking would swallow it, so the -count is left unchanged and the freed slot just becomes a bare `ET-`. -Refuses to delete the last remaining workspace. `--force` bypasses the -Jira-linked check for assigned/in-progress workspaces — its Tracker timer is -discarded rather than logged, so log the time first if you need it (`--force` -never bypasses the `static` check). - -### Tasks - -`et jira` wraps the workspace/Tracker/Jira integrations into a single -lifecycle for one task at a time — it doesn't replace `ws`, which keeps -working exactly as before. +### Jira features ```bash et info # (or bare `et`) show the active task's Jira issue and time spent et jira start # pick an active Jira issue and start a task from it -et jira start -k ISD-123 # start a task for a specific issue key directly +et jira start ISD-123 # start a task for a specific issue key directly et jira create # interactively create a new Jira issue +et jira create # pre-fill from a GitHub issue/PR et jira log-time # log the active workspace's tracked time to Jira et jira log-time 2h # log a manually-specified 2h duration instead et jira log-time --all # log every workspace with a linked Jira issue, not just the active one @@ -140,121 +164,10 @@ et jira comment "Looks good" # add a comment to the linked Jira et jira status in-progress # move the linked issue to "In Progress" et jira status # show current status, pick a new one from a numbered list et jira complete # log time, then optionally delete the workspace and close the issue +et jira log-time -j ISD-123 # act on a specific issue instead of the active workspace's one ``` -`et` with no subcommand shows the same Jira issue details as before, plus -the elapsed time of the `ET-` Tracker timer bound to the active -workspace (e.g. `Time spent: 1h 12m 0s`, with `(running)` appended if the -timer is currently running) — but only when the active workspace is part -of the managed (non-`static`) pool; otherwise it shows this help text. - -`et jira start` allocates the first free (non-`static`, unlinked) workspace -slot from the fixed pool of GNOME workspaces. If every workspace is already -taken, it asks whether to add one more (bumping GNOME's `num-workspaces` by -one) — decline and the command cancels without changing anything. It then -creates the slot's `ET-` Tracker timer, and -switches GNOME to it, best-effort moving the terminal window it was run -from along with it (via `wmctrl -r :ACTIVE:`) so it doesn't get left -behind on the old workspace. That last step needs an addressable X11 -window, which native Wayland clients (e.g. many terminal emulators under -GNOME/Wayland) don't have; when it's unsupported, `et jira start` prints -a note but still succeeds. It lists your active Jira issues that aren't -already linked to a workspace, lets you pick one, and links the new -workspace to it. If the selected issue isn't already "In Progress", it -asks whether to -move it there (showing its current status) and does so via Jira's -transitions API if you confirm. - -`et jira start -k KEY` (or `--key KEY`) starts a task for a specific Jira -issue directly instead of picking one from the active-issues list — it -fails if `KEY` is already linked to an existing workspace. It follows the -same steps as above (offering to move the issue to "In Progress" if -needed), plus one more: if the issue isn't already in one of its -project's current active sprints, it asks whether to add it to one (using -the same Agile board auto-discovery/caching as `et jira create --sprint`, -and requiring `jira.project_key` to be set) — if the board has more than -one concurrently active sprint, it prompts you to pick which one; if no -board or active sprint can be resolved, it prints a warning and continues -without touching the sprint rather than failing the command. - -`et jira create [GITHUB_URL]` interactively creates a new Jira issue in -`jira.project_key` (required in config for this command). It prompts for: -the issue type (`Bug`/`Story`/`Task`, default `Story` — defaulting to `Bug` -when `GITHUB_URL` points at a GitHub issue labeled "bug"); the summary -(pre-filled from the GitHub issue/PR title when a URL is given); whether to -assign the issue to yourself (default yes, via your `jira.email`); priority -(`Highest`/`High`/`Medium`/`Low`/`Lowest`, default `Medium`); a component -picked from the project's component list; whether to add the issue to the -project's current sprint (default yes — the Agile board is auto-discovered -on first use and its id saved to `jira.board_id` so later runs skip that -lookup; if the board has more than one concurrently active sprint, you're -prompted to pick which one); an estimate in hours (written to the issue's -time-tracking original estimate); and an optional description (pre-filled -from the GitHub issue/PR body when a URL is given, with the URL itself -always appended as a reference). When `GITHUB_URL` is given, it's also -written to the issue's "Bug link" field, if that custom field exists on -the Jira instance (looked up by name, like the Sprint field — skipped with a -warning otherwise). `GITHUB_URL` accepts -`https://github.com///issues/` and -`https://github.com///pull/` links, fetched via the `gh` -CLI (which must be installed and authenticated) — if the URL can't be -parsed or fetched, `et jira create` warns and falls back to blank -defaults rather than failing outright. - -`et jira log-time` reads the elapsed time from the `ET-` Tracker timer -bound to the active workspace, resolves the Jira issue linked to that -workspace (its `ref`, e.g. set by `et jira start`), and logs it as a -worklog via Jira's own worklog API (no separate Tempo credential needed — -worklogs created this way still show up in Tempo timesheets when Tempo is -configured to sync native Jira worklogs). At least a minute of elapsed time -is required. On success the tracker is reset to 0, unless `--no-reset` is -given. Given an `Xh` duration instead (e.g. `et jira log-time 2h` or `et -jira log-time 1.5h`), that duration is logged manually rather than the -Tracker timer's elapsed time — the Tracker timer isn't read or reset in -that case (so `--no-reset` doesn't apply). - -`et jira log-time --all` logs every workspace in the `workspaces` config -list that has a linked Jira issue, instead of only the active one — -useful for logging a whole day's tracked time across every task at once -without switching between workspaces. Each linked workspace's own Tracker -timer is logged to its own issue and reset immediately on success (or left -untouched otherwise); a workspace with less than a minute of elapsed time, -no Tracker timer at all, or a failing Jira call is skipped (reported at the -end) rather than stopping the rest from being logged. `--no-reset` still -applies (to every workspace logged in that run), but `--all` can't be -combined with an `Xh` duration, `--comment/-m`, or `-j/--jira`, since those -only make sense for a single workspace/issue. - -`et jira comment [MESSAGE]` adds a comment to the Jira issue linked to the -active workspace (or a different issue via `-j/--jira KEY`). Prompts for -the message if not given as an argument. - -`et jira status [in-progress|blocked]` moves the linked issue directly to -"In Progress" or "Blocked" (applied immediately, no confirmation). With no -argument, it shows the linked issue's current status and a numbered list of -the team's workflow statuses (`Untriaged`, `Triaged`, `In Progress`, -`Blocked`, `In Review`, `To Be Deployed`, `Done`, `Rejected`) to pick a new -one from interactively; leave the prompt blank to cancel. - -`et jira log-time`, `et jira complete`, `et jira comment`, and `et jira -status` all accept a `-j`/`--jira KEY` option to act on a specific Jira -issue instead of the one linked to the active workspace — e.g. `et jira -comment "Looks good" -j ISD-123` or `et jira status blocked --jira -ISD-123`. For `comment` and `status`, this also skips workspace resolution -entirely, so those two work even outside a managed workspace. - -`et jira complete` logs the active workspace's tracked time to Jira (like -`et jira log-time`) and tells you how much it logged. It then asks whether -to delete the workspace and whether to move the linked Jira issue to -"Done" — each behind its own confirmation prompt, so both actions are -skipped unless you confirm them. When you confirm the delete, the workspace -is removed exactly like `et ws delete` — GNOME's workspace count is -decremented to reclaim the slot and every non-`static` workspace after it is -shifted one slot to the left (its Tracker timer follows it), so no gap is -left in the middle of your workspaces. If only a single workspace remains -(GNOME can't drop below one), its slot is reset to a bare `ET-` instead. - -### Git +### Git features ```bash et git create-branch # branch off the active workspace's linked issue @@ -262,36 +175,15 @@ et git cb # alias for the above et git create-branch -j ISD-1234 # branch off a specific issue instead ``` -`et git create-branch` (aliased `et git cb`) creates a git branch for the -current task's Jira issue, following Canonical's -[PR branch naming convention](https://github.com/canonical/platform-engineering-docs/blob/main/docs/delivery-workflows/github/pull-requests/index.rst): -`type/scope-short-description-jirakey` (e.g. -`feat/tcp-wildcard-sni-support-isd-1234`). It defaults to the Jira issue -linked to the active workspace; pass `-j`/`--jira KEY` to target a -different issue instead (works outside a managed workspace too, like the -`et jira` commands' own `-j`/`--jira`). - -It first prints the issue's clickable link and summary, then: - -- Proposes a **branch type** (one of `feat`/`fix`/`docs`/`chore`/`test`/`ci`) - computed from the issue: `Bug` → `fix`, `Story` → `feat`, `Task` → - `chore`; a `documentation` label always wins and defaults to `docs` - regardless of issue type. Prompts to accept the default or pick a - different one from the list — this becomes the branch's `type/` prefix, - which can't otherwise be typed in freely. -- Proposes the `scope-short-description` segment as a slug of the issue - summary, editable at the prompt. -- Appends the resolved Jira key (lowercased) as the branch's trailing - identifier — always the actual issue key, not editable. - -Fails with a clear error if not run inside a git repository, or if a local -branch with the computed name already exists. On success, creates the -branch from the current `HEAD` and switches to it (`git checkout -b`). +Follows Canonical's +[PR branch naming convention](https://github.com/canonical/platform-engineering-docs/blob/main/docs/delivery-workflows/github/pull-requests/index.rst) +(`type/scope-short-description-jirakey`), proposing a branch type and +description slug from the issue that you can accept or edit. Run +`et git create-branch --help` for details. ## Configuration -`et` reads `~/.config/et/config.yaml` (override the directory with the -`ET_CONFIG_DIR` environment variable). Example: +`et` reads `~/.config/et/config.yaml`. ```yaml # Jira Cloud REST credentials + query. Required for `et jira start` @@ -326,7 +218,7 @@ with mode `0600` because it may contain a Jira API token. The `jql` value is a plain YAML scalar, so quoting it is optional — quote it only if it starts with a character YAML reserves (`{`, `[`, `*`, `&`, -`!`, `%`, `@`) or contains ` #` or `: `. +`!`, `%`, `@`) or contains `#` or `:`. When `et jira start` reports no issues but the same JQL finds some in the Jira web UI, run `et --debug jira start`: it logs each search request (URL, @@ -349,27 +241,15 @@ against `/rest/api/3/myself` whenever a search comes back empty. > gsettings set org.gnome.desktop.wm.preferences num-workspaces > ``` -> **Known limitation:** `et jira start` applies its changes (Tracker -> timers, then config, then GNOME workspace names) sequentially without a -> rollback. A failure partway through can leave the config and live GNOME -> state temporarily out of sync; re-running the command reconciles them. - - -## Development - -Common tasks are exposed through a [`Justfile`](./Justfile): - -```bash -just install-requirements # uv sync + install pre-commit hooks -just lint # ruff -just static # mypy --strict -just test # pytest + coverage -just test-integ # end-to-end integration tests -just ops # build the distribution artifacts -``` - -`prek` (pre-commit) runs ruff, mypy, and pytest on every commit. +> **Known limitation:** `et jira start` applies its changes sequentially +> without a rollback, which can briefly leave config and live GNOME state +> out of sync on failure — see +> [docs/ARCHITECTURE.md](./docs/ARCHITECTURE.md#known-limitations) for +> details. ## License -Licensed under the [Apache License 2.0](./LICENSE). +The Python project is licensed under the [Apache License 2.0](./LICENSE). +The GNOME Shell extension is licensed separately under +[GPL-3.0-or-later](./gnome-extension/et@seb4stien.github.com/LICENSE), as +required for GNOME Shell extensions. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md new file mode 100644 index 0000000..439b7a8 --- /dev/null +++ b/docs/ARCHITECTURE.md @@ -0,0 +1,81 @@ +# Architecture + +This document covers the internal design of `et` and its companion GNOME +Shell extension — the *why* behind how the pieces fit together. For +day-to-day usage, see the [README](../README.md); for the development +workflow (running tests, linting, building), see +[CONTRIBUTING.md](../CONTRIBUTING.md). + +## Overview + +`et` is a [Typer](https://typer.tiangolo.com/)-based CLI (`src/et/cli.py`) +that's a thin presentation layer over a set of Typer-free, independently +unit-testable modules: + +- `et.config` — loads/saves `~/.config/et/config.yaml` (the `jira` block + and the `workspaces` list). +- `et.workspaces` / `et.ws` — GNOME workspace management (renaming, + deleting, reordering) via `gsettings`. +- `et.et_extension` — talks to the bundled GNOME Shell extension over + D-Bus (`gdbus`) to read/prepare per-workspace counters and display state. +- `et.jira` — a thin Jira Cloud REST API client (`requests`-based). +- `et.jira_create`, `et.jira_time`, `et.task`, `et.git_branch`, + `et.config_wizard` — orchestration modules that combine the above to + implement each `et` subcommand's business logic, each taking injected + prompt/confirm/warn callbacks so they can be unit tested without Typer + or a live terminal. + +Business-logic modules never import `typer`; `cli.py` is the only module +that wires interactive prompts to `typer.prompt`/`typer.confirm` and +converts raised errors into `Error: ...` messages + a non-zero exit code. +This split is what lets almost all of `et`'s behavior be tested with plain +pytest, without spawning a shell or a real GNOME session. + +## The `et` GNOME Shell extension + +`wmctrl` relies on the X11 window-manager protocol, so it can't detect the +active workspace on a Wayland session (the default since Ubuntu 26.04). +Since GNOME Shell doesn't expose the active workspace over D-Bus by +default, this repo ships a companion extension, +[`gnome-extension/et@seb4stien.github.com`](../gnome-extension/et@seb4stien.github.com), +that exposes Shell state through D-Bus. It also owns the per-workspace +counters: when an `et`-managed `dynamic` workspace is active, its counter +runs automatically; switching away or locking the session pauses it, and +returning or unlocking resumes it. Ordinary keyboard/mouse inactivity +still counts. + +The extension is **ticket-system agnostic** by design. The CLI can give it +a workspace label and reference estimate (for Jira workspaces these are the +issue summary and original estimate), but the extension treats them as +generic display values it neither parses nor validates — this keeps the +extension reusable if `et` ever grows a second ticket-system backend. Its +preferences independently control whether the label, estimate, and current +counter appear, and whether the display is shown in the top panel and +GNOME workspace switcher. The default is to show the label, original +estimate, and counter on both surfaces. Whenever the top panel display is +enabled, a small icon is always shown there as a persistent indicator that +the extension is installed and active, even before any workspace has been +prepared; the label/estimate/counter text is appended alongside it once a +workspace has one. + +### Why the install script copies rather than symlinks the extension + +`scripts/install-gnome-shell-extension.sh` *copies* the extension into +`~/.local/share/gnome-shell/extensions/et@seb4stien.github.com` by default +rather than symlinking it, so the installed copy keeps working even if the +checkout is later re-provisioned (e.g. a fresh clone into a re-created +workspace) on a timeline independent of the GNOME session. GNOME Shell +only scans the extensions directory once at startup, and a symlink whose +target doesn't exist yet at that exact moment gets silently skipped and +never picked up until a full restart happens *after* the target exists. +The `--dev`/`--symlink` flag exists for the opposite tradeoff: when +actively editing `extension.js`, a symlink means you don't need to re-run +the install script after every change (a Shell restart is still required +to pick up new code either way). + +## Known limitations + +`et jira start` applies its changes (extension counter, then config, then +GNOME workspace names) sequentially without a rollback. A failure partway +through can leave the config and live GNOME state temporarily out of sync; +re-running the command reconciles them. diff --git a/gnome-extension/et@seb4stien.github.com/LICENSE b/gnome-extension/et@seb4stien.github.com/LICENSE new file mode 100644 index 0000000..f288702 --- /dev/null +++ b/gnome-extension/et@seb4stien.github.com/LICENSE @@ -0,0 +1,674 @@ + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU General Public License is a free, copyleft license for +software and other kinds of works. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +the GNU General Public License is intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. We, the Free Software Foundation, use the +GNU General Public License for most of our software; it applies also to +any other work released this way by its authors. You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + To protect your rights, we need to prevent others from denying you +these rights or asking you to surrender the rights. Therefore, you have +certain responsibilities if you distribute copies of the software, or if +you modify it: responsibilities to respect the freedom of others. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must pass on to the recipients the same +freedoms that you received. You must make sure that they, too, receive +or can get the source code. And you must show them these terms so they +know their rights. + + Developers that use the GNU GPL protect your rights with two steps: +(1) assert copyright on the software, and (2) offer you this License +giving you legal permission to copy, distribute and/or modify it. + + For the developers' and authors' protection, the GPL clearly explains +that there is no warranty for this free software. For both users' and +authors' sake, the GPL requires that modified versions be marked as +changed, so that their problems will not be attributed erroneously to +authors of previous versions. + + Some devices are designed to deny users access to install or run +modified versions of the software inside them, although the manufacturer +can do so. This is fundamentally incompatible with the aim of +protecting users' freedom to change the software. The systematic +pattern of such abuse occurs in the area of products for individuals to +use, which is precisely where it is most unacceptable. Therefore, we +have designed this version of the GPL to prohibit the practice for those +products. If such problems arise substantially in other domains, we +stand ready to extend this provision to those domains in future versions +of the GPL, as needed to protect the freedom of users. + + Finally, every program is threatened constantly by software patents. +States should not allow patents to restrict development and use of +software on general-purpose computers, but in those that do, we wish to +avoid the special danger that patents applied to a free program could +make it effectively proprietary. To prevent this, the GPL assures that +patents cannot be used to render the program non-free. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Use with the GNU Affero General Public License. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU Affero General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the special requirements of the GNU Affero General Public License, +section 13, concerning interaction through a network will apply to the +combination as such. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If the program does terminal interaction, make it output a short +notice like this when it starts in an interactive mode: + + Copyright (C) + This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, your program's commands +might be different; for a GUI interface, you would use an "about box". + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU GPL, see +. + + The GNU General Public License does not permit incorporating your program +into proprietary programs. If your program is a subroutine library, you +may consider it more useful to permit linking proprietary applications with +the library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. But first, please read +. diff --git a/gnome-extension/et@seb4stien.github.com/extension.js b/gnome-extension/et@seb4stien.github.com/extension.js new file mode 100644 index 0000000..4f2e07b --- /dev/null +++ b/gnome-extension/et@seb4stien.github.com/extension.js @@ -0,0 +1,292 @@ +// SPDX-FileCopyrightText: 2026 Sébastien Georget +// SPDX-License-Identifier: GPL-3.0-or-later + +/* et GNOME Shell extension. + * + * Exposes a small D-Bus service, on GNOME Shell's own session-bus + * connection (no separate bus name needed — the same pattern used by the + * third-party "Window Calls" extension), for state that `et` can't + * otherwise reach under Wayland: the active workspace index, standing in + * for what `wmctrl -d` gives on X11. + * + * It also owns persistent per-workspace counters, replacing the + * third-party Tracker extension: any workspace `et` "prepares" gets a + * generic label, a reference estimate, and an elapsed-time counter that + * runs automatically while that workspace is active, pausing only while + * the session is locked (ordinary keyboard/mouse inactivity still counts). + * The counter, label, and estimate can be shown in the top panel and/or + * GNOME's workspace-switcher popup, independently configurable via + * GSettings/`prefs.js`. + */ + +import Clutter from 'gi://Clutter'; +import Gio from 'gi://Gio'; +import GLib from 'gi://GLib'; +import St from 'gi://St'; + +import {Extension} from 'resource:///org/gnome/shell/extensions/extension.js'; +import * as Main from 'resource:///org/gnome/shell/ui/main.js'; +import * as PanelMenu from 'resource:///org/gnome/shell/ui/panelMenu.js'; + +import {buildDisplayText} from './lib/display.js'; +import {WorkspaceStore, WorkspaceNotFoundError, WorkspaceValidationError} from './lib/workspaceStore.js'; +import {WorkspaceSwitcherLabeler} from './lib/workspaceSwitcherCompat.js'; + +const DBUS_INTERFACE_XML = ` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +`; + +const DBUS_OBJECT_PATH = '/org/gnome/Shell/Extensions/Et'; +const PANEL_ROLE = 'et@seb4stien.github.com'; +const PANEL_UPDATE_INTERVAL_SECONDS = 1; +const CHECKPOINT_INTERVAL_SECONDS = 30; +const DISPLAY_SETTINGS_KEYS = ['show-label', 'show-estimate', 'show-counter', 'show-panel']; + +export default class EtExtension extends Extension { + enable() { + this._settings = this.getSettings(); + this._store = new WorkspaceStore(this._settings); + + this._dbusImpl = Gio.DBusExportedObject.wrapJSObject(DBUS_INTERFACE_XML, this); + this._dbusImpl.export(Gio.DBus.session, DBUS_OBJECT_PATH); + + this._indicator = new PanelMenu.Button(0.5, 'et Workspace Timer', true); + this._panelBox = new St.BoxLayout({ + y_align: Clutter.ActorAlign.CENTER, + }); + this._panelIcon = new St.Icon({ + style_class: 'et-panel-icon', + icon_name: 'preferences-system-time-symbolic', + y_align: Clutter.ActorAlign.CENTER, + }); + this._panelLabel = new St.Label({ + style_class: 'et-panel-label', + y_align: Clutter.ActorAlign.CENTER, + }); + this._panelBox.add_child(this._panelIcon); + this._panelBox.add_child(this._panelLabel); + this._indicator.add_child(this._panelBox); + Main.panel.addToStatusArea(PANEL_ROLE, this._indicator); + + this._switcherLabeler = new WorkspaceSwitcherLabeler( + activeIndex => this._getSwitcherText(activeIndex)); + this._switcherLabeler.enable(); + + this._workspaceManagerSignalId = global.workspace_manager.connect( + 'active-workspace-changed', () => this._syncRunningState()); + this._screenShieldSignalId = Main.screenShield?.connect( + 'locked-changed', () => this._syncRunningState()) ?? null; + this._settingsSignalId = this._settings.connect('changed', (settings, key) => { + if (DISPLAY_SETTINGS_KEYS.includes(key)) + this._updatePanel(); + }); + + this._panelUpdateSourceId = GLib.timeout_add_seconds( + GLib.PRIORITY_DEFAULT, PANEL_UPDATE_INTERVAL_SECONDS, () => { + this._updatePanel(); + return GLib.SOURCE_CONTINUE; + }); + this._checkpointSourceId = GLib.timeout_add_seconds( + GLib.PRIORITY_DEFAULT, CHECKPOINT_INTERVAL_SECONDS, () => { + this._store.checkpoint(); + this._store.save(); + return GLib.SOURCE_CONTINUE; + }); + + this._syncRunningState(); + } + + disable() { + if (this._panelUpdateSourceId) { + GLib.source_remove(this._panelUpdateSourceId); + this._panelUpdateSourceId = null; + } + if (this._checkpointSourceId) { + GLib.source_remove(this._checkpointSourceId); + this._checkpointSourceId = null; + } + if (this._workspaceManagerSignalId) { + global.workspace_manager.disconnect(this._workspaceManagerSignalId); + this._workspaceManagerSignalId = null; + } + if (this._screenShieldSignalId) { + Main.screenShield?.disconnect(this._screenShieldSignalId); + this._screenShieldSignalId = null; + } + if (this._settingsSignalId) { + this._settings.disconnect(this._settingsSignalId); + this._settingsSignalId = null; + } + + this._switcherLabeler?.disable(); + this._switcherLabeler = null; + + this._indicator?.destroy(); + this._indicator = null; + this._panelBox = null; + this._panelIcon = null; + this._panelLabel = null; + + if (this._store) { + this._store.stopAll(); + this._store.save(); + } + this._store = null; + + this._dbusImpl?.unexport(); + this._dbusImpl = null; + + this._settings = null; + } + + // --- D-Bus methods (org.gnome.Shell.Extensions.Et) ------------------- + + GetActiveWorkspaceIndex() { + return global.workspace_manager.get_active_workspace_index(); + } + + PrepareWorkspace(index, label, estimateSeconds) { + this._store.prepare(this._requireExistingWorkspaceIndex(index), label, estimateSeconds); + this._store.save(); + this._syncRunningState(); + } + + GetWorkspaceCounter(index) { + const numericIndex = Number(index); + const elapsedSeconds = this._store.liveElapsedSeconds(numericIndex); + const running = this._store.isRunning(numericIndex); + return [BigInt(Math.floor(elapsedSeconds)), running]; + } + + GetWorkspaceMetadata(index) { + const numericIndex = Number(index); + if (!this._store.has(numericIndex)) + throw new WorkspaceNotFoundError(`workspace ${numericIndex} has no prepared counter`); + const entry = this._store.get(numericIndex); + return [entry.label, BigInt(Math.floor(entry.estimateSeconds))]; + } + + ResetWorkspaceCounter(index) { + this._store.resetElapsed(Number(index)); + this._store.save(); + this._syncRunningState(); + } + + RemoveWorkspace(index) { + const numericIndex = Number(index); + if (this._store.has(numericIndex) && this._store.isRunning(numericIndex)) + this._store.stop(numericIndex); + this._store.remove(numericIndex); + this._store.save(); + this._syncRunningState(); + } + + RemapWorkspaces(moves) { + this._store.remap(moves); + this._store.save(); + this._syncRunningState(); + } + + SetWorkspaceMetadata(index, label, estimateSeconds) { + this._store.setMetadata(this._requireExistingWorkspaceIndex(index), label, estimateSeconds); + this._store.save(); + this._updatePanel(); + } + + // --- Internal helpers -------------------------------------------------- + + _requireExistingWorkspaceIndex(index) { + const numericIndex = Number(index); + const workspaceCount = global.workspace_manager.n_workspaces; + if (!Number.isInteger(numericIndex) || numericIndex < 0 || numericIndex >= workspaceCount) + throw new WorkspaceValidationError(`workspace index out of range: ${index}`); + return numericIndex; + } + + /** Starts/stops counters so only the active, unlocked workspace's counter runs. */ + _syncRunningState() { + if (!this._store) + return; + + const activeIndex = global.workspace_manager.get_active_workspace_index(); + const locked = Main.screenShield?.locked ?? false; + let changed = false; + for (const index of this._store.indices()) { + const shouldRun = !locked && index === activeIndex; + const isRunning = this._store.isRunning(index); + if (shouldRun && !isRunning) { + this._store.start(index); + changed = true; + } else if (!shouldRun && isRunning) { + this._store.stop(index); + changed = true; + } + } + if (changed) + this._store.save(); + this._updatePanel(); + } + + _updatePanel() { + if (!this._indicator || !this._store) + return; + + const activeIndex = global.workspace_manager.get_active_workspace_index(); + const entry = this._store.has(activeIndex) ? this._store.get(activeIndex) : null; + const elapsedSeconds = entry ? this._store.liveElapsedSeconds(activeIndex) : 0; + const text = buildDisplayText(entry, elapsedSeconds, this._settings); + + this._panelLabel.text = text; + this._panelLabel.visible = text.length > 0; + // The icon stays visible whenever the panel display is enabled, even + // with no prepared workspace, as a persistent "extension is active" + // indicator; only the label (which needs a prepared workspace to say + // anything useful) collapses away. + this._indicator.visible = this._settings.get_boolean('show-panel'); + } + + _getSwitcherText(activeIndex) { + if (!this._settings.get_boolean('show-workspace-switcher') || !this._store) + return ''; + if (!this._store.has(activeIndex)) + return ''; + + const entry = this._store.get(activeIndex); + const elapsedSeconds = this._store.liveElapsedSeconds(activeIndex); + return buildDisplayText(entry, elapsedSeconds, this._settings); + } +} diff --git a/gnome-extension/et@seb4stien.github.com/lib/display.js b/gnome-extension/et@seb4stien.github.com/lib/display.js new file mode 100644 index 0000000..d41efad --- /dev/null +++ b/gnome-extension/et@seb4stien.github.com/lib/display.js @@ -0,0 +1,25 @@ +// SPDX-FileCopyrightText: 2026 Sébastien Georget +// SPDX-License-Identifier: GPL-3.0-or-later + +export function formatDuration(totalSeconds) { + const seconds = Math.max(0, Math.floor(totalSeconds)); + const hours = Math.floor(seconds / 3600); + const minutes = Math.floor((seconds % 3600) / 60); + return hours > 0 + ? `${hours}h ${String(minutes).padStart(2, '0')}m` + : `${minutes}m`; +} + +export function buildDisplayText(entry, elapsedSeconds, settings) { + if (!entry) + return ''; + + const parts = []; + if (settings.get_boolean('show-label') && entry.label) + parts.push(entry.label); + if (settings.get_boolean('show-estimate') && entry.estimateSeconds > 0) + parts.push(formatDuration(entry.estimateSeconds)); + if (settings.get_boolean('show-counter')) + parts.push(formatDuration(elapsedSeconds)); + return parts.join(' · '); +} diff --git a/gnome-extension/et@seb4stien.github.com/lib/workspaceStore.js b/gnome-extension/et@seb4stien.github.com/lib/workspaceStore.js new file mode 100644 index 0000000..3eb7f4d --- /dev/null +++ b/gnome-extension/et@seb4stien.github.com/lib/workspaceStore.js @@ -0,0 +1,284 @@ +// SPDX-FileCopyrightText: 2026 Sébastien Georget +// SPDX-License-Identifier: GPL-3.0-or-later + +/* Extension-owned persistent per-workspace counters. + * + * Each "prepared" workspace has an entry keyed by its GNOME workspace index + * holding a generic display label, a reference estimate, and an elapsed + * counter. Entries are persisted as a single JSON blob in the extension's + * GSettings so they survive a GNOME Shell restart; the live "is this + * counter currently running" state is deliberately *not* persisted; it is + * always false right after (re)load; `WorkspaceStore` re-derives who should + * be running from whatever workspace is active (see `syncRunning`). + * + * Timing uses `GLib.get_monotonic_time()` (microseconds since an + * unspecified starting point, unaffected by wall-clock/timezone changes) + * rather than wall-clock time, so counters can't skew from NTP adjustments, + * DST, or the user changing the system clock. + */ + +import GLib from 'gi://GLib'; + +const SETTINGS_KEY = 'workspaces'; + +/** + * @typedef {object} WorkspaceEntry + * @property {string} label + * @property {number} estimateSeconds + * @property {number} elapsedSeconds - Elapsed time accumulated *before* the + * current running span, if any (see `runningSinceUs`). + * @property {number|null} runningSinceUs - Monotonic timestamp (µs) the + * counter started running, or `null` if it isn't currently running. + */ + +export class WorkspaceValidationError extends Error { + constructor(message) { + super(message); + this.name = 'org.gnome.Shell.Extensions.Et.Error.InvalidArgument'; + } +} + +export class WorkspaceNotFoundError extends Error { + constructor(message) { + super(message); + this.name = 'org.gnome.Shell.Extensions.Et.Error.NotFound'; + } +} + +function requireWorkspaceIndex(index) { + const numericIndex = Number(index); + if (!Number.isInteger(numericIndex) || numericIndex < 0) + throw new WorkspaceValidationError(`invalid workspace index: ${index}`); + return numericIndex; +} + +function requireEstimateSeconds(estimateSeconds) { + const numericEstimate = Number(estimateSeconds); + if (!Number.isFinite(numericEstimate) || numericEstimate < 0) + throw new WorkspaceValidationError(`invalid estimate seconds: ${estimateSeconds}`); + return numericEstimate; +} + +function requireLabel(label) { + if (typeof label !== 'string') + throw new WorkspaceValidationError('label must be a string'); + return label; +} + +/** Manages the persisted per-workspace counter store for one GSettings object. */ +export class WorkspaceStore { + constructor(settings, monotonicTime = GLib.get_monotonic_time) { + this._settings = settings; + this._monotonicTime = monotonicTime; + /** @type {Map} */ + this._entries = this._load(); + } + + _load() { + const entries = new Map(); + let raw; + try { + raw = JSON.parse(this._settings.get_string(SETTINGS_KEY) || '{}'); + } catch { + raw = {}; + } + if (raw && typeof raw === 'object') { + for (const [key, value] of Object.entries(raw)) { + const index = Number(key); + if (!Number.isInteger(index) || index < 0) + continue; + entries.set(index, { + label: typeof value?.label === 'string' ? value.label : '', + estimateSeconds: Number.isFinite(value?.estimateSeconds) + ? Math.max(0, value.estimateSeconds) : 0, + elapsedSeconds: Number.isFinite(value?.elapsedSeconds) + ? Math.max(0, value.elapsedSeconds) : 0, + runningSinceUs: null, + }); + } + } + return entries; + } + + /** Persists the current entries (elapsed seconds only; never the running state). */ + save() { + const serializable = {}; + for (const [index, entry] of this._entries) { + serializable[index] = { + label: entry.label, + estimateSeconds: entry.estimateSeconds, + elapsedSeconds: entry.elapsedSeconds, + }; + } + this._settings.set_string(SETTINGS_KEY, JSON.stringify(serializable)); + } + + has(index) { + return this._entries.has(requireWorkspaceIndex(index)); + } + + /** Returns the entry for `index`, or `undefined` if it isn't prepared. */ + get(index) { + return this._entries.get(requireWorkspaceIndex(index)); + } + + /** Returns all prepared indices. */ + indices() { + return [...this._entries.keys()]; + } + + /** + * (Re)creates the counter for `index` at zero with the given + * label/estimate, whether or not one already existed. A workspace slot + * that's being reused for a new task must never inherit the previous + * task's accumulated elapsed time, so this always zeroes it rather than + * only doing so for brand-new slots; use `setMetadata` instead to + * update label/estimate without touching an already-running counter. + * Returns the entry. + */ + prepare(index, label, estimateSeconds) { + const numericIndex = requireWorkspaceIndex(index); + const safeLabel = requireLabel(label); + const safeEstimate = requireEstimateSeconds(estimateSeconds); + + const entry = {label: safeLabel, estimateSeconds: safeEstimate, elapsedSeconds: 0, runningSinceUs: null}; + this._entries.set(numericIndex, entry); + return entry; + } + + /** Updates label/estimate for an already-prepared workspace. */ + setMetadata(index, label, estimateSeconds) { + const entry = this._requireEntry(index); + entry.label = requireLabel(label); + entry.estimateSeconds = requireEstimateSeconds(estimateSeconds); + return entry; + } + + /** + * Resets a prepared workspace's elapsed time and running state to zero + * (stopped). Callers that want the counter to keep running afterward + * (e.g. because its workspace is still the active, unlocked one) should + * re-derive that separately (see `EtExtension._syncRunningState`) + * rather than relying on this to preserve it. + */ + resetElapsed(index) { + const entry = this._requireEntry(index); + entry.elapsedSeconds = 0; + entry.runningSinceUs = null; + return entry; + } + + /** + * Removes a workspace's counter entirely. Unlike the other mutators, + * this never range-checks or errors on an unknown index: it exists + * precisely to clean up stale/out-of-range entries (e.g. after GNOME's + * workspace count shrinks), so removing an already-absent entry is a + * harmless no-op. Returns whether an entry was actually removed. + */ + remove(index) { + return this._entries.delete(requireWorkspaceIndex(index)); + } + + /** + * Atomically moves counters between workspace indices, e.g. when + * workspaces are reordered/shifted. `moves` is a list of `{from, to}` + * pairs, applied as a single simultaneous permutation: a `from` index + * with no prepared entry clears whatever previously sat at its `to` + * index (shift semantics), matching a slot becoming genuinely empty. + */ + remap(moves) { + const seenFrom = new Set(); + const seenTo = new Set(); + const normalizedMoves = []; + for (const move of moves) { + const from = requireWorkspaceIndex(move[0]); + const to = requireWorkspaceIndex(move[1]); + if (from === to) + continue; + if (seenFrom.has(from)) + throw new WorkspaceValidationError(`duplicate source index in RemapWorkspaces: ${from}`); + if (seenTo.has(to)) + throw new WorkspaceValidationError(`duplicate destination index in RemapWorkspaces: ${to}`); + seenFrom.add(from); + seenTo.add(to); + normalizedMoves.push({from, to}); + } + + const previousEntries = this._entries; + const nextEntries = new Map(); + for (const [index, entry] of previousEntries) { + if (!seenFrom.has(index)) + nextEntries.set(index, entry); + } + for (const {from, to} of normalizedMoves) { + const entry = previousEntries.get(from); + if (entry !== undefined) + nextEntries.set(to, entry); + else + nextEntries.delete(to); + } + this._entries = nextEntries; + } + + /** Returns `elapsedSeconds` including any currently-running span. */ + liveElapsedSeconds(index) { + const entry = this._requireEntry(index); + return this._liveElapsedSeconds(entry); + } + + _liveElapsedSeconds(entry) { + if (entry.runningSinceUs === null) + return entry.elapsedSeconds; + const elapsedUs = this._monotonicTime() - entry.runningSinceUs; + return entry.elapsedSeconds + Math.max(0, elapsedUs) / 1e6; + } + + isRunning(index) { + return this._requireEntry(index).runningSinceUs !== null; + } + + /** Starts the counter for `index`, if it isn't already running. */ + start(index) { + const entry = this._requireEntry(index); + if (entry.runningSinceUs === null) + entry.runningSinceUs = this._monotonicTime(); + } + + /** Stops the counter for `index`, folding the running span into `elapsedSeconds`. */ + stop(index) { + const entry = this._requireEntry(index); + if (entry.runningSinceUs !== null) { + entry.elapsedSeconds = this._liveElapsedSeconds(entry); + entry.runningSinceUs = null; + } + } + + /** Stops every running counter (e.g. before disable() tears everything down). */ + stopAll() { + for (const index of this._entries.keys()) + this.stop(index); + } + + /** + * Folds any running span into `elapsedSeconds` and restarts the running + * span from now, without changing whether the counter is running. Used + * for periodic checkpoints so a crash/restart loses only a bounded + * window of time instead of the whole running span. + */ + checkpoint() { + for (const entry of this._entries.values()) { + if (entry.runningSinceUs !== null) { + entry.elapsedSeconds = this._liveElapsedSeconds(entry); + entry.runningSinceUs = this._monotonicTime(); + } + } + } + + _requireEntry(index) { + const numericIndex = requireWorkspaceIndex(index); + const entry = this._entries.get(numericIndex); + if (!entry) + throw new WorkspaceNotFoundError(`workspace ${numericIndex} has no prepared counter`); + return entry; + } +} diff --git a/gnome-extension/et@seb4stien.github.com/lib/workspaceSwitcherCompat.js b/gnome-extension/et@seb4stien.github.com/lib/workspaceSwitcherCompat.js new file mode 100644 index 0000000..dcf3feb --- /dev/null +++ b/gnome-extension/et@seb4stien.github.com/lib/workspaceSwitcherCompat.js @@ -0,0 +1,105 @@ +// SPDX-FileCopyrightText: 2026 Sébastien Georget +// SPDX-License-Identifier: GPL-3.0-or-later + +/* Augments GNOME's native workspace-switcher popup (the small row of dots + * shown briefly on a workspace-switch keybinding) with a text label, without + * replacing or forking it. + * + * The popup's internal shape differs between GNOME 46 and GNOME 50: + * - GNOME 46: a single `WorkspaceSwitcherPopup` actor owns one `_list` + * (an `St.BoxLayout`) holding one indicator per workspace. + * - GNOME 50: the outer `WorkspaceSwitcherPopup` is itself a container of + * one `MonitorWorkspaceSwitcherPopup` child per targeted monitor, each + * with its own `_list`. + * `getIndicatorLists()` below is the compatibility adapter that normalizes + * both shapes to "a list of `_list`s to add our label to". It uses feature + * detection (does the popup have its own `_list`, or is it iterable?) + * rather than branching on the Shell's version number, so it keeps working + * across point releases that don't change this shape. + */ + +import Clutter from 'gi://Clutter'; +import St from 'gi://St'; + +import {InjectionManager} from 'resource:///org/gnome/shell/extensions/extension.js'; +import * as WorkspaceSwitcherPopup from 'resource:///org/gnome/shell/ui/workspaceSwitcherPopup.js'; + +const LABEL_STYLE_CLASS = 'et-workspace-switcher-label'; + +function getIndicatorLists(popup) { + if (popup._list) + return [popup._list]; + if (typeof popup[Symbol.iterator] === 'function') + return [...popup].map(monitorPopup => monitorPopup._list).filter(Boolean); + return []; +} + +/** + * Adds a text label (from `getLabelText(activeWorkspaceIndex)`, called each + * time the popup is shown) alongside the native workspace-switcher popup's + * indicator dots. Uses `InjectionManager` (from the extension base module) + * to wrap `WorkspaceSwitcherPopup.prototype.display` so the original method + * and its behaviour are fully preserved and can be cleanly restored. + */ +export class WorkspaceSwitcherLabeler { + constructor(getLabelText) { + this._getLabelText = getLabelText; + this._injectionManager = null; + this._labels = new Set(); + } + + enable() { + this._injectionManager = new InjectionManager(); + const labeler = this; + this._injectionManager.overrideMethod( + WorkspaceSwitcherPopup.WorkspaceSwitcherPopup.prototype, 'display', + originalDisplay => function (activeWorkspaceIndex) { + originalDisplay.call(this, activeWorkspaceIndex); + labeler._onDisplay(this, activeWorkspaceIndex); + }); + } + + disable() { + this._injectionManager?.clear(); + this._injectionManager = null; + for (const label of this._labels) + label.destroy(); + this._labels.clear(); + } + + _onDisplay(popup, activeWorkspaceIndex) { + let text; + try { + text = this._getLabelText(activeWorkspaceIndex); + } catch (error) { + console.warn(`et: failed to compute workspace switcher label: ${error}`); + return; + } + + try { + for (const list of getIndicatorLists(popup)) { + const previous = list.get_children().find( + child => child.has_style_class_name(LABEL_STYLE_CLASS)); + previous?.destroy(); + + if (!text) + continue; + + const label = new St.Label({ + style_class: LABEL_STYLE_CLASS, + text, + y_align: Clutter.ActorAlign.CENTER, + }); + label.connect('destroy', () => this._labels.delete(label)); + this._labels.add(label); + list.add_child(label); + } + } catch (error) { + // `_list` is a private implementation detail of GNOME Shell's own + // popup; fail soft rather than breaking that popup if a future + // point release changes its shape in a way our adapter above + // doesn't already handle. + console.warn(`et: failed to update workspace switcher label: ${error}`); + } + } +} diff --git a/gnome-extension/et@seb4stien.github.com/metadata.json b/gnome-extension/et@seb4stien.github.com/metadata.json new file mode 100644 index 0000000..346559f --- /dev/null +++ b/gnome-extension/et@seb4stien.github.com/metadata.json @@ -0,0 +1,9 @@ +{ + "uuid": "et@seb4stien.github.com", + "name": "et Workspace Timer", + "description": "D-Bus companion service for the et CLI (https://github.com/seb4stien/et). Exposes GNOME Shell state (the active workspace index) that isn't otherwise reachable under Wayland, and owns persistent per-workspace counters shown in the top panel and workspace switcher, replacing the third-party Tracker extension.", + "shell-version": ["46", "50"], + "url": "https://github.com/seb4stien/et", + "settings-schema": "org.gnome.shell.extensions.et", + "gettext-domain": "et@seb4stien.github.com" +} diff --git a/gnome-extension/et@seb4stien.github.com/prefs.js b/gnome-extension/et@seb4stien.github.com/prefs.js new file mode 100644 index 0000000..db8910d --- /dev/null +++ b/gnome-extension/et@seb4stien.github.com/prefs.js @@ -0,0 +1,61 @@ +// SPDX-FileCopyrightText: 2026 Sébastien Georget +// SPDX-License-Identifier: GPL-3.0-or-later + +/* Preferences window for the et GNOME Shell extension. + * + * Runs out-of-process (via `gnome-extensions prefs`), so it uses the + * `resource:///org/gnome/Shell/Extensions/js/extensions/prefs.js` helper + * module rather than the main-process `.../shell/extensions/extension.js` + * one `extension.js` uses. + */ + +import Adw from 'gi://Adw'; +import Gio from 'gi://Gio'; +import GObject from 'gi://GObject'; +import Gtk from 'gi://Gtk'; + +import {ExtensionPreferences, gettext as _} from 'resource:///org/gnome/Shell/Extensions/js/extensions/prefs.js'; + +const EtPrefsPage = GObject.registerClass( +class EtPrefsPage extends Adw.PreferencesPage { + _init(settings) { + super._init({ + title: _('et Workspace Timer'), + icon_name: 'preferences-system-time-symbolic', + }); + + const contentGroup = new Adw.PreferencesGroup({ + title: _('Displayed information'), + description: _( + 'The workspace label and reference estimate are set by the ' + + 'et CLI when it prepares a workspace; the counter tracks ' + + 'elapsed time automatically.'), + }); + this.add(contentGroup); + contentGroup.add(this._switchRow(settings, 'show-label', _('Show workspace label'))); + contentGroup.add(this._switchRow(settings, 'show-estimate', _('Show reference estimate'))); + contentGroup.add(this._switchRow(settings, 'show-counter', _('Show elapsed counter'))); + + const locationGroup = new Adw.PreferencesGroup({ + title: _('Where to show it'), + }); + this.add(locationGroup); + locationGroup.add(this._switchRow(settings, 'show-panel', _('Top panel'))); + locationGroup.add(this._switchRow(settings, 'show-workspace-switcher', _('Workspace switcher'))); + } + + _switchRow(settings, key, title) { + const toggle = new Gtk.Switch({valign: Gtk.Align.CENTER}); + settings.bind(key, toggle, 'active', Gio.SettingsBindFlags.DEFAULT); + + const row = new Adw.ActionRow({title, activatable_widget: toggle}); + row.add_suffix(toggle); + return row; + } +}); + +export default class EtPrefs extends ExtensionPreferences { + fillPreferencesWindow(window) { + window.add(new EtPrefsPage(this.getSettings())); + } +} diff --git a/gnome-extension/et@seb4stien.github.com/schemas/org.gnome.shell.extensions.et.gschema.xml b/gnome-extension/et@seb4stien.github.com/schemas/org.gnome.shell.extensions.et.gschema.xml new file mode 100644 index 0000000..ce23377 --- /dev/null +++ b/gnome-extension/et@seb4stien.github.com/schemas/org.gnome.shell.extensions.et.gschema.xml @@ -0,0 +1,61 @@ + + + + + + '{}' + Persisted per-workspace counter state + + Internal JSON-encoded store of extension-owned per-workspace counter + state (label, estimate, and elapsed seconds), keyed by workspace + index. Managed entirely by the extension and its D-Bus API; not + meant to be edited by hand. + + + + true + Show the workspace label + + Show the generic workspace label (a descriptive workspace name) set + via SetWorkspaceMetadata/PrepareWorkspace, in the top panel and/or + the workspace switcher. + + + + true + Show the workspace estimate + + Show the reference estimate duration set via + SetWorkspaceMetadata/PrepareWorkspace, in the top panel and/or the + workspace switcher. + + + + true + Show the workspace counter + + Show the live elapsed-time counter, in the top panel and/or the + workspace switcher. + + + + true + Show the display in the top panel + + Show the configured workspace label/estimate/counter as an + indicator in the GNOME top panel. + + + + true + Show the display in the workspace switcher + + Show the configured workspace label/estimate/counter alongside + GNOME's workspace switcher popup. + + + + diff --git a/gnome-extension/et@seb4stien.github.com/stylesheet.css b/gnome-extension/et@seb4stien.github.com/stylesheet.css new file mode 100644 index 0000000..de3f12f --- /dev/null +++ b/gnome-extension/et@seb4stien.github.com/stylesheet.css @@ -0,0 +1,21 @@ +/* SPDX-FileCopyrightText: 2026 Sébastien Georget + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Minimal styling for the panel indicator and the injected workspace- + * switcher label, so the generic "label · estimate · counter" text sits + * comfortably alongside GNOME's own panel/switcher chrome. + */ + +.et-panel-icon { + padding: 0 4px; + icon-size: 16px; +} + +.et-panel-label { + padding: 0 6px; +} + +.et-workspace-switcher-label { + padding: 0 8px; + font-weight: bold; +} diff --git a/pyproject.toml b/pyproject.toml index 77593d5..1142d17 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "et" -version = "0.1.0" +version = "1.0.0rc1" description = "et: a small CLI to track effort and manage your Ubuntu/GNOME workspace." readme = "README.md" license = "Apache-2.0" diff --git a/scripts/check-gnome-shell-extension-live.sh b/scripts/check-gnome-shell-extension-live.sh new file mode 100755 index 0000000..27e1254 --- /dev/null +++ b/scripts/check-gnome-shell-extension-live.sh @@ -0,0 +1,117 @@ +#!/usr/bin/env bash +# Real, end-to-end smoke test of the et GNOME Shell extension's D-Bus +# surface against an actual (headless, isolated) GNOME Shell process -- +# not the mocked `subprocess.run` used by the pytest suite. +# +# This exists because the pytest suite (unit + FakeSystem-based end-to-end +# tests) fully mocks `gsettings`/`gdbus`, so it can never catch bugs that +# only show up when talking to a *real* GNOME Shell process: e.g. the +# extension failing to actually reach D-Bus-callable state, taking longer +# than expected to activate, or the isolation technique used by +# scripts/test-gnome-shell-extension.sh leaking into (or being leaked into +# by) the real session's dconf. Two such bugs were only found by manual +# testing before this script existed. +# +# Uses `gnome-shell --headless` (no parent display needed, unlike `--nested`) +# with a private HOME and complete XDG directory set, including a staged copy +# of the extension. It never installs into or changes the real user session. +# +# By default it skips when GNOME is unavailable. Pass --strict in CI to make +# missing tools or an unexpected Shell major version fail the check. + +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +UUID="et@seb4stien.github.com" +ACTIVATION_TIMEOUT_SECONDS=90 +STRICT=0 +EXPECTED_SHELL_MAJOR="" + +while [ "$#" -gt 0 ]; do + case "$1" in + --strict) + STRICT=1 + ;; + --expected-shell-major) + shift + EXPECTED_SHELL_MAJOR="${1:?--expected-shell-major requires a value}" + ;; + *) + echo "error: unknown argument '$1'" >&2 + exit 2 + ;; + esac + shift +done + +if ! command -v gnome-shell >/dev/null 2>&1 || ! command -v dbus-run-session >/dev/null 2>&1; then + if [ "${STRICT}" -eq 1 ]; then + echo "error: gnome-shell and dbus-run-session are required in strict mode." >&2 + exit 1 + fi + echo "skip: gnome-shell and/or dbus-run-session not found on PATH." + exit 0 +fi +if ! command -v gdbus >/dev/null 2>&1; then + if [ "${STRICT}" -eq 1 ]; then + echo "error: gdbus is required in strict mode." >&2 + exit 1 + fi + echo "skip: gdbus not found on PATH." + exit 0 +fi + +shell_version="$(gnome-shell --version)" +shell_major="$(sed -n 's/^GNOME Shell \([0-9][0-9]*\).*/\1/p' <<<"${shell_version}")" +if [ -n "${EXPECTED_SHELL_MAJOR}" ] && [ "${shell_major}" != "${EXPECTED_SHELL_MAJOR}" ]; then + echo "error: expected GNOME Shell ${EXPECTED_SHELL_MAJOR}, found ${shell_version}" >&2 + exit 1 +fi + +echo "Starting an isolated ${shell_version} headless smoke test for ${UUID}..." + +test_root="$(mktemp -d "${TMPDIR:-/tmp}/et-gnome-shell-test-XXXXXX")" +# shellcheck disable=SC2317,SC2329 # Invoked by the EXIT trap. +cleanup() { + "${REPO_ROOT}/scripts/lib/remove-isolated-gnome-shell-env.sh" "${test_root}" +} +trap cleanup EXIT + +run_command=( + env + ET_TEST_ROOT="${test_root}" + "${REPO_ROOT}/scripts/lib/with-isolated-gnome-shell-env.sh" + dbus-run-session -- + env + ET_ACTIVATION_TIMEOUT_SECONDS="${ACTIVATION_TIMEOUT_SECONDS}" + "${REPO_ROOT}/scripts/lib/check-extension-live.sh" +) + +# See scripts/test-gnome-shell-extension.sh for why this is wrapped in a +# systemd-run --scope: the headless Shell D-Bus-activates a pile of helper +# daemons (gvfs, tracker, evolution-*, goa-daemon, xdg-desktop-portal*, +# ibus...) that daemonize/reparent away from this process tree and are +# never cleaned up if only the main gnome-shell PID is killed. Wrapping +# the whole run in its own cgroup guarantees they're all reaped together +# when it exits, instead of permanently leaking on every run (which was +# observed to noticeably slow down repeated runs on the same machine). +set +e +if command -v systemd-run >/dev/null 2>&1 && command -v systemctl >/dev/null 2>&1 \ + && systemctl --user status >/dev/null 2>&1; then + scope_name="et-live-check-$$.scope" + systemd-run --user --scope --collect \ + --unit="et-live-check-$$" -- \ + "${run_command[@]}" + status=$? + systemctl --user stop "${scope_name}" >/dev/null 2>&1 || true +elif [ "${CI:-}" = "true" ]; then + "${run_command[@]}" + status=$? +else + echo "error: a working user systemd session is required outside disposable CI" \ + "so GNOME helper processes cannot leak into the host session." >&2 + status=1 +fi +set -e + +exit "${status}" diff --git a/scripts/check-gnome-shell-extension.py b/scripts/check-gnome-shell-extension.py new file mode 100755 index 0000000..7138edc --- /dev/null +++ b/scripts/check-gnome-shell-extension.py @@ -0,0 +1,133 @@ +#!/usr/bin/env python3 +"""Validate the bundled GNOME Shell extension source and optional archive.""" + +import argparse +import json +import sys +import xml.etree.ElementTree as ET +import zipfile +from pathlib import Path + +UUID = "et@seb4stien.github.com" +SCHEMA_ID = "org.gnome.shell.extensions.et" +REQUIRED_DBUS_METHODS = { + "GetActiveWorkspaceIndex", + "PrepareWorkspace", + "GetWorkspaceCounter", + "GetWorkspaceMetadata", + "ResetWorkspaceCounter", + "RemoveWorkspace", + "RemapWorkspaces", + "SetWorkspaceMetadata", +} +REQUIRED_SOURCE_FILES = { + "LICENSE", + "extension.js", + "lib/display.js", + "lib/workspaceStore.js", + "lib/workspaceSwitcherCompat.js", + "metadata.json", + "prefs.js", + f"schemas/{SCHEMA_ID}.gschema.xml", + "stylesheet.css", +} + + +def fail(message: str) -> None: + raise ValueError(message) + + +def validate_source(source_dir: Path) -> None: + missing = sorted(path for path in REQUIRED_SOURCE_FILES if not (source_dir / path).is_file()) + if missing: + fail(f"extension source is missing required files: {', '.join(missing)}") + + metadata = json.loads((source_dir / "metadata.json").read_text()) + if metadata.get("uuid") != UUID: + fail(f"metadata uuid must be {UUID!r}") + if metadata.get("name") != "et Workspace Timer": + fail("metadata name must be 'et Workspace Timer'") + if metadata.get("shell-version") != ["46", "50"]: + fail("metadata shell-version must contain exactly GNOME 46 and 50") + if metadata.get("settings-schema") != SCHEMA_ID: + fail(f"metadata settings-schema must be {SCHEMA_ID!r}") + + schema_path = source_dir / "schemas" / f"{SCHEMA_ID}.gschema.xml" + schema_root = ET.parse(schema_path).getroot() + schema = schema_root.find("schema") + if schema is None: + fail("settings schema XML contains no ") + if schema.get("id") != SCHEMA_ID: + fail(f"settings schema id must be {SCHEMA_ID!r}") + if schema.get("path") != "/org/gnome/shell/extensions/et/": + fail("settings schema path must be /org/gnome/shell/extensions/et/") + + key_elements = {element.get("name"): element for element in schema.findall("key")} + keys = set(key_elements) + expected_keys = { + "workspaces", + "show-label", + "show-estimate", + "show-counter", + "show-panel", + "show-workspace-switcher", + } + if keys != expected_keys: + fail(f"unexpected settings keys: {sorted(keys)}") + for key in ("show-label", "show-estimate", "show-counter"): + if key_elements[key].findtext("default") != "true": + fail(f"{key} must be enabled by default") + + extension_source = (source_dir / "extension.js").read_text() + missing_methods = sorted( + method + for method in REQUIRED_DBUS_METHODS + if f'' not in extension_source + ) + if missing_methods: + fail(f"D-Bus interface is missing methods: {', '.join(missing_methods)}") + + for path in source_dir.rglob("*"): + if path.is_file() and path.suffix in {".js", ".json", ".xml", ".css"}: + if "jira" in path.read_text().lower(): + fail(f"extension source must remain ticket-system agnostic: {path}") + + +def validate_archive(archive_path: Path) -> None: + with zipfile.ZipFile(archive_path) as archive: + names = {name.rstrip("/") for name in archive.namelist() if not name.endswith("/")} + + missing = sorted(REQUIRED_SOURCE_FILES - names) + if missing: + fail(f"extension archive is missing required files: {', '.join(missing)}") + + forbidden = sorted( + name + for name in names + if name.endswith("gschemas.compiled") + or "__pycache__" in name + or name.startswith(".") + or "/." in name + ) + if forbidden: + fail(f"extension archive contains generated or hidden files: {', '.join(forbidden)}") + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("source_dir", type=Path) + parser.add_argument("--archive", type=Path) + args = parser.parse_args() + + try: + validate_source(args.source_dir) + if args.archive is not None: + validate_archive(args.archive) + except (OSError, ValueError, json.JSONDecodeError, ET.ParseError, zipfile.BadZipFile) as error: + print(f"error: {error}", file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/install-gnome-shell-extension.sh b/scripts/install-gnome-shell-extension.sh new file mode 100755 index 0000000..7f29a89 --- /dev/null +++ b/scripts/install-gnome-shell-extension.sh @@ -0,0 +1,131 @@ +#!/usr/bin/env bash +# Installs the et GNOME Shell extension into the current user's extensions +# directory and enables it. Safe to re-run. +# +# The extension exposes GNOME Shell state and owns the per-workspace counters +# used by the CLI. It also displays configurable workspace metadata and elapsed +# time in the panel and workspace switcher. +# +# By default this *copies* the extension files so the installed copy is a +# real, persistent directory: this repo's checkout can be re-provisioned +# (e.g. on session/workspace restart) on a timeline independent of the +# GNOME session, and GNOME Shell only scans the extensions directory once +# at startup. A symlink whose target doesn't exist yet at that exact moment +# is silently skipped by GNOME Shell and never picked up until a full +# restart happens *after* the target exists. A copy avoids that race on +# every boot after the first successful install. +# +# Pass --dev (or --symlink) to symlink instead, e.g. for iterating on +# extension.js without re-running this script for every change (a Shell +# restart is still required either way to pick up new code). + +set -euo pipefail + +UUID="et@seb4stien.github.com" +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +SOURCE_DIR="${REPO_ROOT}/gnome-extension/${UUID}" +EXTENSIONS_DIR="${HOME}/.local/share/gnome-shell/extensions" +TARGET_DIR="${EXTENSIONS_DIR}/${UUID}" + +# shellcheck source=scripts/lib/enable-extension-setting.sh +source "${REPO_ROOT}/scripts/lib/enable-extension-setting.sh" + +MODE="copy" +for arg in "$@"; do + case "${arg}" in + --dev|--symlink) + MODE="symlink" + ;; + *) + echo "error: unknown argument '${arg}' (expected --dev/--symlink)" >&2 + exit 1 + ;; + esac +done + +if ! command -v gnome-extensions >/dev/null 2>&1; then + echo "error: gnome-extensions not found on PATH; is GNOME Shell installed?" >&2 + exit 1 +fi +if ! command -v gsettings >/dev/null 2>&1; then + echo "error: gsettings not found on PATH; is GNOME Shell installed?" >&2 + exit 1 +fi +if ! command -v glib-compile-schemas >/dev/null 2>&1; then + echo "error: glib-compile-schemas not found on PATH; install GLib development tools" >&2 + exit 1 +fi + +mkdir -p "${EXTENSIONS_DIR}" + +if [ "${MODE}" = "symlink" ]; then + if [ -L "${TARGET_DIR}" ] && [ "$(readlink -f "${TARGET_DIR}")" = "$(readlink -f "${SOURCE_DIR}")" ]; then + echo "et extension already symlinked at ${TARGET_DIR}" + elif [ -e "${TARGET_DIR}" ]; then + rm -rf "${TARGET_DIR}" + ln -s "${SOURCE_DIR}" "${TARGET_DIR}" + echo "replaced ${TARGET_DIR} with a symlink -> ${SOURCE_DIR}" + else + ln -s "${SOURCE_DIR}" "${TARGET_DIR}" + echo "symlinked ${TARGET_DIR} -> ${SOURCE_DIR}" + fi +else + if [ -L "${TARGET_DIR}" ]; then + # Replace a symlink from a previous install (or --dev run) with a + # real copy, so it survives independently of SOURCE_DIR's lifecycle. + rm -f "${TARGET_DIR}" + fi + + if [ -d "${TARGET_DIR}" ] \ + && diff -rq --exclude=gschemas.compiled "${SOURCE_DIR}" "${TARGET_DIR}" >/dev/null 2>&1; then + echo "et extension already installed and up to date at ${TARGET_DIR}" + else + if [ -e "${TARGET_DIR}" ]; then + echo "updating installed copy at ${TARGET_DIR}" + else + echo "installing copy at ${TARGET_DIR}" + fi + rm -rf "${TARGET_DIR}" + cp -r "${SOURCE_DIR}" "${TARGET_DIR}" + echo "note: extension content changed — log out and back in for GNOME Shell to load the update." + fi +fi + +# Copy/symlink installs bypass `gnome-extensions install`, so compile the +# bundled schema explicitly. For --dev the target is a symlink and the compiled +# file lands in the source tree; it is ignored by git. +glib-compile-schemas "${TARGET_DIR}/schemas" + +# A running GNOME Shell only scans ~/.local/share/gnome-shell/extensions at +# startup, so it doesn't know about a brand-new UUID yet: `gnome-extensions +# enable` talks to the *live* shell process and fails with "does not exist" +# for it, without touching anything, until the shell restarts. Fall back to +# flipping the same `enabled-extensions` gsettings key `enable` would set, +# so the extension starts enabled the next time GNOME Shell (re)loads. +if enable_err=$(gnome-extensions enable "${UUID}" 2>&1 >/dev/null); then + echo "enabled ${UUID}" + echo "note: if GNOME Shell was already running, log out/in (or Alt+F2, r on X11) for it to load." +else + enable_gnome_extension_in_settings "${UUID}" + echo "registered ${UUID} as enabled (GNOME Shell hasn't scanned it in yet: ${enable_err})" + + # Query the *running* Shell over D-Bus to tell apart "Shell has never + # heard of this UUID" (needs a full restart to scan it in — commonly a + # boot-time race between this workspace becoming available and GNOME + # Shell's one-time startup scan) from "Shell knows about it but it's in + # an error/out-of-date state" (a real bug worth fixing, not just a + # restart away). Best-effort: skip silently if the D-Bus call fails. + info="$(gdbus call --session --dest org.gnome.Shell.Extensions \ + --object-path /org/gnome/Shell/Extensions \ + --method org.gnome.Shell.Extensions.GetExtensionInfo "${UUID}" 2>/dev/null || true)" + if [[ "${info}" == "({},)" || "${info}" == "(@a{sv} {},)" ]]; then + echo "note: GNOME Shell has no record of ${UUID} at all — log out and" \ + "back in (Wayland) — or Alt+F2, r on X11 — for it to scan the" \ + "extensions directory again." + elif [[ -n "${info}" ]] && err=$(grep -oP "'error': <'\K[^']*" <<< "${info}") && [[ -n "${err}" ]]; then + echo "warning: GNOME Shell already scanned ${UUID} but reports an error:" + echo " ${err}" + else + echo "note: log out and back in (Wayland) — or Alt+F2, r on X11 — for GNOME Shell to load it." + fi +fi diff --git a/scripts/install-gnome-test-requirements.sh b/scripts/install-gnome-test-requirements.sh new file mode 100755 index 0000000..a4f044b --- /dev/null +++ b/scripts/install-gnome-test-requirements.sh @@ -0,0 +1,38 @@ +#!/usr/bin/env bash +# Installs the Ubuntu system packages used to validate and exercise the GNOME +# Shell extension. Does nothing when all required commands are already present. + +set -euo pipefail + +required_commands=( + dbus-run-session + gjs + glib-compile-schemas + gnome-shell + shellcheck +) + +missing=0 +for command_name in "${required_commands[@]}"; do + if ! command -v "${command_name}" >/dev/null 2>&1; then + missing=1 + break + fi +done + +if [ "${missing}" -eq 0 ]; then + exit 0 +fi + +if ! command -v apt-get >/dev/null 2>&1; then + echo "error: install these commands before testing: ${required_commands[*]}" >&2 + exit 1 +fi + +sudo apt-get update +sudo apt-get install --yes \ + dbus-daemon \ + gjs \ + gnome-shell \ + libglib2.0-bin \ + shellcheck diff --git a/scripts/lib/check-extension-live.sh b/scripts/lib/check-extension-live.sh new file mode 100755 index 0000000..37d3d62 --- /dev/null +++ b/scripts/lib/check-extension-live.sh @@ -0,0 +1,167 @@ +#!/usr/bin/env bash +# Helper for scripts/check-gnome-shell-extension-live.sh, run *inside* the +# private dbus-run-session + XDG_CONFIG_HOME environment it sets up. +# +# Expects ET_EXTENSION_UUID and ET_ACTIVATION_TIMEOUT_SECONDS in the +# environment. Exits non-zero if the extension fails to activate or any +# D-Bus method call doesn't behave as expected. + +set -uo pipefail + +: "${ET_EXTENSION_UUID:?ET_EXTENSION_UUID must be set}" +: "${ET_ACTIVATION_TIMEOUT_SECONDS:?ET_ACTIVATION_TIMEOUT_SECONDS must be set}" +: "${ET_TEST_ROOT:?ET_TEST_ROOT must be set}" + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +# shellcheck source=scripts/lib/enable-extension-setting.sh +source "${REPO_ROOT}/scripts/lib/enable-extension-setting.sh" + +DEST="org.gnome.Shell" +OBJECT_PATH="/org/gnome/Shell/Extensions/Et" +IFACE="org.gnome.Shell.Extensions.Et" +SHELL_LOG="${ET_TEST_ROOT}/gnome-shell.log" + +failures=0 + +save_diagnostics() { + if [ -n "${ET_DIAGNOSTICS_DIR:-}" ]; then + mkdir -p "${ET_DIAGNOSTICS_DIR}" + cp "${SHELL_LOG}" "${ET_DIAGNOSTICS_DIR}/gnome-shell.log" + gnome-extensions info "${ET_EXTENSION_UUID}" \ + >"${ET_DIAGNOSTICS_DIR}/extension-info.txt" 2>&1 || true + fi +} + +# Pre-seed enabled-extensions before the Shell starts scanning extensions +# (see scripts/lib/run-nested-test-shell.sh for the full rationale/quirks: +# the `@as []` GVariant-annotated empty array form, and why nothing should +# be printed for an empty list). +enable_gnome_extension_in_settings "${ET_EXTENSION_UUID}" + +gnome-shell --headless --wayland >"${SHELL_LOG}" 2>&1 & +shell_pid=$! +trap 'kill "${shell_pid}" 2>/dev/null || true; wait "${shell_pid}" 2>/dev/null || true' EXIT + +wait_for_state() { + local expected_state="$1" + local attempts="$2" + local info state + for _ in $(seq 1 "${attempts}"); do + info="$(gnome-extensions info "${ET_EXTENSION_UUID}" 2>/dev/null)" || true + state="$(sed -n 's/^\s*State:\s*//p' <<<"${info}")" + if [ "${state}" = "${expected_state}" ]; then + return 0 + fi + if [ "${expected_state}" = "ACTIVE" ] && echo "${info}" | grep -q "Enabled:\s*No"; then + gnome-extensions enable "${ET_EXTENSION_UUID}" 2>/dev/null || true + fi + sleep 1 + done + return 1 +} + +echo "Waiting for ${ET_EXTENSION_UUID} to activate (up to ${ET_ACTIVATION_TIMEOUT_SECONDS}s)..." +if ! wait_for_state ACTIVE "${ET_ACTIVATION_TIMEOUT_SECONDS}"; then + echo "FAIL: ${ET_EXTENSION_UUID} did not reach ACTIVE within ${ET_ACTIVATION_TIMEOUT_SECONDS}s" >&2 + save_diagnostics + cat "${SHELL_LOG}" >&2 + exit 1 +fi +echo "PASS: ${ET_EXTENSION_UUID} reached ACTIVE" + +call() { + gdbus call --session --dest "${DEST}" --object-path "${OBJECT_PATH}" \ + --method "${IFACE}.$1" "${@:2}" +} + +# Asserts `call "$@"` (a method name plus its positional args) succeeds and +# its output contains $expect_substring; on mismatch, records a failure and +# prints the actual output for debugging, but keeps running the rest of the +# checks so a single early failure doesn't hide later ones. +assert_call_contains() { + local expect_substring="$1" + shift + local out + out="$(call "$@" 2>&1)" + if [[ "${out}" == *"${expect_substring}"* ]]; then + echo "PASS: $1 -> ${out}" + else + echo "FAIL: $1 expected to contain '${expect_substring}', got: ${out}" >&2 + failures=$((failures + 1)) + fi +} + +# Asserts a call succeeds and its output matches an extended regular +# expression. This is used for elapsed time, where the exact value is expected +# to vary slightly with scheduling. +assert_call_matches() { + local expected_regex="$1" + shift + local out + out="$(call "$@" 2>&1)" + if [[ "${out}" =~ ${expected_regex} ]]; then + echo "PASS: $1 -> ${out}" + else + echo "FAIL: $1 expected to match '${expected_regex}', got: ${out}" >&2 + failures=$((failures + 1)) + fi +} + +# 1. Invalid and never-prepared workspaces expose stable D-Bus errors. +assert_call_contains "Error.NotFound" GetWorkspaceMetadata 3 +assert_call_contains "Error.InvalidArgument" PrepareWorkspace 999 "invalid" 0 + +# 2. Prepare active workspace 0, then verify metadata and the running counter. +assert_call_contains "()" PrepareWorkspace 0 "smoke-test" 120 +assert_call_contains "'smoke-test', uint64 120" GetWorkspaceMetadata 0 +assert_call_contains "true" GetWorkspaceCounter 0 +sleep 2 +assert_call_matches "uint64 [1-9][0-9]*, true" GetWorkspaceCounter 0 + +# 3. Update its label without resetting the counter, then reset it. +# sync_workspace_label's usage from `et ws rename`). +assert_call_contains "()" SetWorkspaceMetadata 0 "smoke-test-renamed" 120 +assert_call_contains "'smoke-test-renamed', uint64 120" GetWorkspaceMetadata 0 +assert_call_contains "()" ResetWorkspaceCounter 0 +assert_call_contains "true" GetWorkspaceCounter 0 + +# 4. Remapping is simultaneous and rejects duplicate endpoints. +assert_call_contains "()" RemapWorkspaces "[(uint32 0, uint32 1)]" +assert_call_contains "'smoke-test-renamed', uint64 120" GetWorkspaceMetadata 1 +assert_call_contains "Error.InvalidArgument" RemapWorkspaces \ + "[(uint32 1, uint32 0), (uint32 1, uint32 2)]" +assert_call_contains "()" RemapWorkspaces "[(uint32 1, uint32 0)]" + +# 5. GetActiveWorkspaceIndex responds (headless Shell always reports 0). +assert_call_contains "uint32 0" GetActiveWorkspaceIndex + +# 6. Disable/re-enable restores persisted data and re-exports D-Bus cleanly. +if gnome-extensions disable "${ET_EXTENSION_UUID}" \ + && wait_for_state INACTIVE "${ET_ACTIVATION_TIMEOUT_SECONDS}"; then + echo "PASS: extension disabled cleanly" +else + echo "FAIL: extension did not disable cleanly" >&2 + failures=$((failures + 1)) +fi +if gnome-extensions enable "${ET_EXTENSION_UUID}" \ + && wait_for_state ACTIVE "${ET_ACTIVATION_TIMEOUT_SECONDS}"; then + echo "PASS: extension re-enabled cleanly" +else + echo "FAIL: extension did not re-enable cleanly" >&2 + failures=$((failures + 1)) +fi +assert_call_contains "'smoke-test-renamed', uint64 120" GetWorkspaceMetadata 0 + +# 7. Removing it makes it unprepared again. +assert_call_contains "()" RemoveWorkspace 0 +assert_call_contains "Error.NotFound" GetWorkspaceMetadata 0 + +if [ "${failures}" -eq 0 ]; then + echo "All live D-Bus checks passed." + exit 0 +else + echo "${failures} live D-Bus check(s) failed." >&2 + save_diagnostics + cat "${SHELL_LOG}" >&2 + exit 1 +fi diff --git a/scripts/lib/enable-extension-setting.sh b/scripts/lib/enable-extension-setting.sh new file mode 100644 index 0000000..76b6307 --- /dev/null +++ b/scripts/lib/enable-extension-setting.sh @@ -0,0 +1,22 @@ +#!/usr/bin/env bash + +enable_gnome_extension_in_settings() { + local extension_uuid="$1" + local enabled_extensions + + enabled_extensions="$( + gsettings get org.gnome.shell enabled-extensions \ + | python3 -c ' +import ast +import re +import sys + +items = ast.literal_eval(re.sub(r"^@\S+\s+", "", sys.stdin.read())) +extension_uuid = sys.argv[1] +if extension_uuid not in items: + items.append(extension_uuid) +print(repr(items)) +' "${extension_uuid}" + )" + gsettings set org.gnome.shell enabled-extensions "${enabled_extensions}" +} diff --git a/scripts/lib/remove-isolated-gnome-shell-env.sh b/scripts/lib/remove-isolated-gnome-shell-env.sh new file mode 100755 index 0000000..57af47f --- /dev/null +++ b/scripts/lib/remove-isolated-gnome-shell-env.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash +# Removes one disposable test root created for GNOME Shell testing. + +set -euo pipefail + +test_root="$(realpath -m "${1:?expected the disposable test root path}")" +temporary_parent="$(realpath -m "${TMPDIR:-/tmp}")" +if [ "$(dirname "${test_root}")" != "${temporary_parent}" ] \ + || [[ "$(basename "${test_root}")" != et-gnome-shell-test-* ]]; then + echo "error: refusing to remove unexpected test root: ${test_root}" >&2 + exit 2 +fi + +for mount_path in "${test_root}/runtime/doc" "${test_root}/runtime/gvfs"; do + if mountpoint -q "${mount_path}" 2>/dev/null; then + if command -v fusermount3 >/dev/null 2>&1; then + fusermount3 -uz "${mount_path}" 2>/dev/null || true + elif command -v fusermount >/dev/null 2>&1; then + fusermount -uz "${mount_path}" 2>/dev/null || true + fi + if mountpoint -q "${mount_path}" 2>/dev/null; then + echo "error: refusing to remove test root while ${mount_path} is mounted" >&2 + exit 1 + fi + fi +done + +rm -rf --one-file-system "${test_root}" diff --git a/scripts/lib/run-nested-test-shell.sh b/scripts/lib/run-nested-test-shell.sh new file mode 100755 index 0000000..b363e68 --- /dev/null +++ b/scripts/lib/run-nested-test-shell.sh @@ -0,0 +1,70 @@ +#!/usr/bin/env bash +# Helper for scripts/test-gnome-shell-extension.sh, run *inside* the private +# dbus-run-session + XDG_CONFIG_HOME environment it sets up. Kept as its own +# file (rather than an inline `bash -c '...'` string) so its quoting doesn't +# have to fight with the outer script's own quoting. +# +# Expects ET_EXTENSION_UUID in the environment. + +set -euo pipefail + +: "${ET_EXTENSION_UUID:?ET_EXTENSION_UUID must be set}" +: "${ET_TEST_ROOT:?ET_TEST_ROOT must be set}" + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +# shellcheck source=scripts/lib/enable-extension-setting.sh +source "${REPO_ROOT}/scripts/lib/enable-extension-setting.sh" + +echo " - Disposable test root: ${ET_TEST_ROOT}" + +# Pre-seed enabled-extensions in the (empty, private) dconf database +# *before* the Shell starts scanning extensions, so it comes up already +# enabled instead of needing a post-startup retry loop. Preserves whatever +# distro-default extensions are already enabled (e.g. Ubuntu +# dock/appindicators), mirroring install-gnome-shell-extension.sh. +# +# `gsettings get` on an empty array prints the GVariant-annotated form +# `@as []` (needed since an empty array's element type can't otherwise be +# inferred from its contents) instead of plain `[]`; strip that leading +# `@ ` annotation before parsing so an empty enabled-extensions list +# doesn't crash this. Also print nothing (not an empty line) when the list +# is empty, so `mapfile` doesn't capture a stray blank element. +enable_gnome_extension_in_settings "${ET_EXTENSION_UUID}" + +gnome-shell --nested --wayland & +nested_shell_pid=$! +trap 'kill "${nested_shell_pid}" 2>/dev/null' EXIT + +echo "Waiting for the nested Shell to come up (this can take a couple of minutes)..." +active=0 +for _ in $(seq 1 150); do + # `gnome-extensions info` fails (non-zero) until the Shell's D-Bus + # service is up, which is exactly the condition being waited out here + # -- `|| true` keeps that expected, repeated failure from tripping + # `set -e` and aborting the whole script on the very first attempt. + info="$(gnome-extensions info "${ET_EXTENSION_UUID}" 2>/dev/null)" || true + state="$(sed -n 's/^\s*State:\s*//p' <<<"${info}")" + if [ "${state}" = "ACTIVE" ]; then + echo "${ET_EXTENSION_UUID} is ACTIVE in the nested Shell" + active=1 + break + fi + # Pre-seeding enabled-extensions above (before the Shell starts) is + # racy: `gsettings set` can return before the Shell's own read of + # enabled-extensions at startup, so it can come up with the extension + # merely INITIALIZED, not ENABLED, and never pick it up on its own. + # Actively re-assert enablement via the Shell's own D-Bus API once + # reachable -- idempotent/cheap if already enabled -- instead of only + # waiting passively. + if echo "${info}" | grep -q "Enabled:\s*No"; then + gnome-extensions enable "${ET_EXTENSION_UUID}" 2>/dev/null || true + fi + sleep 1 +done +if [ "${active}" -ne 1 ]; then + echo "warning: ${ET_EXTENSION_UUID} did not reach ACTIVE within 150s;" \ + "run 'gnome-extensions info ${ET_EXTENSION_UUID}' in this shell" \ + "to check its current state." >&2 +fi + +exec bash --noprofile --norc diff --git a/scripts/lib/with-isolated-gnome-shell-env.sh b/scripts/lib/with-isolated-gnome-shell-env.sh new file mode 100755 index 0000000..8e398eb --- /dev/null +++ b/scripts/lib/with-isolated-gnome-shell-env.sh @@ -0,0 +1,54 @@ +#!/usr/bin/env bash +# Runs a command with a disposable HOME/XDG environment containing a staged +# copy of the extension. Nothing under the real user's home directory is read +# or modified by GNOME Shell, dconf, or the extension. + +set -euo pipefail + +if [ "$#" -eq 0 ]; then + echo "error: expected a command to run" >&2 + exit 2 +fi + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +UUID="et@seb4stien.github.com" +SOURCE_DIR="${REPO_ROOT}/gnome-extension/${UUID}" + +owns_test_root=0 +if [ -n "${ET_TEST_ROOT:-}" ]; then + test_root="${ET_TEST_ROOT}" + mkdir -p "${test_root}" +else + test_root="$(mktemp -d "${TMPDIR:-/tmp}/et-gnome-shell-test-XXXXXX")" + owns_test_root=1 +fi +cleanup() { + if [ "${owns_test_root}" -eq 1 ]; then + "${REPO_ROOT}/scripts/lib/remove-isolated-gnome-shell-env.sh" "${test_root}" + fi +} +trap cleanup EXIT + +export HOME="${test_root}/home" +export XDG_CONFIG_HOME="${test_root}/config" +export XDG_DATA_HOME="${test_root}/data" +export XDG_CACHE_HOME="${test_root}/cache" +export XDG_STATE_HOME="${test_root}/state" +export XDG_RUNTIME_DIR="${test_root}/runtime" +export ET_TEST_ROOT="${test_root}" +export ET_EXTENSION_UUID="${UUID}" +export ET_EXTENSION_DIR="${XDG_DATA_HOME}/gnome-shell/extensions/${UUID}" + +mkdir -p \ + "${HOME}" \ + "${XDG_CONFIG_HOME}" \ + "${XDG_DATA_HOME}/gnome-shell/extensions" \ + "${XDG_CACHE_HOME}" \ + "${XDG_STATE_HOME}" \ + "${XDG_RUNTIME_DIR}" +chmod 700 "${XDG_RUNTIME_DIR}" + +cp -a "${SOURCE_DIR}" "${ET_EXTENSION_DIR}" +glib-compile-schemas "${ET_EXTENSION_DIR}/schemas" + +"$@" diff --git a/scripts/package-gnome-shell-extension.sh b/scripts/package-gnome-shell-extension.sh new file mode 100755 index 0000000..87d4c17 --- /dev/null +++ b/scripts/package-gnome-shell-extension.sh @@ -0,0 +1,35 @@ +#!/usr/bin/env bash +# Build an extensions.gnome.org-ready ZIP for the bundled extension. + +set -euo pipefail + +UUID="et@seb4stien.github.com" +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +SOURCE_DIR="${REPO_ROOT}/gnome-extension/${UUID}" +DIST_DIR="${REPO_ROOT}/dist" +ARCHIVE="${DIST_DIR}/${UUID}.shell-extension.zip" +SCHEMA="schemas/org.gnome.shell.extensions.et.gschema.xml" + +if ! command -v gnome-extensions >/dev/null 2>&1; then + echo "error: gnome-extensions not found on PATH; is GNOME Shell installed?" >&2 + exit 1 +fi + +python3 "${REPO_ROOT}/scripts/check-gnome-shell-extension.py" "${SOURCE_DIR}" +mkdir -p "${DIST_DIR}" + +( + cd "${SOURCE_DIR}" + gnome-extensions pack \ + --force \ + --out-dir="${DIST_DIR}" \ + --schema="${SCHEMA}" \ + --extra-source="LICENSE" \ + --extra-source="lib" \ + . +) + +python3 "${REPO_ROOT}/scripts/check-gnome-shell-extension.py" \ + "${SOURCE_DIR}" \ + --archive "${ARCHIVE}" +echo "built ${ARCHIVE}" diff --git a/scripts/test-gnome-shell-extension.sh b/scripts/test-gnome-shell-extension.sh new file mode 100755 index 0000000..d0a1b38 --- /dev/null +++ b/scripts/test-gnome-shell-extension.sh @@ -0,0 +1,86 @@ +#!/usr/bin/env bash +# Launches a throwaway, nested GNOME Shell session with the et extension +# enabled, for visually iterating on it without touching the real session. +# +# Stages the extension under a disposable HOME/XDG tree, then starts +# `gnome-shell --nested --wayland` inside a private D-Bus session. The nested +# Shell never reads or writes the real extension directory, dconf database, +# cache, state, or enabled-extension list. +# +# Unlike `GSETTINGS_BACKEND=memory` (an earlier attempt at this isolation), +# a private `XDG_CONFIG_HOME` gives the nested session a real, file-backed +# dconf database: settings persist across separate command invocations +# within the nested shell (e.g. `gsettings set ... dynamic-workspaces +# false` followed by a later `et` run), which a per-process, in-memory-only +# backend cannot do. `enabled-extensions` is pre-seeded into that private +# database *before* the nested Shell starts (see scripts/lib/ +# run-nested-test-shell.sh), so it comes up with the extension already +# enabled -- no post-startup D-Bus retry dance needed. +# +# The nested Shell's startup D-Bus-activates a pile of helper daemons +# (gvfs, tracker, evolution-*, goa-daemon, xdg-desktop-portal*, ibus...). +# These daemonize/double-fork and end up reparented away from this +# script's own process tree, so merely killing the nested `gnome-shell` +# PID leaves all of them running forever -- they don't share its lifetime +# and never get cleaned up on their own. Left unchecked, every run of this +# script permanently leaks ~15-25 such processes, which was observed to +# noticeably (and increasingly, run after run) slow down the whole +# machine. `systemd-run --user --scope` puts the entire run in its own +# cgroup; when the wrapped command exits, systemd kills every remaining +# process in that cgroup regardless of what it got reparented to, +# guaranteeing a clean teardown. The script fails closed if a user systemd +# session is unavailable rather than risking leaked host processes. +# +# Exiting the dropped-into shell tears the nested Shell (and every helper +# process it started) down and removes the private config directory. + +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + +if ! command -v gnome-shell >/dev/null 2>&1; then + echo "error: gnome-shell not found on PATH; is GNOME Shell installed?" >&2 + exit 1 +fi +if ! command -v dbus-run-session >/dev/null 2>&1; then + echo "error: dbus-run-session not found on PATH; install dbus-user-session" >&2 + exit 1 +fi +if ! command -v systemd-run >/dev/null 2>&1 || ! command -v systemctl >/dev/null 2>&1 \ + || ! systemctl --user status >/dev/null 2>&1; then + echo "error: a working user systemd session is required so every helper" \ + "process started by the nested Shell can be cleaned up safely." >&2 + exit 1 +fi + +echo +echo "Launching a fully isolated nested GNOME Shell test session..." +echo " - GNOME Shell version: $(gnome-shell --version)" +echo " - Extension files, HOME, dconf, caches, and runtime state are staged" \ + "under a temporary directory printed after startup." +echo " - This shell's D-Bus session is the nested Shell's own: 'et', 'gdbus'," \ + "and 'gnome-extensions' commands run here target it, not your real session." +echo " - Alt+F2, then 'lg', opens Looking Glass inside the nested Shell." +echo " - Exit this shell (Ctrl+D / 'exit') to stop the nested Shell and" \ + "discard every staged file and setting." +echo + +test_root="$(mktemp -d "${TMPDIR:-/tmp}/et-gnome-shell-test-XXXXXX")" +# shellcheck disable=SC2317,SC2329 # Invoked by the EXIT trap. +cleanup() { + "${REPO_ROOT}/scripts/lib/remove-isolated-gnome-shell-env.sh" "${test_root}" +} +trap cleanup EXIT + +scope_name="et-test-extension-$$.scope" +set +e +systemd-run --user --scope --collect \ + --unit="et-test-extension-$$" -- \ + env ET_TEST_ROOT="${test_root}" \ + "${REPO_ROOT}/scripts/lib/with-isolated-gnome-shell-env.sh" \ + dbus-run-session -- \ + "${REPO_ROOT}/scripts/lib/run-nested-test-shell.sh" +status=$? +systemctl --user stop "${scope_name}" >/dev/null 2>&1 || true +set -e +exit "${status}" diff --git a/src/et/cli.py b/src/et/cli.py index 877ba8e..a35d13c 100644 --- a/src/et/cli.py +++ b/src/et/cli.py @@ -8,7 +8,24 @@ import typer -from et.config import ConfigError, load_config, load_workspace_names +from et.config import ( + VALID_WORKSPACE_TYPES, + ConfigError, + WorkspaceConfigEntry, + get_config_path, + load_config, + load_workspace_names, + save_config, +) +from et.config_wizard import ConfigWizardPrompts, run_config_wizard +from et.duration import format_duration, parse_hours_to_seconds +from et.et_extension import ( + EtExtensionError, + WorkspaceCounter, + WorkspaceCounterNotFoundError, + get_workspace_counter, + sync_workspace_label, +) from et.git_branch import ( BRANCH_TYPES, GitBranchError, @@ -42,13 +59,6 @@ get_current_status_for_current_workspace, set_status_for_current_workspace, ) -from et.tracker import ( - TrackerError, - find_timer_for_workspace, - format_duration, - load_timers, - parse_hours_to_seconds, -) from et.workspaces import ( WorkspaceError, get_active_workspace_index, @@ -96,7 +106,7 @@ def _hyperlink(text: str, url: str) -> str: app.add_typer(ws_app, name="ws") jira_app = typer.Typer( - help="Convenience layer around ws (plus the Tracker/Jira integrations) for a single " + help="Convenience layer around ws (plus the counter/Jira integrations) for a single " "task's lifecycle.", no_args_is_help=True, ) @@ -189,11 +199,186 @@ def _show_active_workspace_info(ctx: typer.Context) -> None: _print_workspace_jira_info(index, config) try: _print_workspace_time_spent(index) - except TrackerError as error: + except EtExtensionError as error: typer.echo(f"Error: {error}", err=True) raise typer.Exit(code=1) from error +def _prompt_required(label: str, default: str) -> str: + """Prompt for `label`, pre-filling `default` when non-empty (else no default).""" + if default: + return str(typer.prompt(label, default=default)) + return str(typer.prompt(label)) + + +def _prompt_optional(label: str, default: str) -> str: + """Prompt for `label`, defaulting to (and accepting) a blank answer.""" + return str(typer.prompt(label, default=default)) + + +def _prompt_pat(default: str) -> str: + """Prompt for the Jira API token, hiding input and letting a blank answer keep `default`.""" + if default: + entered = typer.prompt( + "Jira API token (leave blank to keep the current one)", + default="", + hide_input=True, + show_default=False, + ) + return str(entered) or default + return str(typer.prompt("Jira API token", hide_input=True)) + + +def _resolve_credential_check_failure(message: str) -> str: + typer.echo(f"Warning: {message}", err=True) + return _prompt_from_list("What do you want to do?", ("retry", "keep", "discard"), "retry") + + +def _format_workspace_entry(entry: WorkspaceConfigEntry) -> str: + details = f"{entry.name} (type={entry.type}" + if entry.ref: + details += f", ref={entry.ref}" + if entry.description: + details += f", description={entry.description}" + return details + ")" + + +def _prompt_workspace_action(entries: list[WorkspaceConfigEntry]) -> str: + typer.echo("\nWorkspaces:") + if not entries: + typer.echo(" (none configured yet)") + for position, entry in enumerate(entries, start=1): + typer.echo(f" {position}. {_format_workspace_entry(entry)}") + + return _prompt_from_list( + "What do you want to do?", + ("add", "edit", "remove", "done"), + "add" if not entries else "done", + ) + + +def _select_workspace_entry(entries: list[WorkspaceConfigEntry], purpose: str) -> int | None: + if not entries: + typer.echo(f"No workspaces to {purpose}.") + return None + + typer.echo(f"Pick a workspace to {purpose} (0 to cancel):") + for position, entry in enumerate(entries, start=1): + typer.echo(f" {position}. {entry.name}") + + choice = typer.prompt("Number", default="0") + try: + selected = int(choice) + except ValueError: + return None + if selected < 1 or selected > len(entries): + return None + return selected - 1 + + +def _prompt_workspace_entry( + default: WorkspaceConfigEntry | None, +) -> WorkspaceConfigEntry | None: + default_name = default.name if default else "" + name = str(typer.prompt("Workspace name (blank to cancel)", default=default_name)) + if not name: + return None + + workspace_type = _prompt_from_list( + "Type", VALID_WORKSPACE_TYPES, default.type if default else "static" + ) + + ref: str | None = None + if workspace_type != "static": + default_ref = (default.ref if default else "") or "" + entered_ref = typer.prompt("Jira ref (optional, e.g. jira:ISD-321)", default=default_ref) + ref = str(entered_ref) or None + + default_description = (default.description if default else "") or "" + description = str(typer.prompt("Description (optional)", default=default_description)) + + return WorkspaceConfigEntry( + name=name, + type=workspace_type, + ref=ref, + description=description or None, + ) + + +def _confirm_save_config(config: EtConfig) -> bool: + typer.echo("\nAbout to write this configuration:") + if config.jira is not None: + typer.echo(f" jira.base_url: {config.jira.base_url}") + typer.echo(f" jira.email: {config.jira.email}") + typer.echo(" jira.pat: ********") + typer.echo(f" jira.jql: {config.jira.jql}") + typer.echo(f" jira.project_key: {config.jira.project_key or '(none)'}") + typer.echo(f" jira.board_id: {config.jira.board_id or '(none)'}") + else: + typer.echo(" jira: (not configured)") + typer.echo(f" workspaces: {len(config.workspaces)} entry(ies)") + for entry in config.workspaces: + typer.echo(f" - {_format_workspace_entry(entry)}") + return typer.confirm(f"Save to {get_config_path()}?", default=True) + + +@app.command("config") +def config_cmd() -> None: + """Interactively create or update `~/.config/et/config.yaml`. + + Walks through the "jira" block (base_url/email/pat/jql/project_key/ + board_id — testing the credentials against Jira's API once collected, + with the option to retry, keep them anyway, or discard the Jira block + on failure) and the "workspaces" list (add/edit/remove entries), then + writes the result to the config file. Pre-fills every field from the + existing config when one is already present. + """ + try: + existing: EtConfig | None = load_config() + except ConfigError as error: + if "config file not found" not in str(error): + typer.echo(f"Error: {error}", err=True) + raise typer.Exit(code=1) from error + existing = None + + prompts = ConfigWizardPrompts( + confirm_configure_jira=lambda _has_existing: typer.confirm( + "Configure Jira integration?", default=True + ), + prompt_base_url=lambda default: _prompt_required( + "Jira base URL (e.g. https://your-org.atlassian.net)", default + ), + prompt_email=lambda default: _prompt_required("Jira account email", default), + prompt_pat=_prompt_pat, + prompt_jql=lambda default: _prompt_required( + "JQL query (e.g. assignee = currentUser() AND statusCategory != Done)", default + ), + prompt_project_key=lambda default: _prompt_optional( + "Jira project key (optional, e.g. ISD)", default + ), + prompt_board_id=lambda default: _prompt_optional("Jira board id (optional)", default), + resolve_credential_check_failure=_resolve_credential_check_failure, + prompt_workspace_action=_prompt_workspace_action, + select_workspace_entry=_select_workspace_entry, + prompt_workspace_entry=_prompt_workspace_entry, + confirm_save=_confirm_save_config, + warn=lambda message: typer.echo(f"Warning: {message}", err=True), + ) + + result = run_config_wizard(prompts, existing) + if result is None: + typer.echo("Cancelled.") + return + + try: + save_config(result) + except ConfigError as error: + typer.echo(f"Error: {error}", err=True) + raise typer.Exit(code=1) from error + + typer.echo(f"Wrote {get_config_path()}") + + @ws_app.command("rename") def rename( new_name: str | None = typer.Argument( @@ -205,7 +390,13 @@ def rename( help="Rename all workspaces using the 'workspaces' list from ~/.config/et/config.yaml.", ), ) -> None: - """Rename the current (active) workspace to NEW_NAME, or all workspaces with --all.""" + """Rename the current (active) workspace to NEW_NAME, or all workspaces with --all. + + Best-effort: if the renamed workspace is already tracked (prepared via + `et jira start` et al.), its displayed label is kept in sync with the + new name; untracked and `static` workspaces are unaffected, and the + counter itself is never touched. + """ if all_workspaces: if new_name is not None: typer.echo("Error: NEW_NAME must not be given together with --all", err=True) @@ -219,6 +410,7 @@ def rename( raise typer.Exit(code=1) from error for index, name in zip(indices, names, strict=True): + sync_workspace_label(index, name) typer.echo(f"Renamed workspace {index + 1} to '{name}'") return @@ -232,6 +424,7 @@ def rename( typer.echo(f"Error: {error}", err=True) raise typer.Exit(code=1) from error + sync_workspace_label(index, new_name) typer.echo(f"Renamed workspace {index + 1} to '{new_name}'") @@ -277,17 +470,14 @@ def _print_workspace_jira_info(index: int, config: EtConfig) -> None: def _print_workspace_time_spent(index: int) -> None: - """Print the elapsed time of the ET- tracker bound to workspace `index`, if any.""" - entries = load_timers() - timer = find_timer_for_workspace(entries, index) - if timer is None: - typer.echo("No tracker for this workspace.") + """Print the elapsed time of the extension counter bound to workspace `index`.""" + try: + counter = get_workspace_counter(index) + except WorkspaceCounterNotFoundError: + typer.echo("No counter for this workspace.") return - - elapsed = timer.get("timeElapsed", 0) - seconds = elapsed if isinstance(elapsed, (int, float)) else 0 - running = " (running)" if timer.get("running") else "" - typer.echo(f"Time spent: {format_duration(seconds)}{running}") + running = " (running)" if counter.running else "" + typer.echo(f"Time spent: {format_duration(counter.elapsed_seconds)}{running}") @ws_app.command("delete") @@ -295,7 +485,8 @@ def ws_delete( force: bool = typer.Option( False, "--force", - help="Delete even if the workspace is still linked to a Jira issue; its tracker is lost.", + help="Delete even if the workspace is still linked to a Jira issue; its counter is " + "lost.", ), ) -> None: """Delete the active workspace, shifting later workspaces left to fill the gap. @@ -303,9 +494,9 @@ def ws_delete( Only works when the active workspace is free (not `static`, and not linked to a Jira issue) — use `et jira complete` (or `et jira log-time`) first if it's still tracking something, or pass `--force` - to delete it anyway (its Tracker timer, if any, is discarded rather - than logged). Every non-static workspace after it (and its Tracker - timer) shifts one slot to the left, then the now-empty trailing slot is + to delete it anyway (its extension counter, if any, is discarded rather + than logged). Every non-static workspace after it (and its extension + counter) shifts one slot to the left, then the now-empty trailing slot is removed by decrementing GNOME's workspace count (unless the last workspace is `static`, in which case the count is left unchanged). """ @@ -322,19 +513,20 @@ def ws_delete( ) -def _format_organize_row(row: OrganizePlanRow) -> str: +def _format_organize_row( + row: OrganizePlanRow, counters_by_slot: dict[int, WorkspaceCounter | None] +) -> str: """Format one `OrganizePlanRow` for the `ws organize` confirmation summary.""" key = jira_key_from_ref(row.entry.ref) or "-" - if row.timer is not None: - elapsed = row.timer.get("timeElapsed", 0) - seconds = elapsed if isinstance(elapsed, (int, float)) else 0 - running = " (running)" if row.timer.get("running") else "" - timer_desc = f"{format_duration(seconds)}{running}" + counter = counters_by_slot[row.old_slot] + if counter is None: + counter_desc = "no counter" else: - timer_desc = "no timer" + running = " (running)" if counter.running else "" + counter_desc = f"{format_duration(counter.elapsed_seconds)}{running}" return ( f" {row.old_slot + 1:>3} -> {row.new_slot + 1:<3} " - f"{row.entry.name:<20} {key:<12} {timer_desc}" + f"{row.entry.name:<20} {key:<12} {counter_desc}" ) @@ -347,16 +539,15 @@ def ws_organize() -> None: (falling back to `vi`) on a text listing of the dynamic workspaces; reorder the lines (without adding, removing, or duplicating any) and save to express the desired new order. Shows a before/after summary - (slot, name, linked Jira issue, tracker time) and asks for confirmation - before applying anything. Each moved workspace's Tracker timer follows - it to its new slot. The active GNOME workspace's index is left + (slot, name, linked Jira issue, counter time) and asks for confirmation + before applying anything. Each moved workspace's extension counter + follows it to its new slot. The active GNOME workspace's index is left unchanged — whatever task ends up in that slot is simply what's shown. """ try: config = load_config() count = get_workspace_count() - entries = load_timers() - except (ConfigError, WorkspaceError, TrackerError) as error: + except (ConfigError, WorkspaceError) as error: typer.echo(f"Error: {error}", err=True) raise typer.Exit(code=1) from error @@ -366,7 +557,12 @@ def ws_organize() -> None: typer.echo("Nothing to organize: fewer than 2 dynamic workspaces.") return - candidates = list_organize_candidates(workspaces_list, slots, entries) + try: + candidates = list_organize_candidates(workspaces_list, slots) + except EtExtensionError as error: + typer.echo(f"Error: {error}", err=True) + raise typer.Exit(code=1) from error + counters_by_slot = {candidate.slot: candidate.counter for candidate in candidates} editor_content = build_organize_editor_content(candidates) try: @@ -381,23 +577,23 @@ def ws_organize() -> None: return try: - plan = build_organize_plan(workspaces_list, entries, slots, new_order) + plan = build_organize_plan(workspaces_list, slots, new_order) except WsOrganizeError as error: typer.echo(f"Error: {error}", err=True) raise typer.Exit(code=1) from error typer.echo("Proposed workspace order:") - typer.echo(" old -> new name jira timer") + typer.echo(" old -> new name jira counter") for row in sorted(plan, key=lambda row: row.new_slot): - typer.echo(_format_organize_row(row)) + typer.echo(_format_organize_row(row, counters_by_slot)) if not typer.confirm("Apply this reordering?", default=False): typer.echo("Aborted.") return try: - apply_organize_plan(config, workspaces_list, entries, plan) - except (ConfigError, WorkspaceError, TrackerError, WsOrganizeError) as error: + apply_organize_plan(config, workspaces_list, plan) + except (ConfigError, WorkspaceError, EtExtensionError, WsOrganizeError) as error: typer.echo(f"Error: {error}", err=True) raise typer.Exit(code=1) from error @@ -408,14 +604,7 @@ def _print_task_created(result: TaskCreateResult) -> None: key = jira_key_from_ref(result.ref) ref_suffix = f" (linked to {key})" if key else "" typer.echo(f"Created workspace {result.workspace_index + 1}: '{result.name}'{ref_suffix}") - if not result.timer_created: - typer.echo(f"Tracker already existed for workspace {result.workspace_index + 1}") typer.echo(f"Switched to workspace {result.workspace_index + 1}") - if not result.window_moved: - typer.echo( - "Note: could not move this terminal window to the new workspace " - "(unsupported here, e.g. under Wayland)." - ) def _jira_key_option() -> str | None: @@ -438,17 +627,15 @@ def _jira_key_option() -> str | None: @jira_app.command("start") def jira_start( - key: str | None = typer.Option( + key: str | None = typer.Argument( None, - "--key", - "-k", help=( "Jira issue key to start directly (e.g. ISD-123), instead of picking one " "from your active issues." ), ), ) -> None: - """Start a new task: allocate a workspace slot, its Tracker timer, and switch to it. + """Start a new task: allocate a workspace slot, its extension counter, and switch to it. Lists your active Jira issues that aren't already linked to a workspace and lets you pick one (its summary becomes the workspace @@ -459,7 +646,7 @@ def jira_start( was run from is moved along to the new workspace, so it doesn't get left behind. - With `-k/--key KEY`, skips the picker and starts that specific issue + With KEY given, skips the picker and starts that specific issue directly (failing if it's already linked to a workspace). It follows the same steps as above, plus one more: if the issue isn't already in its project's current active sprint, offers to add it there too. @@ -706,13 +893,27 @@ def warn(message: str) -> None: typer.echo(f"Created {_hyperlink(result.key, result.url)}") +def _print_counter_reset_warning(result: LogTimeResult) -> None: + workspace = result.workspace_index + 1 + typer.echo( + f"Warning: Jira accepted the worklog, but workspace {workspace}'s counter " + f"could not be reset: {result.counter_reset_error}. Do not log it again. " + "After the extension is available, reset only the counter with: " + "`gdbus call --session --dest org.gnome.Shell " + "--object-path /org/gnome/Shell/Extensions/Et " + "--method org.gnome.Shell.Extensions.Et.ResetWorkspaceCounter " + f"uint32 {result.workspace_index}`", + err=True, + ) + + @jira_app.command("log-time") def jira_log_time( hours: str | None = typer.Argument( None, metavar="[Xh]", help='Manually log this many hours (e.g. "2h" or "1.5h") instead of reading the ' - "Tracker timer's elapsed time.", + "extension counter's elapsed time.", ), comment: str | None = typer.Option( None, "--comment", "-m", help="Worklog description/comment to attach in Jira." @@ -720,7 +921,7 @@ def jira_log_time( no_reset: bool = typer.Option( False, "--no-reset", - help="Don't reset the tracker after logging (leaves its elapsed time as-is). " + help="Don't reset the counter after logging (leaves its elapsed time as-is). " "Only meaningful without a manual [Xh] duration.", ), all_workspaces: bool = typer.Option( @@ -733,24 +934,24 @@ def jira_log_time( ) -> None: """Log the active task's tracked time to its Jira issue. - With no argument, reads the elapsed time from the ET- Tracker timer + With no argument, reads the elapsed time from the extension counter bound to the active workspace, logs it as a Jira worklog for the linked issue (via Jira's own worklog API, which also shows up in Tempo timesheets when Tempo is configured to sync native Jira worklogs), and - resets the tracker to 0 afterwards (unless --no-reset is given). + resets the counter to 0 afterwards (unless --no-reset is given). Given an [Xh] duration (e.g. "et jira log-time 2h"), logs that duration - instead, without reading or resetting the Tracker timer at all. + instead, without reading or resetting the extension counter at all. With --all, loops over every workspace in the config's `workspaces` list that has a linked Jira issue (not just the active one) and logs - each one's own Tracker timer to its own issue, without switching GNOME - workspaces. Can't be combined with [Xh], --comment/-m, or -j/--jira, - which only make sense for a single workspace/issue. + each one's own extension counter to its own issue, without switching + GNOME workspaces. Can't be combined with [Xh], --comment/-m, or + -j/--jira, which only make sense for a single workspace/issue. """ if hours is not None and no_reset: typer.echo( - "Error: --no-reset only applies when logging the Tracker timer's elapsed " + "Error: --no-reset only applies when logging the extension counter's elapsed " "time, not a manual [Xh] duration", err=True, ) @@ -782,8 +983,10 @@ def jira_log_time( f"Logged {duration} to {_jira_ref_link(logged.issue_key)}{summary_display} " f"(workspace {logged.workspace_index + 1})" ) - if logged.tracker_reset: - typer.echo("Reset tracker to 0") + if logged.counter_reset: + typer.echo("Reset counter to 0") + elif logged.counter_reset_error is not None: + _print_counter_reset_warning(logged) for skipped in all_results.skipped: typer.echo( f"Skipped workspace {skipped.workspace_index + 1} " @@ -810,8 +1013,10 @@ def jira_log_time( f"Logged {duration} to {_jira_ref_link(result.issue_key)} " f"(workspace {result.workspace_index + 1})" ) - if result.tracker_reset: - typer.echo("Reset tracker to 0") + if result.counter_reset: + typer.echo("Reset counter to 0") + elif result.counter_reset_error is not None: + _print_counter_reset_warning(result) @jira_app.command("complete") @@ -855,6 +1060,9 @@ def confirm_done(result: LogTimeResult) -> bool: typer.echo(f"Error: {error}", err=True) raise typer.Exit(code=1) from error + if result.log_result.counter_reset_error is not None: + _print_counter_reset_warning(result.log_result) + workspace_number = result.log_result.workspace_index + 1 if result.workspace_freed: typer.echo(f"Deleted workspace {workspace_number}") diff --git a/src/et/config.py b/src/et/config.py index 18bf157..5b54973 100644 --- a/src/et/config.py +++ b/src/et/config.py @@ -12,6 +12,7 @@ from __future__ import annotations import os +import tempfile from dataclasses import dataclass, field from pathlib import Path @@ -219,9 +220,28 @@ def save_config(config: EtConfig) -> None: data["workspaces"] = workspaces_data path = get_config_path() - path.parent.mkdir(parents=True, exist_ok=True) - path.write_text(yaml.safe_dump(data, sort_keys=False)) - path.chmod(0o600) + serialized = yaml.safe_dump(data, sort_keys=False) + temporary_path: Path | None = None + try: + path.parent.mkdir(parents=True, exist_ok=True) + file_descriptor, temporary_name = tempfile.mkstemp( + prefix=f".{path.name}.", + dir=path.parent, + text=True, + ) + temporary_path = Path(temporary_name) + os.fchmod(file_descriptor, 0o600) + with os.fdopen(file_descriptor, "w") as temporary_file: + temporary_file.write(serialized) + temporary_file.flush() + os.fsync(temporary_file.fileno()) + os.replace(temporary_path, path) + temporary_path = None + except OSError as exc: + raise ConfigError(f"could not write config file {path}: {exc}") from exc + finally: + if temporary_path is not None: + temporary_path.unlink(missing_ok=True) diff --git a/src/et/config_wizard.py b/src/et/config_wizard.py new file mode 100644 index 0000000..b7b301a --- /dev/null +++ b/src/et/config_wizard.py @@ -0,0 +1,154 @@ +"""Orchestrates `et config`: interactively build/update `~/.config/et/config.yaml`. + +Collects the "jira" block (base_url/email/pat/jql/project_key/board_id) and +the "workspaces" list via injected prompt/confirm/select/warn callbacks (so +this module stays Typer-free and unit testable, mirroring `et.jira_create` +and `et.jira_time`). Pre-fills defaults from the existing config when one +is already present. This module never touches the filesystem itself: +`run_config_wizard` returns the assembled `EtConfig` (or `None` if the user +backs out of the final save confirmation), leaving the actual `save_config` +call to the CLI layer. +""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass + +from et.config import DEFAULT_PRIORITY_ORDER, EtConfig, JiraConfig, WorkspaceConfigEntry +from et.jira import JiraError, check_credentials + + +def _no_op_warn(message: str) -> None: + """Default no-op `warn` callback.""" + + +@dataclass(frozen=True) +class ConfigWizardPrompts: + """Callbacks `run_config_wizard` uses to collect each field/decision. + + Kept as plain callables (rather than e.g. a Typer-specific type) so the + CLI layer can wire these to `typer.prompt`/`typer.confirm` while tests + can supply canned answers. + """ + + # Jira block. + confirm_configure_jira: Callable[[bool], bool] + prompt_base_url: Callable[[str], str] + prompt_email: Callable[[str], str] + prompt_pat: Callable[[str], str] + prompt_jql: Callable[[str], str] + prompt_project_key: Callable[[str], str] + prompt_board_id: Callable[[str], str] + resolve_credential_check_failure: Callable[[str], str] + + # Workspaces block. `prompt_workspace_action` returns one of "add", + # "edit", "remove", "done". + prompt_workspace_action: Callable[[list[WorkspaceConfigEntry]], str] + select_workspace_entry: Callable[[list[WorkspaceConfigEntry], str], int | None] + prompt_workspace_entry: Callable[[WorkspaceConfigEntry | None], WorkspaceConfigEntry | None] + + # Final review. + confirm_save: Callable[[EtConfig], bool] + warn: Callable[[str], None] = _no_op_warn + + +def _collect_jira(prompts: ConfigWizardPrompts, existing: JiraConfig | None) -> JiraConfig | None: + if not prompts.confirm_configure_jira(existing is not None): + return None + + base_url = existing.base_url if existing else "" + email = existing.email if existing else "" + pat = existing.pat if existing else "" + jql = existing.jql if existing else "" + project_key = existing.project_key or "" if existing else "" + board_id = existing.board_id or "" if existing else "" + priority_order = list(existing.priority_order) if existing else list(DEFAULT_PRIORITY_ORDER) + + while True: + base_url = prompts.prompt_base_url(base_url) + email = prompts.prompt_email(email) + pat = prompts.prompt_pat(pat) + jql = prompts.prompt_jql(jql) + project_key = prompts.prompt_project_key(project_key) + board_id = prompts.prompt_board_id(board_id) + + candidate = JiraConfig( + base_url=base_url, + email=email, + pat=pat, + jql=jql, + priority_order=priority_order, + project_key=project_key or None, + board_id=board_id or None, + ) + + try: + check_credentials(candidate) + except JiraError as exc: + action = prompts.resolve_credential_check_failure(str(exc)) + if action == "retry": + continue + if action == "discard": + return None + # "keep": fall through and use the candidate as typed anyway. + + return candidate + + +def _collect_workspaces( + prompts: ConfigWizardPrompts, existing: list[WorkspaceConfigEntry] +) -> list[WorkspaceConfigEntry]: + entries = list(existing) + + while True: + action = prompts.prompt_workspace_action(entries) + + if action == "done": + return entries + + if action == "add": + entry = prompts.prompt_workspace_entry(None) + if entry is not None: + entries.append(entry) + continue + + if action == "edit": + index = prompts.select_workspace_entry(entries, "edit") + if index is None: + continue + edited = prompts.prompt_workspace_entry(entries[index]) + if edited is not None: + entries[index] = edited + continue + + if action == "remove": + index = prompts.select_workspace_entry(entries, "remove") + if index is not None: + del entries[index] + continue + + # Unrecognized action: treat as "done" rather than looping forever. + return entries + + +def run_config_wizard(prompts: ConfigWizardPrompts, existing: EtConfig | None) -> EtConfig | None: + """Interactively collect the full config and return it, or `None` if declined. + + Pre-fills every field from `existing` when given (i.e. a config file + was already present). Returns `None` if the user declines the final + `confirm_save` summary prompt, in which case the caller must not write + anything to disk. + """ + existing_jira = existing.jira if existing else None + existing_workspaces = existing.workspaces if existing else [] + + jira = _collect_jira(prompts, existing_jira) + workspaces = _collect_workspaces(prompts, existing_workspaces) + + config = EtConfig(jira=jira, workspaces=workspaces) + + if not prompts.confirm_save(config): + return None + + return config diff --git a/src/et/duration.py b/src/et/duration.py new file mode 100644 index 0000000..cfe106a --- /dev/null +++ b/src/et/duration.py @@ -0,0 +1,44 @@ +"""Small, dependency-free helpers for formatting/parsing time durations. + +Used by `et.cli`/`et.ws` to display a workspace counter's elapsed time +(`format_duration`) and by `et jira log-time [Xh]` to parse a +manually-specified duration (`parse_hours_to_seconds`). Neither function +does any I/O, so they're kept separate from `et.et_extension` (the D-Bus +client that actually reads/writes counters) to stay independently testable +and reusable regardless of how a duration's seconds were obtained. +""" + +from __future__ import annotations + +import re + +_HOURS_DURATION_RE = re.compile(r"^\s*(\d+(?:\.\d+)?)\s*[hH]\s*$") + + +def format_duration(seconds: float) -> str: + """Format a number of seconds as e.g. "2h 15m 30s".""" + total_seconds = int(seconds) + hours, remainder = divmod(total_seconds, 3600) + minutes, secs = divmod(remainder, 60) + return f"{hours}h {minutes}m {secs}s" + + +def parse_hours_to_seconds(text: str) -> int: + """Parse an hours-only duration like "2h" or "1.5h" into whole seconds. + + Used for `et jira log-time`'s manual duration argument. Raises + `ValueError` if `text` isn't a positive number followed by "h" (e.g. + missing the "h" suffix, non-numeric, zero, or negative). + """ + match = _HOURS_DURATION_RE.match(text) + if match is None: + raise ValueError(f"invalid duration {text!r}: expected a format like '2h' or '1.5h'") + + hours = float(match.group(1)) + if hours <= 0: + raise ValueError(f"invalid duration {text!r}: must be greater than 0h") + + return round(hours * 3600) + + +__all__ = ["format_duration", "parse_hours_to_seconds"] diff --git a/src/et/et_extension.py b/src/et/et_extension.py new file mode 100644 index 0000000..fd9541c --- /dev/null +++ b/src/et/et_extension.py @@ -0,0 +1,282 @@ +"""Typed D-Bus client for the in-house `et` GNOME Shell extension. + +Replaces the third-party Tracker extension's GSettings-JSON-based timer +storage: the `et` extension (`et@seb4stien.github.com`) now owns +per-workspace counters itself, exposed over its own small D-Bus service on +GNOME Shell's session-bus connection (destination `org.gnome.Shell`, object +path `/org/gnome/Shell/Extensions/Et`, interface +`org.gnome.Shell.Extensions.Et`). Every call in this module shells out to +`gdbus call` and centralizes invocation/error handling: a single `_call` +helper builds the command line, decides whether a failure means "the +extension isn't installed/enabled" (gdbus reports an Unknown{Method,Object, +Interface} error) or some other problem, and raises `EtExtensionError` +accordingly. Has no Typer/CLI dependency so callers can unit test by mocking +`subprocess.run`. + +The extension is deliberately ticket-system agnostic: `label` and +`estimate_seconds` are just generic display values (for Jira workspaces, +the CLI passes the issue summary and original estimate), never Jira +terminology itself. +""" + +from __future__ import annotations + +import re +import shutil +import subprocess +from collections.abc import Sequence +from dataclasses import dataclass + +ET_DBUS_DEST = "org.gnome.Shell" +ET_DBUS_OBJECT_PATH = "/org/gnome/Shell/Extensions/Et" +ET_DBUS_INTERFACE = "org.gnome.Shell.Extensions.Et" +ET_EXTENSION_UUID = "et@seb4stien.github.com" + +# gdbus reports these as the error name/message when the destination, +# object, or interface it was asked to call doesn't exist yet -- the +# tell-tale sign that the et extension itself isn't installed/enabled, +# rather than some other D-Bus failure. +_NOT_INSTALLED_TOKENS = ("UnknownMethod", "UnknownObject", "UnknownInterface") +_COUNTER_NOT_FOUND_ERROR = f"{ET_DBUS_INTERFACE}.Error.NotFound" + + +class EtExtensionError(RuntimeError): + """Raised when a D-Bus call to the et GNOME Shell extension fails.""" + + +class WorkspaceCounterNotFoundError(EtExtensionError): + """Raised when a workspace has not been prepared with a counter.""" + + +@dataclass(frozen=True) +class WorkspaceCounter: + """A workspace's tracked elapsed time and running state. + + Returned by `get_workspace_counter`, mirroring the extension's + `GetWorkspaceCounter(u index) -> (t elapsedSeconds, b running)` D-Bus + method. + """ + + elapsed_seconds: int + running: bool + + +@dataclass(frozen=True) +class WorkspaceMetadata: + """A workspace's current display label and reference estimate. + + Returned by `get_workspace_metadata`, mirroring the extension's + `GetWorkspaceMetadata(u index) -> (s label, t estimateSeconds)` D-Bus + method. + """ + + label: str + estimate_seconds: int + + +def _quote_gvariant_string(value: str) -> str: + """Return `value` as a single-quoted GVariant text-format string literal. + + Backslashes and single quotes are escaped so arbitrary labels (Jira + summaries, in practice) round-trip through `gdbus call` unscathed. + """ + escaped = value.replace("\\", "\\\\").replace("'", "\\'") + return f"'{escaped}'" + + +def _unescape_gvariant_string(value: str) -> str: + """Reverse `_quote_gvariant_string`'s escaping on a parsed `gdbus` string.""" + return re.sub(r"\\(.)", r"\1", value) + + +def _require_gdbus() -> None: + if shutil.which("gdbus") is None: + raise EtExtensionError("required command not found: gdbus") + + +def _call(method: str, *args: str) -> str: + """Invoke `method` on the et extension's D-Bus object and return raw stdout. + + Centralizes every gdbus invocation used by this module: builds the + `gdbus call --session --dest ... --method Interface.Method ARGS...` + command line, and turns a non-zero exit into a helpful + `EtExtensionError` -- one message when gdbus reports an + Unknown{Method,Object,Interface} error (the extension isn't + installed/enabled), another for any other failure. + """ + _require_gdbus() + result = subprocess.run( + [ + "gdbus", + "call", + "--session", + "--dest", + ET_DBUS_DEST, + "--object-path", + ET_DBUS_OBJECT_PATH, + "--method", + f"{ET_DBUS_INTERFACE}.{method}", + *args, + ], + capture_output=True, + text=True, + check=False, + ) + if result.returncode != 0: + stderr = result.stderr.strip() + if _COUNTER_NOT_FOUND_ERROR in stderr: + raise WorkspaceCounterNotFoundError( + f"workspace counter is not prepared: {stderr}" + ) + if any(token in stderr for token in _NOT_INSTALLED_TOKENS): + raise EtExtensionError( + "the et GNOME Shell extension doesn't seem to be installed/enabled " + f"(`gdbus call` failed: {stderr}). Run " + "`scripts/install-gnome-shell-extension.sh` (or " + f"`gnome-extensions enable {ET_EXTENSION_UUID}`) and log back in." + ) + raise EtExtensionError(f"gdbus call to {method} failed: {stderr}") + return result.stdout + + +def get_active_workspace_index() -> int: + """Return the 0-based index of the currently active workspace. + + Used on Wayland, where `wmctrl` can't see the compositor's state (see + `et.workspaces.get_active_workspace_index`, which falls back to this on + a detected Wayland session). + """ + stdout = _call("GetActiveWorkspaceIndex") + match = re.search(r"uint32\s+(\d+)", stdout) + if match is None: + raise EtExtensionError( + f"could not parse active workspace index from gdbus output: {stdout!r}" + ) + return int(match.group(1)) + + +def prepare_workspace(index: int, label: str, estimate_seconds: int) -> None: + """Ensure workspace `index`'s counter is reset to zero, with `label`/`estimate_seconds` set. + + Called whenever et allocates a workspace slot for a new task (whether + the slot is brand new or a reused one), so a reused slot never inherits + a previous task's accumulated time. + """ + _call( + "PrepareWorkspace", + f"uint32 {index}", + _quote_gvariant_string(label), + f"uint64 {estimate_seconds}", + ) + + +def get_workspace_counter(index: int) -> WorkspaceCounter: + """Return workspace `index`'s current elapsed time and running state.""" + stdout = _call("GetWorkspaceCounter", f"uint32 {index}") + match = re.search(r"uint64\s+(\d+)\s*,\s*(true|false)", stdout) + if match is None: + raise EtExtensionError(f"could not parse workspace counter from gdbus output: {stdout!r}") + return WorkspaceCounter(elapsed_seconds=int(match.group(1)), running=match.group(2) == "true") + + +def get_workspace_metadata(index: int) -> WorkspaceMetadata: + """Return workspace `index`'s current display label and reference estimate. + + Raises `WorkspaceCounterNotFoundError` if the workspace was never + prepared (the extension reports the same not-found error as + `get_workspace_counter` for that case). + """ + stdout = _call("GetWorkspaceMetadata", f"uint32 {index}") + match = re.search(r"'((?:[^'\\]|\\.)*)'\s*,\s*uint64\s+(\d+)", stdout) + if match is None: + raise EtExtensionError( + f"could not parse workspace metadata from gdbus output: {stdout!r}" + ) + return WorkspaceMetadata( + label=_unescape_gvariant_string(match.group(1)), + estimate_seconds=int(match.group(2)), + ) + + +def reset_workspace_counter(index: int) -> None: + """Reset workspace `index`'s counter to zero (elapsed time and running state).""" + _call("ResetWorkspaceCounter", f"uint32 {index}") + + +def remove_workspace(index: int) -> None: + """Discard workspace `index`'s counter and display metadata entirely.""" + _call("RemoveWorkspace", f"uint32 {index}") + + +def remap_workspaces(moves: Sequence[tuple[int, int]]) -> None: + """Atomically relocate each `(old_index, new_index)` pair in `moves`. + + Used when workspaces shift (`et ws delete`, `et jira complete`) or are + freely permuted (`et ws organize`), so every counter follows its + workspace to its new slot in a single D-Bus call rather than one call + per moved slot. A no-op (no D-Bus call at all) when `moves` is empty. + """ + if not moves: + return + pairs = ", ".join(f"(uint32 {old}, uint32 {new})" for old, new in moves) + _call("RemapWorkspaces", f"[{pairs}]") + + +def set_workspace_metadata(index: int, label: str, estimate_seconds: int) -> None: + """Update workspace `index`'s display label/estimate without touching its counter.""" + _call( + "SetWorkspaceMetadata", + f"uint32 {index}", + _quote_gvariant_string(label), + f"uint64 {estimate_seconds}", + ) + + +def sync_workspace_label(index: int, label: str) -> bool: + """Best-effort: update workspace `index`'s stored label to `label`, if it's tracked. + + Renaming a GNOME workspace (`et ws rename`) is a lower-level primitive + than "preparing" one for tracking (`prepare_workspace`), and must never + gain a hard dependency on the et extension, nor ever start/reset + tracking for a workspace that isn't already tracked (including + `static` ones, which are never prepared). So this silently does + nothing — returning `False` — both when `index` was never prepared + (`WorkspaceCounterNotFoundError`) and when the extension itself can't + be reached at all (any other `EtExtensionError`, e.g. not installed/ + enabled, or `gdbus` missing), mirroring the existing best-effort + `move_active_window_to_workspace` step in `task.create_task_workspace`. + Preserves the workspace's existing estimate and never touches its + counter (only `SetWorkspaceMetadata` is called, never + `PrepareWorkspace`). Returns whether the label was actually updated. + """ + try: + metadata = get_workspace_metadata(index) + except EtExtensionError: + return False + + try: + set_workspace_metadata(index, label, metadata.estimate_seconds) + except EtExtensionError: + return False + return True + + +__all__ = [ + "ET_DBUS_DEST", + "ET_DBUS_OBJECT_PATH", + "ET_DBUS_INTERFACE", + "ET_EXTENSION_UUID", + "EtExtensionError", + "WorkspaceCounterNotFoundError", + "WorkspaceCounter", + "WorkspaceMetadata", + "get_active_workspace_index", + "prepare_workspace", + "get_workspace_counter", + "get_workspace_metadata", + "reset_workspace_counter", + "remove_workspace", + "remap_workspaces", + "set_workspace_metadata", + "sync_workspace_label", +] diff --git a/src/et/gnome_extensions.py b/src/et/gnome_extensions.py deleted file mode 100644 index 848b039..0000000 --- a/src/et/gnome_extensions.py +++ /dev/null @@ -1,90 +0,0 @@ -"""Thin wrapper around the `gnome-extensions` CLI. - -Used to temporarily disable/re-enable a GNOME Shell extension around a -GSettings write. A *running* extension often keeps its own in-memory copy -of its settings and silently overwrites (clobbers) externally-written -changes it doesn't already know about the next time it resaves. Disabling -the extension before the write and re-enabling it afterwards forces a full -re-initialization that re-reads GSettings from scratch, so the external -change is picked up instead of being erased. -""" - -from __future__ import annotations - -import shutil -import subprocess -import time -from collections.abc import Iterator -from contextlib import contextmanager - -MAX_ATTEMPTS = 3 -RETRY_INTERVAL_SECONDS = 5 - - -class GnomeExtensionsError(RuntimeError): - """Raised when a `gnome-extensions` operation cannot be completed.""" - - -def _run(*args: str) -> subprocess.CompletedProcess[str]: - """Run `gnome-extensions `, retrying transient failures. - - A failure to reach the running GNOME Shell (e.g. right after a session - change, such as deleting a workspace) is often transient, so a failed - invocation is retried up to `MAX_ATTEMPTS` times, waiting - `RETRY_INTERVAL_SECONDS` between attempts, before giving up. - """ - if shutil.which("gnome-extensions") is None: - raise GnomeExtensionsError("required command not found: gnome-extensions") - - for attempt in range(1, MAX_ATTEMPTS + 1): - result = subprocess.run( - ["gnome-extensions", *args], - capture_output=True, - text=True, - check=False, - ) - if result.returncode == 0 or attempt == MAX_ATTEMPTS: - return result - time.sleep(RETRY_INTERVAL_SECONDS) - raise AssertionError("unreachable: loop always returns or raises") - - -def is_extension_enabled(uuid: str) -> bool: - """Return whether the extension identified by `uuid` is currently enabled.""" - result = _run("list", "--enabled") - if result.returncode != 0: - raise GnomeExtensionsError(f"gnome-extensions list failed: {result.stderr.strip()}") - return uuid in result.stdout.splitlines() - - -def disable_extension(uuid: str) -> None: - """Disable the extension identified by `uuid`.""" - result = _run("disable", uuid) - if result.returncode != 0: - raise GnomeExtensionsError(f"gnome-extensions disable failed: {result.stderr.strip()}") - - -def enable_extension(uuid: str) -> None: - """Enable the extension identified by `uuid`.""" - result = _run("enable", uuid) - if result.returncode != 0: - raise GnomeExtensionsError(f"gnome-extensions enable failed: {result.stderr.strip()}") - - -@contextmanager -def reload_around(uuid: str) -> Iterator[None]: - """Disable `uuid` (if enabled) for the duration of the block, then re-enable it. - - If the extension isn't currently enabled, this is a no-op: there's no - running instance to clobber the change, so nothing needs disabling. - Re-enabling always happens (even if the block raises) so the extension - is never left disabled because of a write failure. - """ - was_enabled = is_extension_enabled(uuid) - if was_enabled: - disable_extension(uuid) - try: - yield - finally: - if was_enabled: - enable_extension(uuid) diff --git a/src/et/gsettings.py b/src/et/gsettings.py index 9e5b9b1..8e9bbff 100644 --- a/src/et/gsettings.py +++ b/src/et/gsettings.py @@ -1,15 +1,14 @@ -"""Thin wrapper around the `gsettings` CLI for array-of-strings values. +"""Thin wrapper around the `gsettings` CLI. -Shells out to `gsettings` to read/write GVariant `as` (array of strings) -values, used both for GNOME's workspace names and for the Tracker -extension's JSON-encoded timer list. Has no Typer/CLI dependency so callers -can unit test by mocking `subprocess.run`. +Shells out to `gsettings` to read/write GNOME's own settings: workspace +names (an array-of-strings `as` value), the fixed workspace count, and +whether dynamic workspaces are enabled. Has no Typer/CLI dependency so +callers can unit test by mocking `subprocess.run`. """ from __future__ import annotations import ast -import os import shutil import subprocess @@ -23,32 +22,14 @@ def _require_binary(name: str) -> None: raise GSettingsError(f"required command not found: {name}") -def _build_env(schema_dir: str | None) -> dict[str, str] | None: - """Build a subprocess environment with GSETTINGS_SCHEMA_DIR set, if needed. - - `schema_dir` lets callers point at a schema compiled outside the standard - system locations (e.g. a per-user GNOME Shell extension's own compiled - schema), which the plain `gsettings` CLI otherwise can't see. - """ - if schema_dir is None: - return None - return {**os.environ, "GSETTINGS_SCHEMA_DIR": schema_dir} - - -def read_string_array(schema: str, key: str, schema_dir: str | None = None) -> list[str]: - """Return the current array-of-strings value of `schema`'s `key`. - - If `schema_dir` is given, `gsettings` is invoked with `GSETTINGS_SCHEMA_DIR` - set to it, so schemas compiled outside the standard system locations - (e.g. a user-installed GNOME Shell extension's own schema) can be found. - """ +def read_string_array(schema: str, key: str) -> list[str]: + """Return the current array-of-strings value of `schema`'s `key`.""" _require_binary("gsettings") result = subprocess.run( ["gsettings", "get", schema, key], capture_output=True, text=True, check=False, - env=_build_env(schema_dir), ) if result.returncode != 0: raise GSettingsError(f"gsettings get failed: {result.stderr.strip()}") @@ -67,7 +48,7 @@ def read_string_array(schema: str, key: str, schema_dir: str | None = None) -> l return values -def _set_raw(schema: str, key: str, raw_value: str, schema_dir: str | None = None) -> None: +def _set_raw(schema: str, key: str, raw_value: str) -> None: """Write a raw GVariant-syntax value string to `schema`'s `key`.""" _require_binary("gsettings") result = subprocess.run( @@ -75,33 +56,22 @@ def _set_raw(schema: str, key: str, raw_value: str, schema_dir: str | None = Non capture_output=True, text=True, check=False, - env=_build_env(schema_dir), ) if result.returncode != 0: raise GSettingsError(f"gsettings set failed: {result.stderr.strip()}") -def write_string_array( - schema: str, key: str, values: list[str], schema_dir: str | None = None -) -> None: - """Write the given list of strings to `schema`'s `key`. - - See `read_string_array` for the meaning of `schema_dir`. - """ - _set_raw(schema, key, repr(values), schema_dir=schema_dir) - - -def set_boolean(schema: str, key: str, value: bool, schema_dir: str | None = None) -> None: - """Write a boolean value to `schema`'s `key`.""" - _set_raw(schema, key, "true" if value else "false", schema_dir=schema_dir) +def write_string_array(schema: str, key: str, values: list[str]) -> None: + """Write the given list of strings to `schema`'s `key`.""" + _set_raw(schema, key, repr(values)) -def set_int(schema: str, key: str, value: int, schema_dir: str | None = None) -> None: +def set_int(schema: str, key: str, value: int) -> None: """Write an integer value to `schema`'s `key`.""" - _set_raw(schema, key, str(value), schema_dir=schema_dir) + _set_raw(schema, key, str(value)) -def _get_raw(schema: str, key: str, schema_dir: str | None = None) -> str: +def _get_raw(schema: str, key: str) -> str: """Return the raw, stripped stdout of `gsettings get schema key`.""" _require_binary("gsettings") result = subprocess.run( @@ -109,16 +79,15 @@ def _get_raw(schema: str, key: str, schema_dir: str | None = None) -> str: capture_output=True, text=True, check=False, - env=_build_env(schema_dir), ) if result.returncode != 0: raise GSettingsError(f"gsettings get failed: {result.stderr.strip()}") return result.stdout.strip() -def read_boolean(schema: str, key: str, schema_dir: str | None = None) -> bool: +def read_boolean(schema: str, key: str) -> bool: """Return the current boolean value of `schema`'s `key`.""" - raw = _get_raw(schema, key, schema_dir=schema_dir) + raw = _get_raw(schema, key) if raw == "true": return True if raw == "false": @@ -126,13 +95,13 @@ def read_boolean(schema: str, key: str, schema_dir: str | None = None) -> bool: raise GSettingsError(f"unexpected {schema} {key} boolean value: {raw!r}") -def read_int(schema: str, key: str, schema_dir: str | None = None) -> int: +def read_int(schema: str, key: str) -> int: """Return the current integer value of `schema`'s `key`. Tolerates GVariant type-annotated output (e.g. "uint32 4") by taking the trailing token. """ - raw = _get_raw(schema, key, schema_dir=schema_dir) + raw = _get_raw(schema, key) token = raw.split()[-1] if raw else raw try: return int(token) diff --git a/src/et/jira.py b/src/et/jira.py index fc3dd4e..9b9bece 100644 --- a/src/et/jira.py +++ b/src/et/jira.py @@ -15,6 +15,7 @@ import logging from dataclasses import dataclass +from typing import Literal import requests @@ -60,6 +61,7 @@ class JiraIssue: summary: str priority: str status: str = "" + original_estimate_seconds: int | None = None @dataclass(frozen=True) @@ -114,34 +116,62 @@ def text_to_adf(text: str) -> dict[str, object]: } -def _get_json(jira_config: JiraConfig, url: str, params: dict[str, str] | None = None) -> object: - """GET `url` with Jira Basic auth and return the parsed JSON body. - - Raises `JiraError` if the request cannot be made, Jira returns a - non-200 status, or the body isn't valid JSON. - """ +def _request( + jira_config: JiraConfig, + method: Literal["GET", "POST"], + url: str, + *, + expected_statuses: tuple[int, ...], + params: dict[str, str] | None = None, + json: dict[str, object] | None = None, +) -> requests.Response: + """Send one authenticated Jira request and validate its status.""" try: - response = requests.get( - url, - params=params, - auth=(jira_config.email, jira_config.pat), - timeout=30, - ) + if method == "GET": + response = requests.get( + url, + params=params, + auth=(jira_config.email, jira_config.pat), + timeout=30, + ) + else: + response = requests.post( + url, + json=json, + auth=(jira_config.email, jira_config.pat), + timeout=30, + ) except requests.RequestException as exc: raise JiraError(f"could not reach Jira at {url}: {exc}") from exc - if response.status_code != 200: + if response.status_code not in expected_statuses: raise JiraError( f"Jira API request to {url} failed with status {response.status_code}: " f"{response.text.strip()[:500]}" ) + return response + +def _response_json(response: requests.Response) -> object: + """Parse a Jira response body as JSON with a consistent error.""" try: return response.json() except ValueError as exc: raise JiraError(f"could not parse Jira API response as JSON: {exc}") from exc +def _get_json(jira_config: JiraConfig, url: str, params: dict[str, str] | None = None) -> object: + """GET `url` with Jira Basic auth and return the parsed JSON body.""" + response = _request( + jira_config, + "GET", + url, + expected_statuses=(200,), + params=params, + ) + return _response_json(response) + + def _jira_url(jira_config: JiraConfig, path: str) -> str: return jira_config.base_url.rstrip("/") + "/" + path @@ -223,15 +253,13 @@ def fetch_active_sprints(jira_config: JiraConfig, board_id: str) -> list[JiraSpr """ url = _jira_url(jira_config, BOARD_SPRINT_PATH_TEMPLATE.format(board_id=board_id)) - try: - response = requests.get( - url, - params={"state": "active"}, - auth=(jira_config.email, jira_config.pat), - timeout=30, - ) - except requests.RequestException as exc: - raise JiraError(f"could not reach Jira at {url}: {exc}") from exc + response = _request( + jira_config, + "GET", + url, + expected_statuses=(200, 400), + params={"state": "active"}, + ) if response.status_code == 400 and _mentions_unsupported_sprints(response.text): raise JiraBoardWithoutSprintsError( @@ -244,10 +272,7 @@ def fetch_active_sprints(jira_config: JiraConfig, board_id: str) -> list[JiraSpr f"{response.text.strip()[:500]}" ) - try: - payload = response.json() - except ValueError as exc: - raise JiraError(f"could not parse Jira API response as JSON: {exc}") from exc + payload = _response_json(response) values = payload.get("values") if isinstance(payload, dict) else None if not isinstance(values, list): @@ -276,7 +301,7 @@ def fetch_issue_sprint(jira_config: JiraConfig, issue_key: str) -> JiraSprint | Calls Jira's Agile API `GET /rest/agile/1.0/issue/{key}?fields=sprint` endpoint, which — unlike the plain issue API — exposes the issue's current sprint directly without needing to look up the "Sprint" custom - field's id first. Used by `et jira start -k` to check whether an issue + field's id first. Used by `et jira start KEY` to check whether an issue already belongs to the project's active sprint before adding it. Raises `JiraError` if the request cannot be made or Jira rejects it. """ @@ -302,27 +327,19 @@ def add_issue_to_sprint(jira_config: JiraConfig, sprint_id: str, issue_key: str) Calls Jira's Agile API `POST /rest/agile/1.0/sprint/{sprint_id}/issue` endpoint (which returns 204 No Content on success) with - `{"issues": [issue_key]}`. Used by `et jira start -k` to bring an issue + `{"issues": [issue_key]}`. Used by `et jira start KEY` to bring an issue into the project's current active sprint when it isn't already there. Raises `JiraError` if the request cannot be made or Jira rejects it. """ url = _jira_url(jira_config, SPRINT_ISSUE_PATH_TEMPLATE.format(sprint_id=sprint_id)) - try: - response = requests.post( - url, - json={"issues": [issue_key]}, - auth=(jira_config.email, jira_config.pat), - timeout=30, - ) - except requests.RequestException as exc: - raise JiraError(f"could not reach Jira at {url}: {exc}") from exc - - if response.status_code not in (200, 204): - raise JiraError( - f"Jira API request to {url} failed with status {response.status_code}: " - f"{response.text.strip()[:500]}" - ) + _request( + jira_config, + "POST", + url, + expected_statuses=(200, 204), + json={"issues": [issue_key]}, + ) def fetch_field_id_by_name(jira_config: JiraConfig, field_name: str) -> str | None: @@ -365,26 +382,14 @@ def create_issue(jira_config: JiraConfig, fields: dict[str, object]) -> str: """ url = _jira_url(jira_config, ISSUE_PATH) - try: - response = requests.post( - url, - json={"fields": fields}, - auth=(jira_config.email, jira_config.pat), - timeout=30, - ) - except requests.RequestException as exc: - raise JiraError(f"could not reach Jira at {url}: {exc}") from exc - - if response.status_code not in (200, 201): - raise JiraError( - f"Jira API request to {url} failed with status {response.status_code}: " - f"{response.text.strip()[:500]}" - ) - - try: - payload = response.json() - except ValueError as exc: - raise JiraError(f"could not parse Jira API response as JSON: {exc}") from exc + response = _request( + jira_config, + "POST", + url, + expected_statuses=(200, 201), + json={"fields": fields}, + ) + payload = _response_json(response) key = payload.get("key") if isinstance(payload, dict) else None if not isinstance(key, str) or not key: @@ -409,26 +414,14 @@ def create_worklog( if comment: payload["comment"] = text_to_adf(comment) - try: - response = requests.post( - url, - json=payload, - auth=(jira_config.email, jira_config.pat), - timeout=30, - ) - except requests.RequestException as exc: - raise JiraError(f"could not reach Jira at {url}: {exc}") from exc - - if response.status_code not in (200, 201): - raise JiraError( - f"Jira API request to {url} failed with status {response.status_code}: " - f"{response.text.strip()[:500]}" - ) - - try: - payload_response = response.json() - except ValueError as exc: - raise JiraError(f"could not parse Jira API response as JSON: {exc}") from exc + response = _request( + jira_config, + "POST", + url, + expected_statuses=(200, 201), + json=payload, + ) + payload_response = _response_json(response) if not isinstance(payload_response, dict): raise JiraError(f"unexpected Jira API response from {url}: not a JSON object") @@ -446,26 +439,14 @@ def create_comment(jira_config: JiraConfig, issue_key: str, body: str) -> dict[s """ url = jira_config.base_url.rstrip("/") + "/" + COMMENT_PATH_TEMPLATE.format(key=issue_key) - try: - response = requests.post( - url, - json={"body": text_to_adf(body)}, - auth=(jira_config.email, jira_config.pat), - timeout=30, - ) - except requests.RequestException as exc: - raise JiraError(f"could not reach Jira at {url}: {exc}") from exc - - if response.status_code not in (200, 201): - raise JiraError( - f"Jira API request to {url} failed with status {response.status_code}: " - f"{response.text.strip()[:500]}" - ) - - try: - payload_response = response.json() - except ValueError as exc: - raise JiraError(f"could not parse Jira API response as JSON: {exc}") from exc + response = _request( + jira_config, + "POST", + url, + expected_statuses=(200, 201), + json={"body": text_to_adf(body)}, + ) + payload_response = _response_json(response) if not isinstance(payload_response, dict): raise JiraError(f"unexpected Jira API response from {url}: not a JSON object") @@ -493,17 +474,39 @@ def fetch_issue_status(jira_config: JiraConfig, issue_key: str) -> str: return name +def _parse_original_estimate_seconds(fields: dict[str, object]) -> int | None: + """Return `fields.timetracking.originalEstimateSeconds` if it's a valid duration. + + Must be present and a nonnegative `int` (explicitly excluding `bool`, + which is a subclass of `int` in Python) to be considered valid; + otherwise returns `None` rather than raising, since a missing/malformed + estimate shouldn't fail the whole issue fetch. + """ + timetracking = fields.get("timetracking") + if not isinstance(timetracking, dict): + return None + + value = timetracking.get("originalEstimateSeconds") + if isinstance(value, bool) or not isinstance(value, int) or value < 0: + return None + + return value + + def fetch_issue(jira_config: JiraConfig, issue_key: str) -> JiraIssue: - """Return `issue_key`'s summary, priority, and status as a `JiraIssue`. + """Return `issue_key`'s summary, priority, status, and original estimate as a `JiraIssue`. - Calls Jira's `GET /rest/api/3/issue/{key}?fields=summary,priority,status` - endpoint. Used by `et jira start -k` to look up an issue given directly + Calls Jira's + `GET /rest/api/3/issue/{key}?fields=summary,priority,status,timetracking` + endpoint. Used by `et jira start KEY` to look up an issue given directly by key, rather than picked from `fetch_active_issues`'s candidate list. Raises `JiraError` if the request cannot be made, Jira rejects it, or the response has no usable summary/status. """ url = _jira_url(jira_config, f"{ISSUE_PATH}/{issue_key}") - payload = _get_json(jira_config, url, params={"fields": "summary,priority,status"}) + payload = _get_json( + jira_config, url, params={"fields": "summary,priority,status,timetracking"} + ) if not isinstance(payload, dict): raise JiraError(f"unexpected Jira API response from {url}: not a JSON object") @@ -528,6 +531,7 @@ def fetch_issue(jira_config: JiraConfig, issue_key: str) -> JiraIssue: summary=summary, priority=priority if isinstance(priority, str) else "", status=status, + original_estimate_seconds=_parse_original_estimate_seconds(fields), ) @@ -570,32 +574,23 @@ def _fetch_issue_pages(jira_config: JiraConfig, url: str) -> list[object]: next_page_token: str | None = None while True: - params: dict[str, str] = {"jql": jira_config.jql, "fields": "summary,priority,status"} + params: dict[str, str] = { + "jql": jira_config.jql, + "fields": "summary,priority,status,timetracking", + } if next_page_token is not None: params["nextPageToken"] = next_page_token logger.debug("GET %s as %s with params %r", url, jira_config.email, params) - try: - response = requests.get( - url, - params=params, - auth=(jira_config.email, jira_config.pat), - timeout=30, - ) - except requests.RequestException as exc: - raise JiraError(f"could not reach Jira at {url}: {exc}") from exc - - if response.status_code != 200: - raise JiraError( - f"Jira API request to {url} failed with status {response.status_code}: " - f"{response.text.strip()[:500]}" - ) - - try: - payload = response.json() - except ValueError as exc: - raise JiraError(f"could not parse Jira API response as JSON: {exc}") from exc + response = _request( + jira_config, + "GET", + url, + expected_statuses=(200,), + params=params, + ) + payload = _response_json(response) issues_raw = payload.get("issues") if isinstance(payload, dict) else None if not isinstance(issues_raw, list): @@ -615,12 +610,14 @@ def _fetch_issue_pages(jira_config: JiraConfig, url: str) -> list[object]: return all_issues_raw -def _check_credentials(jira_config: JiraConfig) -> None: +def check_credentials(jira_config: JiraConfig) -> None: """Raise `JiraError` if Jira doesn't accept `jira_config`'s email/token. Unlike the search endpoint, `/myself` has no anonymous mode: it answers 401 when the credentials aren't usable. A network failure here is - ignored, since the caller's own request already succeeded. + ignored, since the caller's own request already succeeded (or, for the + `et config` wizard, isn't fatal — the user can still save unverified + credentials). """ url = jira_config.base_url.rstrip("/") + "/" + MYSELF_PATH @@ -648,7 +645,7 @@ def fetch_active_issues(jira_config: JiraConfig) -> list[JiraIssue]: printed to stderr for each one. Raises `JiraError` if Jira rejects the configured credentials, which an - empty result set can otherwise hide (see `_check_credentials`). + empty result set can otherwise hide (see `check_credentials`). """ url = jira_config.base_url.rstrip("/") + "/" + SEARCH_PATH issues_raw = _fetch_issue_pages(jira_config, url) @@ -656,7 +653,7 @@ def fetch_active_issues(jira_config: JiraConfig) -> list[JiraIssue]: # Jira serves an unauthenticated search anonymously rather than # refusing it, so a bad token looks like "you have no issues". if not issues_raw: - _check_credentials(jira_config) + check_credentials(jira_config) issues: list[JiraIssue] = [] for raw_issue in issues_raw: @@ -666,15 +663,22 @@ def fetch_active_issues(jira_config: JiraConfig) -> list[JiraIssue]: if not isinstance(key, str) or not key: logger.warning("skipping Jira issue with missing or invalid 'key': %r", raw_issue) continue - fields = raw_issue.get("fields", {}) - priority_field = fields.get("priority") or {} - status_field = fields.get("status") or {} + fields = raw_issue.get("fields") + if not isinstance(fields, dict): + logger.warning("skipping Jira issue %s with invalid 'fields': %r", key, fields) + continue + priority_field = fields.get("priority") + status_field = fields.get("status") + priority = priority_field.get("name") if isinstance(priority_field, dict) else "" + status = status_field.get("name") if isinstance(status_field, dict) else "" + summary = fields.get("summary") issues.append( JiraIssue( key=key, - summary=fields.get("summary") or "", - priority=priority_field.get("name") or "", - status=status_field.get("name") or "", + summary=summary if isinstance(summary, str) else "", + priority=priority if isinstance(priority, str) else "", + status=status if isinstance(status, str) else "", + original_estimate_seconds=_parse_original_estimate_seconds(fields), ) ) @@ -702,25 +706,8 @@ def fetch_transitions(jira_config: JiraConfig, issue_key: str) -> list[JiraTrans """ url = jira_config.base_url.rstrip("/") + "/" + TRANSITIONS_PATH_TEMPLATE.format(key=issue_key) - try: - response = requests.get( - url, - auth=(jira_config.email, jira_config.pat), - timeout=30, - ) - except requests.RequestException as exc: - raise JiraError(f"could not reach Jira at {url}: {exc}") from exc - - if response.status_code != 200: - raise JiraError( - f"Jira API request to {url} failed with status {response.status_code}: " - f"{response.text.strip()[:500]}" - ) - - try: - payload = response.json() - except ValueError as exc: - raise JiraError(f"could not parse Jira API response as JSON: {exc}") from exc + response = _request(jira_config, "GET", url, expected_statuses=(200,)) + payload = _response_json(response) transitions_raw = payload.get("transitions") if isinstance(payload, dict) else None if not isinstance(transitions_raw, list): @@ -733,12 +720,14 @@ def fetch_transitions(jira_config: JiraConfig, issue_key: str) -> list[JiraTrans transition_id = raw_transition.get("id") if not isinstance(transition_id, str) or not transition_id: continue - to_field = raw_transition.get("to") or {} + to_field = raw_transition.get("to") + to_status = to_field.get("name") if isinstance(to_field, dict) else "" + name = raw_transition.get("name") transitions.append( JiraTransition( id=transition_id, - name=raw_transition.get("name") or "", - to_status=to_field.get("name") or "", + name=name if isinstance(name, str) else "", + to_status=to_status if isinstance(to_status, str) else "", ) ) @@ -754,18 +743,10 @@ def transition_issue(jira_config: JiraConfig, issue_key: str, transition_id: str """ url = jira_config.base_url.rstrip("/") + "/" + TRANSITIONS_PATH_TEMPLATE.format(key=issue_key) - try: - response = requests.post( - url, - json={"transition": {"id": transition_id}}, - auth=(jira_config.email, jira_config.pat), - timeout=30, - ) - except requests.RequestException as exc: - raise JiraError(f"could not reach Jira at {url}: {exc}") from exc - - if response.status_code not in (200, 204): - raise JiraError( - f"Jira API request to {url} failed with status {response.status_code}: " - f"{response.text.strip()[:500]}" - ) + _request( + jira_config, + "POST", + url, + expected_statuses=(200, 204), + json={"transition": {"id": transition_id}}, + ) diff --git a/src/et/jira_time.py b/src/et/jira_time.py index dfd0147..e57dabe 100644 --- a/src/et/jira_time.py +++ b/src/et/jira_time.py @@ -1,18 +1,19 @@ -"""Orchestrates `et jira log-time`: log the active workspace's Tracker time +"""Orchestrates `et jira log-time`: log the active workspace's counter time to its linked Jira issue. -Reads the ET- Tracker timer bound to the active workspace, resolves the -Jira issue linked to that workspace (its "jira:" ref, normally set by -`et jira start`), and logs the elapsed time as a Jira worklog for that issue -(via `et.jira.create_worklog` — Jira's own worklog API, which still shows up -in Tempo timesheets when Tempo is configured to sync native Jira worklogs, -without needing a separate Tempo API token). On success the timer is reset +Reads the extension counter bound to the active workspace (via +`et.et_extension.get_workspace_counter`), resolves the Jira issue linked to +that workspace (its "jira:" ref, normally set by `et jira start`), and +logs the elapsed time as a Jira worklog for that issue (via +`et.jira.create_worklog` — Jira's own worklog API, which still shows up in +Tempo timesheets when Tempo is configured to sync native Jira worklogs, +without needing a separate Tempo API token). On success the counter is reset to 0 (unless disabled), since that same elapsed time has just been recorded and shouldn't be logged again next time. Also exposes `log_manual_time_for_current_workspace`, used by `et jira log-time [Xh]` to -log a manually-specified duration without touching the Tracker timer at +log a manually-specified duration without touching the extension counter at all, and `log_time_for_all_workspaces`, used by `et jira log-time --all` to -log every Jira-linked workspace's tracker in one go regardless of which +log every Jira-linked workspace's counter in one go regardless of which workspace is active. Has no Typer/CLI dependency. """ @@ -20,14 +21,14 @@ from dataclasses import dataclass -from et import tracker, workspaces +from et import et_extension, workspaces from et.config import EtConfig, JiraConfig, load_config +from et.et_extension import EtExtensionError from et.jira import JiraError, create_worklog, fetch_issue from et.jira_ref import jira_key_from_ref -from et.tracker import TrackerError # Below this, the elapsed time is almost certainly just a stray few seconds -# (e.g. a timer left running by accident) rather than real tracked work. +# (e.g. a counter left running by accident) rather than real tracked work. MIN_LOGGABLE_SECONDS = 60 @@ -48,8 +49,9 @@ class LogTimeResult: workspace_index: int issue_key: str seconds_logged: int - tracker_reset: bool + counter_reset: bool summary: str = "" + counter_reset_error: str | None = None @dataclass(frozen=True) @@ -79,7 +81,7 @@ def resolve_issue_key(config: EtConfig, *, issue_key: str | None = None) -> tupl linked issue. Used by actions that don't need the workspace index itself (comment, status) — `log-time`/`complete` use `resolve_active_issue` directly since they also need the index (for - the Tracker timer / workspace deletion). + the extension counter / workspace deletion). Raises `JiraLogTimeError` if there's no 'jira' config block, or (when `issue_key` isn't given) no Jira issue is linked to the active @@ -105,7 +107,7 @@ def resolve_active_issue( When `issue_key` is given, it's used directly (e.g. from `et jira`'s `-j/--jira KEY` override) — the active workspace's linked ref is never consulted for the issue key, only for its index (still needed by - Tracker-timer-based actions like `log-time`). Otherwise the active + counter-based actions like `log-time`). Otherwise the active workspace's linked issue ("jira:" ref) is resolved as before. Raises `JiraLogTimeError` if there's no 'jira' config block, or (when @@ -134,32 +136,39 @@ def resolve_active_issue( def log_time_for_current_workspace( *, description: str | None = None, reset: bool = True, issue_key: str | None = None ) -> LogTimeResult: - """Log the active workspace's ET- tracker elapsed time to its Jira issue. + """Log the active workspace's extension counter elapsed time to its Jira issue. Logs against `issue_key` if given (e.g. via `-j/--jira`), instead of the - issue linked to the active workspace — the Tracker timer read/reset - still applies to the active workspace regardless. + issue linked to the active workspace — the counter read/reset still + applies to the active workspace regardless. Raises `ConfigError` if the config file is missing/malformed, `WorkspaceError` if the active workspace can't be determined, and `JiraLogTimeError` if: there's no 'jira' config block, no Jira issue is - linked to the active workspace (and none was given explicitly), no - Tracker timer is bound to it (or its elapsed time is under - `MIN_LOGGABLE_SECONDS`), or the Jira API call fails. - - On success, resets the tracker to 0 (unless `reset=False`) so the same - elapsed time isn't accidentally logged again later. + linked to the active workspace (and none was given explicitly), the + extension counter can't be read (e.g. the extension isn't + installed/enabled), its elapsed time is under `MIN_LOGGABLE_SECONDS`, + or the Jira API call fails. + + On success, resets the counter to 0 (unless `reset=False`) so the same + elapsed time isn't accidentally logged again later. The reset only + happens after the Jira worklog call has actually succeeded. If Jira + accepts the worklog but the reset fails, returns a partial-success + result with `counter_reset_error` populated so callers can warn against + retrying the already-committed worklog. """ config: EtConfig = load_config() jira_config, index, resolved_key = resolve_active_issue(config, issue_key=issue_key) - entries = tracker.load_timers() - timer = tracker.find_timer_for_workspace(entries, index) - elapsed = timer.get("timeElapsed", 0) if timer is not None else 0 - seconds = int(elapsed) if isinstance(elapsed, (int, float)) else 0 + try: + counter = et_extension.get_workspace_counter(index) + except EtExtensionError as exc: + raise JiraLogTimeError(str(exc)) from exc + + seconds = counter.elapsed_seconds if seconds < MIN_LOGGABLE_SECONDS: raise JiraLogTimeError( - f"workspace {index + 1}'s tracker has only {seconds}s elapsed " + f"workspace {index + 1}'s counter has only {seconds}s elapsed " f"(minimum {MIN_LOGGABLE_SECONDS}s to log)" ) @@ -168,20 +177,19 @@ def log_time_for_current_workspace( except JiraError as exc: raise JiraLogTimeError(str(exc)) from exc + counter_reset_error: str | None = None if reset: - assert timer is not None # seconds >= MIN_LOGGABLE_SECONDS implies a timer was found - timer["timeElapsed"] = 0 - timer["running"] = False try: - tracker.save_timers_with_reload(entries, "resetting timer after logging to Jira") - except TrackerError as exc: - raise JiraLogTimeError(str(exc)) from exc + et_extension.reset_workspace_counter(index) + except EtExtensionError as exc: + counter_reset_error = str(exc) return LogTimeResult( workspace_index=index, issue_key=resolved_key, seconds_logged=seconds, - tracker_reset=reset, + counter_reset=reset and counter_reset_error is None, + counter_reset_error=counter_reset_error, ) @@ -191,13 +199,13 @@ def log_manual_time_for_current_workspace( """Log a manually-specified `seconds` duration to the active workspace's Jira issue. Like `log_time_for_current_workspace`, but the duration comes from the - caller (e.g. `et jira log-time 2h`) instead of the Tracker timer's - elapsed time — so the Tracker timer is never read or reset. Logs - against `issue_key` if given (e.g. via `-j/--jira`), instead of the - issue linked to the active workspace. Raises + caller (e.g. `et jira log-time 2h`) instead of the extension counter's + elapsed time — so the counter is never read or reset. Logs against + `issue_key` if given (e.g. via `-j/--jira`), instead of the issue linked + to the active workspace. Raises `ConfigError`/`WorkspaceError`/`JiraLogTimeError` under the same conditions as `log_time_for_current_workspace` (minus the - Tracker-timer-related ones). + counter-related ones). """ config: EtConfig = load_config() jira_config, index, resolved_key = resolve_active_issue(config, issue_key=issue_key) @@ -211,7 +219,7 @@ def log_manual_time_for_current_workspace( workspace_index=index, issue_key=resolved_key, seconds_logged=seconds, - tracker_reset=False, + counter_reset=False, ) @@ -223,26 +231,30 @@ def log_time_for_all_workspaces( Unlike `log_time_for_current_workspace`, this never consults the active GNOME workspace: it walks every entry in the config's `workspaces` list, and for each one with a linked Jira issue - ("jira:" ref) logs its ET- Tracker timer's elapsed time to that + ("jira:" ref) logs its extension counter's elapsed time to that issue. Workspaces with no linked issue are ignored entirely (not even reported). A workspace is skipped (recorded in `AllLogTimeResult.skipped`, not - raised) when: it has no Tracker timer, its elapsed time is under - `MIN_LOGGABLE_SECONDS`, or the Jira API call for that one issue fails - — so a single bad workspace never stops the rest from being logged. - A skipped workspace's timer is left untouched either way. + raised) when: its extension counter can't be read (e.g. it was never + prepared, or the extension isn't installed/enabled), its elapsed time + is under `MIN_LOGGABLE_SECONDS`, or the Jira API call for that one + issue fails — so a single bad workspace never stops the rest from + being logged. A skipped workspace's counter is left untouched either + way. On success for a given workspace, if `reset` is True, that workspace's - timer is zeroed and saved immediately (one `save_timers_with_reload` - call per successfully-logged workspace, not batched at the end) — so - an interruption partway through, or a later workspace's Jira call + counter is reset immediately (one `reset_workspace_counter` call per + successfully-logged workspace, not batched at the end) — so an + interruption partway through, or a later workspace's Jira call failing, never leaves an already-logged workspace un-reset, and never resets a workspace whose Jira call actually failed. Raises `ConfigError` if the config file is missing/malformed, and - `JiraLogTimeError` only when there's no 'jira' config block at all - (every other failure becomes a per-workspace skip instead). + `JiraLogTimeError` only when there's no 'jira' config block at all. + Every per-workspace failure becomes either a skip or, when Jira accepted + the worklog but the counter reset failed, a partial-success + `LogTimeResult` with `counter_reset_error` populated. """ config: EtConfig = load_config() if config.jira is None: @@ -252,7 +264,6 @@ def log_time_for_all_workspaces( ) jira_config = config.jira - entries = tracker.load_timers() logged: list[LogTimeResult] = [] skipped: list[SkippedWorkspace] = [] @@ -261,20 +272,19 @@ def log_time_for_all_workspaces( if issue_key is None: continue - timer = tracker.find_timer_for_workspace(entries, index) - elapsed = timer.get("timeElapsed", 0) if timer is not None else 0 - seconds = int(elapsed) if isinstance(elapsed, (int, float)) else 0 - - if timer is None: + try: + counter = et_extension.get_workspace_counter(index) + except EtExtensionError as exc: skipped.append( SkippedWorkspace( workspace_index=index, issue_key=issue_key, - reason="no Tracker timer for this workspace", + reason=f"no prepared counter for this workspace: {exc}", ) ) continue + seconds = counter.elapsed_seconds if seconds < MIN_LOGGABLE_SECONDS: skipped.append( SkippedWorkspace( @@ -301,23 +311,21 @@ def log_time_for_all_workspaces( except JiraError: summary = "" + counter_reset_error: str | None = None if reset: - timer["timeElapsed"] = 0 - timer["running"] = False try: - tracker.save_timers_with_reload( - entries, f"resetting workspace {index + 1}'s timer after logging to Jira" - ) - except TrackerError as exc: - raise JiraLogTimeError(str(exc)) from exc + et_extension.reset_workspace_counter(index) + except EtExtensionError as exc: + counter_reset_error = str(exc) logged.append( LogTimeResult( workspace_index=index, issue_key=issue_key, seconds_logged=seconds, - tracker_reset=reset, + counter_reset=reset and counter_reset_error is None, summary=summary, + counter_reset_error=counter_reset_error, ) ) diff --git a/src/et/task.py b/src/et/task.py index 32ef1b7..24d9877 100644 --- a/src/et/task.py +++ b/src/et/task.py @@ -1,27 +1,27 @@ """Orchestrates the `et jira` command group: a friendlier, task-centric -layer on top of the Tracker and Jira integrations (`et.tracker`, +layer on top of the et extension and Jira integrations (`et.et_extension`, `et.jira`, `et.jira_ref`, `et.jira_time`), plus `et ws`. `et jira start` allocates a free workspace slot among GNOME's fixed set of workspaces (prompting, via an injected `confirm_grow` callback, to add one -more workspace when they're all taken), creates its Tracker timer, and +more workspace when they're all taken), prepares its extension counter, and switches GNOME to it — moving the terminal window it's run from along with -it — picking the slot's name/description/Jira link from the user's active -Jira issues (and offering to move the selected issue to "In Progress" if it -isn't already). `et jira complete` logs the active workspace's tracked time -to Jira (reusing `et.jira_time.log_time_for_current_workspace`), then — each -behind its own confirmation prompt — optionally deletes that workspace -(reclaiming its GNOME workspace slot the same way `et ws delete` does: -shrinking GNOME's workspace count and shifting every non-`static` slot -after it one slot to the left, moving each one's Tracker timer along with -it, then switching to a surviving workspace) and/or moves the linked Jira -issue to "Done". `et jira status`/`et jira comment` reuse the same -active-workspace-to-issue-key resolution (`et.jira_time.resolve_issue_key`, -or `resolve_active_issue` when the workspace index is also needed) -to transition the linked issue's status or add a comment to it. Every -current-ticket action also accepts an `issue_key` override (`et jira`'s -`-j/--jira KEY`), letting it target a different Jira issue than the one -linked to the active workspace. Has no +it — picking the slot's name/description/Jira link/original estimate from +the user's active Jira issues (and offering to move the selected issue to +"In Progress" if it isn't already). `et jira complete` logs the active +workspace's tracked time to Jira (reusing +`et.jira_time.log_time_for_current_workspace`), then — each behind its own +confirmation prompt — optionally deletes that workspace (reclaiming its +GNOME workspace slot the same way `et ws delete` does: shrinking GNOME's +workspace count and shifting every non-`static` slot after it one slot to +the left, moving each one's extension counter along with it, then switching +to a surviving workspace) and/or moves the linked Jira issue to "Done". `et +jira status`/`et jira comment` reuse the same active-workspace-to-issue-key +resolution (`et.jira_time.resolve_issue_key`, or `resolve_active_issue` +when the workspace index is also needed) to transition the linked issue's +status or add a comment to it. Every current-ticket action also accepts an +`issue_key` override (`et jira`'s `-j/--jira KEY`), letting it target a +different Jira issue than the one linked to the active workspace. Has no Typer/CLI dependency. """ @@ -30,7 +30,7 @@ from collections.abc import Callable from dataclasses import dataclass, replace -from et import tracker, workspaces +from et import et_extension, workspaces from et.config import ( ConfigError, EtConfig, @@ -39,6 +39,7 @@ load_config, save_config, ) +from et.et_extension import EtExtensionError from et.jira import ( JiraBoardWithoutSprintsError, JiraError, @@ -57,7 +58,6 @@ ) from et.jira_ref import JIRA_REF_PREFIX, default_entry, jira_key_from_ref, truncate_summary from et.jira_time import LogTimeResult, log_time_for_current_workspace, resolve_issue_key -from et.tracker import TrackerError, find_timer_for_workspace from et.workspaces import WorkspaceError from et.ws import WsDeleteError, delete_active_workspace @@ -86,6 +86,20 @@ class TaskError(RuntimeError): """Raised when a task cannot be created or completed.""" +def _task_error_with_rollback( + error: Exception, + rollback_errors: list[str], + *, + extension_state_uncertain: bool = False, +) -> TaskError: + if not rollback_errors and not extension_state_uncertain: + return TaskError(str(error)) + details = list(rollback_errors) + if extension_state_uncertain: + details.append("the extension may have applied the D-Bus mutation before reporting failure") + return TaskError(f"{error}; operation may be partially applied: {'; '.join(details)}") + + @dataclass(frozen=True) class TaskCreateResult: """Summary of what `create_task_workspace` did.""" @@ -93,7 +107,6 @@ class TaskCreateResult: workspace_index: int name: str ref: str | None - timer_created: bool window_moved: bool @@ -125,6 +138,7 @@ def create_task_workspace( name: str, description: str | None = None, ref: str | None = None, + estimate_seconds: int | None = None, *, confirm_grow: Callable[[int], bool] = lambda count: False, ) -> TaskCreateResult | None: @@ -135,17 +149,19 @@ def create_task_workspace( `confirm_grow(count)`; when it returns True the workspace count is bumped by one (via `set_workspace_count`) and the new slot is used, otherwise this returns `None` without changing anything. Saves the updated config, - creates the slot's Tracker timer, renames the GNOME workspaces to match, - switches the active GNOME workspace to the new slot, and best-effort moves - the currently focused window (typically the terminal the command was run + prepares the slot's extension counter (with a generic display label + — `description` if given, else `name` — and `estimate_seconds`, defaulting + to 0 when not given), renames the GNOME workspaces to match, switches the + active GNOME workspace to the new slot, and best-effort moves the + currently focused window (typically the terminal the command was run from) there too — this last step is skipped without failing the whole command if unsupported (e.g. for native Wayland clients, which have no addressable X11 window for `wmctrl` to move); `TaskCreateResult.window_moved` reports whether it worked. Raises `ConfigError` if the config file is missing/malformed, and - `WorkspaceError`/`TrackerError` (via `TaskError`) if the underlying - GNOME/Tracker operations fail. + `WorkspaceError`/`EtExtensionError` (via `TaskError`) if the underlying + GNOME/extension operations fail. """ config: EtConfig = load_config() @@ -173,17 +189,54 @@ def create_task_workspace( description=description, ) + label = description if description is not None else name + original_names = [entry.name for entry in config.workspaces] + original_names.extend( + default_entry(index, "dynamic").name + for index in range(len(original_names), count) + ) + count_changed = False + config_saved = False + names_changed = False + counter_prepared = False try: if grew: workspaces.set_workspace_count(count + 1) - timer_created = tracker.prepare_timer_for_workspace( - slot, count + 1 if grew else count - )[1] + count_changed = True save_config(replace(config, workspaces=workspaces_list)) + config_saved = True workspaces.rename_all_workspaces([entry.name for entry in workspaces_list]) + names_changed = True + et_extension.prepare_workspace(slot, label, estimate_seconds or 0) + counter_prepared = True workspaces.switch_to_workspace(slot) - except (WorkspaceError, TrackerError, ConfigError) as exc: - raise TaskError(str(exc)) from exc + except (WorkspaceError, EtExtensionError, ConfigError) as exc: + rollback_errors: list[str] = [] + if counter_prepared: + try: + et_extension.remove_workspace(slot) + except EtExtensionError as rollback_error: + rollback_errors.append(f"counter cleanup failed: {rollback_error}") + if names_changed: + try: + workspaces.rename_all_workspaces(original_names) + except WorkspaceError as rollback_error: + rollback_errors.append(f"workspace-name restore failed: {rollback_error}") + if config_saved: + try: + save_config(config) + except ConfigError as rollback_error: + rollback_errors.append(f"config restore failed: {rollback_error}") + if count_changed: + try: + workspaces.set_workspace_count(count) + except WorkspaceError as rollback_error: + rollback_errors.append(f"workspace-count restore failed: {rollback_error}") + raise _task_error_with_rollback( + exc, + rollback_errors, + extension_state_uncertain=isinstance(exc, EtExtensionError), + ) from exc # Best-effort: moving the focused window across workspaces requires an # addressable X11 window, which native Wayland clients (e.g. many @@ -200,7 +253,6 @@ def create_task_workspace( workspace_index=slot, name=name, ref=ref, - timer_created=timer_created, window_moved=window_moved, ) @@ -309,16 +361,7 @@ def add_comment_to_current_workspace(body: str, *, issue_key: str | None = None) def persist_board_id(config: EtConfig, jira: JiraConfig, board_id: str) -> None: """Save `board_id` as `jira.board_id`, so later runs skip board discovery.""" - updated_jira = JiraConfig( - base_url=jira.base_url, - email=jira.email, - pat=jira.pat, - jql=jira.jql, - priority_order=jira.priority_order, - project_key=jira.project_key, - board_id=board_id, - ) - save_config(EtConfig(jira=updated_jira, workspaces=config.workspaces)) + save_config(replace(config, jira=replace(jira, board_id=board_id))) def resolve_board_id(config: EtConfig, jira: JiraConfig, project_key: str) -> str | None: @@ -368,7 +411,7 @@ def fetch_active_sprints_or_warn( is reported via `warn` and treated as "skip the sprint" (empty list) — this function owns all such warnings, so callers should not warn again on an empty return. Shared by `et jira create --sprint` and `et - jira start -k`. + jira start KEY`. """ try: sprints = fetch_active_sprints(jira, board_id) @@ -403,7 +446,7 @@ def _maybe_transition_to_in_progress( """Move `issue` to "In Progress" if it isn't already and `confirm_transition` agrees. Shared by `create_task_from_jira` (interactive picker) and - `create_task_from_jira_key` (`-k/--key`): both offer the same + `create_task_from_jira_key` (positional `KEY`): both offer the same "not already In Progress? move it there" step before creating the workspace. """ @@ -470,6 +513,7 @@ def create_task_from_jira( name=truncate_summary(issue.summary), description=issue.summary, ref=f"{JIRA_REF_PREFIX}{issue.key}", + estimate_seconds=issue.original_estimate_seconds, confirm_grow=confirm_grow, ) @@ -483,7 +527,7 @@ def ensure_issue_in_active_sprint( ) -> bool: """Add `issue_key` to one of its project's current active sprints, if needed. - Used by `create_task_from_jira_key` (`et jira start -k`). Degrades + Used by `create_task_from_jira_key` (`et jira start KEY`). Degrades gracefully rather than failing the whole command: if `jira.project_key` isn't set, no Agile board can be resolved/discovered for it, or no sprint is currently active, `warn(...)` is called and this returns @@ -596,6 +640,7 @@ def create_task_from_jira_key( name=truncate_summary(issue.summary), description=issue.summary, ref=f"{JIRA_REF_PREFIX}{issue.key}", + estimate_seconds=issue.original_estimate_seconds, confirm_grow=confirm_grow, ) if result is None: @@ -609,17 +654,17 @@ def _free_workspace_slot(index: int) -> None: Delegates to `delete_active_workspace` — the same logic `et ws delete` uses — so the workspace is actually removed rather than merely blanked: GNOME's workspace count is decremented, every non-`static` slot after - the freed one (and its Tracker timer) shifts one slot to the left to - close the gap, and GNOME switches to a surviving workspace. `force=True` + the freed one (and its extension counter) shifts one slot to the left + to close the gap, and GNOME switches to a surviving workspace. `force=True` because the slot is still linked to the just-completed Jira issue (whose - Tracker timer was already reset to zero by the logging step, so + extension counter was already reset to zero by the logging step, so discarding it loses nothing). Falls back to resetting the slot to a bare "ET-" entry (without touching GNOME's workspace count) when there is only a single workspace left, since GNOME can't drop below one workspace. - Raises `TaskError` if the underlying config/tracker/GNOME operations + Raises `TaskError` if the underlying config/extension/GNOME operations fail. """ try: @@ -638,30 +683,46 @@ def _free_workspace_slot(index: int) -> None: def _reset_workspace_slot(index: int) -> None: - """Reset workspace `index` to a bare "ET-" slot and drop its timer. + """Reset workspace `index` to a bare "ET-" slot and drop its counter. Used when the workspace can't be reclaimed by shrinking GNOME's workspace count (only one workspace remains): clears the slot's - name/ref/description and discards its (already-reset) Tracker timer, - leaving GNOME's workspace count untouched. + name/ref/description and discards its (already-reset) extension + counter, leaving GNOME's workspace count untouched. - Raises `TaskError` if the underlying config/tracker operations fail. + Raises `TaskError` if the underlying config/extension operations fail. """ config: EtConfig = load_config() workspaces_list = list(config.workspaces) if index < len(workspaces_list): workspaces_list[index] = default_entry(index, workspaces_list[index].type) + original_names = [entry.name for entry in config.workspaces] + config_saved = False + names_changed = False try: - entries = tracker.load_timers() - stale_timer = find_timer_for_workspace(entries, index) - if stale_timer is not None: - entries.remove(stale_timer) - tracker.save_timers_with_reload(entries, "clearing timer after completing a task") save_config(replace(config, workspaces=workspaces_list)) + config_saved = True workspaces.rename_all_workspaces([entry.name for entry in workspaces_list]) - except (ConfigError, WorkspaceError, TrackerError) as exc: - raise TaskError(str(exc)) from exc + names_changed = True + et_extension.remove_workspace(index) + except (ConfigError, WorkspaceError, EtExtensionError) as exc: + rollback_errors: list[str] = [] + if names_changed: + try: + workspaces.rename_all_workspaces(original_names) + except WorkspaceError as rollback_error: + rollback_errors.append(f"workspace-name restore failed: {rollback_error}") + if config_saved: + try: + save_config(config) + except ConfigError as rollback_error: + rollback_errors.append(f"config restore failed: {rollback_error}") + raise _task_error_with_rollback( + exc, + rollback_errors, + extension_state_uncertain=isinstance(exc, EtExtensionError), + ) from exc def complete_task_for_current_workspace( @@ -676,17 +737,20 @@ def complete_task_for_current_workspace( Delegates the logging step to `et.jira_time.log_time_for_current_workspace` (which already resets the - tracker on success) and, once it succeeds, calls `on_logged(log_result)` + counter on success) and, once it succeeds, calls `on_logged(log_result)` so the caller can report the logged time before any prompts. Logs against `issue_key` if given (e.g. via `-j/--jira`), instead of the issue linked to the active workspace — workspace deletion still applies to the active workspace regardless, since it's not tied to which issue the time was logged against. - Then `confirm_delete(log_result)` is called: if it returns `True`, the - workspace is deleted the same way `et ws delete` does — GNOME's + Then `confirm_delete(log_result)` is called, unless Jira accepted the + worklog but the counter reset failed. In that partial-success case the + workspace is deliberately kept in place so the recovery command's slot + number remains safe. If deletion is confirmed, the workspace is deleted + the same way `et ws delete` does — GNOME's workspace count is decremented to reclaim the slot, every non-`static` - slot after it (with its Tracker timer) shifts one slot to the left to + slot after it (with its extension counter) shifts one slot to the left to close the gap, GNOME workspace names are renamed to match, and GNOME switches to a surviving workspace. When only a single workspace remains (so GNOME can't shrink further) the slot is just reset to a bare @@ -707,7 +771,7 @@ def complete_task_for_current_workspace( on_logged(log_result) workspace_freed = False - if confirm_delete(log_result): + if log_result.counter_reset_error is None and confirm_delete(log_result): _free_workspace_slot(log_result.workspace_index) workspace_freed = True diff --git a/src/et/tracker.py b/src/et/tracker.py deleted file mode 100644 index 7a42af5..0000000 --- a/src/et/tracker.py +++ /dev/null @@ -1,252 +0,0 @@ -"""Logic for managing the GNOME Shell "Tracker" extension's timers. - -Tracker (tracker@aliakseiz.github.com) persists all of its timers as a list -of JSON-encoded strings in a single GSettings key. This module reads/writes -that key via `et.gsettings` and knows how to find or create the timer -associated with a given workspace. It has no Typer/CLI dependency. New -timers are named "ET-" (1-indexed) rather than the -workspace's GNOME name, since workspace names can be renamed or unset and -would make timer names unstable over time. - -A running Tracker instance keeps its own in-memory copy of its timers and -resaves it (verbatim) in reaction to almost any GSettings change, silently -erasing brand-new entries it doesn't already know about. To avoid that, new -timers are written while Tracker is briefly disabled via `et.gnome_extensions`, -then Tracker is re-enabled so it fully reloads from GSettings from scratch. -""" - -from __future__ import annotations - -import json -import os -import re -import uuid -from pathlib import Path - -from et import gsettings -from et.gnome_extensions import GnomeExtensionsError, reload_around -from et.gsettings import GSettingsError - -TRACKER_SCHEMA = "org.gnome.shell.extensions.tracker" -TRACKER_TIMERS_KEY = "timers" -TRACKER_EXTENSION_UUID = "tracker@aliakseiz.github.com" - -# Auto-created timers are named "ET-" (1-indexed). This -# pattern is used to tell et's own timers apart from user-created ones, so -# cleanup never touches timers a user named themselves (or the "settings" -# sentinel, which has no name). -_ET_TIMER_NAME_RE = re.compile(r"^ET-\d+$") - -# A Tracker entry is either a timer (id, name, timeElapsed, running, selected, -# workspaceId) or the special {"id": "settings", "totalTimeSelected": ...} -# sentinel. Values are JSON-compatible (str, int, bool, or None). -TimerEntry = dict[str, object] - - -class TrackerError(RuntimeError): - """Raised when a Tracker timer operation cannot be completed.""" - - -def _find_extension_schema_dir() -> str | None: - """Locate Tracker's compiled schema directory, if installed as an extension. - - GNOME Shell extensions installed per-user (the common case for - extensions.gnome.org installs) ship their own compiled schema under - `~/.local/share/gnome-shell/extensions//schemas/`. GNOME Shell - itself knows how to load these, but the plain `gsettings` CLI only looks - in the standard system schema directories and doesn't find them unless - `GSETTINGS_SCHEMA_DIR` points at that directory. System-wide extension - installs (e.g. via a distro package) are also checked as a fallback. - """ - data_home = Path(os.environ.get("XDG_DATA_HOME", str(Path.home() / ".local" / "share"))) - candidates = [ - data_home / "gnome-shell" / "extensions" / TRACKER_EXTENSION_UUID / "schemas", - Path("/usr/share/gnome-shell/extensions") / TRACKER_EXTENSION_UUID / "schemas", - ] - for candidate in candidates: - if (candidate / "gschemas.compiled").is_file(): - return str(candidate) - return None - - -def load_timers() -> list[TimerEntry]: - """Return the raw list of Tracker entries (timers and the settings sentinel).""" - try: - raw_entries = gsettings.read_string_array( - TRACKER_SCHEMA, TRACKER_TIMERS_KEY, schema_dir=_find_extension_schema_dir() - ) - except GSettingsError as exc: - raise TrackerError( - f"could not read Tracker timers (is the Tracker GNOME extension " - f"installed and enabled?): {exc}" - ) from exc - - entries = [] - for raw_entry in raw_entries: - try: - entries.append(json.loads(raw_entry)) - except json.JSONDecodeError as exc: - raise TrackerError(f"could not parse Tracker timer entry: {raw_entry!r}") from exc - - return entries - - -def save_timers(entries: list[TimerEntry]) -> None: - """Write the given list of Tracker entries back to GSettings.""" - raw_entries = [json.dumps(entry) for entry in entries] - try: - gsettings.write_string_array( - TRACKER_SCHEMA, - TRACKER_TIMERS_KEY, - raw_entries, - schema_dir=_find_extension_schema_dir(), - ) - except GSettingsError as exc: - raise TrackerError(f"could not write Tracker timers: {exc}") from exc - - -def find_timer_for_workspace(entries: list[TimerEntry], workspace_id: int) -> TimerEntry | None: - """Return the first entry associated with `workspace_id`, if any.""" - for entry in entries: - if entry.get("workspaceId") == workspace_id: - return entry - return None - - -def build_new_timer(workspace_id: int, name: str) -> TimerEntry: - """Build a new Tracker timer dict for `workspace_id`, matching Tracker's own shape. - - `autoResume` is set to `True` so Tracker auto-starts this timer whenever - its workspace becomes active (and auto-pauses it otherwise) — the same - state Tracker itself sets when a user manually presses play on a - workspace-bound timer. Without it, Tracker only tracks the association - but never starts the timer on its own. - """ - return { - "id": str(uuid.uuid4()), - "name": name, - "timeElapsed": 0, - "running": False, - "selected": False, - "workspaceId": workspace_id, - "autoResume": True, - } - - -def _is_et_timer(entry: TimerEntry) -> bool: - """Return whether `entry` is an et-created "ET-" timer. - - Never matches user-named timers or the "settings" sentinel (which has no - "name"), so cleanup only ever touches timers et created itself. - """ - name = entry.get("name") - return isinstance(name, str) and _ET_TIMER_NAME_RE.match(name) is not None - - -def _remove_orphaned_et_timers(entries: list[TimerEntry], workspace_count: int) -> bool: - """Drop (in-place) et-created timers bound to a workspace beyond `workspace_count`. - - A leftover "ET-" timer whose `workspaceId` is out of range (negative, - or `>= workspace_count`) is a stale remnant from a previous run whose - workspace no longer exists. Returns whether anything was removed. - """ - - def is_orphan(entry: TimerEntry) -> bool: - workspace_id = entry.get("workspaceId") - if not _is_et_timer(entry) or not isinstance(workspace_id, int): - return False - return not (0 <= workspace_id < workspace_count) - - orphans = [entry for entry in entries if is_orphan(entry)] - for entry in orphans: - entries.remove(entry) - return bool(orphans) - - -def _reset_timer(entry: TimerEntry) -> bool: - """Zero out (in-place) a reused timer's elapsed time and running state. - - Returns whether anything changed, so a reused-but-already-zero timer - doesn't trigger a needless GSettings write. - """ - if not entry.get("timeElapsed") and not entry.get("running"): - return False - entry["timeElapsed"] = 0 - entry["running"] = False - return True - - -def save_timers_with_reload(entries: list[TimerEntry], action: str) -> None: - """Save `entries` via the disable/write/enable reload dance, wrapping errors.""" - try: - with reload_around(TRACKER_EXTENSION_UUID): - save_timers(entries) - except GnomeExtensionsError as exc: - raise TrackerError( - f"could not reload the Tracker extension after {action} " - f"(the extension may need a manual reload to pick this up): {exc}" - ) from exc - - -def prepare_timer_for_workspace(index: int, workspace_count: int) -> tuple[str, bool]: - """Ensure a fresh, zeroed Tracker timer exists for workspace `index`. - - Cleans up stale state before (re)using the slot: - - * Any orphaned "ET-" timer bound to a workspace beyond `workspace_count` - (a leftover from a previous run) is removed. - * If a timer already exists for `index`, its elapsed time and running - state are reset to zero so a reused slot never inherits a previous - task's accumulated time. - * Otherwise a new timer is created for `index`. - - The disable/write/enable reload dance runs only if something actually - changed. Returns (timer_name, created), where `created` is False when an - existing timer was reused (after being reset). - """ - entries = load_timers() - changed = _remove_orphaned_et_timers(entries, workspace_count) - - existing = find_timer_for_workspace(entries, index) - if existing is not None: - name = str(existing["name"]) - created = False - changed = _reset_timer(existing) or changed - else: - name = f"ET-{index + 1}" - entries.append(build_new_timer(index, name)) - created = True - changed = True - - if changed: - save_timers_with_reload(entries, "preparing the workspace timer") - return name, created - - -def format_duration(seconds: float) -> str: - """Format a number of seconds as e.g. "2h 15m 30s".""" - total_seconds = int(seconds) - hours, remainder = divmod(total_seconds, 3600) - minutes, secs = divmod(remainder, 60) - return f"{hours}h {minutes}m {secs}s" - - -_HOURS_DURATION_RE = re.compile(r"^\s*(\d+(?:\.\d+)?)\s*[hH]\s*$") - - -def parse_hours_to_seconds(text: str) -> int: - """Parse an hours-only duration like "2h" or "1.5h" into whole seconds. - - Used for `et jira log-time`'s manual duration argument. Raises - `ValueError` if `text` isn't a positive number followed by "h" (e.g. - missing the "h" suffix, non-numeric, zero, or negative). - """ - match = _HOURS_DURATION_RE.match(text) - if match is None: - raise ValueError(f"invalid duration {text!r}: expected a format like '2h' or '1.5h'") - - hours = float(match.group(1)) - if hours <= 0: - raise ValueError(f"invalid duration {text!r}: must be greater than 0h") - - return round(hours * 3600) diff --git a/src/et/workspaces.py b/src/et/workspaces.py index bca1eb2..9fea698 100644 --- a/src/et/workspaces.py +++ b/src/et/workspaces.py @@ -1,17 +1,21 @@ """Logic for inspecting and renaming GNOME/Ubuntu workspaces. -This module shells out to `wmctrl` (to find the active workspace) and, via -`et.gsettings`, to `gsettings` (to read/write GNOME's workspace-names -setting). It has no Typer/CLI dependency so it can be unit tested by mocking -`subprocess.run`. +This module shells out to `wmctrl` (to find the active workspace on X11) or, +on Wayland, delegates to `et.et_extension` (the companion `et` GNOME Shell +extension's D-Bus client), since `wmctrl` doesn't work under Wayland. It +also uses, via `et.gsettings`, `gsettings` (to read/write GNOME's +workspace-names/workspace-count settings). It has no Typer/CLI dependency +so it can be unit tested by mocking `subprocess.run`. """ from __future__ import annotations +import os import shutil import subprocess -from et import gsettings +from et import et_extension, gsettings +from et.et_extension import EtExtensionError from et.gsettings import GSettingsError WORKSPACE_NAMES_SCHEMA = "org.gnome.desktop.wm.preferences" @@ -30,8 +34,25 @@ def _require_binary(name: str) -> None: raise WorkspaceError(f"required command not found: {name}") +def _is_wayland_session() -> bool: + """Return whether the current session is running under Wayland. + + `wmctrl` only works on X11, so callers use this to decide whether to + fall back to the `et` GNOME Shell extension's D-Bus service instead. + """ + if os.environ.get("XDG_SESSION_TYPE") == "wayland": + return True + return bool(os.environ.get("WAYLAND_DISPLAY")) + + def get_active_workspace_index() -> int: """Return the 0-based index of the currently active workspace.""" + if _is_wayland_session(): + try: + return et_extension.get_active_workspace_index() + except EtExtensionError as exc: + raise WorkspaceError(str(exc)) from exc + _require_binary("wmctrl") result = subprocess.run( ["wmctrl", "-d"], diff --git a/src/et/ws.py b/src/et/ws.py index e643a50..c6fae0d 100644 --- a/src/et/ws.py +++ b/src/et/ws.py @@ -3,7 +3,7 @@ `shift_workspaces_left` is also reused by `et.task` (`et jira complete`'s "shift everything after the freed slot left, instead of leaving a gap" logic), which is why it lives here rather than directly in `et.workspaces` -(which has no config/tracker dependency). Has no Typer/CLI dependency. +(which has no config/counter dependency). Has no Typer/CLI dependency. """ from __future__ import annotations @@ -15,10 +15,14 @@ from dataclasses import dataclass, replace from pathlib import Path -from et import tracker, workspaces +from et import duration, et_extension, workspaces from et.config import ConfigError, EtConfig, WorkspaceConfigEntry, load_config, save_config +from et.et_extension import ( + EtExtensionError, + WorkspaceCounter, + WorkspaceCounterNotFoundError, +) from et.jira_ref import default_entry, jira_key_from_ref -from et.tracker import TimerEntry, TrackerError, find_timer_for_workspace from et.workspaces import WorkspaceError @@ -30,6 +34,23 @@ class WsOrganizeError(RuntimeError): """Raised when the dynamic workspaces cannot be reorganized.""" +def _error_with_rollback( + error_type: type[WsDeleteError] | type[WsOrganizeError], + error: Exception, + rollback_errors: list[str], + *, + extension_state_uncertain: bool = False, +) -> WsDeleteError | WsOrganizeError: + if not rollback_errors and not extension_state_uncertain: + return error_type(str(error)) + details = list(rollback_errors) + if extension_state_uncertain: + details.append("the extension may have applied the D-Bus mutation before reporting failure") + return error_type( + f"{error}; operation may be partially applied: {'; '.join(details)}" + ) + + @dataclass(frozen=True) class WsDeleteResult: """Summary of what `delete_active_workspace` did.""" @@ -44,48 +65,43 @@ class OrganizeCandidate: slot: int entry: WorkspaceConfigEntry - timer: TimerEntry | None + counter: WorkspaceCounter | None @dataclass(frozen=True) class OrganizePlanRow: """One row of a computed `ws organize` plan: what ends up in `new_slot`. - `entry` and `timer` are the *post-move* values (timer already has its - `workspaceId`/`name` updated to `new_slot`, if it moved). + `entry` is the *post-move* value. """ old_slot: int new_slot: int entry: WorkspaceConfigEntry - timer: TimerEntry | None def shift_workspaces_left( - workspaces_list: list[WorkspaceConfigEntry], entries: list[TimerEntry], freed_index: int -) -> bool: + workspaces_list: list[WorkspaceConfigEntry], freed_index: int +) -> list[tuple[int, int]]: """Shift every non-`static` slot after `freed_index` one slot to the left. `freed_index` (already reset to a bare "ET-" entry by the caller) is filled with whatever was in the next non-static slot, that slot is filled with the one after it, and so on, leaving a single bare slot at - the *end* of the non-static range instead of a gap in the middle. Each - moved workspace's bound Tracker timer (if any) follows it — its - `workspaceId`/`name` are updated in place — and a stale timer left - behind at `freed_index` (e.g. the just-reset-to-zero timer of a task - that was just completed, or an already-deleted workspace's leftover - timer) is discarded rather than duplicated. - - Mutates `workspaces_list` and `entries` in place. Returns whether - `entries` changed (so the caller knows whether to save it). No-op if + the *end* of the non-static range instead of a gap in the middle. + + Mutates `workspaces_list` in place. Returns the ordered list of + `(old_index, new_index)` moves describing how each slot's extension + counter needs to be relocated to mirror the config change — callers + pass this straight to `et.et_extension.remap_workspaces`. Empty if `freed_index` isn't a non-static slot, or is already the last one. """ non_static_slots = [i for i, entry in enumerate(workspaces_list) if entry.type != "static"] if freed_index not in non_static_slots: - return False + return [] freed_position = non_static_slots.index(freed_index) - timers_changed = False + moves: list[tuple[int, int]] = [] for position in range(freed_position, len(non_static_slots) - 1): dst = non_static_slots[position] @@ -107,18 +123,9 @@ def shift_workspaces_left( ) workspaces_list[src] = default_entry(src, workspaces_list[src].type) - existing_at_dst = find_timer_for_workspace(entries, dst) - if existing_at_dst is not None: - entries.remove(existing_at_dst) - timers_changed = True - - moved_timer = find_timer_for_workspace(entries, src) - if moved_timer is not None: - moved_timer["workspaceId"] = dst - moved_timer["name"] = f"ET-{dst + 1}" - timers_changed = True + moves.append((src, dst)) - return timers_changed + return moves def _pad_workspaces_list( @@ -160,10 +167,10 @@ def delete_active_workspace(*, force: bool = False) -> WsDeleteResult: slot to reuse). Raises `WsDeleteError` if the active workspace is `static`; use `et jira complete` (or `et jira log-time`) first to free a Jira-linked workspace, or pass `force=True` to delete it anyway - (its Tracker timer, if any, is discarded rather than logged). + (its extension counter, if any, is discarded rather than logged). - Every non-static workspace after the active one (and its Tracker - timer) is shifted one slot to the left, same as `et jira complete`, so + Every non-static workspace after the active one (and its extension + counter) is shifted one slot to the left, same as `et jira complete`, so the freed bare slot ends up at the end of the non-static range. That now-empty trailing slot is then removed: GNOME's workspace count (`num-workspaces`) is decremented by one. The one exception is when the @@ -175,7 +182,7 @@ def delete_active_workspace(*, force: bool = False) -> WsDeleteResult: Raises `ConfigError` if the config file is missing/malformed, `WorkspaceError` (unwrapped) if the active workspace can't be determined, and `WsDeleteError` if the checks above fail or the - underlying GNOME/Tracker operations fail. + underlying GNOME/extension operations fail. """ config: EtConfig = load_config() index = workspaces.get_active_workspace_index() @@ -189,6 +196,7 @@ def delete_active_workspace(*, force: bool = False) -> WsDeleteResult: raise WsDeleteError("cannot delete the last remaining workspace") workspaces_list = _pad_workspaces_list(config.workspaces, max(count, index + 1)) + original_names = [item.name for item in workspaces_list[:count]] entry = workspaces_list[index] if entry.type == "static": @@ -200,23 +208,9 @@ def delete_active_workspace(*, force: bool = False) -> WsDeleteResult: "(or pass --force to delete it anyway)" ) - try: - entries = tracker.load_timers() - except TrackerError as exc: - raise WsDeleteError(str(exc)) from exc - workspaces_list[index] = default_entry(index, entry.type) - # Discard the deleted workspace's own Tracker timer up front. The shift - # below only removes it as a side effect of moving a later timer into - # this slot, so it would be left orphaned when there's nothing to shift - # (e.g. deleting the last non-static workspace). - deleted_timer = find_timer_for_workspace(entries, index) - if deleted_timer is not None: - entries.remove(deleted_timer) - - timers_changed = shift_workspaces_left(workspaces_list, entries, index) - timers_changed = timers_changed or deleted_timer is not None + moves = shift_workspaces_left(workspaces_list, index) # Reclaim the freed slot by shrinking GNOME's workspace count, unless the # highest-numbered workspace is static (shrinking removes the last GNOME @@ -229,18 +223,54 @@ def delete_active_workspace(*, force: bool = False) -> WsDeleteResult: saved_list = list(workspaces_list) _trim_trailing_default_entries(saved_list) + config_saved = False + names_changed = False + count_changed = False try: - if timers_changed: - tracker.save_timers_with_reload(entries, "shifting timers after deleting a workspace") - if shrink: - workspaces.set_workspace_count(new_count) save_config(replace(config, workspaces=saved_list)) - # Rename after any shrink so the workspace-names array matches the - # (possibly reduced) set of live GNOME workspaces. + config_saved = True workspaces.rename_all_workspaces(rename_names) + names_changed = True + if shrink: + workspaces.set_workspace_count(new_count) + count_changed = True + # Remapping is simultaneous: when moves exist, the deleted slot is + # overwritten by the first source and the final source is removed. + # A standalone remove is only needed when there is nothing to shift. + if moves: + et_extension.remap_workspaces(moves) + else: + et_extension.remove_workspace(index) + except (ConfigError, WorkspaceError, EtExtensionError) as exc: + rollback_errors: list[str] = [] + if count_changed: + try: + workspaces.set_workspace_count(count) + except WorkspaceError as rollback_error: + rollback_errors.append(f"workspace-count restore failed: {rollback_error}") + if names_changed: + try: + workspaces.rename_all_workspaces(original_names) + except WorkspaceError as rollback_error: + rollback_errors.append(f"workspace-name restore failed: {rollback_error}") + if config_saved: + try: + save_config(config) + except ConfigError as rollback_error: + rollback_errors.append(f"config restore failed: {rollback_error}") + raise _error_with_rollback( + WsDeleteError, + exc, + rollback_errors, + extension_state_uncertain=isinstance(exc, EtExtensionError), + ) from exc + + try: workspaces.switch_to_workspace(min(index, new_count - 1)) - except (ConfigError, WorkspaceError, TrackerError) as exc: - raise WsDeleteError(str(exc)) from exc + except WorkspaceError as exc: + raise WsDeleteError( + f"workspace was deleted, but switching to the surviving workspace failed: {exc}" + ) from exc return WsDeleteResult(workspace_index=index, remaining_workspaces=new_count) @@ -261,28 +291,34 @@ def prepare_organize( def list_organize_candidates( - workspaces_list: list[WorkspaceConfigEntry], slots: list[int], entries: list[TimerEntry] + workspaces_list: list[WorkspaceConfigEntry], slots: list[int] ) -> list[OrganizeCandidate]: - """Return one `OrganizeCandidate` per slot in `slots`, in the given order.""" - return [ - OrganizeCandidate( - slot=slot, entry=workspaces_list[slot], timer=find_timer_for_workspace(entries, slot) + """Return one `OrganizeCandidate` per slot in `slots`, in the given order. + + Fetches each slot's counter directly from the extension. A free slot that + has never been prepared has no counter and is represented by `None`; + other extension failures still propagate. + """ + candidates = [] + for slot in slots: + try: + counter = et_extension.get_workspace_counter(slot) + except WorkspaceCounterNotFoundError: + counter = None + candidates.append( + OrganizeCandidate(slot=slot, entry=workspaces_list[slot], counter=counter) ) - for slot in slots - ] + return candidates def format_organize_candidate_line(candidate: OrganizeCandidate) -> str: """Format one `OrganizeCandidate` as a single tab-separated editor line.""" key = jira_key_from_ref(candidate.entry.ref) or "-" - if candidate.timer is not None: - elapsed = candidate.timer.get("timeElapsed", 0) - seconds = elapsed if isinstance(elapsed, (int, float)) else 0 - running = " (running)" if candidate.timer.get("running") else "" - timer_desc = f"{tracker.format_duration(seconds)}{running}" - else: - timer_desc = "no timer" - return f"{candidate.slot + 1}\t{candidate.entry.name}\t{key}\t{timer_desc}" + if candidate.counter is None: + return f"{candidate.slot + 1}\t{candidate.entry.name}\t{key}\tno counter" + running = " (running)" if candidate.counter.running else "" + counter_desc = f"{duration.format_duration(candidate.counter.elapsed_seconds)}{running}" + return f"{candidate.slot + 1}\t{candidate.entry.name}\t{key}\t{counter_desc}" def build_organize_editor_content(candidates: list[OrganizeCandidate]) -> str: @@ -298,7 +334,7 @@ def build_organize_editor_content(candidates: list[OrganizeCandidate]) -> str: "# Do not add, remove, or duplicate lines -- only reorder them.", "# Lines starting with '#' (and blank lines) are ignored.", "#", - "# slot\tname\tjira\ttimer", + "# slot\tname\tjira\tcounter", ] lines = [format_organize_candidate_line(candidate) for candidate in candidates] return "\n".join(header + lines) + "\n" @@ -337,27 +373,22 @@ def parse_organize_order(lines: list[str], valid_slots: list[int]) -> list[int]: def build_organize_plan( workspaces_list: list[WorkspaceConfigEntry], - entries: list[TimerEntry], slots: list[int], new_order: list[int], ) -> list[OrganizePlanRow]: """Compute the result of permuting `slots`' contents according to `new_order`. - `new_order[i]` is the *original* slot whose entry/timer ends up at - `slots[i]`. Since this is a closed permutation over the same slot set - (unlike `shift_workspaces_left`'s partial shift), every slot is both a - source and a destination exactly once, so no entry or Tracker timer is - ever dropped or orphaned — each row just describes where its old - slot's content is going. A moved timer (its slot actually changes) has - its `workspaceId`/`name` updated to match its new slot, matching the - "ET-" convention `shift_workspaces_left` already uses; an unmoved - timer is returned unchanged. + `new_order[i]` is the *original* slot whose entry ends up at `slots[i]`. + Since this is a closed permutation over the same slot set (unlike + `shift_workspaces_left`'s partial shift), every slot is both a source + and a destination exactly once, so no entry or counter is ever dropped + or orphaned — each row just describes where its old slot's content is + going. """ if sorted(new_order) != sorted(slots): raise WsOrganizeError("new_order must be a permutation of slots") old_entries_by_slot = {slot: workspaces_list[slot] for slot in slots} - old_timers_by_slot = {slot: find_timer_for_workspace(entries, slot) for slot in slots} rows = [] for position, src_slot in enumerate(new_order): @@ -370,21 +401,7 @@ def build_organize_plan( description=source_entry.description, ) - source_timer = old_timers_by_slot[src_slot] - moved_timer: TimerEntry | None = None - if source_timer is not None: - if src_slot == dst_slot: - moved_timer = source_timer - else: - moved_timer = dict(source_timer) - moved_timer["workspaceId"] = dst_slot - moved_timer["name"] = f"ET-{dst_slot + 1}" - - rows.append( - OrganizePlanRow( - old_slot=src_slot, new_slot=dst_slot, entry=moved_entry, timer=moved_timer - ) - ) + rows.append(OrganizePlanRow(old_slot=src_slot, new_slot=dst_slot, entry=moved_entry)) return rows @@ -392,42 +409,55 @@ def build_organize_plan( def apply_organize_plan( config: EtConfig, workspaces_list: list[WorkspaceConfigEntry], - entries: list[TimerEntry], plan: list[OrganizePlanRow], ) -> None: - """Persist a `build_organize_plan` result: config, Tracker timers, GNOME names. - - Rebuilds `workspaces_list` slot-by-slot from `plan`, and rebuilds - `entries` by dropping every timer bound to an affected slot and - re-adding each row's (possibly re-indexed) timer — safe because `plan` - is a closed permutation of the same slot set, so every affected slot - gets exactly one timer back. Raises `WsOrganizeError` wrapping any - `ConfigError`/`WorkspaceError`/`TrackerError`. + """Persist a `build_organize_plan` result: config, extension counters, GNOME names. + + Rebuilds `workspaces_list` slot-by-slot from `plan`, and relocates every + moved slot's counter with a single atomic `remap_workspaces` call — safe + because `plan` is a closed permutation of the same slot set, so nothing + is ever freed. Raises `WsOrganizeError` wrapping any + `ConfigError`/`WorkspaceError`/`EtExtensionError`. """ new_workspaces_list = list(workspaces_list) for row in plan: new_workspaces_list[row.new_slot] = row.entry - affected_slots = {row.new_slot for row in plan} - new_entries = [entry for entry in entries if entry.get("workspaceId") not in affected_slots] - for row in plan: - if row.timer is not None: - new_entries.append(row.timer) - - timers_changed = any(row.old_slot != row.new_slot and row.timer is not None for row in plan) + moves = [(row.old_slot, row.new_slot) for row in plan if row.old_slot != row.new_slot] saved_list = list(new_workspaces_list) _trim_trailing_default_entries(saved_list) rename_names = [item.name for item in new_workspaces_list] + original_names = [item.name for item in workspaces_list] + config_saved = False + names_changed = False try: - if timers_changed: - tracker.save_timers_with_reload(new_entries, "reorganizing workspaces") save_config(replace(config, workspaces=saved_list)) + config_saved = True workspaces.rename_all_workspaces(rename_names) - except (ConfigError, WorkspaceError, TrackerError) as exc: - raise WsOrganizeError(str(exc)) from exc + names_changed = True + if moves: + et_extension.remap_workspaces(moves) + except (ConfigError, WorkspaceError, EtExtensionError) as exc: + rollback_errors: list[str] = [] + if names_changed: + try: + workspaces.rename_all_workspaces(original_names) + except WorkspaceError as rollback_error: + rollback_errors.append(f"workspace-name restore failed: {rollback_error}") + if config_saved: + try: + save_config(config) + except ConfigError as rollback_error: + rollback_errors.append(f"config restore failed: {rollback_error}") + raise _error_with_rollback( + WsOrganizeError, + exc, + rollback_errors, + extension_state_uncertain=isinstance(exc, EtExtensionError), + ) from exc def open_in_editor(content: str) -> str: diff --git a/tests/gnome-extension/test-extension.js b/tests/gnome-extension/test-extension.js new file mode 100644 index 0000000..91e272b --- /dev/null +++ b/tests/gnome-extension/test-extension.js @@ -0,0 +1,207 @@ +// SPDX-FileCopyrightText: 2026 Sébastien Georget +// SPDX-License-Identifier: GPL-3.0-or-later + +import {buildDisplayText, formatDuration} from '../../gnome-extension/et@seb4stien.github.com/lib/display.js'; +import { + WorkspaceNotFoundError, + WorkspaceStore, + WorkspaceValidationError, +} from '../../gnome-extension/et@seb4stien.github.com/lib/workspaceStore.js'; + +class FakeSettings { + constructor(workspaces = '{}', booleans = {}) { + this._workspaces = workspaces; + this._booleans = booleans; + } + + get_string(key) { + if (key !== 'workspaces') + throw new Error(`unexpected string setting: ${key}`); + return this._workspaces; + } + + set_string(key, value) { + if (key !== 'workspaces') + throw new Error(`unexpected string setting: ${key}`); + this._workspaces = value; + } + + get_boolean(key) { + return this._booleans[key] ?? false; + } +} + +function fail(message) { + throw new Error(message); +} + +function assertEqual(actual, expected, message = '') { + if (actual !== expected) + fail(`${message} expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`); +} + +function assertDeepEqual(actual, expected, message = '') { + assertEqual(JSON.stringify(actual), JSON.stringify(expected), message); +} + +function assertThrows(callback, ErrorType, messagePart) { + try { + callback(); + } catch (error) { + if (!(error instanceof ErrorType)) + fail(`expected ${ErrorType.name}, got ${error.constructor.name}: ${error.message}`); + if (messagePart && !error.message.includes(messagePart)) + fail(`expected error containing ${JSON.stringify(messagePart)}, got ${error.message}`); + return; + } + fail(`expected ${ErrorType.name} to be thrown`); +} + +const tests = []; + +function test(name, callback) { + tests.push([name, callback]); +} + +test('formatDuration formats minutes, hours, and negative input', () => { + assertEqual(formatDuration(0), '0m'); + assertEqual(formatDuration(3599), '59m'); + assertEqual(formatDuration(3600), '1h 00m'); + assertEqual(formatDuration(7265), '2h 01m'); + assertEqual(formatDuration(-20), '0m'); +}); + +test('buildDisplayText respects every display toggle', () => { + const entry = {label: 'Task', estimateSeconds: 7200}; + assertEqual(buildDisplayText(null, 60, new FakeSettings()), ''); + assertEqual(buildDisplayText(entry, 60, new FakeSettings()), ''); + assertEqual(buildDisplayText(entry, 60, new FakeSettings('{}', { + 'show-label': true, + 'show-estimate': true, + 'show-counter': true, + })), 'Task · 2h 00m · 1m'); + assertEqual(buildDisplayText( + {label: '', estimateSeconds: 0}, + 60, + new FakeSettings('{}', { + 'show-label': true, + 'show-estimate': true, + 'show-counter': true, + }), + ), '1m'); +}); + +test('WorkspaceStore sanitizes persisted state', () => { + const settings = new FakeSettings(JSON.stringify({ + 0: {label: 'Task', estimateSeconds: 20, elapsedSeconds: 10}, + 1: {label: 3, estimateSeconds: -5, elapsedSeconds: -2}, + invalid: {label: 'ignored'}, + '-1': {label: 'ignored'}, + })); + const store = new WorkspaceStore(settings); + assertDeepEqual(store.indices(), [0, 1]); + assertDeepEqual(store.get(0), { + label: 'Task', + estimateSeconds: 20, + elapsedSeconds: 10, + runningSinceUs: null, + }); + assertDeepEqual(store.get(1), { + label: '', + estimateSeconds: 0, + elapsedSeconds: 0, + runningSinceUs: null, + }); +}); + +test('WorkspaceStore tolerates corrupt persisted JSON', () => { + const store = new WorkspaceStore(new FakeSettings('{broken')); + assertDeepEqual(store.indices(), []); +}); + +test('WorkspaceStore mutators validate and persist entries', () => { + const settings = new FakeSettings(); + const store = new WorkspaceStore(settings); + store.prepare(1, 'Task', 90); + store.setMetadata(1, 'Renamed', 120); + store.save(); + assertDeepEqual(JSON.parse(settings._workspaces), { + 1: {label: 'Renamed', estimateSeconds: 120, elapsedSeconds: 0}, + }); + store.resetElapsed(1); + assertEqual(store.remove(1), true); + assertEqual(store.remove(1), false); + assertThrows(() => store.prepare(-1, 'Task', 0), WorkspaceValidationError, 'index'); + assertThrows(() => store.prepare(0, 3, 0), WorkspaceValidationError, 'label'); + assertThrows(() => store.prepare(0, 'Task', -1), WorkspaceValidationError, 'estimate'); + assertThrows(() => store.get(0.5), WorkspaceValidationError, 'index'); + assertThrows(() => store.liveElapsedSeconds(0), WorkspaceNotFoundError, 'no prepared'); +}); + +test('WorkspaceStore start, stop, checkpoint, and reload use monotonic time', () => { + let nowUs = 1_000_000; + const settings = new FakeSettings(); + const store = new WorkspaceStore(settings, () => nowUs); + store.prepare(0, 'Task', 0); + store.start(0); + assertEqual(store.isRunning(0), true); + nowUs = 3_500_000; + assertEqual(store.liveElapsedSeconds(0), 2.5); + store.checkpoint(); + store.save(); + nowUs = 5_000_000; + assertEqual(store.liveElapsedSeconds(0), 4); + store.stop(0); + assertEqual(store.liveElapsedSeconds(0), 4); + + const reloaded = new WorkspaceStore(settings, () => nowUs); + assertEqual(reloaded.liveElapsedSeconds(0), 2.5); + assertEqual(reloaded.isRunning(0), false); +}); + +test('WorkspaceStore ignores negative monotonic deltas', () => { + let nowUs = 5_000_000; + const store = new WorkspaceStore(new FakeSettings(), () => nowUs); + store.prepare(0, 'Task', 0); + store.start(0); + nowUs = 4_000_000; + assertEqual(store.liveElapsedSeconds(0), 0); +}); + +test('WorkspaceStore remaps entries simultaneously', () => { + const store = new WorkspaceStore(new FakeSettings()); + store.prepare(0, 'Zero', 0); + store.prepare(1, 'One', 0); + store.prepare(3, 'Three', 0); + store.remap([[0, 1], [1, 2], [2, 3]]); + assertEqual(store.has(0), false); + assertEqual(store.get(1).label, 'Zero'); + assertEqual(store.get(2).label, 'One'); + assertEqual(store.has(3), false); + assertThrows( + () => store.remap([[0, 1], [0, 2]]), + WorkspaceValidationError, + 'duplicate source', + ); + assertThrows( + () => store.remap([[0, 2], [1, 2]]), + WorkspaceValidationError, + 'duplicate destination', + ); +}); + +let failures = 0; +for (const [name, callback] of tests) { + try { + callback(); + print(`PASS: ${name}`); + } catch (error) { + failures++; + printerr(`FAIL: ${name}\n${error.stack ?? error}`); + } +} + +if (failures > 0) + throw new Error(`${failures} GJS test(s) failed`); + +print(`All ${tests.length} GJS tests passed.`); diff --git a/tests/integration/test_end_to_end.py b/tests/integration/test_end_to_end.py index 2b6bbd7..e5e32d5 100644 --- a/tests/integration/test_end_to_end.py +++ b/tests/integration/test_end_to_end.py @@ -1,20 +1,21 @@ """End-to-end integration tests for the `et` CLI. Unlike the unit tests (which mock each module's public helpers), these -exercise the *whole* stack — Typer command -> tracker/workspaces -> -gsettings/gnome-extensions -> subprocess — and only replace the two real -process boundaries: `subprocess.run` (the actual `gsettings`/`wmctrl`/ -`gnome-extensions` calls) and `shutil.which` (binary discovery). - -`FakeSystem` stands in for the machine: it keeps GSettings keys in an -in-memory dict and answers the handful of external commands et shells out -to, so a command's full read/modify/write cycle can be asserted against -persisted state. +exercise the *whole* stack — Typer command -> workspaces/et_extension -> +gsettings/gdbus -> subprocess — and only replace the two real process +boundaries: `subprocess.run` (the actual `gsettings`/`wmctrl`/`gdbus` calls) +and `shutil.which` (binary discovery). + +`FakeSystem` stands in for the machine: it keeps GSettings keys and the et +GNOME Shell extension's per-workspace counters in memory, and answers the +handful of external commands et shells out to, so a command's full +read/modify/write cycle can be asserted against persisted state. """ from __future__ import annotations import ast +import re import subprocess from unittest.mock import patch @@ -26,20 +27,30 @@ runner = CliRunner() WORKSPACE_NAMES = ("org.gnome.desktop.wm.preferences", "workspace-names") -TRACKER_TIMERS = ("org.gnome.shell.extensions.tracker", "timers") +NUM_WORKSPACES = ("org.gnome.desktop.wm.preferences", "num-workspaces") DYNAMIC_WORKSPACES = ("org.gnome.mutter", "dynamic-workspaces") -TRACKER_UUID = "tracker@aliakseiz.github.com" class FakeSystem: - """In-memory stand-in for gsettings/wmctrl/gnome-extensions.""" + """In-memory stand-in for gsettings/wmctrl/the et GNOME Shell extension's D-Bus service.""" def __init__(self, active_workspace: int = 0) -> None: self.active_workspace = active_workspace - self.enabled_extensions = {TRACKER_UUID} + # workspace index -> {"elapsed": int, "running": bool}, mirroring the + # et extension's own per-workspace counter state. A missing entry + # means "never prepared", which reads the same as a fresh counter + # (elapsed=0, running=False) -- the "always present" model described + # in et.et_extension. + self.counters: dict[int, dict[str, object]] = {} + # workspace index -> {"label": str, "estimateSeconds": int}, the + # other half of a prepared workspace's entry (kept in a separate + # dict here purely for fake-implementation convenience; the real + # extension stores both together). Follows the same + # missing-means-never-prepared convention as `counters`. + self.metadata: dict[int, dict[str, object]] = {} self.gsettings: dict[tuple[str, str], str] = { WORKSPACE_NAMES: "@as []", - TRACKER_TIMERS: "@as []", + NUM_WORKSPACES: "1", DYNAMIC_WORKSPACES: "false", } @@ -49,8 +60,8 @@ def run(self, args, capture_output=True, text=True, check=False, env=None): return self._run_gsettings(args) if program == "wmctrl": return self._run_wmctrl() - if program == "gnome-extensions": - return self._run_gnome_extensions(args) + if program == "gdbus": + return self._run_gdbus(args) raise AssertionError(f"unexpected command: {args!r}") def _completed(self, stdout: str = "", stderr: str = "", returncode: int = 0): @@ -75,16 +86,108 @@ def _run_wmctrl(self): lines.append(f"{index} {marker} DG: 1920x1080 VP: 0,0 WA: 0,0 1920x1080 W{index}") return self._completed(stdout="\n".join(lines) + "\n") - def _run_gnome_extensions(self, args): - action = args[1] - if action == "list": - return self._completed(stdout="\n".join(sorted(self.enabled_extensions)) + "\n") - uuid = args[2] - if action == "disable": - self.enabled_extensions.discard(uuid) - elif action == "enable": - self.enabled_extensions.add(uuid) - return self._completed() + @staticmethod + def _parse_uint(token: str) -> int: + return int(token.split()[-1]) + + @staticmethod + def _parse_gvariant_string(token: str) -> str: + match = re.match(r"'((?:[^'\\]|\\.)*)'", token) + assert match is not None, f"unparseable gvariant string: {token!r}" + return re.sub(r"\\(.)", r"\1", match.group(1)) + + @staticmethod + def _format_gvariant_string(value: str) -> str: + return "'" + value.replace("\\", "\\\\").replace("'", "\\'") + "'" + + @staticmethod + def _parse_moves(token: str) -> list[tuple[int, int]]: + pairs = re.findall(r"uint32\s+(\d+)\s*,\s*uint32\s+(\d+)", token) + return [(int(old), int(new)) for old, new in pairs] + + def _counter(self, index: int) -> dict[str, object]: + return self.counters.get(index, {"elapsed": 0, "running": False}) + + def _run_gdbus(self, args): + # Generically dispatches every `org.gnome.Shell.Extensions.Et` + # method used by et.et_extension, keeping counters state in memory + # instead of hardcoding a single reply the way the old fake did. + assert args[1] == "call" + method = args[8].rsplit(".", 1)[-1] + call_args = args[9:] + + if method == "GetActiveWorkspaceIndex": + return self._completed(stdout=f"(uint32 {self.active_workspace},)\n") + + if method == "PrepareWorkspace": + index = self._parse_uint(call_args[0]) + self.counters[index] = {"elapsed": 0, "running": False} + self.metadata[index] = { + "label": self._parse_gvariant_string(call_args[1]), + "estimateSeconds": self._parse_uint(call_args[2]), + } + return self._completed(stdout="()\n") + + if method == "GetWorkspaceCounter": + index = self._parse_uint(call_args[0]) + counter = self._counter(index) + running = "true" if counter["running"] else "false" + return self._completed(stdout=f"(uint64 {counter['elapsed']}, {running})\n") + + if method == "GetWorkspaceMetadata": + index = self._parse_uint(call_args[0]) + if index not in self.metadata: + return self._completed( + returncode=1, + stderr=( + "Error: GDBus.Error:org.gnome.Shell.Extensions.Et.Error.NotFound: " + f"workspace {index} has no prepared counter" + ), + ) + entry = self.metadata[index] + label = self._format_gvariant_string(str(entry["label"])) + return self._completed(stdout=f"({label}, uint64 {entry['estimateSeconds']})\n") + + if method == "ResetWorkspaceCounter": + index = self._parse_uint(call_args[0]) + self.counters[index] = {"elapsed": 0, "running": False} + return self._completed(stdout="()\n") + + if method == "RemoveWorkspace": + index = self._parse_uint(call_args[0]) + self.counters.pop(index, None) + self.metadata.pop(index, None) + return self._completed(stdout="()\n") + + if method == "RemapWorkspaces": + moves = self._parse_moves(call_args[0]) + # Snapshot every source before writing any destination, so a + # chain of moves (e.g. a left-shift) reads pre-move data only -- + # mirroring the extension's own atomic semantics. + snapshot = {old: self._counter(old) for old, _new in moves} + metadata_snapshot = {old: self.metadata.get(old) for old, _new in moves} + targets = {new for _old, new in moves} + for old, new in moves: + self.counters[new] = snapshot[old] + if metadata_snapshot[old] is not None: + self.metadata[new] = metadata_snapshot[old] + else: + self.metadata.pop(new, None) + for old, _new in moves: + if old not in targets: + self.counters.pop(old, None) + self.metadata.pop(old, None) + return self._completed(stdout="()\n") + + if method == "SetWorkspaceMetadata": + index = self._parse_uint(call_args[0]) + self.metadata[index] = { + "label": self._parse_gvariant_string(call_args[1]), + "estimateSeconds": self._parse_uint(call_args[2]), + } + return self._completed(stdout="()\n") + + raise AssertionError(f"unexpected et extension method: {method}") def read_string_array(self, schema: str, key: str) -> list[str]: raw = self.gsettings[(schema, key)] @@ -96,6 +199,21 @@ def read_string_array(self, schema: str, key: str) -> list[str]: @pytest.fixture def system(tmp_path, monkeypatch): monkeypatch.setenv("ET_CONFIG_DIR", str(tmp_path)) + monkeypatch.delenv("WAYLAND_DISPLAY", raising=False) + monkeypatch.setenv("XDG_SESSION_TYPE", "x11") + fake = FakeSystem() + with ( + patch("shutil.which", return_value="/usr/bin/fake"), + patch("subprocess.run", side_effect=fake.run), + ): + yield fake + + +@pytest.fixture +def wayland_system(tmp_path, monkeypatch): + """Same as `system`, but simulating a Wayland session (uses gdbus, not wmctrl).""" + monkeypatch.setenv("ET_CONFIG_DIR", str(tmp_path)) + monkeypatch.setenv("XDG_SESSION_TYPE", "wayland") fake = FakeSystem() with ( patch("shutil.which", return_value="/usr/bin/fake"), @@ -113,3 +231,117 @@ def test_ws_rename_persists_active_workspace_name(system): assert "Renamed workspace 3 to 'focus'" in result.output assert system.read_string_array(*WORKSPACE_NAMES) == ["", "", "focus"] + +def test_ws_rename_persists_active_workspace_name_on_wayland(wayland_system): + wayland_system.active_workspace = 2 + + result = runner.invoke(app, ["ws", "rename", "focus"]) + + assert result.exit_code == 0, result.output + assert "Renamed workspace 3 to 'focus'" in result.output + assert wayland_system.read_string_array(*WORKSPACE_NAMES) == ["", "", "focus"] + + +def test_ws_rename_syncs_label_of_an_already_prepared_workspace(system): + system.active_workspace = 1 + system.counters[1] = {"elapsed": 900, "running": False} + system.metadata[1] = {"label": "Old label", "estimateSeconds": 3600} + + result = runner.invoke(app, ["ws", "rename", "New name"]) + + assert result.exit_code == 0, result.output + assert "Renamed workspace 2 to 'New name'" in result.output + # Label follows the rename, estimate is preserved, counter is untouched. + assert system.metadata[1] == {"label": "New name", "estimateSeconds": 3600} + assert system.counters[1] == {"elapsed": 900, "running": False} + + +def test_ws_rename_all_syncs_label_of_already_prepared_workspaces(system, tmp_path): + _write_config( + tmp_path, + " - name: One\n - name: Two\n", + ) + system.metadata[1] = {"label": "Stale label", "estimateSeconds": 1800} + system.counters[1] = {"elapsed": 42, "running": False} + + result = runner.invoke(app, ["ws", "rename", "--all"]) + + assert result.exit_code == 0, result.output + assert system.metadata[1] == {"label": "Two", "estimateSeconds": 1800} + assert system.counters[1] == {"elapsed": 42, "running": False} + # Workspace 0 was never prepared -- renaming it must not start tracking. + assert 0 not in system.metadata + assert 0 not in system.counters + + +def test_ws_rename_does_not_error_on_an_unprepared_workspace(system): + system.active_workspace = 0 + + result = runner.invoke(app, ["ws", "rename", "static workspace"]) + + assert result.exit_code == 0, result.output + assert "Renamed workspace 1 to 'static workspace'" in result.output + assert 0 not in system.metadata + assert 0 not in system.counters + + +def _write_config(tmp_path, workspaces_yaml: str) -> None: + (tmp_path / "config.yaml").write_text(f"workspaces:\n{workspaces_yaml}") + + +def test_info_shows_extension_counter_elapsed_time(system, tmp_path): + _write_config( + tmp_path, + " - name: Fix login timeout\n ref: jira:PROJ-1\n description: Fix it\n", + ) + system.active_workspace = 0 + system.counters[0] = {"elapsed": 5445, "running": True} + + result = runner.invoke(app, ["info"]) + + assert result.exit_code == 0, result.output + assert "Workspace 1: Fix login timeout" in result.output + assert "Time spent: 1h 30m 45s (running)" in result.output + + +def test_ws_delete_shifts_remaining_workspace_counter_into_freed_slot(system, tmp_path): + _write_config( + tmp_path, + " - name: ISD-A\n ref: jira:ISD-A\n" + " - name: ET-2\n" + " - name: ISD-C\n ref: jira:ISD-C\n", + ) + system.gsettings[WORKSPACE_NAMES] = repr(["ISD-A", "ET-2", "ISD-C"]) + system.gsettings[NUM_WORKSPACES] = "3" + system.active_workspace = 1 + system.counters[0] = {"elapsed": 100, "running": False} + system.counters[1] = {"elapsed": 50, "running": False} + system.counters[2] = {"elapsed": 200, "running": True} + + result = runner.invoke(app, ["ws", "delete"]) + + assert result.exit_code == 0, result.output + assert "Deleted workspace 2 (now managing 2 workspaces)" in result.output + assert system.gsettings[NUM_WORKSPACES] == "2" + # `rename_all_workspaces` only overwrites the renamed prefix, so the + # stale trailing entry from the now-unused slot 2 is left in place. + assert system.read_string_array(*WORKSPACE_NAMES) == ["ISD-A", "ISD-C", "ISD-C"] + # Slot 1's own (unrelated) counter is discarded, and slot 2's counter + # follows its workspace into slot 1 via a single atomic remap. + assert system.counters[0] == {"elapsed": 100, "running": False} + assert system.counters[1] == {"elapsed": 200, "running": True} + assert 2 not in system.counters + + +def test_ws_delete_rejects_linked_workspace_without_force(system, tmp_path): + _write_config(tmp_path, " - name: ISD-A\n ref: jira:ISD-A\n - name: ET-2\n") + system.gsettings[WORKSPACE_NAMES] = repr(["ISD-A", "ET-2"]) + system.gsettings[NUM_WORKSPACES] = "2" + system.active_workspace = 0 + + result = runner.invoke(app, ["ws", "delete"]) + + assert result.exit_code == 1 + assert "linked to ISD-A" in result.output + + diff --git a/tests/test_cli.py b/tests/test_cli.py index dfb9f1f..a870c47 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,5 +1,5 @@ """Tests for the `et` CLI, focused on presentation behaviour not covered by unit tests -of the underlying config/tracker/workspace/jira modules.""" +of the underlying config/et_extension/workspace/jira modules.""" from __future__ import annotations @@ -11,11 +11,15 @@ from typer.testing import CliRunner from et.cli import _hyperlink, app -from et.config import EtConfig, JiraConfig, WorkspaceConfigEntry +from et.config import ConfigError, EtConfig, JiraConfig, WorkspaceConfigEntry +from et.et_extension import ( + EtExtensionError, + WorkspaceCounter, + WorkspaceCounterNotFoundError, +) from et.jira import JiraIssue, JiraSprint from et.jira_time import AllLogTimeResult, JiraLogTimeError, LogTimeResult, SkippedWorkspace from et.task import TaskCompleteResult, TaskCreateResult, TaskError -from et.tracker import TrackerError from et.workspaces import WorkspaceError runner = CliRunner() @@ -56,11 +60,11 @@ def _config(workspaces: list[WorkspaceConfigEntry] | None = None) -> EtConfig: ) -@patch("et.cli.load_timers") +@patch("et.cli.get_workspace_counter") @patch("et.cli.get_active_workspace_index") @patch("et.cli.load_config") def test_bare_invocation_shows_jira_info_and_time_spent_for_non_static_workspace( - mock_load_config, mock_index, mock_load_timers + mock_load_config, mock_index, mock_get_counter ): mock_index.return_value = 1 mock_load_config.return_value = _config( @@ -73,7 +77,7 @@ def test_bare_invocation_shows_jira_info_and_time_spent_for_non_static_workspace ), ] ) - mock_load_timers.return_value = [] + mock_get_counter.return_value = WorkspaceCounter(0, False) with patch("sys.stdout.isatty", return_value=False): result = runner.invoke(app, []) @@ -83,15 +87,15 @@ def test_bare_invocation_shows_jira_info_and_time_spent_for_non_static_workspace assert "Fix login timeout on mobile clients" in result.stdout assert "jira:PROJ-1" in result.stdout assert "https://example.atlassian.net/browse/PROJ-1" not in result.stdout # only in tty link - assert "No tracker for this workspace." in result.stdout + assert "Time spent: 0h 0m 0s" in result.stdout @patch("et.cli._hyperlink", side_effect=lambda text, url: f"<{url}|{text}>") -@patch("et.cli.load_timers", return_value=[]) +@patch("et.cli.get_workspace_counter", return_value=WorkspaceCounter(0, False)) @patch("et.cli.get_active_workspace_index") @patch("et.cli.load_config") def test_bare_invocation_uses_hyperlink_helper_with_jira_browse_url( - mock_load_config, mock_index, _mock_load_timers, mock_hyperlink + mock_load_config, mock_index, _mock_get_counter, mock_hyperlink ): mock_index.return_value = 0 mock_load_config.return_value = _config( @@ -112,11 +116,11 @@ def test_bare_invocation_uses_hyperlink_helper_with_jira_browse_url( assert "" in result.stdout -@patch("et.cli.load_timers", return_value=[]) +@patch("et.cli.get_workspace_counter", return_value=WorkspaceCounter(0, False)) @patch("et.cli.get_active_workspace_index") @patch("et.cli.load_config") def test_bare_invocation_reports_no_jira_issue_when_workspace_has_no_ref( - mock_load_config, mock_index, _mock_load_timers + mock_load_config, mock_index, _mock_get_counter ): mock_index.return_value = 0 mock_load_config.return_value = _config([WorkspaceConfigEntry(name="misc")]) @@ -127,6 +131,24 @@ def test_bare_invocation_reports_no_jira_issue_when_workspace_has_no_ref( assert "No Jira issue linked to this workspace." in result.stdout +@patch( + "et.cli.get_workspace_counter", + side_effect=WorkspaceCounterNotFoundError("not prepared"), +) +@patch("et.cli.get_active_workspace_index", return_value=0) +@patch("et.cli.load_config") +def test_bare_invocation_allows_free_workspace_without_counter( + mock_load_config, _mock_index, _mock_get_counter +): + mock_load_config.return_value = _config([WorkspaceConfigEntry(name="ET-1")]) + + result = runner.invoke(app, []) + + assert result.exit_code == 0 + assert "No Jira issue linked to this workspace." in result.stdout + assert "No counter for this workspace." in result.stdout + + @patch("et.cli.get_active_workspace_index") @patch("et.cli.load_config") def test_bare_invocation_shows_help_when_workspace_is_static(mock_load_config, mock_index): @@ -165,24 +187,24 @@ def test_bare_invocation_shows_help_when_active_workspace_lookup_fails(mock_inde assert "Interact with GNOME/Ubuntu workspaces." in result.stdout -@patch("et.cli.load_timers", side_effect=TrackerError("no schema")) +@patch("et.cli.get_workspace_counter", side_effect=EtExtensionError("extension down")) @patch("et.cli.get_active_workspace_index") @patch("et.cli.load_config") -def test_bare_invocation_reports_tracker_error(mock_load_config, mock_index, _mock_load_timers): +def test_bare_invocation_reports_extension_error(mock_load_config, mock_index, _mock_get_counter): mock_index.return_value = 0 mock_load_config.return_value = _config([WorkspaceConfigEntry(name="misc")]) result = runner.invoke(app, []) assert result.exit_code == 1 - assert "Error: no schema" in result.output + assert "Error: extension down" in result.output -@patch("et.cli.load_timers", return_value=[]) +@patch("et.cli.get_workspace_counter", return_value=WorkspaceCounter(0, False)) @patch("et.cli.get_active_workspace_index") @patch("et.cli.load_config") def test_info_command_shows_jira_info_and_time_spent_for_non_static_workspace( - mock_load_config, mock_index, _mock_load_timers + mock_load_config, mock_index, _mock_get_counter ): mock_index.return_value = 0 mock_load_config.return_value = _config( @@ -216,6 +238,81 @@ def test_info_command_shows_full_app_help_when_workspace_is_static(mock_load_con assert "Interact with GNOME/Ubuntu workspaces." in result.stdout +def test_config_command_is_registered(): + result = runner.invoke(app, ["config", "--help"]) + + assert result.exit_code == 0 + assert "config.yaml" in result.stdout + + +@patch("et.cli.save_config") +@patch("et.cli.run_config_wizard") +@patch("et.cli.load_config", side_effect=ConfigError("config file not found: ...")) +def test_config_command_writes_result_when_confirmed(mock_load_config, mock_wizard, mock_save): + mock_wizard.return_value = _config() + + result = runner.invoke(app, ["config"]) + + assert result.exit_code == 0 + assert "Wrote" in result.stdout + mock_wizard.assert_called_once() + # `existing` is `None` since the config file doesn't exist yet. + assert mock_wizard.call_args.args[1] is None + mock_save.assert_called_once_with(_config()) + + +@patch("et.cli.save_config") +@patch("et.cli.run_config_wizard") +@patch("et.cli.load_config") +def test_config_command_passes_existing_config_to_wizard(mock_load_config, mock_wizard, mock_save): + existing = _config([WorkspaceConfigEntry(name="mails")]) + mock_load_config.return_value = existing + mock_wizard.return_value = existing + + result = runner.invoke(app, ["config"]) + + assert result.exit_code == 0 + assert mock_wizard.call_args.args[1] is existing + mock_save.assert_called_once_with(existing) + + +@patch("et.cli.save_config") +@patch("et.cli.run_config_wizard") +@patch("et.cli.load_config", side_effect=ConfigError("config file not found: ...")) +def test_config_command_reports_cancellation_without_saving( + mock_load_config, mock_wizard, mock_save +): + mock_wizard.return_value = None + + result = runner.invoke(app, ["config"]) + + assert result.exit_code == 0 + assert "Cancelled." in result.stdout + mock_save.assert_not_called() + + +@patch("et.cli.run_config_wizard") +@patch("et.cli.load_config", side_effect=ConfigError("could not parse config file ...")) +def test_config_command_reports_load_config_error(mock_load_config, mock_wizard): + result = runner.invoke(app, ["config"]) + + assert result.exit_code == 1 + assert "Error: could not parse config file" in result.output + mock_wizard.assert_not_called() + + +@patch("et.cli.save_config", side_effect=ConfigError("could not write config file ...")) +@patch("et.cli.run_config_wizard") +@patch("et.cli.load_config", side_effect=ConfigError("config file not found: ...")) +def test_config_command_reports_save_config_error(mock_load_config, mock_wizard, mock_save): + mock_wizard.return_value = _config() + + result = runner.invoke(app, ["config"]) + + assert result.exit_code == 1 + assert "Error: could not write config file" in result.output + + @patch("et.cli.delete_active_workspace") def test_ws_delete_reports_summary(mock_delete): from et.ws import WsDeleteResult @@ -265,10 +362,9 @@ def _organize_config(): ) -@patch("et.cli.load_timers", return_value=[]) @patch("et.cli.get_workspace_count", return_value=1) @patch("et.cli.load_config") -def test_ws_organize_reports_nothing_to_organize(mock_load_config, _mock_count, _mock_timers): +def test_ws_organize_reports_nothing_to_organize(mock_load_config, _mock_count): mock_load_config.return_value = _config([WorkspaceConfigEntry(name="ISD-A")]) result = runner.invoke(app, ["ws", "organize"]) @@ -277,11 +373,13 @@ def test_ws_organize_reports_nothing_to_organize(mock_load_config, _mock_count, assert "Nothing to organize" in result.stdout +@patch("et.ws.et_extension.get_workspace_counter", return_value=WorkspaceCounter(0, False)) @patch("et.cli.open_in_editor") -@patch("et.cli.load_timers", return_value=[]) @patch("et.cli.get_workspace_count", return_value=2) @patch("et.cli.load_config") -def test_ws_organize_reports_no_changes(mock_load_config, _mock_count, _mock_timers, mock_editor): +def test_ws_organize_reports_no_changes( + mock_load_config, _mock_count, mock_editor, _mock_counter +): mock_load_config.return_value = _organize_config() mock_editor.return_value = "1\n2\n" @@ -291,13 +389,13 @@ def test_ws_organize_reports_no_changes(mock_load_config, _mock_count, _mock_tim assert "No changes." in result.stdout +@patch("et.ws.et_extension.get_workspace_counter", return_value=WorkspaceCounter(0, False)) @patch("et.cli.apply_organize_plan") @patch("et.cli.open_in_editor") -@patch("et.cli.load_timers", return_value=[]) @patch("et.cli.get_workspace_count", return_value=2) @patch("et.cli.load_config") def test_ws_organize_shows_summary_and_aborts_on_decline( - mock_load_config, _mock_count, _mock_timers, mock_editor, mock_apply + mock_load_config, _mock_count, mock_editor, mock_apply, _mock_counter ): mock_load_config.return_value = _organize_config() mock_editor.return_value = "2\n1\n" @@ -312,13 +410,41 @@ def test_ws_organize_shows_summary_and_aborts_on_decline( mock_apply.assert_not_called() +@patch( + "et.ws.et_extension.get_workspace_counter", + side_effect=[ + WorkspaceCounter(120, False), + WorkspaceCounterNotFoundError("not prepared"), + ], +) +@patch("et.cli.apply_organize_plan") +@patch("et.cli.open_in_editor", return_value="2\n1\n") +@patch("et.cli.get_workspace_count", return_value=2) +@patch("et.cli.load_config") +def test_ws_organize_allows_free_workspace_without_counter( + mock_load_config, _mock_count, _mock_editor, mock_apply, _mock_counter +): + mock_load_config.return_value = _config( + [ + WorkspaceConfigEntry(name="ISD-A", ref="jira:ISD-A"), + WorkspaceConfigEntry(name="ET-2"), + ] + ) + + result = runner.invoke(app, ["ws", "organize"], input="n\n") + + assert result.exit_code == 0 + assert "no counter" in result.stdout + mock_apply.assert_not_called() + + +@patch("et.ws.et_extension.get_workspace_counter", return_value=WorkspaceCounter(0, False)) @patch("et.cli.apply_organize_plan") @patch("et.cli.open_in_editor") -@patch("et.cli.load_timers", return_value=[]) @patch("et.cli.get_workspace_count", return_value=2) @patch("et.cli.load_config") def test_ws_organize_applies_on_confirm( - mock_load_config, _mock_count, _mock_timers, mock_editor, mock_apply + mock_load_config, _mock_count, mock_editor, mock_apply, _mock_counter ): mock_load_config.return_value = _organize_config() mock_editor.return_value = "2\n1\n" @@ -328,18 +454,18 @@ def test_ws_organize_applies_on_confirm( assert result.exit_code == 0 assert "Reorganized 2 workspaces." in result.stdout mock_apply.assert_called_once() - plan = mock_apply.call_args[0][3] + plan = mock_apply.call_args[0][2] by_new_slot = {row.new_slot: row for row in plan} assert by_new_slot[0].entry.name == "ISD-B" assert by_new_slot[1].entry.name == "ISD-A" +@patch("et.ws.et_extension.get_workspace_counter", return_value=WorkspaceCounter(0, False)) @patch("et.cli.open_in_editor") -@patch("et.cli.load_timers", return_value=[]) @patch("et.cli.get_workspace_count", return_value=2) @patch("et.cli.load_config") def test_ws_organize_reports_invalid_editor_result( - mock_load_config, _mock_count, _mock_timers, mock_editor + mock_load_config, _mock_count, mock_editor, _mock_counter ): mock_load_config.return_value = _organize_config() mock_editor.return_value = "1\n1\n" @@ -350,6 +476,20 @@ def test_ws_organize_reports_invalid_editor_result( assert "Error:" in result.output +@patch("et.ws.et_extension.get_workspace_counter", side_effect=EtExtensionError("extension down")) +@patch("et.cli.get_workspace_count", return_value=2) +@patch("et.cli.load_config") +def test_ws_organize_reports_extension_error_from_list_candidates( + mock_load_config, _mock_count, _mock_counter +): + mock_load_config.return_value = _organize_config() + + result = runner.invoke(app, ["ws", "organize"]) + + assert result.exit_code == 1 + assert "Error: extension down" in result.output + + # --- static-workspace startup check ------------------------------------------ @@ -385,7 +525,7 @@ def test_root_exits_when_static_check_fails(_mock_dynamic): @patch("et.cli.create_task_from_jira") def test_jira_start_lists_issues_and_creates_from_selection(mock_create_from_jira): mock_create_from_jira.return_value = TaskCreateResult( - workspace_index=2, name="ISD-2", ref="jira:ISD-2", timer_created=True, window_moved=True + workspace_index=2, name="ISD-2", ref="jira:ISD-2", window_moved=True ) def fake_create_from_jira(select_issue, confirm_transition, confirm_grow): @@ -406,13 +546,12 @@ def fake_create_from_jira(select_issue, confirm_transition, confirm_grow): assert "Active issues not yet linked to a workspace:" in result.stdout assert "ISD-2" in result.stdout assert "Created workspace 3: 'ISD-2' (linked to ISD-2)" in result.stdout - assert "Note: could not move this terminal window" not in result.stdout @patch("et.cli.create_task_from_jira") def test_jira_start_notes_when_window_could_not_be_moved(mock_create_from_jira): mock_create_from_jira.return_value = TaskCreateResult( - workspace_index=2, name="ISD-2", ref="jira:ISD-2", timer_created=True, window_moved=False + workspace_index=2, name="ISD-2", ref="jira:ISD-2", window_moved=False ) def fake_create_from_jira(select_issue, confirm_transition, confirm_grow): @@ -430,7 +569,6 @@ def fake_create_from_jira(select_issue, confirm_transition, confirm_grow): result = runner.invoke(app, ["jira", "start"], input="1\n") assert result.exit_code == 0 - assert "Note: could not move this terminal window to the new workspace" in result.stdout @patch("et.cli.create_task_from_jira") @@ -533,10 +671,10 @@ def fake_create_from_jira(select_issue, confirm_transition, confirm_grow): @patch("et.cli.create_task_from_jira_key") def test_jira_start_with_key_creates_task_directly(mock_create_from_jira_key): mock_create_from_jira_key.return_value = TaskCreateResult( - workspace_index=2, name="ISD-2", ref="jira:ISD-2", timer_created=True, window_moved=True + workspace_index=2, name="ISD-2", ref="jira:ISD-2", window_moved=True ) - result = runner.invoke(app, ["jira", "start", "-k", "ISD-2"]) + result = runner.invoke(app, ["jira", "start", "ISD-2"]) assert result.exit_code == 0 assert "Created workspace 3: 'ISD-2' (linked to ISD-2)" in result.stdout @@ -548,7 +686,7 @@ def test_jira_start_with_key_creates_task_directly(mock_create_from_jira_key): def test_jira_start_with_key_reports_error(mock_create_from_jira_key): mock_create_from_jira_key.side_effect = TaskError("ISD-2 is already linked to workspace 1") - result = runner.invoke(app, ["jira", "start", "--key", "ISD-2"]) + result = runner.invoke(app, ["jira", "start", "ISD-2"]) assert result.exit_code == 1 assert "Error: ISD-2 is already linked to workspace 1" in result.output @@ -569,13 +707,13 @@ def fake_create_from_jira_key( issue = JiraIssue(key=issue_key, summary="Second issue", priority="High", status="To Do") captured["confirmed"] = confirm_transition(issue) return TaskCreateResult( - workspace_index=0, name="ISD-2", ref="jira:ISD-2", timer_created=True, + workspace_index=0, name="ISD-2", ref="jira:ISD-2", window_moved=True, ) mock_create_from_jira_key.side_effect = fake_create_from_jira_key - result = runner.invoke(app, ["jira", "start", "-k", "ISD-2"], input="y\n") + result = runner.invoke(app, ["jira", "start", "ISD-2"], input="y\n") assert result.exit_code == 0 assert "ISD-2 is currently 'To Do'. Move it to 'In Progress'?" in result.stdout @@ -594,13 +732,13 @@ def fake_create_from_jira_key( del warn captured["selected"] = select_sprint([JiraSprint(id="7", name="Sprint 7")]) return TaskCreateResult( - workspace_index=0, name="ISD-2", ref="jira:ISD-2", timer_created=True, + workspace_index=0, name="ISD-2", ref="jira:ISD-2", window_moved=True, ) mock_create_from_jira_key.side_effect = fake_create_from_jira_key - result = runner.invoke(app, ["jira", "start", "-k", "ISD-2"], input="y\n") + result = runner.invoke(app, ["jira", "start", "ISD-2"], input="y\n") assert result.exit_code == 0 assert "ISD-2 isn't in the current sprint 'Sprint 7'. Add it?" in result.stdout @@ -623,13 +761,13 @@ def fake_create_from_jira_key( [JiraSprint(id="7", name="Sprint 7"), JiraSprint(id="8", name="Sprint 8")] ) return TaskCreateResult( - workspace_index=0, name="ISD-2", ref="jira:ISD-2", timer_created=True, + workspace_index=0, name="ISD-2", ref="jira:ISD-2", window_moved=True, ) mock_create_from_jira_key.side_effect = fake_create_from_jira_key - result = runner.invoke(app, ["jira", "start", "-k", "ISD-2"], input="2\n") + result = runner.invoke(app, ["jira", "start", "ISD-2"], input="2\n") assert result.exit_code == 0 assert "ISD-2 isn't in any of the project's active sprints:" in result.stdout @@ -647,13 +785,13 @@ def fake_create_from_jira_key( del confirm_grow warn("no active sprint found on the project's board; skipping sprint") return TaskCreateResult( - workspace_index=0, name="ISD-2", ref="jira:ISD-2", timer_created=True, + workspace_index=0, name="ISD-2", ref="jira:ISD-2", window_moved=True, ) mock_create_from_jira_key.side_effect = fake_create_from_jira_key - result = runner.invoke(app, ["jira", "start", "-k", "ISD-2"]) + result = runner.invoke(app, ["jira", "start", "ISD-2"]) assert result.exit_code == 0 assert "Warning: no active sprint found" in result.output @@ -664,7 +802,7 @@ def fake_create_from_jira_key( @patch("et.cli.log_time_for_current_workspace") def test_jira_log_time_logs_and_reports_duration(mock_log_time, mock_load_config, mock_hyperlink): mock_log_time.return_value = LogTimeResult( - workspace_index=1, issue_key="ISD-321", seconds_logged=4320, tracker_reset=True + workspace_index=1, issue_key="ISD-321", seconds_logged=4320, counter_reset=True ) mock_load_config.return_value = _config([]) @@ -680,13 +818,36 @@ def test_jira_log_time_logs_and_reports_duration(mock_log_time, mock_load_config mock_log_time.assert_called_once_with(description="note", reset=True, issue_key=None) +@patch("et.cli.load_config") +@patch("et.cli.log_time_for_current_workspace") +def test_jira_log_time_warns_when_worklog_succeeds_but_reset_fails( + mock_log_time, mock_load_config +): + mock_log_time.return_value = LogTimeResult( + workspace_index=1, + issue_key="ISD-321", + seconds_logged=4320, + counter_reset=False, + counter_reset_error="extension unavailable", + ) + mock_load_config.return_value = _config([]) + + with patch("sys.stdout.isatty", return_value=False): + result = runner.invoke(app, ["jira", "log-time"]) + + assert result.exit_code == 0 + assert "Jira accepted the worklog" in result.output + assert "Do not log it again" in result.output + assert "ResetWorkspaceCounter uint32 1" in result.output + + @patch("et.cli.load_config") @patch("et.cli.log_manual_time_for_current_workspace") def test_jira_log_time_with_hours_argument_logs_manual_duration( mock_log_manual, mock_load_config ): mock_log_manual.return_value = LogTimeResult( - workspace_index=1, issue_key="ISD-321", seconds_logged=7200, tracker_reset=False + workspace_index=1, issue_key="ISD-321", seconds_logged=7200, counter_reset=False ) mock_load_config.return_value = _config([]) @@ -695,7 +856,7 @@ def test_jira_log_time_with_hours_argument_logs_manual_duration( assert result.exit_code == 0 assert "Logged 2h 0m 0s to jira:ISD-321 (workspace 2)" in result.stdout - assert "Reset tracker to 0" not in result.stdout + assert "Reset counter to 0" not in result.stdout mock_log_manual.assert_called_once_with(7200, description="manual", issue_key=None) @@ -710,7 +871,7 @@ def test_jira_log_time_with_invalid_hours_argument_reports_error(): @patch("et.cli.log_manual_time_for_current_workspace") def test_jira_log_time_forwards_jira_option(mock_log_manual, mock_load_config): mock_log_manual.return_value = LogTimeResult( - workspace_index=1, issue_key="ISD-999", seconds_logged=7200, tracker_reset=False + workspace_index=1, issue_key="ISD-999", seconds_logged=7200, counter_reset=False ) mock_load_config.return_value = _config([]) @@ -751,7 +912,7 @@ def test_jira_log_time_all_reports_logged_and_skipped_workspaces( workspace_index=0, issue_key="ISD-321", seconds_logged=3600, - tracker_reset=True, + counter_reset=True, summary="Epic summary", ) ], @@ -771,13 +932,40 @@ def test_jira_log_time_all_reports_logged_and_skipped_workspaces( "Logged 1h 0m 0s to " "'Epic summary'" in result.stdout ) - assert "Reset tracker to 0" in result.stdout + assert "Reset counter to 0" in result.stdout assert ( "Skipped workspace 3 (): " "only 10s elapsed (minimum 60s)" in result.stdout ) +@patch("et.cli.load_config") +@patch("et.cli.log_time_for_all_workspaces") +def test_jira_log_time_all_warns_for_each_failed_counter_reset( + mock_log_all, mock_load_config +): + mock_log_all.return_value = AllLogTimeResult( + logged=[ + LogTimeResult( + workspace_index=2, + issue_key="ISD-654", + seconds_logged=3600, + counter_reset=False, + counter_reset_error="extension unavailable", + ) + ], + skipped=[], + ) + mock_load_config.return_value = _config([]) + + with patch("sys.stdout.isatty", return_value=False): + result = runner.invoke(app, ["jira", "log-time", "--all"]) + + assert result.exit_code == 0 + assert "Jira accepted the worklog" in result.output + assert "ResetWorkspaceCounter uint32 2" in result.output + + @patch("et.cli.load_config") @patch("et.cli.log_time_for_all_workspaces") def test_jira_log_time_all_reports_no_linked_workspaces(mock_log_all, mock_load_config): @@ -817,7 +1005,7 @@ def test_jira_log_time_all_reports_error(mock_log_all, mock_load_config): def test_jira_complete_logs_time_and_frees_workspace(mock_complete): def fake_complete(comment, issue_key, on_logged, confirm_delete, confirm_done): log_result = LogTimeResult( - workspace_index=1, issue_key="ISD-321", seconds_logged=780, tracker_reset=True + workspace_index=1, issue_key="ISD-321", seconds_logged=780, counter_reset=True ) on_logged(log_result) freed = confirm_delete(log_result) @@ -850,7 +1038,7 @@ def test_jira_complete_uses_hyperlink_helper_for_issue_key( def fake_complete(comment, issue_key, on_logged, confirm_delete, confirm_done): log_result = LogTimeResult( - workspace_index=1, issue_key="ISD-321", seconds_logged=780, tracker_reset=True + workspace_index=1, issue_key="ISD-321", seconds_logged=780, counter_reset=True ) on_logged(log_result) freed = confirm_delete(log_result) @@ -877,7 +1065,7 @@ def fake_complete(comment, issue_key, on_logged, confirm_delete, confirm_done): def test_jira_complete_skips_cleanup_when_declined(mock_complete): def fake_complete(comment, issue_key, on_logged, confirm_delete, confirm_done): log_result = LogTimeResult( - workspace_index=1, issue_key="ISD-321", seconds_logged=780, tracker_reset=True + workspace_index=1, issue_key="ISD-321", seconds_logged=780, counter_reset=True ) on_logged(log_result) freed = confirm_delete(log_result) @@ -906,6 +1094,34 @@ def test_jira_complete_reports_error(mock_complete): assert "Error: no Jira issue linked to workspace 1" in result.output +@patch("et.cli.complete_task_for_current_workspace") +def test_jira_complete_warns_after_workspace_is_kept_for_failed_counter_reset(mock_complete): + log_result = LogTimeResult( + workspace_index=1, + issue_key="ISD-321", + seconds_logged=780, + counter_reset=False, + counter_reset_error="extension unavailable", + ) + + def fake_complete(comment, issue_key, on_logged, confirm_delete, confirm_done): + on_logged(log_result) + return TaskCompleteResult( + log_result=log_result, + workspace_freed=False, + moved_to_done=False, + ) + + mock_complete.side_effect = fake_complete + + with patch("sys.stdout.isatty", return_value=False): + result = runner.invoke(app, ["jira", "complete"]) + + assert result.exit_code == 0 + assert "Jira accepted the worklog" in result.output + assert "ResetWorkspaceCounter uint32 1" in result.output + + @patch("et.cli.load_config") @patch("et.cli.add_comment_to_current_workspace") def test_jira_comment_adds_comment_from_argument(mock_add_comment, mock_load_config): diff --git a/tests/test_config.py b/tests/test_config.py index 813d68a..9bf99bf 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -2,6 +2,8 @@ from __future__ import annotations +from unittest.mock import patch + import pytest from et.config import ( @@ -249,6 +251,21 @@ def test_save_config_round_trips_through_load_config(config_dir): assert mode == 0o600 +@patch("et.config.os.replace", side_effect=OSError("replace failed")) +def test_save_config_preserves_existing_file_when_atomic_replace_fails( + _mock_replace, config_dir +): + path = config_dir / "config.yaml" + original = "workspaces:\n - name: original\n" + path.write_text(original) + + with pytest.raises(ConfigError, match="could not write config file"): + save_config(EtConfig(jira=None, workspaces=[WorkspaceConfigEntry(name="updated")])) + + assert path.read_text() == original + assert list(config_dir.glob(".config.yaml.*")) == [] + + def test_load_workspace_names_still_returns_plain_name_list(config_dir): (config_dir / "config.yaml").write_text( "workspaces:\n - name: mails\n - name: handson\n" diff --git a/tests/test_config_wizard.py b/tests/test_config_wizard.py new file mode 100644 index 0000000..5624604 --- /dev/null +++ b/tests/test_config_wizard.py @@ -0,0 +1,248 @@ +"""Tests for et.config_wizard, mocking et.jira.check_credentials.""" + +from __future__ import annotations + +from unittest.mock import patch + +from et.config import EtConfig, JiraConfig, WorkspaceConfigEntry +from et.config_wizard import ConfigWizardPrompts, run_config_wizard +from et.jira import JiraError + + +def _jira_config(**overrides: object) -> JiraConfig: + defaults: dict[str, object] = dict( + base_url="https://example.atlassian.net", + email="me@example.com", + pat="secret-token", + jql="assignee = currentUser()", + project_key="PROJ", + board_id=None, + ) + defaults.update(overrides) + return JiraConfig(**defaults) # type: ignore[arg-type] + + +def _prompts(**overrides: object) -> ConfigWizardPrompts: + defaults: dict[str, object] = dict( + confirm_configure_jira=lambda has_existing: True, + prompt_base_url=lambda default: default or "https://example.atlassian.net", + prompt_email=lambda default: default or "me@example.com", + prompt_pat=lambda default: default or "secret-token", + prompt_jql=lambda default: default or "assignee = currentUser()", + prompt_project_key=lambda default: default, + prompt_board_id=lambda default: default, + resolve_credential_check_failure=lambda message: "keep", + prompt_workspace_action=lambda entries: "done", + select_workspace_entry=lambda entries, purpose: None, + prompt_workspace_entry=lambda default: None, + confirm_save=lambda config: True, + warn=lambda message: None, + ) + defaults.update(overrides) + return ConfigWizardPrompts(**defaults) # type: ignore[arg-type] + + +@patch("et.config_wizard.check_credentials") +def test_run_config_wizard_builds_fresh_jira_config(mock_check): + result = run_config_wizard(_prompts(), existing=None) + + assert result is not None + assert result.jira is not None + assert result.jira.base_url == "https://example.atlassian.net" + assert result.jira.email == "me@example.com" + assert result.jira.pat == "secret-token" + assert result.jira.jql == "assignee = currentUser()" + assert result.workspaces == [] + mock_check.assert_called_once() + + +@patch("et.config_wizard.check_credentials") +def test_run_config_wizard_declines_jira_block(mock_check): + result = run_config_wizard( + _prompts(confirm_configure_jira=lambda has_existing: False), existing=None + ) + + assert result is not None + assert result.jira is None + mock_check.assert_not_called() + + +@patch("et.config_wizard.check_credentials") +def test_run_config_wizard_prefills_from_existing_jira_config(mock_check): + existing = EtConfig(jira=_jira_config(project_key="OLD"), workspaces=[]) + seen_defaults: dict[str, str] = {} + + def prompt_project_key(default: str) -> str: + seen_defaults["project_key"] = default + return default + + result = run_config_wizard(_prompts(prompt_project_key=prompt_project_key), existing=existing) + + assert seen_defaults["project_key"] == "OLD" + assert result is not None + assert result.jira is not None + assert result.jira.project_key == "OLD" + + +@patch("et.config_wizard.check_credentials", side_effect=JiraError("bad credentials")) +def test_run_config_wizard_keeps_jira_config_on_credential_failure_when_told_to_keep(mock_check): + result = run_config_wizard( + _prompts(resolve_credential_check_failure=lambda message: "keep"), existing=None + ) + + assert result is not None + assert result.jira is not None + assert result.jira.base_url == "https://example.atlassian.net" + + +@patch("et.config_wizard.check_credentials", side_effect=JiraError("bad credentials")) +def test_run_config_wizard_discards_jira_config_on_credential_failure(mock_check): + result = run_config_wizard( + _prompts(resolve_credential_check_failure=lambda message: "discard"), existing=None + ) + + assert result is not None + assert result.jira is None + + +@patch("et.config_wizard.check_credentials") +def test_run_config_wizard_retries_credentials_until_success(mock_check): + mock_check.side_effect = [JiraError("bad credentials"), None] + attempts = {"count": 0} + + def resolve_credential_check_failure(message: str) -> str: + attempts["count"] += 1 + return "retry" + + result = run_config_wizard( + _prompts(resolve_credential_check_failure=resolve_credential_check_failure), + existing=None, + ) + + assert attempts["count"] == 1 + assert mock_check.call_count == 2 + assert result is not None + assert result.jira is not None + + +@patch("et.config_wizard.check_credentials") +def test_run_config_wizard_adds_a_workspace_entry(mock_check): + actions = iter(["add", "done"]) + new_entry = WorkspaceConfigEntry(name="mails", type="static") + + result = run_config_wizard( + _prompts( + confirm_configure_jira=lambda has_existing: False, + prompt_workspace_action=lambda entries: next(actions), + prompt_workspace_entry=lambda default: new_entry if default is None else None, + ), + existing=None, + ) + + assert result is not None + assert result.workspaces == [new_entry] + mock_check.assert_not_called() + + +@patch("et.config_wizard.check_credentials") +def test_run_config_wizard_edits_a_workspace_entry(mock_check): + existing_entry = WorkspaceConfigEntry(name="mails", type="dynamic") + existing = EtConfig(jira=None, workspaces=[existing_entry]) + edited_entry = WorkspaceConfigEntry(name="mails", type="static") + actions = iter(["edit", "done"]) + + result = run_config_wizard( + _prompts( + confirm_configure_jira=lambda has_existing: False, + prompt_workspace_action=lambda entries: next(actions), + select_workspace_entry=lambda entries, purpose: 0, + prompt_workspace_entry=lambda default: edited_entry, + ), + existing=existing, + ) + + assert result is not None + assert result.workspaces == [edited_entry] + mock_check.assert_not_called() + + +@patch("et.config_wizard.check_credentials") +def test_run_config_wizard_removes_a_workspace_entry(mock_check): + existing_entry = WorkspaceConfigEntry(name="mails", type="dynamic") + existing = EtConfig(jira=None, workspaces=[existing_entry]) + actions = iter(["remove", "done"]) + + result = run_config_wizard( + _prompts( + confirm_configure_jira=lambda has_existing: False, + prompt_workspace_action=lambda entries: next(actions), + select_workspace_entry=lambda entries, purpose: 0, + ), + existing=existing, + ) + + assert result is not None + assert result.workspaces == [] + + +@patch("et.config_wizard.check_credentials") +def test_run_config_wizard_ignores_declined_add(mock_check): + actions = iter(["add", "done"]) + + result = run_config_wizard( + _prompts( + confirm_configure_jira=lambda has_existing: False, + prompt_workspace_action=lambda entries: next(actions), + prompt_workspace_entry=lambda default: None, + ), + existing=None, + ) + + assert result is not None + assert result.workspaces == [] + + +@patch("et.config_wizard.check_credentials") +def test_run_config_wizard_ignores_cancelled_edit_selection(mock_check): + existing_entry = WorkspaceConfigEntry(name="mails", type="dynamic") + existing = EtConfig(jira=None, workspaces=[existing_entry]) + actions = iter(["edit", "done"]) + + result = run_config_wizard( + _prompts( + confirm_configure_jira=lambda has_existing: False, + prompt_workspace_action=lambda entries: next(actions), + select_workspace_entry=lambda entries, purpose: None, + ), + existing=existing, + ) + + assert result is not None + assert result.workspaces == [existing_entry] + + +@patch("et.config_wizard.check_credentials") +def test_run_config_wizard_treats_unrecognized_action_as_done(mock_check): + result = run_config_wizard( + _prompts( + confirm_configure_jira=lambda has_existing: False, + prompt_workspace_action=lambda entries: "bogus", + ), + existing=None, + ) + + assert result is not None + assert result.workspaces == [] + + +@patch("et.config_wizard.check_credentials") +def test_run_config_wizard_returns_none_when_save_declined(mock_check): + result = run_config_wizard( + _prompts( + confirm_configure_jira=lambda has_existing: False, + confirm_save=lambda config: False, + ), + existing=None, + ) + + assert result is None diff --git a/tests/test_duration.py b/tests/test_duration.py new file mode 100644 index 0000000..3c51b61 --- /dev/null +++ b/tests/test_duration.py @@ -0,0 +1,37 @@ +"""Tests for et.duration: format_duration/parse_hours_to_seconds.""" + +from __future__ import annotations + +import pytest + +from et.duration import format_duration, parse_hours_to_seconds + + +def test_format_duration_renders_hours_minutes_seconds(): + assert format_duration(3725) == "1h 2m 5s" + assert format_duration(0) == "0h 0m 0s" + assert format_duration(59.9) == "0h 0m 59s" + + +@pytest.mark.parametrize( + ("text", "expected_seconds"), + [ + ("2h", 7200), + ("1h", 3600), + ("1.5h", 5400), + ("0.5h", 1800), + ("2H", 7200), + (" 2h ", 7200), + ], +) +def test_parse_hours_to_seconds_parses_valid_durations(text, expected_seconds): + assert parse_hours_to_seconds(text) == expected_seconds + + +@pytest.mark.parametrize( + "text", + ["2", "2m", "2h30m", "abc", "", "1.5", "-1h", "0h"], +) +def test_parse_hours_to_seconds_rejects_invalid_durations(text): + with pytest.raises(ValueError, match="invalid duration"): + parse_hours_to_seconds(text) diff --git a/tests/test_et_extension.py b/tests/test_et_extension.py new file mode 100644 index 0000000..4d1fe18 --- /dev/null +++ b/tests/test_et_extension.py @@ -0,0 +1,316 @@ +"""Tests for et.et_extension, mocking the gdbus subprocess calls.""" + +from __future__ import annotations + +import subprocess +from unittest.mock import patch + +import pytest + +from et.et_extension import ( + ET_DBUS_DEST, + ET_DBUS_INTERFACE, + ET_DBUS_OBJECT_PATH, + EtExtensionError, + WorkspaceCounter, + WorkspaceCounterNotFoundError, + WorkspaceMetadata, + get_active_workspace_index, + get_workspace_counter, + get_workspace_metadata, + prepare_workspace, + remap_workspaces, + remove_workspace, + reset_workspace_counter, + set_workspace_metadata, + sync_workspace_label, +) + + +def _completed( + stdout: str = "", stderr: str = "", returncode: int = 0 +) -> subprocess.CompletedProcess: + return subprocess.CompletedProcess(args=[], returncode=returncode, stdout=stdout, stderr=stderr) + + +def _gdbus_command(method: str, *args: str) -> list[str]: + return [ + "gdbus", + "call", + "--session", + "--dest", + ET_DBUS_DEST, + "--object-path", + ET_DBUS_OBJECT_PATH, + "--method", + f"{ET_DBUS_INTERFACE}.{method}", + *args, + ] + + +@patch("et.et_extension.shutil.which", return_value="/usr/bin/gdbus") +@patch("et.et_extension.subprocess.run") +def test_get_active_workspace_index_parses_uint32(mock_run, _mock_which): + mock_run.return_value = _completed(stdout="(uint32 2,)\n") + assert get_active_workspace_index() == 2 + mock_run.assert_called_once_with( + _gdbus_command("GetActiveWorkspaceIndex"), + capture_output=True, + text=True, + check=False, + ) + + +@patch("et.et_extension.shutil.which", return_value=None) +def test_get_active_workspace_index_raises_when_gdbus_missing(_mock_which): + with pytest.raises(EtExtensionError, match="gdbus"): + get_active_workspace_index() + + +@patch("et.et_extension.shutil.which", return_value="/usr/bin/gdbus") +@patch("et.et_extension.subprocess.run") +def test_get_active_workspace_index_raises_helpful_message_when_extension_missing( + mock_run, _mock_which +): + mock_run.return_value = _completed( + returncode=1, + stderr=( + "Error: GDBus.Error:org.freedesktop.DBus.Error.UnknownMethod: " + "No such interface \u201corg.gnome.Shell.Extensions.Et\u201d" + ), + ) + with pytest.raises(EtExtensionError, match="et GNOME Shell extension"): + get_active_workspace_index() + + +@patch("et.et_extension.shutil.which", return_value="/usr/bin/gdbus") +@patch("et.et_extension.subprocess.run") +def test_get_active_workspace_index_raises_on_other_dbus_failure(mock_run, _mock_which): + mock_run.return_value = _completed(returncode=1, stderr="some other dbus error") + with pytest.raises(EtExtensionError, match="gdbus call"): + get_active_workspace_index() + + +@patch("et.et_extension.shutil.which", return_value="/usr/bin/gdbus") +@patch("et.et_extension.subprocess.run") +def test_get_active_workspace_index_raises_when_output_unparseable(mock_run, _mock_which): + mock_run.return_value = _completed(stdout="()\n") + with pytest.raises(EtExtensionError, match="could not parse"): + get_active_workspace_index() + + +@patch("et.et_extension.shutil.which", return_value="/usr/bin/gdbus") +@patch("et.et_extension.subprocess.run") +def test_prepare_workspace_sends_typed_arguments(mock_run, _mock_which): + mock_run.return_value = _completed(stdout="()\n") + prepare_workspace(3, "My Task", 3600) + mock_run.assert_called_once_with( + _gdbus_command("PrepareWorkspace", "uint32 3", "'My Task'", "uint64 3600"), + capture_output=True, + text=True, + check=False, + ) + + +@patch("et.et_extension.shutil.which", return_value="/usr/bin/gdbus") +@patch("et.et_extension.subprocess.run") +def test_prepare_workspace_escapes_quotes_and_backslashes_in_label(mock_run, _mock_which): + mock_run.return_value = _completed(stdout="()\n") + prepare_workspace(0, "it's a \\test\\", 0) + mock_run.assert_called_once_with( + _gdbus_command("PrepareWorkspace", "uint32 0", "'it\\'s a \\\\test\\\\'", "uint64 0"), + capture_output=True, + text=True, + check=False, + ) + + +@patch("et.et_extension.shutil.which", return_value="/usr/bin/gdbus") +@patch("et.et_extension.subprocess.run") +def test_get_workspace_counter_parses_elapsed_and_running(mock_run, _mock_which): + mock_run.return_value = _completed(stdout="(uint64 123, true)\n") + assert get_workspace_counter(3) == WorkspaceCounter(elapsed_seconds=123, running=True) + mock_run.assert_called_once_with( + _gdbus_command("GetWorkspaceCounter", "uint32 3"), + capture_output=True, + text=True, + check=False, + ) + + +@patch("et.et_extension.shutil.which", return_value="/usr/bin/gdbus") +@patch("et.et_extension.subprocess.run") +def test_get_workspace_counter_parses_not_running(mock_run, _mock_which): + mock_run.return_value = _completed(stdout="(uint64 0, false)\n") + assert get_workspace_counter(0) == WorkspaceCounter(elapsed_seconds=0, running=False) + + +@patch("et.et_extension.shutil.which", return_value="/usr/bin/gdbus") +@patch("et.et_extension.subprocess.run") +def test_get_workspace_counter_raises_when_output_unparseable(mock_run, _mock_which): + mock_run.return_value = _completed(stdout="()\n") + with pytest.raises(EtExtensionError, match="could not parse"): + get_workspace_counter(0) + + +@patch("et.et_extension.shutil.which", return_value="/usr/bin/gdbus") +@patch("et.et_extension.subprocess.run") +def test_get_workspace_counter_distinguishes_unprepared_workspace(mock_run, _mock_which): + mock_run.return_value = _completed( + returncode=1, + stderr=( + "Error: GDBus.Error:org.gnome.Shell.Extensions.Et.Error.NotFound: " + "workspace 2 has no prepared counter" + ), + ) + with pytest.raises(WorkspaceCounterNotFoundError, match="not prepared"): + get_workspace_counter(2) + + +@patch("et.et_extension.shutil.which", return_value="/usr/bin/gdbus") +@patch("et.et_extension.subprocess.run") +def test_reset_workspace_counter_calls_expected_method(mock_run, _mock_which): + mock_run.return_value = _completed(stdout="()\n") + reset_workspace_counter(2) + mock_run.assert_called_once_with( + _gdbus_command("ResetWorkspaceCounter", "uint32 2"), + capture_output=True, + text=True, + check=False, + ) + + +@patch("et.et_extension.shutil.which", return_value="/usr/bin/gdbus") +@patch("et.et_extension.subprocess.run") +def test_remove_workspace_calls_expected_method(mock_run, _mock_which): + mock_run.return_value = _completed(stdout="()\n") + remove_workspace(5) + mock_run.assert_called_once_with( + _gdbus_command("RemoveWorkspace", "uint32 5"), + capture_output=True, + text=True, + check=False, + ) + + +@patch("et.et_extension.shutil.which", return_value="/usr/bin/gdbus") +@patch("et.et_extension.subprocess.run") +def test_remap_workspaces_sends_array_of_tuples(mock_run, _mock_which): + mock_run.return_value = _completed(stdout="()\n") + remap_workspaces([(0, 1), (2, 3)]) + mock_run.assert_called_once_with( + _gdbus_command("RemapWorkspaces", "[(uint32 0, uint32 1), (uint32 2, uint32 3)]"), + capture_output=True, + text=True, + check=False, + ) + + +@patch("et.et_extension.shutil.which", return_value="/usr/bin/gdbus") +@patch("et.et_extension.subprocess.run") +def test_remap_workspaces_with_empty_moves_does_not_call_gdbus(mock_run, _mock_which): + remap_workspaces([]) + mock_run.assert_not_called() + + +@patch("et.et_extension.shutil.which", return_value="/usr/bin/gdbus") +@patch("et.et_extension.subprocess.run") +def test_set_workspace_metadata_sends_typed_arguments(mock_run, _mock_which): + mock_run.return_value = _completed(stdout="()\n") + set_workspace_metadata(1, "Another label", 7200) + mock_run.assert_called_once_with( + _gdbus_command("SetWorkspaceMetadata", "uint32 1", "'Another label'", "uint64 7200"), + capture_output=True, + text=True, + check=False, + ) + + +@patch("et.et_extension.shutil.which", return_value="/usr/bin/gdbus") +@patch("et.et_extension.subprocess.run") +def test_call_raises_generic_error_with_method_name(mock_run, _mock_which): + mock_run.return_value = _completed(returncode=1, stderr="custom extension error") + with pytest.raises(EtExtensionError, match="ResetWorkspaceCounter"): + reset_workspace_counter(0) + + +@patch("et.et_extension.shutil.which", return_value="/usr/bin/gdbus") +@patch("et.et_extension.subprocess.run") +def test_get_workspace_metadata_parses_label_and_estimate(mock_run, _mock_which): + mock_run.return_value = _completed(stdout="('My Task', uint64 3600)\n") + assert get_workspace_metadata(3) == WorkspaceMetadata(label="My Task", estimate_seconds=3600) + mock_run.assert_called_once_with( + _gdbus_command("GetWorkspaceMetadata", "uint32 3"), + capture_output=True, + text=True, + check=False, + ) + + +@patch("et.et_extension.shutil.which", return_value="/usr/bin/gdbus") +@patch("et.et_extension.subprocess.run") +def test_get_workspace_metadata_unescapes_quotes_and_backslashes_in_label(mock_run, _mock_which): + mock_run.return_value = _completed(stdout="('it\\'s a \\\\test\\\\', uint64 0)\n") + assert get_workspace_metadata(0) == WorkspaceMetadata( + label="it's a \\test\\", estimate_seconds=0 + ) + + +@patch("et.et_extension.shutil.which", return_value="/usr/bin/gdbus") +@patch("et.et_extension.subprocess.run") +def test_get_workspace_metadata_raises_when_output_unparseable(mock_run, _mock_which): + mock_run.return_value = _completed(stdout="()\n") + with pytest.raises(EtExtensionError, match="could not parse"): + get_workspace_metadata(0) + + +@patch("et.et_extension.shutil.which", return_value="/usr/bin/gdbus") +@patch("et.et_extension.subprocess.run") +def test_get_workspace_metadata_distinguishes_unprepared_workspace(mock_run, _mock_which): + mock_run.return_value = _completed( + returncode=1, + stderr=( + "Error: GDBus.Error:org.gnome.Shell.Extensions.Et.Error.NotFound: " + "workspace 2 has no prepared counter" + ), + ) + with pytest.raises(WorkspaceCounterNotFoundError, match="not prepared"): + get_workspace_metadata(2) + + +@patch("et.et_extension.shutil.which", return_value="/usr/bin/gdbus") +@patch("et.et_extension.subprocess.run") +def test_sync_workspace_label_updates_prepared_workspace_preserving_estimate( + mock_run, _mock_which +): + mock_run.side_effect = [ + _completed(stdout="('Old label', uint64 3600)\n"), + _completed(stdout="()\n"), + ] + assert sync_workspace_label(1, "New label") is True + assert mock_run.call_count == 2 + mock_run.assert_called_with( + _gdbus_command("SetWorkspaceMetadata", "uint32 1", "'New label'", "uint64 3600"), + capture_output=True, + text=True, + check=False, + ) + + +@patch("et.et_extension.shutil.which", return_value="/usr/bin/gdbus") +@patch("et.et_extension.subprocess.run") +def test_sync_workspace_label_is_a_silent_no_op_when_not_prepared(mock_run, _mock_which): + mock_run.return_value = _completed( + returncode=1, + stderr=( + "Error: GDBus.Error:org.gnome.Shell.Extensions.Et.Error.NotFound: " + "workspace 2 has no prepared counter" + ), + ) + assert sync_workspace_label(2, "New label") is False + mock_run.assert_called_once() + + +@patch("et.et_extension.shutil.which", return_value=None) +def test_sync_workspace_label_is_a_silent_no_op_when_extension_unreachable(_mock_which): + assert sync_workspace_label(0, "New label") is False diff --git a/tests/test_gnome_extension_tooling.py b/tests/test_gnome_extension_tooling.py new file mode 100644 index 0000000..6b67f8d --- /dev/null +++ b/tests/test_gnome_extension_tooling.py @@ -0,0 +1,123 @@ +"""Tests for the GNOME Shell extension validation and isolation tooling.""" + +from __future__ import annotations + +import json +import os +import shutil +import subprocess +import zipfile +from pathlib import Path + +REPO_ROOT = Path(__file__).parents[1] +EXTENSION_DIR = REPO_ROOT / "gnome-extension" / "et@seb4stien.github.com" +VALIDATOR = REPO_ROOT / "scripts" / "check-gnome-shell-extension.py" +ISOLATED_ENV = REPO_ROOT / "scripts" / "lib" / "with-isolated-gnome-shell-env.sh" +ENABLE_EXTENSION_SETTING = REPO_ROOT / "scripts" / "lib" / "enable-extension-setting.sh" + + +def test_validator_accepts_source_and_complete_archive(tmp_path: Path) -> None: + archive = tmp_path / "extension.zip" + with zipfile.ZipFile(archive, "w") as output: + for path in EXTENSION_DIR.rglob("*"): + if path.is_file() and path.name != "gschemas.compiled": + output.write(path, path.relative_to(EXTENSION_DIR)) + + result = subprocess.run( + ["python3", str(VALIDATOR), str(EXTENSION_DIR), "--archive", str(archive)], + capture_output=True, + text=True, + check=False, + ) + + assert result.returncode == 0, result.stderr + + +def test_validator_rejects_missing_display_module(tmp_path: Path) -> None: + source = tmp_path / "extension" + shutil.copytree(EXTENSION_DIR, source) + (source / "lib" / "display.js").unlink() + + result = subprocess.run( + ["python3", str(VALIDATOR), str(source)], + capture_output=True, + text=True, + check=False, + ) + + assert result.returncode == 1 + assert "lib/display.js" in result.stderr + + +def test_isolated_environment_stages_extension_without_using_host_home(tmp_path: Path) -> None: + host_home = tmp_path / "host-home" + host_home.mkdir() + marker = host_home / "marker" + marker.write_text("unchanged") + output = tmp_path / "environment.json" + temporary_parent = tmp_path / "custom-tmp" + temporary_parent.mkdir() + command = ( + 'python3 -c "import json, os, pathlib; ' + "root = pathlib.Path(os.environ['ET_TEST_ROOT']); " + "extension = pathlib.Path(os.environ['ET_EXTENSION_DIR']); " + "assert pathlib.Path(os.environ['HOME']).is_relative_to(root); " + "assert pathlib.Path(os.environ['XDG_CONFIG_HOME']).is_relative_to(root); " + "assert (extension / 'extension.js').is_file(); " + "assert (extension / 'lib' / 'display.js').is_file(); " + "assert (extension / 'schemas' / 'gschemas.compiled').is_file(); " + "pathlib.Path(os.environ['OUTPUT']).write_text(json.dumps({'root': str(root)}))\"" + ) + + result = subprocess.run( + [str(ISOLATED_ENV), "bash", "-c", command], + env={ + **os.environ, + "HOME": str(host_home), + "OUTPUT": str(output), + "TMPDIR": str(temporary_parent), + }, + capture_output=True, + text=True, + check=False, + ) + + assert result.returncode == 0, result.stderr + assert marker.read_text() == "unchanged" + staged_root = Path(json.loads(output.read_text())["root"]) + assert staged_root.parent == temporary_parent + assert not staged_root.exists() + + +def test_enable_extension_setting_handles_typed_empty_array(tmp_path: Path) -> None: + fake_bin = tmp_path / "bin" + fake_bin.mkdir() + calls = tmp_path / "calls" + fake_gsettings = fake_bin / "gsettings" + fake_gsettings.write_text( + "#!/usr/bin/env bash\n" + "if [ \"$1\" = get ]; then\n" + " echo '@as []'\n" + "else\n" + " printf '%s\\n' \"$*\" >\"${CALLS_FILE}\"\n" + "fi\n" + ) + fake_gsettings.chmod(0o755) + + result = subprocess.run( + [ + "bash", + "-c", + f"source {ENABLE_EXTENSION_SETTING}; " + "enable_gnome_extension_in_settings et@example.com", + ], + env={**os.environ, "PATH": f"{fake_bin}:{os.environ['PATH']}", "CALLS_FILE": str(calls)}, + capture_output=True, + text=True, + check=False, + ) + + assert result.returncode == 0, result.stderr + assert calls.read_text().strip() == ( + "set org.gnome.shell enabled-extensions ['et@example.com']" + ) diff --git a/tests/test_gnome_extensions.py b/tests/test_gnome_extensions.py deleted file mode 100644 index e6319b9..0000000 --- a/tests/test_gnome_extensions.py +++ /dev/null @@ -1,117 +0,0 @@ -"""Tests for et.gnome_extensions, mocking the gnome-extensions subprocess calls.""" - -from __future__ import annotations - -import subprocess -from unittest.mock import patch - -import pytest - -from et.gnome_extensions import ( - GnomeExtensionsError, - disable_extension, - enable_extension, - is_extension_enabled, - reload_around, -) - -UUID = "tracker@aliakseiz.github.com" - - -def _completed( - stdout: str = "", stderr: str = "", returncode: int = 0 -) -> subprocess.CompletedProcess: - return subprocess.CompletedProcess(args=[], returncode=returncode, stdout=stdout, stderr=stderr) - - -@patch("et.gnome_extensions.shutil.which", return_value="/usr/bin/gnome-extensions") -@patch("et.gnome_extensions.subprocess.run") -def test_is_extension_enabled_true_when_listed(mock_run, _mock_which): - mock_run.return_value = _completed(stdout=f"other-ext@example.com\n{UUID}\n") - assert is_extension_enabled(UUID) is True - - -@patch("et.gnome_extensions.shutil.which", return_value="/usr/bin/gnome-extensions") -@patch("et.gnome_extensions.subprocess.run") -def test_is_extension_enabled_false_when_not_listed(mock_run, _mock_which): - mock_run.return_value = _completed(stdout="other-ext@example.com\n") - assert is_extension_enabled(UUID) is False - - -@patch("et.gnome_extensions.shutil.which", return_value=None) -def test_is_extension_enabled_raises_when_binary_missing(_mock_which): - with pytest.raises(GnomeExtensionsError, match="gnome-extensions"): - is_extension_enabled(UUID) - - -@patch("et.gnome_extensions.time.sleep") -@patch("et.gnome_extensions.shutil.which", return_value="/usr/bin/gnome-extensions") -@patch("et.gnome_extensions.subprocess.run") -def test_is_extension_enabled_retries_then_succeeds(mock_run, _mock_which, mock_sleep): - mock_run.side_effect = [ - _completed(stderr="Failed to connect to GNOME Shell", returncode=1), - _completed(stderr="Failed to connect to GNOME Shell", returncode=1), - _completed(stdout=f"{UUID}\n"), - ] - assert is_extension_enabled(UUID) is True - assert mock_run.call_count == 3 - assert mock_sleep.call_count == 2 - mock_sleep.assert_called_with(5) - - -@patch("et.gnome_extensions.time.sleep") -@patch("et.gnome_extensions.shutil.which", return_value="/usr/bin/gnome-extensions") -@patch("et.gnome_extensions.subprocess.run") -def test_is_extension_enabled_raises_after_max_attempts(mock_run, _mock_which, mock_sleep): - mock_run.return_value = _completed(stderr="Failed to connect to GNOME Shell", returncode=1) - with pytest.raises(GnomeExtensionsError, match="list failed"): - is_extension_enabled(UUID) - assert mock_run.call_count == 3 - assert mock_sleep.call_count == 2 - - -@patch("et.gnome_extensions.shutil.which", return_value="/usr/bin/gnome-extensions") -@patch("et.gnome_extensions.subprocess.run") -def test_disable_extension_raises_on_nonzero_exit(mock_run, _mock_which): - mock_run.return_value = _completed(stderr="boom", returncode=1) - with pytest.raises(GnomeExtensionsError, match="disable failed"): - disable_extension(UUID) - - -@patch("et.gnome_extensions.shutil.which", return_value="/usr/bin/gnome-extensions") -@patch("et.gnome_extensions.subprocess.run") -def test_enable_extension_raises_on_nonzero_exit(mock_run, _mock_which): - mock_run.return_value = _completed(stderr="boom", returncode=1) - with pytest.raises(GnomeExtensionsError, match="enable failed"): - enable_extension(UUID) - - -@patch("et.gnome_extensions.enable_extension") -@patch("et.gnome_extensions.disable_extension") -@patch("et.gnome_extensions.is_extension_enabled", return_value=True) -def test_reload_around_disables_and_reenables_when_enabled( - mock_is_enabled, mock_disable, mock_enable -): - with reload_around(UUID): - mock_disable.assert_called_once_with(UUID) - mock_enable.assert_not_called() - mock_enable.assert_called_once_with(UUID) - - -@patch("et.gnome_extensions.enable_extension") -@patch("et.gnome_extensions.disable_extension") -@patch("et.gnome_extensions.is_extension_enabled", return_value=False) -def test_reload_around_is_noop_when_not_enabled(mock_is_enabled, mock_disable, mock_enable): - with reload_around(UUID): - pass - mock_disable.assert_not_called() - mock_enable.assert_not_called() - - -@patch("et.gnome_extensions.enable_extension") -@patch("et.gnome_extensions.disable_extension") -@patch("et.gnome_extensions.is_extension_enabled", return_value=True) -def test_reload_around_reenables_even_if_block_raises(mock_is_enabled, mock_disable, mock_enable): - with pytest.raises(ValueError), reload_around(UUID): - raise ValueError("boom") - mock_enable.assert_called_once_with(UUID) diff --git a/tests/test_gsettings.py b/tests/test_gsettings.py index ba05416..58b2232 100644 --- a/tests/test_gsettings.py +++ b/tests/test_gsettings.py @@ -12,7 +12,6 @@ read_boolean, read_int, read_string_array, - set_boolean, set_int, write_string_array, ) @@ -79,15 +78,6 @@ def test_write_string_array_raises_on_nonzero_exit(mock_run, _mock_which): write_string_array("org.example.schema", "some-key", ["x"]) -@patch("et.gsettings.shutil.which", return_value="/usr/bin/gsettings") -@patch("et.gsettings.subprocess.run") -def test_set_boolean_writes_lowercase_true_or_false(mock_run, _mock_which): - mock_run.return_value = _completed() - set_boolean("org.example.schema", "some-flag", False) - args = mock_run.call_args[0][0] - assert args == ["gsettings", "set", "org.example.schema", "some-flag", "false"] - - @patch("et.gsettings.shutil.which", return_value="/usr/bin/gsettings") @patch("et.gsettings.subprocess.run") def test_set_int_writes_stringified_number(mock_run, _mock_which): diff --git a/tests/test_jira.py b/tests/test_jira.py index 9efe5fe..1ea834a 100644 --- a/tests/test_jira.py +++ b/tests/test_jira.py @@ -13,6 +13,7 @@ JiraError, JiraIssue, JiraSprint, + JiraTransition, add_issue_to_sprint, create_comment, create_issue, @@ -55,10 +56,18 @@ def _response(issues: list[dict], next_page_token: str | None = None) -> MagicMo return response -def _issue(key: str, summary: str, priority: str, status: str | None = None) -> dict: +def _issue( + key: str, + summary: str, + priority: str, + status: str | None = None, + original_estimate_seconds: int | None = None, +) -> dict: fields: dict[str, object] = {"summary": summary, "priority": {"name": priority}} if status is not None: fields["status"] = {"name": status} + if original_estimate_seconds is not None: + fields["timetracking"] = {"originalEstimateSeconds": original_estimate_seconds} return {"key": key, "fields": fields} @@ -87,7 +96,10 @@ def test_fetch_active_issues_passes_jql_and_basic_auth(mock_get): args, kwargs = mock_get.call_args_list[0] assert args[0] == "https://example.atlassian.net/rest/api/3/search/jql" - assert kwargs["params"] == {"jql": config.jql, "fields": "summary,priority,status"} + assert kwargs["params"] == { + "jql": config.jql, + "fields": "summary,priority,status,timetracking", + } assert kwargs["auth"] == (config.email, config.pat) @@ -123,6 +135,22 @@ def test_fetch_active_issues_skips_issue_without_key(mock_get, caplog): assert "missing or invalid 'key'" in caplog.text +@patch("et.jira.requests.get") +def test_fetch_active_issues_skips_issue_with_invalid_fields(mock_get, caplog): + mock_get.return_value = _response( + [ + {"key": "PROJ-1", "fields": []}, + _issue("PROJ-2", "Has valid fields", "Low"), + ] + ) + + with caplog.at_level("WARNING", logger="et.jira"): + issues = fetch_active_issues(_config()) + + assert [issue.key for issue in issues] == ["PROJ-2"] + assert "invalid 'fields'" in caplog.text + + @patch("et.jira.requests.get") def test_fetch_active_issues_raises_on_non_200_status(mock_get): response = MagicMock() @@ -253,6 +281,26 @@ def test_fetch_active_issues_defaults_status_to_empty_string_when_missing(mock_g assert issues[0].status == "" +@patch("et.jira.requests.get") +def test_fetch_active_issues_parses_original_estimate_seconds(mock_get): + mock_get.return_value = _response( + [_issue("PROJ-1", "Task A", "High", original_estimate_seconds=1800)] + ) + + issues = fetch_active_issues(_config()) + + assert issues[0].original_estimate_seconds == 1800 + + +@patch("et.jira.requests.get") +def test_fetch_active_issues_defaults_original_estimate_seconds_to_none_when_missing(mock_get): + mock_get.return_value = _response([_issue("PROJ-1", "Task A", "High")]) + + issues = fetch_active_issues(_config()) + + assert issues[0].original_estimate_seconds is None + + def _json_response(status_code: int, payload: object) -> MagicMock: response = MagicMock() response.status_code = status_code @@ -356,6 +404,18 @@ def test_fetch_transitions_skips_transitions_without_id(mock_get): assert transitions == [] +@patch("et.jira.requests.get") +def test_fetch_transitions_tolerates_invalid_nested_fields(mock_get): + mock_get.return_value = _json_response( + 200, + {"transitions": [{"id": "31", "name": ["invalid"], "to": ["invalid"]}]}, + ) + + transitions = fetch_transitions(_config(), "PROJ-1") + + assert transitions == [JiraTransition(id="31", name="", to_status="")] + + @patch("et.jira.requests.get") def test_fetch_transitions_raises_on_non_200_status(mock_get): mock_get.return_value = _json_response(404, {"errorMessages": ["not found"]}) @@ -491,7 +551,47 @@ def test_fetch_issue_returns_summary_priority_and_status(mock_get): ) args, kwargs = mock_get.call_args assert args[0] == "https://example.atlassian.net/rest/api/3/issue/PROJ-1" - assert kwargs["params"] == {"fields": "summary,priority,status"} + assert kwargs["params"] == {"fields": "summary,priority,status,timetracking"} + + +@patch("et.jira.requests.get") +def test_fetch_issue_parses_original_estimate_seconds(mock_get): + mock_get.return_value = _json_response( + 200, + { + "fields": { + "summary": "A summary", + "status": {"name": "To Do"}, + "timetracking": {"originalEstimateSeconds": 3600}, + } + }, + ) + + issue = fetch_issue(_config(), "PROJ-1") + + assert issue.original_estimate_seconds == 3600 + + +@pytest.mark.parametrize( + "timetracking", + [ + None, + {}, + {"originalEstimateSeconds": -1}, + {"originalEstimateSeconds": "3600"}, + {"originalEstimateSeconds": True}, + ], +) +@patch("et.jira.requests.get") +def test_fetch_issue_ignores_invalid_original_estimate_seconds(mock_get, timetracking): + fields: dict[str, object] = {"summary": "A summary", "status": {"name": "To Do"}} + if timetracking is not None: + fields["timetracking"] = timetracking + mock_get.return_value = _json_response(200, {"fields": fields}) + + issue = fetch_issue(_config(), "PROJ-1") + + assert issue.original_estimate_seconds is None @patch("et.jira.requests.get") diff --git a/tests/test_jira_time.py b/tests/test_jira_time.py index 34e9073..6904e4a 100644 --- a/tests/test_jira_time.py +++ b/tests/test_jira_time.py @@ -1,4 +1,4 @@ -"""Tests for et.jira_time, mocking et.workspaces/et.tracker/et.jira.""" +"""Tests for et.jira_time, mocking et.workspaces/et.et_extension/et.jira.""" from __future__ import annotations @@ -7,6 +7,7 @@ import pytest from et.config import EtConfig, JiraConfig, WorkspaceConfigEntry +from et.et_extension import EtExtensionError, WorkspaceCounter from et.jira import JiraError, JiraIssue from et.jira_time import ( JiraLogTimeError, @@ -15,7 +16,6 @@ log_time_for_current_workspace, resolve_issue_key, ) -from et.tracker import TrackerError from et.workspaces import WorkspaceError @@ -37,76 +37,61 @@ def _config( ) -def _timer(workspace_id: int, elapsed: int, name: str = "ET-1") -> dict: - return { - "id": "timer-1", - "name": name, - "timeElapsed": elapsed, - "running": True, - "selected": False, - "workspaceId": workspace_id, - "autoResume": True, - } - - def _issue(key: str, *, summary: str = "Some summary") -> JiraIssue: return JiraIssue(key=key, summary=summary, priority="Medium", status="In Progress") -@patch("et.jira_time.tracker.save_timers_with_reload") +@patch("et.jira_time.et_extension.reset_workspace_counter") @patch("et.jira_time.create_worklog") -@patch("et.jira_time.tracker.load_timers") +@patch("et.jira_time.et_extension.get_workspace_counter") @patch("et.jira_time.workspaces.get_active_workspace_index", return_value=0) @patch("et.jira_time.load_config") -def test_log_time_logs_elapsed_seconds_and_resets_tracker( +def test_log_time_logs_elapsed_seconds_and_resets_counter( mock_load_config, mock_index, - mock_load_timers, + mock_get_counter, mock_create_worklog, - mock_save_timers, + mock_reset_counter, ): mock_load_config.return_value = _config( [WorkspaceConfigEntry(name="ISD-321", ref="jira:ISD-321")] ) - mock_load_timers.return_value = [_timer(0, 4320)] + mock_get_counter.return_value = WorkspaceCounter(elapsed_seconds=4320, running=True) result = log_time_for_current_workspace(description="Fixed it") assert result.workspace_index == 0 assert result.issue_key == "ISD-321" assert result.seconds_logged == 4320 - assert result.tracker_reset is True + assert result.counter_reset is True mock_create_worklog.assert_called_once_with( mock_load_config.return_value.jira, "ISD-321", 4320, comment="Fixed it" ) + mock_reset_counter.assert_called_once_with(0) - saved_entries, _reason = mock_save_timers.call_args[0] - assert saved_entries[0]["timeElapsed"] == 0 - assert saved_entries[0]["running"] is False - -@patch("et.jira_time.tracker.save_timers_with_reload") +@patch("et.jira_time.et_extension.reset_workspace_counter") @patch("et.jira_time.create_worklog") -@patch("et.jira_time.tracker.load_timers") +@patch("et.jira_time.et_extension.get_workspace_counter") @patch("et.jira_time.workspaces.get_active_workspace_index", return_value=0) @patch("et.jira_time.load_config") -def test_log_time_does_not_reset_tracker_when_disabled( +def test_log_time_does_not_reset_counter_when_disabled( mock_load_config, mock_index, - mock_load_timers, + mock_get_counter, mock_create_worklog, - mock_save_timers, + mock_reset_counter, ): mock_load_config.return_value = _config( [WorkspaceConfigEntry(name="ISD-321", ref="jira:ISD-321")] ) - mock_load_timers.return_value = [_timer(0, 4320)] + mock_get_counter.return_value = WorkspaceCounter(elapsed_seconds=4320, running=True) result = log_time_for_current_workspace(reset=False) - assert result.tracker_reset is False - mock_save_timers.assert_not_called() + assert result.counter_reset is False + mock_reset_counter.assert_not_called() @patch("et.jira_time.workspaces.get_active_workspace_index", return_value=0) @@ -129,46 +114,49 @@ def test_log_time_raises_when_no_jira_issue_linked_to_workspace( log_time_for_current_workspace() -@patch("et.jira_time.tracker.load_timers", return_value=[]) +@patch( + "et.jira_time.et_extension.get_workspace_counter", + side_effect=EtExtensionError("extension not installed"), +) @patch("et.jira_time.workspaces.get_active_workspace_index", return_value=0) @patch("et.jira_time.load_config") -def test_log_time_raises_when_no_tracker_bound_to_workspace( - mock_load_config, mock_index, mock_load_timers +def test_log_time_raises_when_counter_cannot_be_read( + mock_load_config, mock_index, mock_get_counter ): mock_load_config.return_value = _config( [WorkspaceConfigEntry(name="ISD-321", ref="jira:ISD-321")] ) - with pytest.raises(JiraLogTimeError, match="minimum 60s"): + with pytest.raises(JiraLogTimeError, match="extension not installed"): log_time_for_current_workspace() -@patch("et.jira_time.tracker.load_timers") +@patch("et.jira_time.et_extension.get_workspace_counter") @patch("et.jira_time.workspaces.get_active_workspace_index", return_value=0) @patch("et.jira_time.load_config") def test_log_time_raises_when_elapsed_time_below_minimum( - mock_load_config, mock_index, mock_load_timers + mock_load_config, mock_index, mock_get_counter ): mock_load_config.return_value = _config( [WorkspaceConfigEntry(name="ISD-321", ref="jira:ISD-321")] ) - mock_load_timers.return_value = [_timer(0, 30)] + mock_get_counter.return_value = WorkspaceCounter(elapsed_seconds=30, running=False) with pytest.raises(JiraLogTimeError, match="only 30s elapsed"): log_time_for_current_workspace() @patch("et.jira_time.create_worklog", side_effect=JiraError("boom")) -@patch("et.jira_time.tracker.load_timers") +@patch("et.jira_time.et_extension.get_workspace_counter") @patch("et.jira_time.workspaces.get_active_workspace_index", return_value=0) @patch("et.jira_time.load_config") def test_log_time_wraps_jira_errors( - mock_load_config, mock_index, mock_load_timers, mock_create_worklog + mock_load_config, mock_index, mock_get_counter, mock_create_worklog ): mock_load_config.return_value = _config( [WorkspaceConfigEntry(name="ISD-321", ref="jira:ISD-321")] ) - mock_load_timers.return_value = [_timer(0, 4320)] + mock_get_counter.return_value = WorkspaceCounter(elapsed_seconds=4320, running=True) with pytest.raises(JiraLogTimeError, match="boom"): log_time_for_current_workspace() @@ -186,25 +174,31 @@ def test_log_time_propagates_workspace_errors(mock_load_config, mock_index): log_time_for_current_workspace() -@patch("et.jira_time.tracker.save_timers_with_reload", side_effect=TrackerError("gsettings boom")) +@patch( + "et.jira_time.et_extension.reset_workspace_counter", + side_effect=EtExtensionError("extension boom"), +) @patch("et.jira_time.create_worklog") -@patch("et.jira_time.tracker.load_timers") +@patch("et.jira_time.et_extension.get_workspace_counter") @patch("et.jira_time.workspaces.get_active_workspace_index", return_value=0) @patch("et.jira_time.load_config") -def test_log_time_wraps_tracker_errors_on_reset( +def test_log_time_reports_partial_success_when_counter_reset_fails( mock_load_config, mock_index, - mock_load_timers, + mock_get_counter, mock_create_worklog, - mock_save_timers, + mock_reset_counter, ): mock_load_config.return_value = _config( [WorkspaceConfigEntry(name="ISD-321", ref="jira:ISD-321")] ) - mock_load_timers.return_value = [_timer(0, 4320)] + mock_get_counter.return_value = WorkspaceCounter(elapsed_seconds=4320, running=True) - with pytest.raises(JiraLogTimeError, match="gsettings boom"): - log_time_for_current_workspace() + result = log_time_for_current_workspace() + + assert result.counter_reset is False + assert result.counter_reset_error == "extension boom" + mock_create_worklog.assert_called_once() # --- log_manual_time_for_current_workspace -------------------------------- @@ -213,7 +207,7 @@ def test_log_time_wraps_tracker_errors_on_reset( @patch("et.jira_time.create_worklog") @patch("et.jira_time.workspaces.get_active_workspace_index", return_value=0) @patch("et.jira_time.load_config") -def test_log_manual_time_logs_given_seconds_without_touching_tracker( +def test_log_manual_time_logs_given_seconds_without_touching_counter( mock_load_config, mock_index, mock_create_worklog ): mock_load_config.return_value = _config( @@ -225,7 +219,7 @@ def test_log_manual_time_logs_given_seconds_without_touching_tracker( assert result.workspace_index == 0 assert result.issue_key == "ISD-321" assert result.seconds_logged == 7200 - assert result.tracker_reset is False + assert result.counter_reset is False mock_create_worklog.assert_called_once_with( mock_load_config.return_value.jira, "ISD-321", 7200, comment="Manual entry" @@ -264,23 +258,24 @@ def test_log_manual_time_wraps_jira_errors(mock_load_config, mock_index, mock_cr log_manual_time_for_current_workspace(7200) +@patch("et.jira_time.et_extension.reset_workspace_counter") @patch("et.jira_time.create_worklog") -@patch("et.jira_time.tracker.load_timers") +@patch("et.jira_time.et_extension.get_workspace_counter") @patch("et.jira_time.workspaces.get_active_workspace_index", return_value=0) @patch("et.jira_time.load_config") def test_log_time_uses_explicit_issue_key_override( - mock_load_config, mock_index, mock_load_timers, mock_create_worklog + mock_load_config, mock_index, mock_get_counter, mock_create_worklog, mock_reset_counter ): - # Even with an override, the workspace index is still needed (Tracker - # timer / workspace deletion are tied to the physical workspace, not - # the ticket), so get_active_workspace_index is still called. + # Even with an override, the workspace index is still needed (the + # extension counter / workspace deletion are tied to the physical + # workspace, not the ticket), so get_active_workspace_index is still + # called. mock_load_config.return_value = _config( [WorkspaceConfigEntry(name="ISD-321", ref="jira:ISD-321")] ) - mock_load_timers.return_value = [_timer(0, 4320)] + mock_get_counter.return_value = WorkspaceCounter(elapsed_seconds=4320, running=True) - with patch("et.jira_time.tracker.save_timers_with_reload"): - result = log_time_for_current_workspace(issue_key="ISD-999") + result = log_time_for_current_workspace(issue_key="ISD-999") assert result.issue_key == "ISD-999" mock_create_worklog.assert_called_once_with( @@ -344,12 +339,16 @@ def test_resolve_issue_key_falls_back_to_active_workspace_without_override(mock_ @patch("et.jira_time.fetch_issue") -@patch("et.jira_time.tracker.save_timers_with_reload") +@patch("et.jira_time.et_extension.reset_workspace_counter") @patch("et.jira_time.create_worklog") -@patch("et.jira_time.tracker.load_timers") +@patch("et.jira_time.et_extension.get_workspace_counter") @patch("et.jira_time.load_config") def test_log_all_logs_and_resets_every_linked_workspace( - mock_load_config, mock_load_timers, mock_create_worklog, mock_save_timers, mock_fetch_issue + mock_load_config, + mock_get_counter, + mock_create_worklog, + mock_reset_counter, + mock_fetch_issue, ): mock_load_config.return_value = _config( [ @@ -358,7 +357,11 @@ def test_log_all_logs_and_resets_every_linked_workspace( WorkspaceConfigEntry(name="ISD-654", ref="jira:ISD-654"), ] ) - mock_load_timers.return_value = [_timer(0, 3600), _timer(2, 1800, name="ET-3")] + counters = { + 0: WorkspaceCounter(elapsed_seconds=3600, running=False), + 2: WorkspaceCounter(elapsed_seconds=1800, running=False), + } + mock_get_counter.side_effect = lambda index: counters[index] mock_fetch_issue.side_effect = lambda _jira_config, key: _issue(key, summary=f"{key} summary") result = log_time_for_all_workspaces(description="Weekly sync") @@ -366,7 +369,7 @@ def test_log_all_logs_and_resets_every_linked_workspace( assert [r.issue_key for r in result.logged] == ["ISD-321", "ISD-654"] assert [r.seconds_logged for r in result.logged] == [3600, 1800] assert [r.summary for r in result.logged] == ["ISD-321 summary", "ISD-654 summary"] - assert all(r.tracker_reset for r in result.logged) + assert all(r.counter_reset for r in result.logged) assert result.skipped == [] assert mock_create_worklog.call_count == 2 @@ -376,22 +379,28 @@ def test_log_all_logs_and_resets_every_linked_workspace( mock_create_worklog.assert_any_call( mock_load_config.return_value.jira, "ISD-654", 1800, comment="Weekly sync" ) - # One save per successfully-logged workspace, not batched. - assert mock_save_timers.call_count == 2 + # One reset per successfully-logged workspace, not batched. + assert mock_reset_counter.call_count == 2 + mock_reset_counter.assert_any_call(0) + mock_reset_counter.assert_any_call(2) @patch("et.jira_time.fetch_issue", side_effect=JiraError("summary fetch failed")) -@patch("et.jira_time.tracker.save_timers_with_reload") +@patch("et.jira_time.et_extension.reset_workspace_counter") @patch("et.jira_time.create_worklog") -@patch("et.jira_time.tracker.load_timers") +@patch("et.jira_time.et_extension.get_workspace_counter") @patch("et.jira_time.load_config") def test_log_all_falls_back_to_empty_summary_when_fetch_fails( - mock_load_config, mock_load_timers, mock_create_worklog, mock_save_timers, mock_fetch_issue + mock_load_config, + mock_get_counter, + mock_create_worklog, + mock_reset_counter, + mock_fetch_issue, ): mock_load_config.return_value = _config( [WorkspaceConfigEntry(name="ISD-321", ref="jira:ISD-321")] ) - mock_load_timers.return_value = [_timer(0, 3600)] + mock_get_counter.return_value = WorkspaceCounter(elapsed_seconds=3600, running=False) result = log_time_for_all_workspaces() @@ -399,17 +408,17 @@ def test_log_all_falls_back_to_empty_summary_when_fetch_fails( assert result.logged[0].summary == "" -@patch("et.jira_time.tracker.save_timers_with_reload") +@patch("et.jira_time.et_extension.reset_workspace_counter") @patch("et.jira_time.create_worklog") -@patch("et.jira_time.tracker.load_timers") +@patch("et.jira_time.et_extension.get_workspace_counter") @patch("et.jira_time.load_config") def test_log_all_skips_workspace_below_minimum_elapsed( - mock_load_config, mock_load_timers, mock_create_worklog, mock_save_timers + mock_load_config, mock_get_counter, mock_create_worklog, mock_reset_counter ): mock_load_config.return_value = _config( [WorkspaceConfigEntry(name="ISD-321", ref="jira:ISD-321")] ) - mock_load_timers.return_value = [_timer(0, 10)] + mock_get_counter.return_value = WorkspaceCounter(elapsed_seconds=10, running=False) result = log_time_for_all_workspaces() @@ -419,15 +428,18 @@ def test_log_all_skips_workspace_below_minimum_elapsed( assert result.skipped[0].issue_key == "ISD-321" assert "only 10s elapsed" in result.skipped[0].reason mock_create_worklog.assert_not_called() - mock_save_timers.assert_not_called() + mock_reset_counter.assert_not_called() -@patch("et.jira_time.tracker.save_timers_with_reload") +@patch("et.jira_time.et_extension.reset_workspace_counter") @patch("et.jira_time.create_worklog") -@patch("et.jira_time.tracker.load_timers", return_value=[]) +@patch( + "et.jira_time.et_extension.get_workspace_counter", + side_effect=EtExtensionError("no counter"), +) @patch("et.jira_time.load_config") -def test_log_all_skips_workspace_with_no_timer( - mock_load_config, mock_load_timers, mock_create_worklog, mock_save_timers +def test_log_all_skips_workspace_with_no_counter( + mock_load_config, mock_get_counter, mock_create_worklog, mock_reset_counter ): mock_load_config.return_value = _config( [WorkspaceConfigEntry(name="ISD-321", ref="jira:ISD-321")] @@ -436,18 +448,22 @@ def test_log_all_skips_workspace_with_no_timer( result = log_time_for_all_workspaces() assert result.logged == [] - assert result.skipped[0].reason == "no Tracker timer for this workspace" + assert "no prepared counter for this workspace" in result.skipped[0].reason mock_create_worklog.assert_not_called() - mock_save_timers.assert_not_called() + mock_reset_counter.assert_not_called() @patch("et.jira_time.fetch_issue") -@patch("et.jira_time.tracker.save_timers_with_reload") +@patch("et.jira_time.et_extension.reset_workspace_counter") @patch("et.jira_time.create_worklog", side_effect=JiraError("boom")) -@patch("et.jira_time.tracker.load_timers") +@patch("et.jira_time.et_extension.get_workspace_counter") @patch("et.jira_time.load_config") def test_log_all_skips_workspace_whose_jira_call_fails_but_logs_the_rest( - mock_load_config, mock_load_timers, mock_create_worklog, mock_save_timers, mock_fetch_issue + mock_load_config, + mock_get_counter, + mock_create_worklog, + mock_reset_counter, + mock_fetch_issue, ): mock_load_config.return_value = _config( [ @@ -455,8 +471,11 @@ def test_log_all_skips_workspace_whose_jira_call_fails_but_logs_the_rest( WorkspaceConfigEntry(name="ISD-654", ref="jira:ISD-654"), ] ) - timers = [_timer(0, 3600), _timer(1, 1800, name="ET-2")] - mock_load_timers.return_value = timers + counters = { + 0: WorkspaceCounter(elapsed_seconds=3600, running=False), + 1: WorkspaceCounter(elapsed_seconds=1800, running=False), + } + mock_get_counter.side_effect = lambda index: counters[index] mock_fetch_issue.return_value = _issue("ISD-654") def create_worklog_side_effect(_jira_config, key, *_args, **_kwargs): @@ -470,9 +489,8 @@ def create_worklog_side_effect(_jira_config, key, *_args, **_kwargs): assert [s.issue_key for s in result.skipped] == ["ISD-321"] assert result.skipped[0].reason == "boom" assert [r.issue_key for r in result.logged] == ["ISD-654"] - # The failed workspace's timer must be left untouched. - assert timers[0]["timeElapsed"] == 3600 - mock_save_timers.assert_called_once() + # The failed workspace's counter must be left untouched. + mock_reset_counter.assert_called_once_with(1) @patch("et.jira_time.load_config") @@ -483,12 +501,12 @@ def test_log_all_raises_when_no_jira_config(mock_load_config): log_time_for_all_workspaces() -@patch("et.jira_time.tracker.save_timers_with_reload") +@patch("et.jira_time.et_extension.reset_workspace_counter") @patch("et.jira_time.create_worklog") -@patch("et.jira_time.tracker.load_timers", return_value=[]) +@patch("et.jira_time.et_extension.get_workspace_counter") @patch("et.jira_time.load_config") def test_log_all_ignores_workspaces_with_no_linked_issue( - mock_load_config, mock_load_timers, mock_create_worklog, mock_save_timers + mock_load_config, mock_get_counter, mock_create_worklog, mock_reset_counter ): mock_load_config.return_value = _config( [WorkspaceConfigEntry(name="misc"), WorkspaceConfigEntry(name="static", type="static")] @@ -499,23 +517,57 @@ def test_log_all_ignores_workspaces_with_no_linked_issue( assert result.logged == [] assert result.skipped == [] mock_create_worklog.assert_not_called() + mock_get_counter.assert_not_called() @patch("et.jira_time.fetch_issue") -@patch("et.jira_time.tracker.save_timers_with_reload") +@patch("et.jira_time.et_extension.reset_workspace_counter") @patch("et.jira_time.create_worklog") -@patch("et.jira_time.tracker.load_timers") +@patch("et.jira_time.et_extension.get_workspace_counter") @patch("et.jira_time.load_config") def test_log_all_does_not_reset_when_disabled( - mock_load_config, mock_load_timers, mock_create_worklog, mock_save_timers, mock_fetch_issue + mock_load_config, + mock_get_counter, + mock_create_worklog, + mock_reset_counter, + mock_fetch_issue, ): mock_load_config.return_value = _config( [WorkspaceConfigEntry(name="ISD-321", ref="jira:ISD-321")] ) - mock_load_timers.return_value = [_timer(0, 3600)] + mock_get_counter.return_value = WorkspaceCounter(elapsed_seconds=3600, running=False) mock_fetch_issue.return_value = _issue("ISD-321") result = log_time_for_all_workspaces(reset=False) - assert result.logged[0].tracker_reset is False - mock_save_timers.assert_not_called() + assert result.logged[0].counter_reset is False + mock_reset_counter.assert_not_called() + + +@patch("et.jira_time.fetch_issue") +@patch( + "et.jira_time.et_extension.reset_workspace_counter", + side_effect=EtExtensionError("reset boom"), +) +@patch("et.jira_time.create_worklog") +@patch("et.jira_time.et_extension.get_workspace_counter") +@patch("et.jira_time.load_config") +def test_log_all_reports_partial_success_when_reset_fails_after_successful_log( + mock_load_config, + mock_get_counter, + mock_create_worklog, + mock_reset_counter, + mock_fetch_issue, +): + mock_load_config.return_value = _config( + [WorkspaceConfigEntry(name="ISD-321", ref="jira:ISD-321")] + ) + mock_get_counter.return_value = WorkspaceCounter(elapsed_seconds=3600, running=False) + mock_fetch_issue.return_value = _issue("ISD-321") + + result = log_time_for_all_workspaces() + + assert len(result.logged) == 1 + assert result.logged[0].counter_reset is False + assert result.logged[0].counter_reset_error == "reset boom" + mock_create_worklog.assert_called_once() diff --git a/tests/test_task.py b/tests/test_task.py index 6bf05cc..fc1e96b 100644 --- a/tests/test_task.py +++ b/tests/test_task.py @@ -1,12 +1,13 @@ -"""Tests for et.task, mocking et.config/et.workspaces/et.tracker/et.jira/et.jira_time.""" +"""Tests for et.task, mocking et.config/et.workspaces/et.et_extension/et.jira/et.jira_time.""" from __future__ import annotations -from unittest.mock import MagicMock, patch +from unittest.mock import MagicMock, call, patch import pytest from et.config import ConfigError, EtConfig, JiraConfig, WorkspaceConfigEntry +from et.et_extension import EtExtensionError from et.jira import JiraError, JiraIssue, JiraSprint, JiraTransition from et.jira_time import JiraLogTimeError, LogTimeResult from et.task import ( @@ -23,7 +24,6 @@ get_current_status_for_current_workspace, set_status_for_current_workspace, ) -from et.tracker import TrackerError from et.workspaces import WorkspaceError from et.ws import WsDeleteError @@ -55,7 +55,7 @@ def _config( @patch("et.task.workspaces.switch_to_workspace") @patch("et.task.workspaces.rename_all_workspaces") @patch("et.task.save_config") -@patch("et.task.tracker.prepare_timer_for_workspace", return_value=("ET-1", True)) +@patch("et.task.et_extension.prepare_workspace") @patch("et.task.workspaces.set_workspace_count") @patch("et.task.workspaces.get_workspace_count", return_value=5) @patch("et.task.load_config") @@ -63,7 +63,7 @@ def test_create_task_workspace_uses_first_free_slot( mock_load_config, _mock_get_count, mock_set_count, - mock_add_tracker, + mock_prepare_workspace, mock_save_config, mock_rename_all, mock_switch, @@ -82,10 +82,9 @@ def test_create_task_workspace_uses_first_free_slot( assert result.workspace_index == 1 assert result.name == "my-task" assert result.ref is None - assert result.timer_created is True mock_set_count.assert_not_called() - mock_add_tracker.assert_called_once_with(1, 5) + mock_prepare_workspace.assert_called_once_with(1, "doing stuff", 0) saved_config = mock_save_config.call_args[0][0] assert saved_config.workspaces[1] == WorkspaceConfigEntry( name="my-task", ref=None, description="doing stuff" @@ -100,7 +99,7 @@ def test_create_task_workspace_uses_first_free_slot( @patch("et.task.workspaces.switch_to_workspace") @patch("et.task.workspaces.rename_all_workspaces") @patch("et.task.save_config") -@patch("et.task.tracker.prepare_timer_for_workspace", return_value=("ET-1", True)) +@patch("et.task.et_extension.prepare_workspace") @patch("et.task.workspaces.set_workspace_count") @patch("et.task.workspaces.get_workspace_count", return_value=1) @patch("et.task.load_config") @@ -108,7 +107,7 @@ def test_create_task_workspace_succeeds_when_window_move_unsupported( mock_load_config, _mock_get_count, _mock_set_count, - _mock_add_tracker, + _mock_prepare_workspace, _mock_save_config, _mock_rename_all, mock_switch, @@ -128,7 +127,7 @@ def test_create_task_workspace_succeeds_when_window_move_unsupported( @patch("et.task.workspaces.switch_to_workspace") @patch("et.task.workspaces.rename_all_workspaces") @patch("et.task.save_config") -@patch("et.task.tracker.prepare_timer_for_workspace", return_value=("ET-2", True)) +@patch("et.task.et_extension.prepare_workspace") @patch("et.task.workspaces.set_workspace_count") @patch("et.task.workspaces.get_workspace_count", return_value=3) @patch("et.task.load_config") @@ -136,7 +135,7 @@ def test_create_task_workspace_uses_implicit_bare_slot_within_count( mock_load_config, _mock_get_count, mock_set_count, - mock_add_tracker, + mock_prepare_workspace, mock_save_config, _mock_rename_all, _mock_switch, @@ -153,14 +152,14 @@ def test_create_task_workspace_uses_implicit_bare_slot_within_count( assert result is not None assert result.workspace_index == 1 mock_set_count.assert_not_called() - mock_add_tracker.assert_called_once_with(1, 3) + mock_prepare_workspace.assert_called_once_with(1, "my-task", 0) @patch("et.task.workspaces.move_active_window_to_workspace") @patch("et.task.workspaces.switch_to_workspace") @patch("et.task.workspaces.rename_all_workspaces") @patch("et.task.save_config") -@patch("et.task.tracker.prepare_timer_for_workspace", return_value=("ET-3", True)) +@patch("et.task.et_extension.prepare_workspace") @patch("et.task.workspaces.set_workspace_count") @patch("et.task.workspaces.get_workspace_count", return_value=2) @patch("et.task.load_config") @@ -168,7 +167,7 @@ def test_create_task_workspace_prompts_and_grows_when_all_full( mock_load_config, _mock_get_count, mock_set_count, - mock_add_tracker, + mock_prepare_workspace, mock_save_config, _mock_rename_all, _mock_switch, @@ -193,20 +192,20 @@ def confirm_grow(count: int) -> bool: assert result.workspace_index == 2 assert seen_count == [2] mock_set_count.assert_called_once_with(3) - mock_add_tracker.assert_called_once_with(2, 3) + mock_prepare_workspace.assert_called_once_with(2, "my-task", 0) saved_config = mock_save_config.call_args[0][0] assert len(saved_config.workspaces) == 3 @patch("et.task.workspaces.set_workspace_count") @patch("et.task.save_config") -@patch("et.task.tracker.prepare_timer_for_workspace") +@patch("et.task.et_extension.prepare_workspace") @patch("et.task.workspaces.get_workspace_count", return_value=2) @patch("et.task.load_config") def test_create_task_workspace_returns_none_when_grow_declined( mock_load_config, _mock_get_count, - mock_add_tracker, + mock_prepare_workspace, mock_save_config, mock_set_count, ): @@ -221,7 +220,7 @@ def test_create_task_workspace_returns_none_when_grow_declined( assert result is None mock_set_count.assert_not_called() - mock_add_tracker.assert_not_called() + mock_prepare_workspace.assert_not_called() mock_save_config.assert_not_called() @@ -234,24 +233,28 @@ def test_create_task_workspace_wraps_workspace_error(mock_load_config, _mock_get create_task_workspace("my-task") -@patch("et.task.tracker.prepare_timer_for_workspace", side_effect=TrackerError("tracker boom")) +@patch( + "et.task.et_extension.prepare_workspace", + side_effect=EtExtensionError("extension boom"), +) +@patch("et.task.save_config") @patch("et.task.workspaces.get_workspace_count", return_value=1) @patch("et.task.load_config") -def test_create_task_workspace_wraps_tracker_error( - mock_load_config, _mock_get_count, _mock_add_tracker +def test_create_task_workspace_wraps_extension_error( + mock_load_config, _mock_get_count, _mock_save_config, _mock_prepare_workspace ): mock_load_config.return_value = _config([WorkspaceConfigEntry(name="ET-1")]) - with pytest.raises(TaskError, match="tracker boom"): + with pytest.raises(TaskError, match="extension boom"): create_task_workspace("my-task") @patch("et.task.save_config", side_effect=ConfigError("config boom")) -@patch("et.task.tracker.prepare_timer_for_workspace", return_value=("ET-1", True)) +@patch("et.task.et_extension.prepare_workspace") @patch("et.task.workspaces.get_workspace_count", return_value=1) @patch("et.task.load_config") def test_create_task_workspace_wraps_config_error( - mock_load_config, _mock_get_count, _mock_add_tracker, _mock_save_config + mock_load_config, _mock_get_count, _mock_prepare_workspace, _mock_save_config ): mock_load_config.return_value = _config([WorkspaceConfigEntry(name="ET-1")]) @@ -259,13 +262,52 @@ def test_create_task_workspace_wraps_config_error( create_task_workspace("my-task") +@patch("et.task.workspaces.switch_to_workspace") +@patch("et.task.et_extension.prepare_workspace", side_effect=EtExtensionError("prepare boom")) +@patch("et.task.workspaces.rename_all_workspaces") +@patch("et.task.save_config") +@patch("et.task.workspaces.set_workspace_count") +@patch("et.task.workspaces.get_workspace_count", return_value=1) +@patch("et.task.load_config") +def test_create_task_workspace_rolls_back_reversible_state_on_failure( + mock_load_config, + _mock_get_count, + mock_set_count, + mock_save_config, + mock_rename_all, + mock_prepare, + mock_switch, +): + original_config = _config([WorkspaceConfigEntry(name="ISD-A", ref="jira:ISD-A")]) + mock_load_config.return_value = original_config + + with pytest.raises(TaskError, match="operation may be partially applied"): + create_task_workspace("my-task", confirm_grow=lambda _count: True) + + assert mock_set_count.call_args_list == [call(2), call(1)] + assert mock_save_config.call_args_list[-1] == call(original_config) + assert mock_rename_all.call_args_list == [call(["ISD-A", "my-task"]), call(["ISD-A"])] + mock_prepare.assert_called_once_with(1, "my-task", 0) + mock_switch.assert_not_called() + + # --- create_task_from_jira -------------------------------------------------- def _issue( - key: str, summary: str = "Some issue", priority: str = "High", status: str = "" + key: str, + summary: str = "Some issue", + priority: str = "High", + status: str = "", + original_estimate_seconds: int | None = None, ) -> JiraIssue: - return JiraIssue(key=key, summary=summary, priority=priority, status=status) + return JiraIssue( + key=key, + summary=summary, + priority=priority, + status=status, + original_estimate_seconds=original_estimate_seconds, + ) @patch("et.task.load_config") @@ -329,10 +371,25 @@ def confirm_grow(count: int) -> bool: name="A rather long issue summary he", description="A rather long issue summary here", ref="jira:ISD-2", + estimate_seconds=None, confirm_grow=confirm_grow, ) +@patch("et.task.create_task_workspace") +@patch("et.task.fetch_active_issues") +@patch("et.task.load_config") +def test_create_task_from_jira_forwards_original_estimate_seconds( + mock_load_config, mock_fetch, mock_create_task_workspace +): + mock_load_config.return_value = _config() + mock_fetch.return_value = [_issue("ISD-2", original_estimate_seconds=3600)] + + create_task_from_jira(select_issue=lambda issues: issues[0]) + + assert mock_create_task_workspace.call_args.kwargs["estimate_seconds"] == 3600 + + @patch("et.task.create_task_workspace") @patch("et.task.fetch_active_issues") @patch("et.task.load_config") @@ -486,11 +543,29 @@ def confirm_grow(count: int) -> bool: name="A rather long issue summary he", description="A rather long issue summary here", ref="jira:ISD-2", + estimate_seconds=None, confirm_grow=confirm_grow, ) mock_ensure_sprint.assert_called_once() +@patch("et.task.ensure_issue_in_active_sprint") +@patch("et.task.create_task_workspace") +@patch("et.task.fetch_issue") +@patch("et.task.load_config") +def test_create_task_from_jira_key_forwards_original_estimate_seconds( + mock_load_config, mock_fetch_issue, mock_create_task_workspace, _mock_ensure_sprint +): + mock_load_config.return_value = _config() + mock_fetch_issue.return_value = _issue( + "ISD-2", status="In Progress", original_estimate_seconds=7200 + ) + + create_task_from_jira_key("ISD-2") + + assert mock_create_task_workspace.call_args.kwargs["estimate_seconds"] == 7200 + + @patch("et.task.ensure_issue_in_active_sprint") @patch("et.task.create_task_workspace") @patch("et.task.fetch_issue") @@ -751,18 +826,6 @@ def test_ensure_issue_in_active_sprint_warns_when_fetch_issue_sprint_fails( # --- complete_task_for_current_workspace ------------------------------------ -def _timer(workspace_id: int, name: str, elapsed: int = 0, running: bool = False) -> dict: - return { - "id": f"timer-{workspace_id}", - "name": name, - "timeElapsed": elapsed, - "running": running, - "selected": False, - "workspaceId": workspace_id, - "autoResume": True, - } - - @patch("et.task.delete_active_workspace") @patch("et.task.workspaces.get_workspace_count", return_value=3) @patch("et.task.load_config") @@ -771,7 +834,7 @@ def test_complete_task_deletes_workspace_after_logging( mock_log_time, mock_load_config, _mock_count, mock_delete ): mock_log_time.return_value = LogTimeResult( - workspace_index=1, issue_key="ISD-2", seconds_logged=780, tracker_reset=True + workspace_index=1, issue_key="ISD-2", seconds_logged=780, counter_reset=True ) mock_load_config.return_value = _config( [ @@ -796,7 +859,7 @@ def test_complete_task_deletes_workspace_after_logging( @patch("et.task.log_time_for_current_workspace") def test_complete_task_forwards_issue_key_override(mock_log_time, mock_load_config): mock_log_time.return_value = LogTimeResult( - workspace_index=1, issue_key="ISD-999", seconds_logged=780, tracker_reset=True + workspace_index=1, issue_key="ISD-999", seconds_logged=780, counter_reset=True ) mock_load_config.return_value = _config( [WorkspaceConfigEntry(name="ISD-2", ref="jira:ISD-2")] @@ -810,8 +873,7 @@ def test_complete_task_forwards_issue_key_override(mock_log_time, mock_load_conf @patch("et.task.workspaces.rename_all_workspaces") @patch("et.task.save_config") -@patch("et.task.tracker.save_timers_with_reload") -@patch("et.task.tracker.load_timers") +@patch("et.task.et_extension.remove_workspace") @patch("et.task.delete_active_workspace") @patch("et.task.workspaces.get_workspace_count", return_value=1) @patch("et.task.load_config") @@ -821,21 +883,18 @@ def test_complete_task_resets_slot_when_single_workspace_left( mock_load_config, _mock_count, mock_delete, - mock_load_timers, - mock_save_timers, + mock_remove, mock_save_config, mock_rename_all, ): # GNOME can't drop below one workspace, so completing the only workspace # resets its slot to a bare "ET-1" instead of deleting it. mock_log_time.return_value = LogTimeResult( - workspace_index=0, issue_key="ISD-1", seconds_logged=780, tracker_reset=True + workspace_index=0, issue_key="ISD-1", seconds_logged=780, counter_reset=True ) mock_load_config.return_value = _config( [WorkspaceConfigEntry(name="ISD-1", ref="jira:ISD-1", description="stuff")] ) - stale_timer = _timer(0, "ET-1", elapsed=0) - mock_load_timers.return_value = [stale_timer] result = complete_task_for_current_workspace(confirm_delete=lambda _result: True) @@ -845,9 +904,7 @@ def test_complete_task_resets_slot_when_single_workspace_left( saved_config = mock_save_config.call_args[0][0] assert saved_config.workspaces == [WorkspaceConfigEntry(name="ET-1")] mock_rename_all.assert_called_once_with(["ET-1"]) - - saved_timers = mock_save_timers.call_args[0][0] - assert stale_timer not in saved_timers + mock_remove.assert_called_once_with(0) @patch("et.task.log_time_for_current_workspace", side_effect=JiraLogTimeError("no timer")) @@ -864,7 +921,7 @@ def test_complete_task_wraps_ws_delete_error( mock_log_time, mock_load_config, _mock_count, _mock_delete ): mock_log_time.return_value = LogTimeResult( - workspace_index=0, issue_key="ISD-2", seconds_logged=780, tracker_reset=True + workspace_index=0, issue_key="ISD-2", seconds_logged=780, counter_reset=True ) mock_load_config.return_value = _config([WorkspaceConfigEntry(name="ISD-2", ref="jira:ISD-2")]) @@ -874,15 +931,15 @@ def test_complete_task_wraps_ws_delete_error( @patch("et.task.workspaces.rename_all_workspaces", side_effect=WorkspaceError("rename boom")) @patch("et.task.save_config") -@patch("et.task.tracker.load_timers", return_value=[]) +@patch("et.task.et_extension.remove_workspace") @patch("et.task.workspaces.get_workspace_count", return_value=1) @patch("et.task.load_config") @patch("et.task.log_time_for_current_workspace") def test_complete_task_wraps_workspace_error_after_logging( - mock_log_time, mock_load_config, _mock_count, _mock_load_timers, _mock_save_config, _rename + mock_log_time, mock_load_config, _mock_count, _mock_remove, _mock_save_config, _rename ): mock_log_time.return_value = LogTimeResult( - workspace_index=0, issue_key="ISD-2", seconds_logged=780, tracker_reset=True + workspace_index=0, issue_key="ISD-2", seconds_logged=780, counter_reset=True ) mock_load_config.return_value = _config([WorkspaceConfigEntry(name="ISD-2", ref="jira:ISD-2")]) @@ -890,19 +947,23 @@ def test_complete_task_wraps_workspace_error_after_logging( complete_task_for_current_workspace(confirm_delete=lambda _result: True) -@patch("et.task.tracker.load_timers", side_effect=TrackerError("no schema")) +@patch( + "et.task.et_extension.remove_workspace", + side_effect=EtExtensionError("extension down"), +) +@patch("et.task.save_config") @patch("et.task.workspaces.get_workspace_count", return_value=1) @patch("et.task.load_config") @patch("et.task.log_time_for_current_workspace") -def test_complete_task_wraps_tracker_error_loading_timers( - mock_log_time, mock_load_config, _mock_count, _mock_load_timers +def test_complete_task_wraps_extension_error_freeing_slot( + mock_log_time, mock_load_config, _mock_count, _mock_save_config, _mock_remove ): mock_log_time.return_value = LogTimeResult( - workspace_index=0, issue_key="ISD-2", seconds_logged=780, tracker_reset=True + workspace_index=0, issue_key="ISD-2", seconds_logged=780, counter_reset=True ) mock_load_config.return_value = _config([WorkspaceConfigEntry(name="ISD-2", ref="jira:ISD-2")]) - with pytest.raises(TaskError, match="no schema"): + with pytest.raises(TaskError, match="extension down"): complete_task_for_current_workspace(confirm_delete=lambda _result: True) @@ -910,7 +971,7 @@ def test_complete_task_wraps_tracker_error_loading_timers( @patch("et.task.log_time_for_current_workspace") def test_complete_task_reports_logged_time_before_prompts(mock_log_time, mock_load_config): log_result = LogTimeResult( - workspace_index=0, issue_key="ISD-2", seconds_logged=780, tracker_reset=True + workspace_index=0, issue_key="ISD-2", seconds_logged=780, counter_reset=True ) mock_log_time.return_value = log_result mock_load_config.return_value = _config([WorkspaceConfigEntry(name="ISD-2", ref="jira:ISD-2")]) @@ -925,14 +986,13 @@ def test_complete_task_reports_logged_time_before_prompts(mock_log_time, mock_lo @patch("et.task.workspaces.rename_all_workspaces") @patch("et.task.save_config") -@patch("et.task.tracker.load_timers", return_value=[]) @patch("et.task.load_config") @patch("et.task.log_time_for_current_workspace") def test_complete_task_leaves_workspace_when_delete_declined( - mock_log_time, mock_load_config, _mock_load_timers, mock_save_config, mock_rename_all + mock_log_time, mock_load_config, mock_save_config, mock_rename_all ): mock_log_time.return_value = LogTimeResult( - workspace_index=0, issue_key="ISD-2", seconds_logged=780, tracker_reset=True + workspace_index=0, issue_key="ISD-2", seconds_logged=780, counter_reset=True ) mock_load_config.return_value = _config([WorkspaceConfigEntry(name="ISD-2", ref="jira:ISD-2")]) @@ -943,6 +1003,27 @@ def test_complete_task_leaves_workspace_when_delete_declined( mock_rename_all.assert_not_called() +@patch("et.task.delete_active_workspace") +@patch("et.task.log_time_for_current_workspace") +def test_complete_task_keeps_workspace_when_counter_reset_failed( + mock_log_time, mock_delete_workspace +): + mock_log_time.return_value = LogTimeResult( + workspace_index=0, + issue_key="ISD-2", + seconds_logged=780, + counter_reset=False, + counter_reset_error="extension unavailable", + ) + confirm_delete = MagicMock(return_value=True) + + result = complete_task_for_current_workspace(confirm_delete=confirm_delete) + + assert result.workspace_freed is False + confirm_delete.assert_not_called() + mock_delete_workspace.assert_not_called() + + @patch("et.task.transition_issue") @patch("et.task.fetch_transitions") @patch("et.task.load_config") @@ -951,7 +1032,7 @@ def test_complete_task_moves_issue_to_done_when_confirmed( mock_log_time, mock_load_config, mock_fetch_transitions, mock_transition_issue ): mock_log_time.return_value = LogTimeResult( - workspace_index=0, issue_key="ISD-2", seconds_logged=780, tracker_reset=True + workspace_index=0, issue_key="ISD-2", seconds_logged=780, counter_reset=True ) mock_load_config.return_value = _config([WorkspaceConfigEntry(name="ISD-2", ref="jira:ISD-2")]) mock_fetch_transitions.return_value = [ @@ -973,7 +1054,7 @@ def test_complete_task_raises_when_no_done_transition_available( mock_log_time, mock_load_config, mock_fetch_transitions, mock_transition_issue ): mock_log_time.return_value = LogTimeResult( - workspace_index=0, issue_key="ISD-2", seconds_logged=780, tracker_reset=True + workspace_index=0, issue_key="ISD-2", seconds_logged=780, counter_reset=True ) mock_load_config.return_value = _config([WorkspaceConfigEntry(name="ISD-2", ref="jira:ISD-2")]) mock_fetch_transitions.return_value = [ diff --git a/tests/test_tracker.py b/tests/test_tracker.py deleted file mode 100644 index d3ef6ac..0000000 --- a/tests/test_tracker.py +++ /dev/null @@ -1,188 +0,0 @@ -"""Tests for et.tracker, mocking the gsettings, gnome_extensions, and workspaces layers.""" - -from __future__ import annotations - -import json -from contextlib import nullcontext -from unittest.mock import patch - -import pytest - -from et.gnome_extensions import GnomeExtensionsError -from et.gsettings import GSettingsError -from et.tracker import ( - TrackerError, - build_new_timer, - find_timer_for_workspace, - format_duration, - parse_hours_to_seconds, - prepare_timer_for_workspace, -) - -SETTINGS_SENTINEL = {"id": "settings", "totalTimeSelected": True} - - -def test_find_timer_for_workspace_returns_matching_entry(): - entries = [ - SETTINGS_SENTINEL, - {"id": "a", "name": "mails", "workspaceId": 0}, - {"id": "b", "name": "handson", "workspaceId": 1}, - ] - assert find_timer_for_workspace(entries, 1) == {"id": "b", "name": "handson", "workspaceId": 1} - - -def test_find_timer_for_workspace_ignores_sentinel_and_returns_none_when_missing(): - entries = [SETTINGS_SENTINEL, {"id": "a", "name": "mails", "workspaceId": 0}] - assert find_timer_for_workspace(entries, 5) is None - - -def test_build_new_timer_matches_tracker_default_shape(): - timer = build_new_timer(2, "focus") - assert timer["name"] == "focus" - assert timer["workspaceId"] == 2 - assert timer["timeElapsed"] == 0 - assert timer["running"] is False - assert timer["selected"] is False - assert timer["autoResume"] is True - assert isinstance(timer["id"], str) and timer["id"] - - -@patch("et.tracker.reload_around", return_value=nullcontext()) -@patch("et.tracker.gsettings.write_string_array") -@patch("et.tracker.gsettings.read_string_array", return_value=[]) -def test_prepare_timer_creates_timer_for_explicit_index(mock_read, mock_write, mock_reload): - name, created = prepare_timer_for_workspace(4, 5) - assert (name, created) == ("ET-5", True) - written_entries = [json.loads(raw) for raw in mock_write.call_args[0][2]] - assert written_entries[0]["workspaceId"] == 4 - - -@patch("et.tracker.reload_around", return_value=nullcontext()) -@patch("et.tracker.gsettings.write_string_array") -@patch( - "et.tracker.gsettings.read_string_array", - return_value=[ - json.dumps( - { - "id": "existing", - "name": "mails", - "timeElapsed": 42, - "running": True, - "selected": False, - "workspaceId": 2, - } - ), - ], -) -def test_prepare_timer_resets_reused_slot_time(mock_read, mock_write, mock_reload): - name, created = prepare_timer_for_workspace(2, 3) - assert (name, created) == ("mails", False) - written_entries = [json.loads(raw) for raw in mock_write.call_args[0][2]] - assert written_entries[0]["timeElapsed"] == 0 - assert written_entries[0]["running"] is False - - -@patch("et.tracker.reload_around", return_value=nullcontext()) -@patch("et.tracker.gsettings.write_string_array") -@patch( - "et.tracker.gsettings.read_string_array", - return_value=[ - json.dumps( - { - "id": "existing", - "name": "ET-3", - "timeElapsed": 0, - "running": False, - "selected": False, - "workspaceId": 2, - } - ), - ], -) -def test_prepare_timer_is_noop_when_reused_slot_already_zero(mock_read, mock_write, mock_reload): - name, created = prepare_timer_for_workspace(2, 3) - assert (name, created) == ("ET-3", False) - mock_write.assert_not_called() - - -@patch("et.tracker.reload_around", return_value=nullcontext()) -@patch("et.tracker.gsettings.write_string_array") -@patch( - "et.tracker.gsettings.read_string_array", - return_value=[ - json.dumps(SETTINGS_SENTINEL), - json.dumps( - {"id": "stale", "name": "ET-9", "timeElapsed": 99, "workspaceId": 8} - ), - json.dumps( - {"id": "keep", "name": "my-notes", "timeElapsed": 7, "workspaceId": 8} - ), - ], -) -def test_prepare_timer_removes_orphaned_et_timers_beyond_count(mock_read, mock_write, mock_reload): - name, created = prepare_timer_for_workspace(0, 3) - assert (name, created) == ("ET-1", True) - written_entries = [json.loads(raw) for raw in mock_write.call_args[0][2]] - names = [entry.get("name") for entry in written_entries] - assert "ET-9" not in names # orphaned et timer beyond the workspace count - assert "my-notes" in names # user-named timer left untouched - assert SETTINGS_SENTINEL in written_entries - assert "ET-1" in names - - -@patch("et.tracker.reload_around", return_value=nullcontext()) -@patch("et.tracker.gsettings.write_string_array") -@patch( - "et.tracker.gsettings.read_string_array", - return_value=[json.dumps(SETTINGS_SENTINEL)], -) -def test_prepare_timer_preserves_sentinel_entry(mock_read, mock_write, mock_reload): - prepare_timer_for_workspace(0, 1) - written_entries = [json.loads(raw) for raw in mock_write.call_args[0][2]] - assert written_entries[0] == SETTINGS_SENTINEL - assert len(written_entries) == 2 - - -@patch("et.tracker.gsettings.read_string_array", side_effect=GSettingsError("no schema")) -def test_prepare_timer_wraps_gsettings_error(mock_read): - with pytest.raises(TrackerError, match="Tracker GNOME extension"): - prepare_timer_for_workspace(0, 1) - - -@patch("et.tracker.reload_around", side_effect=GnomeExtensionsError("could not disable")) -@patch("et.tracker.gsettings.write_string_array") -@patch("et.tracker.gsettings.read_string_array", return_value=[]) -def test_prepare_timer_wraps_gnome_extensions_error(mock_read, mock_write, mock_reload): - with pytest.raises(TrackerError, match="reload the Tracker extension"): - prepare_timer_for_workspace(0, 1) - mock_write.assert_not_called() - - -def test_format_duration_renders_hours_minutes_seconds(): - assert format_duration(3725) == "1h 2m 5s" - assert format_duration(0) == "0h 0m 0s" - assert format_duration(59.9) == "0h 0m 59s" - - -@pytest.mark.parametrize( - ("text", "expected_seconds"), - [ - ("2h", 7200), - ("1h", 3600), - ("1.5h", 5400), - ("0.5h", 1800), - ("2H", 7200), - (" 2h ", 7200), - ], -) -def test_parse_hours_to_seconds_parses_valid_durations(text, expected_seconds): - assert parse_hours_to_seconds(text) == expected_seconds - - -@pytest.mark.parametrize( - "text", - ["2", "2m", "2h30m", "abc", "", "1.5", "-1h", "0h"], -) -def test_parse_hours_to_seconds_rejects_invalid_durations(text): - with pytest.raises(ValueError, match="invalid duration"): - parse_hours_to_seconds(text) diff --git a/tests/test_workspaces.py b/tests/test_workspaces.py index 9a96c3e..99058ea 100644 --- a/tests/test_workspaces.py +++ b/tests/test_workspaces.py @@ -7,6 +7,7 @@ import pytest +from et.et_extension import EtExtensionError from et.gsettings import GSettingsError from et.workspaces import ( WorkspaceError, @@ -36,16 +37,18 @@ def _completed( ) +@patch("et.workspaces._is_wayland_session", return_value=False) @patch("et.workspaces.shutil.which", return_value="/usr/bin/wmctrl") @patch("et.workspaces.subprocess.run") -def test_get_active_workspace_index_returns_marked_index(mock_run, _mock_which): +def test_get_active_workspace_index_returns_marked_index(mock_run, _mock_which, _mock_wayland): mock_run.return_value = _completed(stdout=WMCTRL_OUTPUT) assert get_active_workspace_index() == 1 +@patch("et.workspaces._is_wayland_session", return_value=False) @patch("et.workspaces.shutil.which", return_value="/usr/bin/wmctrl") @patch("et.workspaces.subprocess.run") -def test_get_active_workspace_index_raises_when_no_marker(mock_run, _mock_which): +def test_get_active_workspace_index_raises_when_no_marker(mock_run, _mock_which, _mock_wayland): mock_run.return_value = _completed( stdout="0 - DG: 1920x1080 VP: 0,0 WA: 0,0 1920x1055 Workspace 1\n" ) @@ -53,12 +56,49 @@ def test_get_active_workspace_index_raises_when_no_marker(mock_run, _mock_which) get_active_workspace_index() +@patch("et.workspaces._is_wayland_session", return_value=False) @patch("et.workspaces.shutil.which", return_value=None) -def test_get_active_workspace_index_raises_when_wmctrl_missing(_mock_which): +def test_get_active_workspace_index_raises_when_wmctrl_missing(_mock_which, _mock_wayland): with pytest.raises(WorkspaceError, match="wmctrl"): get_active_workspace_index() +@pytest.mark.parametrize( + ("xdg_session_type", "wayland_display", "expected"), + [ + ("wayland", "", True), + ("x11", "", False), + ("", "wayland-0", True), + ("", "", False), + ], +) +def test_is_wayland_session_detects_from_env(xdg_session_type, wayland_display, expected): + from et.workspaces import _is_wayland_session + + env = {"XDG_SESSION_TYPE": xdg_session_type, "WAYLAND_DISPLAY": wayland_display} + with patch("et.workspaces.os.environ", env): + assert _is_wayland_session() is expected + + +@patch("et.workspaces._is_wayland_session", return_value=True) +@patch("et.workspaces.et_extension.get_active_workspace_index", return_value=2) +def test_get_active_workspace_index_delegates_to_et_extension_on_wayland( + mock_get_index, _mock_wayland +): + assert get_active_workspace_index() == 2 + mock_get_index.assert_called_once_with() + + +@patch("et.workspaces._is_wayland_session", return_value=True) +@patch( + "et.workspaces.et_extension.get_active_workspace_index", + side_effect=EtExtensionError("the et GNOME Shell extension doesn't seem to be installed"), +) +def test_get_active_workspace_index_wraps_et_extension_error(mock_get_index, _mock_wayland): + with pytest.raises(WorkspaceError, match="et GNOME Shell extension"): + get_active_workspace_index() + + @patch( "et.workspaces.gsettings.read_string_array", return_value=["", "Focus", ""], diff --git a/tests/test_ws.py b/tests/test_ws.py index 3f4360b..3af755d 100644 --- a/tests/test_ws.py +++ b/tests/test_ws.py @@ -1,13 +1,17 @@ -"""Tests for et.ws, mocking et.config/et.workspaces/et.tracker.""" +"""Tests for et.ws, mocking et.config/et.workspaces/et.et_extension.""" from __future__ import annotations -from unittest.mock import patch +from unittest.mock import call, patch import pytest from et.config import ConfigError, EtConfig, WorkspaceConfigEntry -from et.tracker import TrackerError +from et.et_extension import ( + EtExtensionError, + WorkspaceCounter, + WorkspaceCounterNotFoundError, +) from et.workspaces import WorkspaceError from et.ws import ( WsDeleteError, @@ -29,55 +33,36 @@ def _config( return EtConfig(jira=None, workspaces=workspaces or []) -def _timer(workspace_id: int, name: str, elapsed: int = 0, running: bool = False) -> dict: - return { - "id": f"timer-{workspace_id}", - "name": name, - "timeElapsed": elapsed, - "running": running, - "selected": False, - "workspaceId": workspace_id, - "autoResume": True, - } - - # --- shift_workspaces_left --------------------------------------------------- -def test_shift_workspaces_left_moves_later_slots_and_timers(): +def test_shift_workspaces_left_moves_later_slots_and_returns_moves(): workspaces_list = [ WorkspaceConfigEntry(name="ISD-A", ref="jira:ISD-A"), WorkspaceConfigEntry(name="ET-2"), WorkspaceConfigEntry(name="ISD-C", ref="jira:ISD-C", description="stuff"), ] - stale_timer = _timer(1, "ET-2", elapsed=0) - c_timer = _timer(2, "ET-3", elapsed=42, running=True) - entries = [stale_timer, c_timer] - changed = shift_workspaces_left(workspaces_list, entries, 1) + moves = shift_workspaces_left(workspaces_list, 1) - assert changed is True + assert moves == [(2, 1)] assert workspaces_list == [ WorkspaceConfigEntry(name="ISD-A", ref="jira:ISD-A"), WorkspaceConfigEntry(name="ISD-C", ref="jira:ISD-C", description="stuff"), WorkspaceConfigEntry(name="ET-3"), ] - assert stale_timer not in entries - assert c_timer in entries - assert c_timer["workspaceId"] == 1 - assert c_timer["name"] == "ET-2" def test_shift_workspaces_left_noop_when_freed_is_static(): workspaces_list = [WorkspaceConfigEntry(name="mails", type="static")] - changed = shift_workspaces_left(workspaces_list, [], 0) - assert changed is False + moves = shift_workspaces_left(workspaces_list, 0) + assert moves == [] def test_shift_workspaces_left_noop_when_last_slot(): workspaces_list = [WorkspaceConfigEntry(name="ET-1")] - changed = shift_workspaces_left(workspaces_list, [], 0) - assert changed is False + moves = shift_workspaces_left(workspaces_list, 0) + assert moves == [] def test_shift_workspaces_left_preserves_destination_type(): @@ -86,8 +71,8 @@ def test_shift_workspaces_left_preserves_destination_type(): WorkspaceConfigEntry(name="ET-2"), WorkspaceConfigEntry(name="ISD-C", ref="jira:ISD-C"), ] - changed = shift_workspaces_left(workspaces_list, [], 1) - assert changed is False # no timers to move + moves = shift_workspaces_left(workspaces_list, 1) + assert moves == [(2, 1)] assert workspaces_list == [ WorkspaceConfigEntry(name="mails", type="static"), WorkspaceConfigEntry(name="ISD-C", ref="jira:ISD-C"), @@ -95,6 +80,23 @@ def test_shift_workspaces_left_preserves_destination_type(): ] +def test_shift_workspaces_left_multi_slot_shift_returns_ordered_moves(): + workspaces_list = [ + WorkspaceConfigEntry(name="ISD-A", ref="jira:ISD-A"), + WorkspaceConfigEntry(name="ISD-B", ref="jira:ISD-B"), + WorkspaceConfigEntry(name="ISD-C", ref="jira:ISD-C"), + WorkspaceConfigEntry(name="ISD-D", ref="jira:ISD-D"), + ] + moves = shift_workspaces_left(workspaces_list, 0) + assert moves == [(1, 0), (2, 1), (3, 2)] + assert workspaces_list == [ + WorkspaceConfigEntry(name="ISD-B", ref="jira:ISD-B"), + WorkspaceConfigEntry(name="ISD-C", ref="jira:ISD-C"), + WorkspaceConfigEntry(name="ISD-D", ref="jira:ISD-D"), + WorkspaceConfigEntry(name="ET-4"), + ] + + # --- _trim_trailing_default_entries ------------------------------------------ @@ -136,8 +138,8 @@ def test_trim_trailing_default_entries_stops_at_static(): @patch("et.ws.workspaces.rename_all_workspaces") @patch("et.ws.workspaces.set_workspace_count") @patch("et.ws.save_config") -@patch("et.ws.tracker.save_timers_with_reload") -@patch("et.ws.tracker.load_timers") +@patch("et.ws.et_extension.remap_workspaces") +@patch("et.ws.et_extension.remove_workspace") @patch("et.ws.workspaces.get_workspace_count", return_value=3) @patch("et.ws.workspaces.get_active_workspace_index", return_value=1) @patch("et.ws.load_config") @@ -145,8 +147,8 @@ def test_delete_active_workspace_shifts_and_shrinks( mock_load_config, _mock_active_index, _mock_get_count, - mock_load_timers, - mock_save_timers, + mock_remove, + mock_remap, mock_save_config, mock_set_count, mock_rename_all, @@ -159,8 +161,6 @@ def test_delete_active_workspace_shifts_and_shrinks( WorkspaceConfigEntry(name="ISD-C", ref="jira:ISD-C"), ] ) - c_timer = _timer(2, "ET-3", elapsed=99) - mock_load_timers.return_value = [c_timer] result = delete_active_workspace() @@ -177,18 +177,18 @@ def test_delete_active_workspace_shifts_and_shrinks( mock_rename_all.assert_called_once_with(["ISD-A", "ISD-C"]) mock_switch.assert_called_once_with(1) - saved_timers = mock_save_timers.call_args[0][0] - assert c_timer in saved_timers - assert c_timer["workspaceId"] == 1 - assert c_timer["name"] == "ET-2" + # The atomic remap overwrites the freed slot and removes the final source, + # so a separate counter removal is unnecessary. + mock_remove.assert_not_called() + mock_remap.assert_called_once_with([(2, 1)]) @patch("et.ws.workspaces.switch_to_workspace") @patch("et.ws.workspaces.rename_all_workspaces") @patch("et.ws.workspaces.set_workspace_count") @patch("et.ws.save_config") -@patch("et.ws.tracker.save_timers_with_reload") -@patch("et.ws.tracker.load_timers", return_value=[]) +@patch("et.ws.et_extension.remap_workspaces") +@patch("et.ws.et_extension.remove_workspace") @patch("et.ws.workspaces.get_workspace_count", return_value=5) @patch("et.ws.workspaces.get_active_workspace_index", return_value=2) @patch("et.ws.load_config") @@ -196,8 +196,8 @@ def test_delete_active_workspace_pads_implicit_slots( mock_load_config, _mock_active_index, _mock_get_count, - _mock_load_timers, - mock_save_timers, + mock_remove, + mock_remap, mock_save_config, mock_set_count, mock_rename_all, @@ -218,7 +218,12 @@ def test_delete_active_workspace_pads_implicit_slots( mock_set_count.assert_called_once_with(4) mock_rename_all.assert_called_once_with(["ISD-A", "ET-2", "ET-3", "ET-4"]) mock_switch.assert_called_once_with(2) - mock_save_timers.assert_not_called() + mock_remove.assert_not_called() + # Even though every shifted slot is a bare placeholder, the shift still + # reports moves (there's nothing content-bearing to distinguish an + # implicit slot from an explicit bare one), so the extension still gets + # an (empty-to-empty) remap call for them. + mock_remap.assert_called_once_with([(3, 2), (4, 3)]) @patch("et.ws.workspaces.get_workspace_count", return_value=2) @@ -268,17 +273,17 @@ def test_delete_active_workspace_rejects_last_remaining( @patch("et.ws.workspaces.rename_all_workspaces") @patch("et.ws.workspaces.set_workspace_count") @patch("et.ws.save_config") -@patch("et.ws.tracker.save_timers_with_reload") -@patch("et.ws.tracker.load_timers") +@patch("et.ws.et_extension.remap_workspaces") +@patch("et.ws.et_extension.remove_workspace") @patch("et.ws.workspaces.get_workspace_count", return_value=2) @patch("et.ws.workspaces.get_active_workspace_index", return_value=0) @patch("et.ws.load_config") -def test_delete_active_workspace_force_discards_linked_ref_and_timer( +def test_delete_active_workspace_force_discards_linked_ref_and_counter( mock_load_config, _mock_active_index, _mock_get_count, - mock_load_timers, - mock_save_timers, + mock_remove, + mock_remap, mock_save_config, mock_set_count, mock_rename_all, @@ -290,9 +295,6 @@ def test_delete_active_workspace_force_discards_linked_ref_and_timer( WorkspaceConfigEntry(name="ISD-B", ref="jira:ISD-B"), ] ) - a_timer = _timer(0, "ET-1", elapsed=500, running=True) - b_timer = _timer(1, "ET-2", elapsed=42) - mock_load_timers.return_value = [a_timer, b_timer] result = delete_active_workspace(force=True) @@ -305,28 +307,25 @@ def test_delete_active_workspace_force_discards_linked_ref_and_timer( mock_rename_all.assert_called_once_with(["ISD-B"]) mock_switch.assert_called_once_with(0) - saved_timers = mock_save_timers.call_args[0][0] - assert a_timer not in saved_timers # discarded, not logged - assert b_timer in saved_timers - assert b_timer["workspaceId"] == 0 - assert b_timer["name"] == "ET-1" + mock_remove.assert_not_called() + mock_remap.assert_called_once_with([(1, 0)]) @patch("et.ws.workspaces.switch_to_workspace") @patch("et.ws.workspaces.rename_all_workspaces") @patch("et.ws.workspaces.set_workspace_count") @patch("et.ws.save_config") -@patch("et.ws.tracker.save_timers_with_reload") -@patch("et.ws.tracker.load_timers") +@patch("et.ws.et_extension.remap_workspaces") +@patch("et.ws.et_extension.remove_workspace") @patch("et.ws.workspaces.get_workspace_count", return_value=2) @patch("et.ws.workspaces.get_active_workspace_index", return_value=1) @patch("et.ws.load_config") -def test_delete_active_workspace_force_last_slot_discards_timer( +def test_delete_active_workspace_force_last_slot_discards_counter( mock_load_config, _mock_active_index, _mock_get_count, - mock_load_timers, - mock_save_timers, + mock_remove, + mock_remap, mock_save_config, mock_set_count, mock_rename_all, @@ -338,8 +337,6 @@ def test_delete_active_workspace_force_last_slot_discards_timer( WorkspaceConfigEntry(name="ISD-B", ref="jira:ISD-B"), ] ) - b_timer = _timer(1, "ET-2", elapsed=500) - mock_load_timers.return_value = [b_timer] result = delete_active_workspace(force=True) @@ -351,19 +348,17 @@ def test_delete_active_workspace_force_last_slot_discards_timer( mock_rename_all.assert_called_once_with(["ISD-A"]) mock_switch.assert_called_once_with(0) # Deleting the last non-static slot has nothing to shift, but the deleted - # workspace's own timer must still be discarded rather than orphaned. - mock_save_timers.assert_called_once() - saved_timers = mock_save_timers.call_args[0][0] - assert b_timer not in saved_timers - assert saved_timers == [] + # workspace's own counter must still be discarded rather than orphaned. + mock_remove.assert_called_once_with(1) + mock_remap.assert_not_called() @patch("et.ws.workspaces.switch_to_workspace") @patch("et.ws.workspaces.rename_all_workspaces") @patch("et.ws.workspaces.set_workspace_count") @patch("et.ws.save_config") -@patch("et.ws.tracker.save_timers_with_reload") -@patch("et.ws.tracker.load_timers", return_value=[]) +@patch("et.ws.et_extension.remap_workspaces") +@patch("et.ws.et_extension.remove_workspace") @patch("et.ws.workspaces.get_workspace_count", return_value=2) @patch("et.ws.workspaces.get_active_workspace_index", return_value=0) @patch("et.ws.load_config") @@ -371,8 +366,8 @@ def test_delete_active_workspace_keeps_count_when_last_is_static( mock_load_config, _mock_active_index, _mock_get_count, - _mock_load_timers, - mock_save_timers, + _mock_remove, + _mock_remap, mock_save_config, mock_set_count, mock_rename_all, @@ -435,22 +430,39 @@ def test_delete_active_workspace_propagates_config_error(_mock_load_config): delete_active_workspace() -@patch("et.ws.tracker.load_timers", side_effect=TrackerError("tracker down")) +@patch("et.ws.workspaces.set_workspace_count") +@patch("et.ws.workspaces.rename_all_workspaces") +@patch("et.ws.save_config") +@patch("et.ws.et_extension.remap_workspaces", side_effect=EtExtensionError("extension down")) @patch("et.ws.workspaces.get_workspace_count", return_value=2) @patch("et.ws.workspaces.get_active_workspace_index", return_value=0) @patch("et.ws.load_config") -def test_delete_active_workspace_wraps_tracker_error( - mock_load_config, _mock_active_index, _mock_get_count, _mock_load_timers +def test_delete_active_workspace_wraps_extension_error( + mock_load_config, + _mock_active_index, + _mock_get_count, + _mock_remap, + mock_save, + mock_rename, + mock_set_count, ): - mock_load_config.return_value = _config([WorkspaceConfigEntry(name="ET-1")]) - with pytest.raises(WsDeleteError, match="tracker down"): + original_config = _config( + [WorkspaceConfigEntry(name="ET-1"), WorkspaceConfigEntry(name="ET-2")] + ) + mock_load_config.return_value = original_config + with pytest.raises(WsDeleteError, match="operation may be partially applied"): delete_active_workspace() + assert mock_set_count.call_args_list == [call(1), call(2)] + assert mock_rename.call_args_list[-1] == call(["ET-1", "ET-2"]) + assert mock_save.call_args_list[-1] == call(original_config) + @patch("et.ws.workspaces.rename_all_workspaces", side_effect=WorkspaceError("rename boom")) @patch("et.ws.workspaces.set_workspace_count") @patch("et.ws.save_config") -@patch("et.ws.tracker.load_timers", return_value=[]) +@patch("et.ws.et_extension.remap_workspaces") +@patch("et.ws.et_extension.remove_workspace") @patch("et.ws.workspaces.get_workspace_count", return_value=2) @patch("et.ws.workspaces.get_active_workspace_index", return_value=0) @patch("et.ws.load_config") @@ -458,7 +470,8 @@ def test_delete_active_workspace_wraps_workspace_error_during_apply( mock_load_config, _mock_active_index, _mock_get_count, - _mock_load_timers, + _mock_remove, + _mock_remap, _mock_save_config, _mock_set_count, _mock_rename_all, @@ -483,25 +496,60 @@ def test_prepare_organize_pads_and_filters_static(): assert slots == [1, 2, 3] -def test_list_organize_candidates_pairs_entries_and_timers(): +@patch("et.ws.et_extension.get_workspace_counter") +def test_list_organize_candidates_pairs_entries_and_counters(mock_get_counter): workspaces_list = [ WorkspaceConfigEntry(name="ISD-A", ref="jira:ISD-A"), WorkspaceConfigEntry(name="ET-2"), ] - entries = [_timer(0, "ET-1", elapsed=99)] - candidates = list_organize_candidates(workspaces_list, [0, 1], entries) + counters = { + 0: WorkspaceCounter(elapsed_seconds=99, running=False), + 1: WorkspaceCounter(elapsed_seconds=0, running=False), + } + mock_get_counter.side_effect = lambda index: counters[index] + + candidates = list_organize_candidates(workspaces_list, [0, 1]) + assert candidates[0].slot == 0 assert candidates[0].entry.name == "ISD-A" - assert candidates[0].timer is entries[0] + assert candidates[0].counter == counters[0] assert candidates[1].slot == 1 - assert candidates[1].timer is None + assert candidates[1].counter == counters[1] + + +@patch("et.ws.et_extension.get_workspace_counter") +def test_list_organize_candidates_allows_free_slot_without_counter(mock_get_counter): + workspaces_list = [ + WorkspaceConfigEntry(name="ISD-A", ref="jira:ISD-A"), + WorkspaceConfigEntry(name="ET-2"), + ] + prepared = WorkspaceCounter(elapsed_seconds=99, running=False) + mock_get_counter.side_effect = [ + prepared, + WorkspaceCounterNotFoundError("not prepared"), + ] + + candidates = list_organize_candidates(workspaces_list, [0, 1]) + + assert candidates[0].counter == prepared + assert candidates[1].counter is None + + +@patch( + "et.ws.et_extension.get_workspace_counter", + side_effect=EtExtensionError("extension down"), +) +def test_list_organize_candidates_propagates_extension_error(mock_get_counter): + workspaces_list = [WorkspaceConfigEntry(name="ISD-A", ref="jira:ISD-A")] + with pytest.raises(EtExtensionError, match="extension down"): + list_organize_candidates(workspaces_list, [0]) # --- parse_organize_order ----------------------------------------------------- def test_parse_organize_order_happy_path(): - lines = ["# comment", "", "2", "1\tISD-A\tISD-A\tno timer", "3"] + lines = ["# comment", "", "2", "1\tISD-A\tISD-A\t0h 0m 0s", "3"] assert parse_organize_order(lines, [0, 1, 2]) == [1, 0, 2] @@ -537,34 +585,19 @@ def test_parse_organize_order_rejects_unparseable_line(): # --- build_organize_plan ------------------------------------------------------ -def test_build_organize_plan_swap_moves_timers(): +def test_build_organize_plan_swap(): workspaces_list = [ WorkspaceConfigEntry(name="ISD-A", ref="jira:ISD-A"), WorkspaceConfigEntry(name="ISD-B", ref="jira:ISD-B"), ] - timer_a = _timer(0, "ET-1", elapsed=10) - timer_b = _timer(1, "ET-2", elapsed=20) - entries = [timer_a, timer_b] - plan = build_organize_plan(workspaces_list, entries, [0, 1], [1, 0]) + plan = build_organize_plan(workspaces_list, [0, 1], [1, 0]) by_new_slot = {row.new_slot: row for row in plan} assert by_new_slot[0].entry.name == "ISD-B" assert by_new_slot[0].old_slot == 1 - assert by_new_slot[0].timer is not None - assert by_new_slot[0].timer["workspaceId"] == 0 - assert by_new_slot[0].timer["name"] == "ET-1" - assert by_new_slot[0].timer["timeElapsed"] == 20 - assert by_new_slot[1].entry.name == "ISD-A" - assert by_new_slot[1].timer is not None - assert by_new_slot[1].timer["workspaceId"] == 1 - assert by_new_slot[1].timer["name"] == "ET-2" - assert by_new_slot[1].timer["timeElapsed"] == 10 - - # Original timers must be untouched (build_organize_plan is pure). - assert timer_a["workspaceId"] == 0 - assert timer_b["workspaceId"] == 1 + assert by_new_slot[1].old_slot == 0 def test_build_organize_plan_three_way_rotation(): @@ -573,10 +606,9 @@ def test_build_organize_plan_three_way_rotation(): WorkspaceConfigEntry(name="ISD-B", ref="jira:ISD-B"), WorkspaceConfigEntry(name="ISD-C", ref="jira:ISD-C"), ] - entries = [_timer(0, "ET-1"), _timer(1, "ET-2"), _timer(2, "ET-3")] # new_order[i] = source slot landing at slots[i]: rotate right by one. - plan = build_organize_plan(workspaces_list, entries, [0, 1, 2], [2, 0, 1]) + plan = build_organize_plan(workspaces_list, [0, 1, 2], [2, 0, 1]) by_new_slot = {row.new_slot: row for row in plan} assert by_new_slot[0].entry.name == "ISD-C" @@ -584,19 +616,17 @@ def test_build_organize_plan_three_way_rotation(): assert by_new_slot[2].entry.name == "ISD-B" -def test_build_organize_plan_noop_order_keeps_timer_identity(): +def test_build_organize_plan_noop_order_keeps_entries_in_place(): workspaces_list = [ WorkspaceConfigEntry(name="ISD-A", ref="jira:ISD-A"), WorkspaceConfigEntry(name="ISD-B", ref="jira:ISD-B"), ] - timer_a = _timer(0, "ET-1") - entries = [timer_a] - plan = build_organize_plan(workspaces_list, entries, [0, 1], [0, 1]) + plan = build_organize_plan(workspaces_list, [0, 1], [0, 1]) by_new_slot = {row.new_slot: row for row in plan} - assert by_new_slot[0].timer is timer_a - assert by_new_slot[1].timer is None + assert by_new_slot[0].old_slot == 0 + assert by_new_slot[1].old_slot == 1 def test_build_organize_plan_static_slots_excluded_from_slots_arg(): @@ -605,8 +635,7 @@ def test_build_organize_plan_static_slots_excluded_from_slots_arg(): WorkspaceConfigEntry(name="ISD-A", ref="jira:ISD-A"), WorkspaceConfigEntry(name="ISD-B", ref="jira:ISD-B"), ] - entries: list[dict] = [] - plan = build_organize_plan(workspaces_list, entries, [1, 2], [2, 1]) + plan = build_organize_plan(workspaces_list, [1, 2], [2, 1]) assert {row.old_slot for row in plan} == {1, 2} assert {row.new_slot for row in plan} == {1, 2} assert all(row.entry.type == "dynamic" for row in plan) @@ -615,13 +644,7 @@ def test_build_organize_plan_static_slots_excluded_from_slots_arg(): def test_build_organize_plan_rejects_non_permutation(): workspaces_list = [WorkspaceConfigEntry(name="ISD-A"), WorkspaceConfigEntry(name="ISD-B")] with pytest.raises(WsOrganizeError): - build_organize_plan(workspaces_list, [], [0, 1], [0, 0]) - - -def test_build_organize_plan_entry_without_timer_produces_none_row(): - workspaces_list = [WorkspaceConfigEntry(name="ISD-A"), WorkspaceConfigEntry(name="ISD-B")] - plan = build_organize_plan(workspaces_list, [], [0, 1], [1, 0]) - assert all(row.timer is None for row in plan) + build_organize_plan(workspaces_list, [0, 1], [0, 0]) # --- apply_organize_plan ------------------------------------------------------- @@ -629,9 +652,9 @@ def test_build_organize_plan_entry_without_timer_produces_none_row(): @patch("et.ws.workspaces.rename_all_workspaces") @patch("et.ws.save_config") -@patch("et.ws.tracker.save_timers_with_reload") -def test_apply_organize_plan_saves_config_timers_and_names( - mock_save_timers, mock_save_config, mock_rename_all +@patch("et.ws.et_extension.remap_workspaces") +def test_apply_organize_plan_saves_config_counters_and_names( + mock_remap, mock_save_config, mock_rename_all ): config = _config( [ @@ -640,18 +663,11 @@ def test_apply_organize_plan_saves_config_timers_and_names( ] ) workspaces_list = list(config.workspaces) - timer_a = _timer(0, "ET-1", elapsed=5) - timer_b = _timer(1, "ET-2", elapsed=15) - entries = [timer_a, timer_b] - plan = build_organize_plan(workspaces_list, entries, [0, 1], [1, 0]) - apply_organize_plan(config, workspaces_list, entries, plan) + plan = build_organize_plan(workspaces_list, [0, 1], [1, 0]) + apply_organize_plan(config, workspaces_list, plan) - mock_save_timers.assert_called_once() - saved_entries = mock_save_timers.call_args[0][0] - saved_by_workspace = {entry["workspaceId"]: entry for entry in saved_entries} - assert saved_by_workspace[0]["timeElapsed"] == 15 - assert saved_by_workspace[1]["timeElapsed"] == 5 + mock_remap.assert_called_once_with([(1, 0), (0, 1)]) mock_save_config.assert_called_once() saved_config = mock_save_config.call_args[0][0] @@ -661,9 +677,9 @@ def test_apply_organize_plan_saves_config_timers_and_names( mock_rename_all.assert_called_once_with(["ISD-B", "ISD-A"]) -@patch("et.ws.tracker.save_timers_with_reload") +@patch("et.ws.et_extension.remap_workspaces") @patch("et.ws.save_config") -def test_apply_organize_plan_skips_timer_save_when_noop(mock_save_config, mock_save_timers): +def test_apply_organize_plan_skips_remap_when_noop(mock_save_config, mock_remap): config = _config( [ WorkspaceConfigEntry(name="ISD-A", ref="jira:ISD-A"), @@ -671,30 +687,52 @@ def test_apply_organize_plan_skips_timer_save_when_noop(mock_save_config, mock_s ] ) workspaces_list = list(config.workspaces) - entries: list[dict] = [] - plan = build_organize_plan(workspaces_list, entries, [0, 1], [0, 1]) + plan = build_organize_plan(workspaces_list, [0, 1], [0, 1]) with patch("et.ws.workspaces.rename_all_workspaces"): - apply_organize_plan(config, workspaces_list, entries, plan) + apply_organize_plan(config, workspaces_list, plan) - mock_save_timers.assert_not_called() + mock_remap.assert_not_called() mock_save_config.assert_called_once() @patch("et.ws.workspaces.rename_all_workspaces", side_effect=WorkspaceError("rename boom")) @patch("et.ws.save_config") -def test_apply_organize_plan_wraps_workspace_error(_mock_save_config, _mock_rename_all): +@patch("et.ws.et_extension.remap_workspaces") +def test_apply_organize_plan_wraps_workspace_error( + _mock_remap, _mock_save_config, _mock_rename_all +): config = _config([WorkspaceConfigEntry(name="ISD-A"), WorkspaceConfigEntry(name="ISD-B")]) workspaces_list = list(config.workspaces) - plan = build_organize_plan(workspaces_list, [], [0, 1], [1, 0]) + plan = build_organize_plan(workspaces_list, [0, 1], [1, 0]) with pytest.raises(WsOrganizeError, match="rename boom"): - apply_organize_plan(config, workspaces_list, [], plan) + apply_organize_plan(config, workspaces_list, plan) +@patch("et.ws.et_extension.remap_workspaces") @patch("et.ws.save_config", side_effect=ConfigError("bad write")) -def test_apply_organize_plan_wraps_config_error(_mock_save_config): +def test_apply_organize_plan_wraps_config_error(_mock_save_config, _mock_remap): config = _config([WorkspaceConfigEntry(name="ISD-A"), WorkspaceConfigEntry(name="ISD-B")]) workspaces_list = list(config.workspaces) - plan = build_organize_plan(workspaces_list, [], [0, 1], [1, 0]) + plan = build_organize_plan(workspaces_list, [0, 1], [1, 0]) with pytest.raises(WsOrganizeError, match="bad write"): - apply_organize_plan(config, workspaces_list, [], plan) + apply_organize_plan(config, workspaces_list, plan) + + +@patch("et.ws.workspaces.rename_all_workspaces") +@patch("et.ws.save_config") +@patch("et.ws.et_extension.remap_workspaces", side_effect=EtExtensionError("extension down")) +def test_apply_organize_plan_wraps_extension_error( + _mock_remap, mock_save_config, mock_rename +): + config = _config([WorkspaceConfigEntry(name="ISD-A"), WorkspaceConfigEntry(name="ISD-B")]) + workspaces_list = list(config.workspaces) + plan = build_organize_plan(workspaces_list, [0, 1], [1, 0]) + with pytest.raises(WsOrganizeError, match="operation may be partially applied"): + apply_organize_plan(config, workspaces_list, plan) + + assert mock_save_config.call_args_list[-1] == call(config) + assert mock_rename.call_args_list == [ + call(["ISD-B", "ISD-A"]), + call(["ISD-A", "ISD-B"]), + ] diff --git a/uv.lock b/uv.lock index 971d43d..50be39b 100644 --- a/uv.lock +++ b/uv.lock @@ -206,7 +206,7 @@ wheels = [ [[package]] name = "et" -version = "0.1.0" +version = "1.0.0rc1" source = { editable = "." } dependencies = [ { name = "pyyaml" },