Plan: Opt-in Issue Cache
Motivation
Today, punchout's TUI fetches Jira issues over the network on every startup. The flow is:
Model.Init() → fetchJIRAIssues() → Jira.GetIssues(jql)
This is a blocking network call that happens before the user sees any issues. On a slow or flaky connection — or when working offline — this makes the tool feel sluggish and, at worst, unusable at launch.
The goal of this change is to make punchout nicer to use by removing that blocking call from the common path: show a locally cached copy of the issues instantly on startup, and let the user decide when to refresh from Jira.
Scope
In scope
- A local, on-disk cache of Jira issues for the TUI.
- An opt-in flag that enables an alternative startup pathway which reads from this cache instead of hitting the network.
- An explicit refresh mechanism (reusing the existing manual reload keybind) that fetches from Jira and updates the cache.
- A one-time bootstrap fetch when no valid cache file exists, so first-run users are never left staring at an uninitialized list. A successful Jira response containing zero issues is valid cached data, not a cache miss.
- Status-bar messaging so it is always clear whether the user is looking at cached or freshly fetched data.
Explicitly out of scope
- The default startup pathway is unchanged. With the flag off, punchout still fetches from the network on startup and never reads the cache. (It does write the cache as a best-effort side effect — see Architectural Decisions.)
- MCP is untouched. The
get_jira_issues tool stays always-live; it neither reads nor writes the cache (see Architectural Decisions).
- No changes to the
Jira service interface or its cloud/on-premise implementations.
- No automatic/background refresh (no stale-while-revalidate). Refresh is always explicit, except for the one-time missing-or-invalid-cache bootstrap.
- No changes to the worklog SQLite database schema or its version.
Behavior
Activation is via a new --use-issue-cache flag (with a matching use_issue_cache field in the TOML config), threaded through to a new UseIssueCache field on the Jira config and surfaced in --list-config.
| Situation |
Default (flag off) |
Cache mode (flag on) |
| Startup, valid cache exists |
network fetch + write-through |
cache read, no network |
| Startup, cache missing or invalid |
network fetch + write-through |
one-time bootstrap fetch + write-through |
| Manual reload keybind |
network fetch + write-through |
network fetch + write-through to cache |
Write-through happens on every successful fetch in both modes; only the read path is gated by the flag (see Architectural Decisions).
Key invariant: in cache mode, the network is only touched on startup when no valid cache file exists. A valid cache containing an empty issue list does not trigger a network request — refreshing is an explicit user action.
Status-bar messages communicate the source of the data:
- No valid cache →
"no cached issues; fetched from JIRA"
- Cache hit →
"loaded cached issues · fetched 2h ago"
- No valid cache and fetch failed →
"no cached issues and couldn't reach JIRA: <err>"
Architectural Decisions
1. The cache is disposable; keep it separate from precious data
The worklog database holds the user's actual time logs — irreplaceable, the whole point of the app. The issue cache is a throwaway convenience copy that can be regenerated from Jira at any moment. These two kinds of data should not share a home. Clearing or corrupting a cache must never put worklog data at risk.
2. Store the cache as a JSON file in the XDG cache directory
The cache lives as a JSON file under os.UserCacheDir() (e.g. ~/.cache/punchout/issues/<hash>.json) rather than inside the SQLite database.
- Correct semantics. The XDG cache directory is the designated "safe to delete" location; it can be pruned by the OS or the user with no consequences.
- No schema or migrations. The issue domain type already serializes to JSON, so persisting it is essentially free, and the cache metadata (JQL, fetch timestamp) rides along in the same object.
- Consistent with existing conventions. punchout already reads its config from the XDG config directory; putting the cache in the XDG cache directory is the natural sibling.
- Safe failure mode. An unreadable or corrupt file is treated as an invalid cache, which cleanly triggers the bootstrap fetch. Writes are atomic (temp-file + rename) to avoid torn files.
The main alternative considered was a new table in the existing SQLite database. It would have reused already-open infrastructure, but at the cost of mixing disposable data into the user's primary data store and of the wrong semantic location. The separation of disposable-from-precious was judged more valuable than reusing the existing storage handle.
3. Cache filenames are keyed by Jira identity and query
Cached Jira results are specific not only to a JQL query, but also to the Jira instance and authenticated user that ran it. Reusing a cache across identities could show a user issues that their credentials are not allowed to access.
The filename is therefore derived from a deterministic SHA-256 hash of:
- a cache format namespace, initially
punchout-issue-cache-v1;
- the Jira installation type;
- the effective Jira URL;
- the effective JQL; and
- an authenticated-principal fingerprint.
For Jira Cloud, the principal fingerprint is SHA-256 over a tagged configured Jira username. For on-premise Jira, where no username is required, it is SHA-256 over a tagged Jira token. The final key inputs are encoded unambiguously before being hashed; they are not joined with bare string concatenation. The raw username, token, URL, and JQL are not exposed in the filename, and the raw token is never persisted. All required inputs are available from the effective userJiraConfig after the config file and CLI overrides have been merged and validated; computing the key requires no network request.
The hash is computed once per TUI invocation and used as the cache filename under <user-cache-dir>/punchout/issues/. Including the format namespace provides a simple invalidation mechanism: an incompatible cache format change can bump v1 to v2, causing punchout to use new files without introducing cache migrations.
The cache still stores the JQL alongside the issues as defensive metadata. If it does not match the effective JQL, or if the file cannot be read or decoded, the cache is invalid and the bootstrap fetch repopulates it.
4. The read pathway is opt-in; the default startup path is preserved
Reading from the cache is gated entirely behind the --use-issue-cache flag. With the flag off, punchout never reads the cache and its startup behavior is the same as today: a network fetch before issues are shown. This keeps the change low-risk and lets users adopt the faster startup deliberately.
5. Write-through is always-on and best-effort
The cache is written on every successful fetch regardless of the flag — including in default mode. This means that by the time a user opts in, their cache is already warm from prior runs, so the first cache-mode launch typically has data to show immediately rather than needing a bootstrap fetch.
Persisting is strictly best-effort: a write failure (e.g. an unwritable cache directory, a full disk, or an undefined cache location) is swallowed — logged at most — and never blocks the fetch, alters the issues shown, or surfaces as an error. Consequently, enabling always-on writes does not weaken the robustness of the default pathway; it only leaves a warm cache behind as a side effect.
6. Bootstrap when no valid cache exists, but never silently refresh
A missing or invalid cache would otherwise leave users with an uninitialized list. Rather than require a manual refresh to get started, cache mode performs a one-time bootstrap fetch when no valid cache file exists. Because writes are always-on (Decision 5), this is a rare edge — only a truly first-ever run, a new Jira identity or query, an invalid cache file, or a new cache format namespace hits it. Crucially, the bootstrap is the only automatic network call in cache mode — once a valid cache exists, all refreshes are explicit.
A successful Jira response containing zero issues is still a valid result. It is written to and read from the cache normally and does not trigger another bootstrap fetch on the next startup. Cache validity is determined by the file and its metadata, never by len(issues).
7. The cache wraps the UI layer, not the service layer
The cache logic lives at the TUI orchestration layer, not behind the Jira.GetIssues interface. The service interface remains an honest representation of "the live source of truth." This preserves the ability to distinguish "read cache" from "fetch live" as separate operations — which the all-or-nothing GetIssues signature cannot express — and keeps the caching concern out of the shared service code used by MCP.
8. MCP stays always-live and cache-free
The cache exists to solve TUI startup latency. MCP's get_jira_issues is a request/response tool with no startup moment to optimize, and for tool calls freshness matters more than latency. MCP therefore neither reads nor writes the cache. A consequence is that running the MCP server does not warm the cache for the TUI — the cache is only ever populated by TUI fetches. Warming the cache from MCP would be a deliberate future addition, not an accidental behavior.
Plan: Opt-in Issue Cache
Motivation
Today, punchout's TUI fetches Jira issues over the network on every startup. The flow is:
Model.Init()→fetchJIRAIssues()→Jira.GetIssues(jql)This is a blocking network call that happens before the user sees any issues. On a slow or flaky connection — or when working offline — this makes the tool feel sluggish and, at worst, unusable at launch.
The goal of this change is to make punchout nicer to use by removing that blocking call from the common path: show a locally cached copy of the issues instantly on startup, and let the user decide when to refresh from Jira.
Scope
In scope
Explicitly out of scope
get_jira_issuestool stays always-live; it neither reads nor writes the cache (see Architectural Decisions).Jiraservice interface or its cloud/on-premise implementations.Behavior
Activation is via a new
--use-issue-cacheflag (with a matchinguse_issue_cachefield in the TOML config), threaded through to a newUseIssueCachefield on the Jira config and surfaced in--list-config.Write-through happens on every successful fetch in both modes; only the read path is gated by the flag (see Architectural Decisions).
Key invariant: in cache mode, the network is only touched on startup when no valid cache file exists. A valid cache containing an empty issue list does not trigger a network request — refreshing is an explicit user action.
Status-bar messages communicate the source of the data:
"no cached issues; fetched from JIRA""loaded cached issues · fetched 2h ago""no cached issues and couldn't reach JIRA: <err>"Architectural Decisions
1. The cache is disposable; keep it separate from precious data
The worklog database holds the user's actual time logs — irreplaceable, the whole point of the app. The issue cache is a throwaway convenience copy that can be regenerated from Jira at any moment. These two kinds of data should not share a home. Clearing or corrupting a cache must never put worklog data at risk.
2. Store the cache as a JSON file in the XDG cache directory
The cache lives as a JSON file under
os.UserCacheDir()(e.g.~/.cache/punchout/issues/<hash>.json) rather than inside the SQLite database.The main alternative considered was a new table in the existing SQLite database. It would have reused already-open infrastructure, but at the cost of mixing disposable data into the user's primary data store and of the wrong semantic location. The separation of disposable-from-precious was judged more valuable than reusing the existing storage handle.
3. Cache filenames are keyed by Jira identity and query
Cached Jira results are specific not only to a JQL query, but also to the Jira instance and authenticated user that ran it. Reusing a cache across identities could show a user issues that their credentials are not allowed to access.
The filename is therefore derived from a deterministic SHA-256 hash of:
punchout-issue-cache-v1;For Jira Cloud, the principal fingerprint is SHA-256 over a tagged configured Jira username. For on-premise Jira, where no username is required, it is SHA-256 over a tagged Jira token. The final key inputs are encoded unambiguously before being hashed; they are not joined with bare string concatenation. The raw username, token, URL, and JQL are not exposed in the filename, and the raw token is never persisted. All required inputs are available from the effective
userJiraConfigafter the config file and CLI overrides have been merged and validated; computing the key requires no network request.The hash is computed once per TUI invocation and used as the cache filename under
<user-cache-dir>/punchout/issues/. Including the format namespace provides a simple invalidation mechanism: an incompatible cache format change can bumpv1tov2, causing punchout to use new files without introducing cache migrations.The cache still stores the JQL alongside the issues as defensive metadata. If it does not match the effective JQL, or if the file cannot be read or decoded, the cache is invalid and the bootstrap fetch repopulates it.
4. The read pathway is opt-in; the default startup path is preserved
Reading from the cache is gated entirely behind the
--use-issue-cacheflag. With the flag off, punchout never reads the cache and its startup behavior is the same as today: a network fetch before issues are shown. This keeps the change low-risk and lets users adopt the faster startup deliberately.5. Write-through is always-on and best-effort
The cache is written on every successful fetch regardless of the flag — including in default mode. This means that by the time a user opts in, their cache is already warm from prior runs, so the first cache-mode launch typically has data to show immediately rather than needing a bootstrap fetch.
Persisting is strictly best-effort: a write failure (e.g. an unwritable cache directory, a full disk, or an undefined cache location) is swallowed — logged at most — and never blocks the fetch, alters the issues shown, or surfaces as an error. Consequently, enabling always-on writes does not weaken the robustness of the default pathway; it only leaves a warm cache behind as a side effect.
6. Bootstrap when no valid cache exists, but never silently refresh
A missing or invalid cache would otherwise leave users with an uninitialized list. Rather than require a manual refresh to get started, cache mode performs a one-time bootstrap fetch when no valid cache file exists. Because writes are always-on (Decision 5), this is a rare edge — only a truly first-ever run, a new Jira identity or query, an invalid cache file, or a new cache format namespace hits it. Crucially, the bootstrap is the only automatic network call in cache mode — once a valid cache exists, all refreshes are explicit.
A successful Jira response containing zero issues is still a valid result. It is written to and read from the cache normally and does not trigger another bootstrap fetch on the next startup. Cache validity is determined by the file and its metadata, never by
len(issues).7. The cache wraps the UI layer, not the service layer
The cache logic lives at the TUI orchestration layer, not behind the
Jira.GetIssuesinterface. The service interface remains an honest representation of "the live source of truth." This preserves the ability to distinguish "read cache" from "fetch live" as separate operations — which the all-or-nothingGetIssuessignature cannot express — and keeps the caching concern out of the shared service code used by MCP.8. MCP stays always-live and cache-free
The cache exists to solve TUI startup latency. MCP's
get_jira_issuesis a request/response tool with no startup moment to optimize, and for tool calls freshness matters more than latency. MCP therefore neither reads nor writes the cache. A consequence is that running the MCP server does not warm the cache for the TUI — the cache is only ever populated by TUI fetches. Warming the cache from MCP would be a deliberate future addition, not an accidental behavior.