Skip to content

Commit 89287a4

Browse files
committed
feat: add GitLab API support and fix pin filtering
Add comprehensive GitLab API support for tools hosted on GitLab: - New latest_gitlab() function with releases and tags API support - Update glab tool to use official GitLab repo (gitlab-org/cli) - Add GitLab project support in github_release_binary installer - Update helper functions for GitLab URLs and methods Fix pin filtering in upgrade guide: - Add explicit check for pinned_version="never" to skip tools permanently - Add semantic version comparison to skip when latest <= pinned - Remove confusing status override in cli_audit.py that showed ✅ for uninstalled tools Add snapshot refresh delay: - 500ms delay before snapshot refresh to allow binary updates to propagate - Fixes race condition where upgrade succeeds but snapshot shows old version Update catalog entries: - glab: Switch from GitHub (profclems/glab v1.22.0) to GitLab (gitlab-org/cli v1.74.0) - pip, pipx, poetry, yarn: Add pinned_version="never" to suppress install prompts
1 parent 454fa90 commit 89287a4

File tree

11 files changed

+198
-47
lines changed

11 files changed

+198
-47
lines changed

catalog/glab.json

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,12 @@
22
"name": "glab",
33
"install_method": "github_release_binary",
44
"description": "GitLab CLI tool bringing GitLab to your command line",
5-
"homepage": "https://github.com/profclems/glab",
6-
"github_repo": "profclems/glab",
5+
"homepage": "https://gitlab.com/gitlab-org/cli",
6+
"gitlab_project": "gitlab-org/cli",
77
"binary_name": "glab",
8-
"download_url_template": "https://github.com/profclems/glab/releases/download/{version}/glab_{version_nov}_Linux_{arch}.tar.gz",
8+
"download_url_template": "https://gitlab.com/api/v4/projects/gitlab-org%2Fcli/packages/generic/glab/{version_nov}/glab_{version_nov}_linux_{arch}.tar.gz",
99
"arch_map": {
10-
"x86_64": "x86_64",
10+
"x86_64": "amd64",
1111
"aarch64": "arm64",
1212
"armv7l": "armv6"
1313
}

catalog/pip.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,5 +10,6 @@
1010
"dnf": "python3-pip",
1111
"pacman": "python-pip"
1212
},
13-
"notes": "pip typically comes with Python 3. Use python3 -m pip if pip command is not available."
13+
"notes": "pip typically comes with Python 3. Use python3 -m pip if pip command is not available.",
14+
"pinned_version": "never"
1415
}

catalog/pipx.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,5 +9,6 @@
99
"brew": "pipx",
1010
"dnf": "pipx",
1111
"pacman": "python-pipx"
12-
}
12+
},
13+
"pinned_version": "never"
1314
}

catalog/poetry.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,5 +10,6 @@
1010
"dnf": "poetry",
1111
"pacman": "python-poetry"
1212
},
13-
"notes": "Some distributions may require installing via pipx or the official installer: curl -sSL https://install.python-poetry.org | python3 -"
13+
"notes": "Some distributions may require installing via pipx or the official installer: curl -sSL https://install.python-poetry.org | python3 -",
14+
"pinned_version": "never"
1415
}

catalog/yarn.json

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,11 @@
55
"homepage": "https://yarnpkg.com/",
66
"binary_name": "yarn",
77
"script": "install_yarn.sh",
8-
"notes": "Installed via Node.js corepack or npm. Requires Node.js to be installed via nvm. Do NOT install via apt (conflicts with cmdtest package)."
9-
,"guide": {
8+
"notes": "Installed via Node.js corepack or npm. Requires Node.js to be installed via nvm. Do NOT install via apt (conflicts with cmdtest package).",
9+
"guide": {
1010
"display_name": "Yarn",
1111
"install_action": "update",
1212
"order": 151
13-
}
13+
},
14+
"pinned_version": "never"
1415
}

cli_audit.py

