Skip to content

Commit 1f8757a

Browse files
fix: add automatic update checks (#19)
1 parent e08f28e commit 1f8757a

4 files changed

Lines changed: 422 additions & 1 deletion

File tree

README.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,10 @@ You can also download a tarball from the GitHub Release page and run `atlassian/
3030

3131
## Update
3232

33+
On interactive commands, the CLI checks for a newer GitHub Release at most once every 24 hours and prints an update notice to stderr when a newer release exists. It never installs updates automatically, and JSON/YAML command output is not modified.
34+
35+
Set `ATLASSIAN_DISABLE_UPDATE_CHECK=1` to disable the automatic check.
36+
3337
Check for a newer GitHub Release:
3438

3539
```bash

src/atlassian_cli/cli.py

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
import os
2+
import sys
23
from pathlib import Path
34

45
import typer
56

7+
from atlassian_cli import __version__
68
from atlassian_cli.auth.headers import parse_cli_headers
79
from atlassian_cli.auth.models import AuthMode
810
from atlassian_cli.commands.init import init_command
@@ -19,7 +21,7 @@
1921
from atlassian_cli.config.template import ensure_default_config
2022
from atlassian_cli.core.context import LazyExecutionContext
2123
from atlassian_cli.core.errors import ConfigError
22-
from atlassian_cli.output.modes import OutputMode
24+
from atlassian_cli.output.modes import OutputMode, is_machine_output
2325
from atlassian_cli.products.bitbucket.commands.branch import app as bitbucket_branch_app
2426
from atlassian_cli.products.bitbucket.commands.commit import app as bitbucket_commit_app
2527
from atlassian_cli.products.bitbucket.commands.pr import app as bitbucket_pr_app
@@ -34,6 +36,7 @@
3436
from atlassian_cli.products.jira.commands.issue import app as jira_issue_app
3537
from atlassian_cli.products.jira.commands.project import app as jira_project_app
3638
from atlassian_cli.products.jira.commands.user import app as jira_user_app
39+
from atlassian_cli.update import check_for_update_notice
3740

3841
app = typer.Typer(help="Atlassian Server/Data Center CLI")
3942

@@ -64,6 +67,7 @@
6467

6568
DEFAULT_CONFIG_FILE = Path("~/.config/atlassian-cli/config.toml").expanduser()
6669
PRODUCT_COMMANDS = {product.value for product in Product}
70+
AUTO_UPDATE_CHECK_EXCLUDED_COMMANDS = {"update"}
6771

6872

6973
def _missing_product_message(config_file: Path, product: Product, *, created: bool) -> str:
@@ -72,6 +76,25 @@ def _missing_product_message(config_file: Path, product: Product, *, created: bo
7276
return f"Fill in [{product.value}] in {config_file} or pass --url."
7377

7478

79+
def _stderr_is_interactive() -> bool:
80+
return sys.stderr.isatty()
81+
82+
83+
def _maybe_notify_update(ctx: typer.Context, *, output: OutputMode) -> None:
84+
if ctx.invoked_subcommand is None or ctx.resilient_parsing:
85+
return
86+
if ctx.invoked_subcommand in AUTO_UPDATE_CHECK_EXCLUDED_COMMANDS:
87+
return
88+
if is_machine_output(output) or not _stderr_is_interactive():
89+
return
90+
try:
91+
notice = check_for_update_notice(__version__)
92+
if notice:
93+
typer.echo(notice, err=True)
94+
except Exception:
95+
return
96+
97+
7598
@app.callback()
7699
def root_callback(
77100
ctx: typer.Context,
@@ -87,6 +110,7 @@ def root_callback(
87110
) -> None:
88111
if ctx.invoked_subcommand is None:
89112
return
113+
_maybe_notify_update(ctx, output=output)
90114
if ctx.invoked_subcommand not in PRODUCT_COMMANDS:
91115
return
92116

src/atlassian_cli/update.py

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import subprocess
66
import sys
77
import tempfile
8+
import time
89
from dataclasses import dataclass
910
from pathlib import Path
1011
from typing import Any
@@ -20,6 +21,9 @@
2021
f"https://raw.githubusercontent.com/{REPO_OWNER}/{REPO_NAME}/{{tag}}/install.sh"
2122
)
2223
REQUEST_TIMEOUT_SECONDS = 10
24+
AUTO_UPDATE_CHECK_TIMEOUT_SECONDS = 2
25+
AUTO_UPDATE_CHECK_INTERVAL_SECONDS = 24 * 60 * 60
26+
AUTO_UPDATE_CHECK_DISABLE_ENV = "ATLASSIAN_DISABLE_UPDATE_CHECK"
2327

2428

2529
class UpdateError(RuntimeError):
@@ -155,6 +159,126 @@ def get_update_info(current_version: str) -> UpdateInfo:
155159
)
156160

157161

162+
def auto_update_check_state_path(
163+
*,
164+
environ: dict[str, str] | None = None,
165+
home: Path | None = None,
166+
) -> Path:
167+
env = os.environ if environ is None else environ
168+
cache_home = env.get("XDG_CACHE_HOME")
169+
base_dir = Path(cache_home).expanduser() if cache_home else (home or Path.home()) / ".cache"
170+
return base_dir / "atlassian-cli" / "update-check.json"
171+
172+
173+
def _env_flag_enabled(value: str | None) -> bool:
174+
return value is not None and value.strip().lower() in {"1", "true", "yes", "on"}
175+
176+
177+
def _read_update_check_state(state_path: Path) -> dict[str, Any]:
178+
try:
179+
payload = json.loads(state_path.read_text(encoding="utf-8"))
180+
except (OSError, json.JSONDecodeError):
181+
return {}
182+
return payload if isinstance(payload, dict) else {}
183+
184+
185+
def _write_update_check_state(state_path: Path, state: dict[str, Any]) -> None:
186+
try:
187+
state_path.parent.mkdir(parents=True, exist_ok=True)
188+
state_path.write_text(json.dumps(state, sort_keys=True), encoding="utf-8")
189+
except OSError:
190+
return
191+
192+
193+
def _record_update_check_error(
194+
state_path: Path | None,
195+
*,
196+
checked_at: int,
197+
error: Exception,
198+
extra_state: dict[str, Any] | None = None,
199+
) -> None:
200+
if state_path is None:
201+
return
202+
state = {
203+
"last_checked_at": checked_at,
204+
"last_error": str(error),
205+
}
206+
if extra_state:
207+
state.update(extra_state)
208+
_write_update_check_state(state_path, state)
209+
210+
211+
def _recently_checked(
212+
state: dict[str, Any],
213+
*,
214+
now: int,
215+
interval_seconds: int,
216+
) -> bool:
217+
if "last_checked_at" not in state:
218+
return False
219+
try:
220+
last_checked_at = int(state["last_checked_at"])
221+
except (TypeError, ValueError):
222+
return False
223+
if now < last_checked_at:
224+
return False
225+
return now - last_checked_at < interval_seconds
226+
227+
228+
def format_update_notice(current_version: str, latest: ReleaseInfo) -> str:
229+
lines = [
230+
f"atlassian-cli {current_version} can be updated to {latest.tag}.",
231+
"Run: atlassian update install",
232+
]
233+
if latest.url:
234+
lines.append(f"Release: {latest.url}")
235+
return "\n".join(lines)
236+
237+
238+
def check_for_update_notice(
239+
current_version: str,
240+
*,
241+
state_path: Path | None = None,
242+
now: int | None = None,
243+
environ: dict[str, str] | None = None,
244+
timeout: int = AUTO_UPDATE_CHECK_TIMEOUT_SECONDS,
245+
interval_seconds: int = AUTO_UPDATE_CHECK_INTERVAL_SECONDS,
246+
) -> str | None:
247+
env = os.environ if environ is None else environ
248+
if _env_flag_enabled(env.get(AUTO_UPDATE_CHECK_DISABLE_ENV)):
249+
return None
250+
251+
checked_at = int(time.time() if now is None else now)
252+
path: Path | None = None
253+
release_state: dict[str, Any] | None = None
254+
try:
255+
path = state_path or auto_update_check_state_path(environ=env)
256+
previous_state = _read_update_check_state(path)
257+
if _recently_checked(previous_state, now=checked_at, interval_seconds=interval_seconds):
258+
return None
259+
260+
latest = fetch_latest_release(timeout=timeout)
261+
release_state = {
262+
"last_checked_at": checked_at,
263+
"latest_tag": latest.tag,
264+
"latest_version": latest.version,
265+
"release_url": latest.url,
266+
}
267+
_write_update_check_state(path, release_state)
268+
update_available = is_newer_version(latest.version, current_version)
269+
except Exception as exc:
270+
_record_update_check_error(
271+
path,
272+
checked_at=checked_at,
273+
error=exc,
274+
extra_state=release_state,
275+
)
276+
return None
277+
if not update_available:
278+
return None
279+
return format_update_notice(current_version, latest)
280+
281+
158282
def default_install_dir(
159283
*,
160284
environ: dict[str, str] | None = None,

0 commit comments

Comments
 (0)