Lines changed: 113 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -827,7 +827,7 @@ def _wcswidth(s: str) -> int:
827827
class Tool:
828828
name: str
829829
candidates: tuple[str, ...]
830-
source_kind: str # "gh" | "pypi" | "crates" | "npm" | "gnu" | "skip"
830+
source_kind: str # "gh" | "gitlab" | "pypi" | "crates" | "npm" | "gnu" | "skip"
831831
source_args: tuple[str, ...] # e.g., (owner, repo) or (package,) or (crate,) or (npm_pkg,) or (gnu_project,)
832832

833833

@@ -902,7 +902,7 @@ class Tool:
902902
# 5) VCS & platforms
903903
Tool("git", ("git",), "gh", ("git", "git")),
904904
Tool("gh", ("gh",), "gh", ("cli", "cli")),
905-
Tool("glab", ("glab",), "gh", ("profclems", "glab")),
905+
Tool("glab", ("glab",), "gitlab", ("gitlab-org", "cli")),
906906
Tool("gam", ("gam",), "gh", ("GAM-team", "GAM")),
907907
# 6) Task runners & build systems
908908
Tool("just", ("just",), "gh", ("casey", "just")),
@@ -1892,6 +1892,8 @@ def upstream_method_for(tool: Tool) -> str:
18921892
return "npm (nvm)"
18931893
if kind == "gh":
18941894
return "github"
1895+
if kind == "gitlab":
1896+
return "gitlab"
18951897
if kind == "gnu":
18961898
return "gnu-ftp"
18971899
return ""
@@ -1904,6 +1906,9 @@ def tool_homepage_url(tool: Tool) -> str:
19041906
if kind == "gh":
19051907
owner, repo = args # type: ignore[misc]
19061908
return f"https://github.com/{owner}/{repo}"
1909+
if kind == "gitlab":
1910+
group, project = args # type: ignore[misc]
1911+
return f"https://gitlab.com/{group}/{project}"
19071912
if kind == "pypi":
19081913
(pkg,) = args # type: ignore[misc]
19091914
return f"https://pypi.org/project/{pkg}/"
@@ -1930,6 +1935,11 @@ def latest_target_url(tool: Tool, latest_tag: str, latest_num: str) -> str:
19301935
if latest_tag:
19311936
return f"https://github.com/{owner}/{repo}/releases/tag/{latest_tag}"
19321937
return f"https://github.com/{owner}/{repo}/releases/latest"
1938+
if kind == "gitlab":
1939+
group, project = args # type: ignore[misc]
1940+
if latest_tag:
1941+
return f"https://gitlab.com/{group}/{project}/-/releases/{latest_tag}"
1942+
return f"https://gitlab.com/{group}/{project}/-/releases"
19331943
if kind == "pypi":
19341944
(pkg,) = args # type: ignore[misc]
19351945
return f"https://pypi.org/project/{pkg}/"
@@ -2162,6 +2172,92 @@ def latest_github(owner: str, repo: str) -> tuple[str, str]:
21622172
return "", ""
21632173

21642174

2175+
def latest_gitlab(group: str, project: str) -> tuple[str, str]:
2176+
"""
2177+
Fetch the latest release from GitLab using the GitLab API.
2178+
Args:
2179+
group: GitLab group/namespace (e.g., "gitlab-org")
2180+
project: Project name (e.g., "cli")
2181+
Returns:
2182+
(tag_name, version_number) tuple or ("", "") if not found
2183+
"""
2184+
if OFFLINE_MODE:
2185+
return "", ""
2186+
2187+
# GitLab API requires URL-encoded project path
2188+
project_path = f"{group}%2F{project}"
2189+
2190+
# Try releases API first (excludes pre-releases by default)
2191+
try:
2192+
url = f"https://gitlab.com/api/v4/projects/{project_path}/releases"
2193+
if AUDIT_DEBUG:
2194+
print(f"# DEBUG: GitLab API {url} (timeout={TIMEOUT_SECONDS}s)", file=sys.stderr, flush=True)
2195+
2196+
data = json.loads(http_get(url))
2197+
2198+
if isinstance(data, list) and data:
2199+
# GitLab releases API returns releases in descending order by default
2200+
# First release is the latest
2201+
release = data[0]
2202+
tag = normalize_version_tag((release.get("tag_name") or "").strip())
2203+
2204+
if tag:
2205+
result = (tag, extract_version_number(tag))
2206+
set_manual_latest(project, tag)
2207+
set_hint(f"gitlab:{group}/{project}", "releases_api")
2208+
if AUDIT_DEBUG:
2209+
print(f"# DEBUG: GitLab found release: {tag}", file=sys.stderr, flush=True)
2210+
return result
2211+
except Exception as e:
2212+
if AUDIT_DEBUG:
2213+
print(f"# DEBUG: GitLab releases API failed: {e}", file=sys.stderr, flush=True)
2214+
pass
2215+
2216+
# Fallback to tags API
2217+
try:
2218+
url = f"https://gitlab.com/api/v4/projects/{project_path}/repository/tags?per_page=20"
2219+
if AUDIT_DEBUG:
2220+
print(f"# DEBUG: GitLab tags API {url}", file=sys.stderr, flush=True)
2221+
2222+
data = json.loads(http_get(url))
2223+
2224+
if isinstance(data, list):
2225+
# Filter stable releases and find highest version
2226+
best: tuple[tuple[int, ...], str, str] | None = None
2227+
2228+
for item in data:
2229+
tag_name = (item.get("name") or "").strip()
2230+
tag = normalize_version_tag(tag_name)
2231+
2232+
# Accept only stable final release tags (v1.2.3, 1.2.3)
2233+
# Exclude rc, alpha, beta, pre, dev suffixes
2234+
if tag and re.match(r"^v?\d+\.\d+(\.\d+)?$", tag):
2235+
ver = extract_version_number(tag)
2236+
if ver:
2237+
try:
2238+
nums = tuple(int(x) for x in ver.split("."))
2239+
tup = (nums, tag, ver)
2240+
if best is None or tup[0] > best[0]:
2241+
best = tup
2242+
except Exception:
2243+
continue
2244+
2245+
if best is not None:
2246+
_, tag, ver = best
2247+
result = (tag, ver)
2248+
set_manual_latest(project, tag)
2249+
set_hint(f"gitlab:{group}/{project}", "tags_api")
2250+
if AUDIT_DEBUG:
2251+
print(f"# DEBUG: GitLab found tag: {tag}", file=sys.stderr, flush=True)
2252+
return result
2253+
except Exception as e:
2254+
if AUDIT_DEBUG:
2255+
print(f"# DEBUG: GitLab tags API failed: {e}", file=sys.stderr, flush=True)
2256+
pass
2257+
2258+
return "", ""
2259+
2260+
21652261
def latest_pypi(package: str) -> tuple[str, str]:
21662262
if OFFLINE_MODE:
21672263
return "", ""
@@ -2365,6 +2461,18 @@ def get_latest(tool: Tool) -> tuple[str, str]:
23652461
return man_tag, man_num
23662462
MANUAL_USED[tool.name] = False
23672463
return tag, num
2464+
if kind == "gitlab":
2465+
group, project = args # type: ignore[misc]
2466+
tag, num = latest_gitlab(group, project)
2467+
if tag or num:
2468+
MANUAL_USED[tool.name] = False
2469+
set_manual_method(tool.name, "gitlab")
2470+
return tag, num
2471+
if manual_available:
2472+
MANUAL_USED[tool.name] = True
2473+
return man_tag, man_num
2474+
MANUAL_USED[tool.name] = False
2475+
return tag, num
23682476
if kind == "pypi":
23692477
(pkg,) = args # type: ignore[misc]
23702478
tag, num = latest_pypi(pkg)
@@ -2530,20 +2638,9 @@ def audit_tool(tool: Tool) -> tuple[str, str, str, str, str, str, str, str]:
25302638
except Exception:
25312639
pass # Catalog read failed, continue with original status
25322640

2533-
# Check if tool is marked as "never install"
2534-
if status == "NOT INSTALLED":
2535-
script_dir = os.path.dirname(os.path.abspath(__file__))
2536-
catalog_file = os.path.join(script_dir, "catalog", f"{tool.name}.json")
2537-
if os.path.exists(catalog_file):
2538-
try:
2539-
with open(catalog_file, "r", encoding="utf-8") as f:
2540-
catalog_data = json.load(f)
2541-
pinned_version = catalog_data.get("pinned_version", "")
2542-
if pinned_version == "never":
2543-
# Tool is marked as never install - treat as up-to-date to suppress prompts
2544-
status = "UP-TO-DATE"
2545-
except Exception:
2546-
pass # Catalog read failed, continue with original status
2641+
# Note: Tools with pinned_version="never" are filtered out in guide.sh,
2642+
# so we don't need to change their status here. Keep them as NOT INSTALLED
2643+
# to avoid confusion (showing ✅ icon when tool isn't actually installed).
25472644

25482645
# Sanitize latest display to numeric (like installed)
25492646
if latest_num:

latest_versions.json

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -9,20 +9,20 @@
99
"gh:arxanas/git-branchless": "latest_redirect",
1010
"gh:ast-grep/ast-grep": "latest_redirect",
1111
"gh:astral-sh/uv": "latest_redirect",
12-
"gh:aws/aws-cli": "atom_filtered",
12+
"gh:aws/aws-cli": "tags_api",
1313
"gh:casey/just": "latest_redirect",
1414
"gh:cli/cli": "latest_redirect",
1515
"gh:composer/composer": "latest_redirect",
1616
"gh:dandavison/delta": "latest_redirect",
1717
"gh:direnv/direnv": "latest_redirect",
18-
"gh:docker/cli": "atom_filtered",
18+
"gh:docker/cli": "tags_api",
1919
"gh:docker/compose": "latest_redirect",
20-
"gh:eradman/entr": "atom_filtered",
20+
"gh:eradman/entr": "tags_api",
2121
"gh:eslint/eslint": "latest_redirect",
2222
"gh:git-lfs/git-lfs": "latest_redirect",
23-
"gh:git/git": "atom_filtered",
23+
"gh:git/git": "tags_api",
2424
"gh:gitleaks/gitleaks": "latest_redirect",
25-
"gh:golang/go": "atom",
25+
"gh:golang/go": "tags_api",
2626
"gh:golangci/golangci-lint": "latest_redirect",
2727
"gh:hashicorp/terraform": "latest_redirect",
2828
"gh:jqlang/jq": "latest_redirect",
@@ -36,7 +36,7 @@
3636
"gh:phiresky/ripgrep-all": "latest_redirect",
3737
"gh:prettier/prettier": "latest_redirect",
3838
"gh:profclems/glab": "latest_redirect",
39-
"gh:python/cpython": "atom_filtered",
39+
"gh:python/cpython": "tags_api",
4040
"gh:rs/curlie": "latest_redirect",
4141
"gh:ruby/ruby": "latest_redirect",
4242
"gh:rubygems/rubygems": "latest_redirect",
@@ -47,6 +47,7 @@
4747
"gh:universal-ctags/ctags": "latest_redirect",
4848
"gh:wagoodman/dive": "latest_redirect",
4949
"gh:watchexec/watchexec": "latest_redirect",
50+
"gitlab:gitlab-org/cli": "releases_api",
5051
"local_dc:docker-compose": "plugin",
5152
"local_flag:ast-grep": "--version",
5253
"local_flag:aws": "--version",
@@ -170,7 +171,7 @@
170171
"git-branchless": "github",
171172
"git-lfs": "github",
172173
"gitleaks": "github",
173-
"glab": "github",
174+
"glab": "gitlab",
174175
"go": "github",
175176
"golangci-lint": "github",
176177
"httpie": "pypi",

scripts/guide.sh

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -221,6 +221,44 @@ while read -r line; do
221221

222222
# Only process tools with catalog entries
223223
if catalog_has_tool "$tool_name"; then
224+
# Check if tool is pinned to a version >= latest available or "never"
225+
pinned_version="$(catalog_get_property "$tool_name" pinned_version)"
226+
227+
# Skip if pinned to "never" (permanently skip installation)
228+
if [ "$pinned_version" = "never" ]; then
229+
continue
230+
fi
231+
232+
latest_version="$(json_field "$tool_name" latest_version)"
233+
234+
if [ -n "$pinned_version" ] && [ -n "$latest_version" ]; then
235+
# Compare versions: skip if latest <= pinned
236+
# Simple numeric comparison for semantic versions
237+
if "$CLI" - "$pinned_version" "$latest_version" <<'PY'
238+
import sys
239+
try:
240+
pinned, latest = sys.argv[1], sys.argv[2]
241+
# Strip 'v' prefix if present
242+
pinned = pinned.lstrip('v')
243+
latest = latest.lstrip('v')
244+
# Split into parts and compare
245+
p_parts = [int(x) for x in pinned.split('.')[:3]]
246+
l_parts = [int(x) for x in latest.split('.')[:3]]
247+
# Pad with zeros if needed
248+
while len(p_parts) < 3: p_parts.append(0)
249+
while len(l_parts) < 3: l_parts.append(0)
250+
# Exit 0 (success) if latest <= pinned (should skip)
251+
sys.exit(0 if tuple(l_parts) <= tuple(p_parts) else 1)
252+
except Exception:
253+
# On error, don't skip (exit 1)
254+
sys.exit(1)
255+
PY
256+
then
257+
# Skip this tool - pinned version is >= latest available
258+
continue
259+
fi
260+
fi
261+
224262
TOOLS_TO_PROCESS+=("$tool_name")
225263
fi
226264
done <<< "$AUDIT_OUTPUT"

scripts/installers/github_release_binary.sh

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,14 @@ if [ -n "$VERSION_URL" ]; then
5656
LATEST="$(curl -fsSL "$VERSION_URL" 2>/dev/null || true)"
5757
fi
5858

59+
# Try GitLab project if available
60+
GITLAB_PROJECT="$(jq -r '.gitlab_project // empty' "$CATALOG_FILE")"
61+
if [ -z "$LATEST" ] && [ -n "$GITLAB_PROJECT" ]; then
62+
ENCODED_PROJECT="${GITLAB_PROJECT//\//%2F}"
63+
LATEST="$(curl -fsSL "https://gitlab.com/api/v4/projects/${ENCODED_PROJECT}/releases?per_page=1" 2>/dev/null | \
64+
jq -r '.[0].tag_name // empty' 2>/dev/null || true)"
65+
fi
66+
5967
# Fallback to GitHub releases if no version URL
6068
if [ -z "$LATEST" ] && [ -n "$GITHUB_REPO" ]; then
6169
LATEST="$(curl -fsSIL -H "User-Agent: cli-audit" -o /dev/null -w '%{url_effective}' \

scripts/lib/install_strategy.sh

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,9 @@ refresh_snapshot() {
8282

8383
echo "# Refreshing snapshot for $tool_name..." >&2
8484

85+
# Brief delay to ensure binary is fully updated and PATH is refreshed
86+
sleep 0.5
87+
8588
# Run audit in merge mode for this specific tool
8689
CLI_AUDIT_COLLECT=1 CLI_AUDIT_MERGE=1 python3 "$audit_script" --only "$tool_name" >/dev/null 2>&1 || {
8790
echo "# Warning: Failed to refresh snapshot for $tool_name" >&2

0 commit comments

Comments
 (0)