github-traffic-tracker 0.2.8a0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- ghtraf/__init__.py +9 -0
- ghtraf/__main__.py +8 -0
- ghtraf/_version.py +70 -0
- ghtraf/cli.py +172 -0
- ghtraf/commands/__init__.py +6 -0
- ghtraf/commands/create.py +392 -0
- ghtraf/config.py +182 -0
- ghtraf/configure.py +215 -0
- ghtraf/gh.py +183 -0
- ghtraf/gist.py +132 -0
- ghtraf/output.py +58 -0
- ghtraf/templates/docs/stats/README.md +52 -0
- ghtraf/templates/docs/stats/favicon.svg +6 -0
- ghtraf/templates/docs/stats/index.html +2715 -0
- github_traffic_tracker-0.2.8a0.dist-info/METADATA +159 -0
- github_traffic_tracker-0.2.8a0.dist-info/RECORD +20 -0
- github_traffic_tracker-0.2.8a0.dist-info/WHEEL +5 -0
- github_traffic_tracker-0.2.8a0.dist-info/entry_points.txt +3 -0
- github_traffic_tracker-0.2.8a0.dist-info/licenses/LICENSE +674 -0
- github_traffic_tracker-0.2.8a0.dist-info/top_level.txt +1 -0
ghtraf/config.py
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
"""Configuration management for ghtraf.
|
|
2
|
+
|
|
3
|
+
Three-layer config resolution (highest priority wins):
|
|
4
|
+
1. CLI flags — explicit on the command line
|
|
5
|
+
2. Project config — .ghtraf.json in the repo directory
|
|
6
|
+
3. Global config — ~/.ghtraf/config.json
|
|
7
|
+
|
|
8
|
+
This means once you run `ghtraf create --owner myorg --repo myproject`,
|
|
9
|
+
the project config remembers it. Future commands need zero flags.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import os
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
# ---------------------------------------------------------------------------
|
|
18
|
+
# Config file locations
|
|
19
|
+
# ---------------------------------------------------------------------------
|
|
20
|
+
def get_global_config_dir():
|
|
21
|
+
"""Return the global config directory (~/.ghtraf/)."""
|
|
22
|
+
return Path.home() / ".ghtraf"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def get_global_config_path():
|
|
26
|
+
"""Return path to the global config file."""
|
|
27
|
+
return get_global_config_dir() / "config.json"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def find_project_config(start_dir=None):
|
|
31
|
+
"""Walk up from start_dir looking for .ghtraf.json.
|
|
32
|
+
|
|
33
|
+
Returns the path if found, None otherwise.
|
|
34
|
+
"""
|
|
35
|
+
current = Path(start_dir or os.getcwd()).resolve()
|
|
36
|
+
for _ in range(20): # safety limit
|
|
37
|
+
candidate = current / ".ghtraf.json"
|
|
38
|
+
if candidate.is_file():
|
|
39
|
+
return candidate
|
|
40
|
+
parent = current.parent
|
|
41
|
+
if parent == current:
|
|
42
|
+
break
|
|
43
|
+
current = parent
|
|
44
|
+
return None
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
# ---------------------------------------------------------------------------
|
|
48
|
+
# Config loading
|
|
49
|
+
# ---------------------------------------------------------------------------
|
|
50
|
+
def load_json(path):
|
|
51
|
+
"""Load a JSON file, returning empty dict on error."""
|
|
52
|
+
try:
|
|
53
|
+
with open(path, encoding="utf-8") as f:
|
|
54
|
+
return json.load(f)
|
|
55
|
+
except (FileNotFoundError, json.JSONDecodeError):
|
|
56
|
+
return {}
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def load_global_config():
|
|
60
|
+
"""Load the global config file."""
|
|
61
|
+
return load_json(get_global_config_path())
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def load_project_config(start_dir=None):
|
|
65
|
+
"""Load the nearest .ghtraf.json walking upward from start_dir."""
|
|
66
|
+
path = find_project_config(start_dir)
|
|
67
|
+
if path:
|
|
68
|
+
return load_json(path), path
|
|
69
|
+
return {}, None
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
# ---------------------------------------------------------------------------
|
|
73
|
+
# Config resolution
|
|
74
|
+
# ---------------------------------------------------------------------------
|
|
75
|
+
def resolve_config(args, keys=None):
|
|
76
|
+
"""Resolve config values using three-layer precedence.
|
|
77
|
+
|
|
78
|
+
For each key in `keys`, checks (in order):
|
|
79
|
+
1. CLI args (from argparse namespace)
|
|
80
|
+
2. Project .ghtraf.json
|
|
81
|
+
3. Global ~/.ghtraf/config.json
|
|
82
|
+
|
|
83
|
+
Returns a dict with resolved values.
|
|
84
|
+
"""
|
|
85
|
+
if keys is None:
|
|
86
|
+
keys = ["owner", "repo", "repo_dir"]
|
|
87
|
+
|
|
88
|
+
project_cfg, _ = load_project_config(
|
|
89
|
+
getattr(args, "repo_dir", None)
|
|
90
|
+
)
|
|
91
|
+
global_cfg = load_global_config()
|
|
92
|
+
|
|
93
|
+
# If global config tracks repos, find matching repo config
|
|
94
|
+
repo_key = None
|
|
95
|
+
if hasattr(args, "owner") and hasattr(args, "repo"):
|
|
96
|
+
cli_owner = getattr(args, "owner", None)
|
|
97
|
+
cli_repo = getattr(args, "repo", None)
|
|
98
|
+
if cli_owner and cli_repo:
|
|
99
|
+
repo_key = f"{cli_owner}/{cli_repo}"
|
|
100
|
+
global_repo_cfg = {}
|
|
101
|
+
if repo_key:
|
|
102
|
+
global_repo_cfg = global_cfg.get("repos", {}).get(repo_key, {})
|
|
103
|
+
|
|
104
|
+
resolved = {}
|
|
105
|
+
for key in keys:
|
|
106
|
+
# Normalize key: argparse uses underscores, JSON may use either
|
|
107
|
+
arg_key = key.replace("-", "_")
|
|
108
|
+
json_key = key.replace("_", "-") if "_" in key else key
|
|
109
|
+
alt_json_key = key.replace("-", "_") if "-" in key else key
|
|
110
|
+
|
|
111
|
+
# Layer 1: CLI
|
|
112
|
+
cli_val = getattr(args, arg_key, None)
|
|
113
|
+
if cli_val is not None:
|
|
114
|
+
resolved[arg_key] = cli_val
|
|
115
|
+
continue
|
|
116
|
+
|
|
117
|
+
# Layer 2: Project config
|
|
118
|
+
proj_val = project_cfg.get(json_key) or project_cfg.get(alt_json_key)
|
|
119
|
+
if proj_val is not None:
|
|
120
|
+
resolved[arg_key] = proj_val
|
|
121
|
+
continue
|
|
122
|
+
|
|
123
|
+
# Layer 3: Global config (repo-specific section)
|
|
124
|
+
global_val = (global_repo_cfg.get(json_key)
|
|
125
|
+
or global_repo_cfg.get(alt_json_key))
|
|
126
|
+
if global_val is not None:
|
|
127
|
+
resolved[arg_key] = global_val
|
|
128
|
+
continue
|
|
129
|
+
|
|
130
|
+
resolved[arg_key] = None
|
|
131
|
+
|
|
132
|
+
return resolved
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
# ---------------------------------------------------------------------------
|
|
136
|
+
# Config writing
|
|
137
|
+
# ---------------------------------------------------------------------------
|
|
138
|
+
def save_project_config(data, repo_dir=None):
|
|
139
|
+
"""Write .ghtraf.json to the repo directory."""
|
|
140
|
+
target = Path(repo_dir or os.getcwd()) / ".ghtraf.json"
|
|
141
|
+
with open(target, "w", encoding="utf-8") as f:
|
|
142
|
+
json.dump(data, f, indent=2)
|
|
143
|
+
f.write("\n")
|
|
144
|
+
return target
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def save_global_config(data):
|
|
148
|
+
"""Write the global config file."""
|
|
149
|
+
config_dir = get_global_config_dir()
|
|
150
|
+
config_dir.mkdir(parents=True, exist_ok=True)
|
|
151
|
+
config_path = get_global_config_path()
|
|
152
|
+
with open(config_path, "w", encoding="utf-8") as f:
|
|
153
|
+
json.dump(data, f, indent=2)
|
|
154
|
+
f.write("\n")
|
|
155
|
+
return config_path
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def register_repo_globally(owner, repo, badge_gist_id=None,
|
|
159
|
+
archive_gist_id=None, repo_dir=None,
|
|
160
|
+
display_name=None, created=None):
|
|
161
|
+
"""Add or update a repo entry in the global config."""
|
|
162
|
+
config = load_global_config()
|
|
163
|
+
config.setdefault("version", 1)
|
|
164
|
+
config.setdefault("repos", {})
|
|
165
|
+
|
|
166
|
+
repo_key = f"{owner}/{repo}"
|
|
167
|
+
entry = config["repos"].get(repo_key, {})
|
|
168
|
+
|
|
169
|
+
if badge_gist_id:
|
|
170
|
+
entry["badge_gist_id"] = badge_gist_id
|
|
171
|
+
if archive_gist_id:
|
|
172
|
+
entry["archive_gist_id"] = archive_gist_id
|
|
173
|
+
if repo_dir:
|
|
174
|
+
entry["repo_dir"] = str(repo_dir)
|
|
175
|
+
if display_name:
|
|
176
|
+
entry["display_name"] = display_name
|
|
177
|
+
if created:
|
|
178
|
+
entry["created"] = created
|
|
179
|
+
|
|
180
|
+
config["repos"][repo_key] = entry
|
|
181
|
+
save_global_config(config)
|
|
182
|
+
return config
|
ghtraf/configure.py
ADDED
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
"""File configuration for ghtraf.
|
|
2
|
+
|
|
3
|
+
Handles updating dashboard HTML, README, and workflow YAML files
|
|
4
|
+
with project-specific values via regex replacement.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
import re
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from ghtraf.output import print_dry, print_ok, print_skip, print_warn
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def apply_replacements(filepath, replacements, config, dry_run=False):
|
|
15
|
+
"""Apply a list of (pattern, template, description) replacements to a file.
|
|
16
|
+
|
|
17
|
+
Args:
|
|
18
|
+
filepath: Path to the file to modify.
|
|
19
|
+
replacements: List of (regex_pattern, format_template, description) tuples.
|
|
20
|
+
config: Dict of values to substitute into templates.
|
|
21
|
+
dry_run: If True, only print what would happen.
|
|
22
|
+
|
|
23
|
+
Returns:
|
|
24
|
+
Count of successful replacements.
|
|
25
|
+
"""
|
|
26
|
+
filepath = Path(filepath)
|
|
27
|
+
if not filepath.exists():
|
|
28
|
+
print_warn(f"File not found: {filepath}")
|
|
29
|
+
return 0
|
|
30
|
+
|
|
31
|
+
content = filepath.read_text(encoding="utf-8")
|
|
32
|
+
original = content
|
|
33
|
+
success = 0
|
|
34
|
+
|
|
35
|
+
for pattern, template, desc in replacements:
|
|
36
|
+
formatted = template.format(**config)
|
|
37
|
+
new_content, count = re.subn(pattern, formatted, content, count=1)
|
|
38
|
+
if count > 0:
|
|
39
|
+
content = new_content
|
|
40
|
+
success += 1
|
|
41
|
+
if dry_run:
|
|
42
|
+
print_dry(f"{desc}")
|
|
43
|
+
else:
|
|
44
|
+
print_ok(f"{desc}")
|
|
45
|
+
else:
|
|
46
|
+
print_skip(f"{desc} (pattern not found)")
|
|
47
|
+
|
|
48
|
+
if not dry_run and content != original:
|
|
49
|
+
filepath.write_text(content, encoding="utf-8")
|
|
50
|
+
|
|
51
|
+
return success
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def configure_dashboard(config, dashboard_path, dry_run=False):
|
|
55
|
+
"""Update the dashboard HTML file with project-specific values.
|
|
56
|
+
|
|
57
|
+
Args:
|
|
58
|
+
config: Dict with owner, repo, display_name_html, gh_username,
|
|
59
|
+
badge_gist_id, archive_gist_id, created.
|
|
60
|
+
dashboard_path: Path to docs/stats/index.html.
|
|
61
|
+
dry_run: If True, only print what would happen.
|
|
62
|
+
|
|
63
|
+
Returns:
|
|
64
|
+
Count of successful replacements.
|
|
65
|
+
"""
|
|
66
|
+
print(f" Updating {dashboard_path}...")
|
|
67
|
+
|
|
68
|
+
replacements = [
|
|
69
|
+
# HTML title
|
|
70
|
+
(r'<title>.*?- Project Statistics</title>',
|
|
71
|
+
'<title>{display_name_html} - Project Statistics</title>',
|
|
72
|
+
"HTML title"),
|
|
73
|
+
# Banner link href
|
|
74
|
+
(r'href="https://github\.com/[^"]+?" class="banner-link"',
|
|
75
|
+
'href="https://github.com/{owner}/{repo}" class="banner-link"',
|
|
76
|
+
"Banner link URL"),
|
|
77
|
+
# Banner title text
|
|
78
|
+
(r'<p class="banner-title">.*?</p>',
|
|
79
|
+
'<p class="banner-title">{display_name_html}</p>',
|
|
80
|
+
"Banner title"),
|
|
81
|
+
# Footer repository link
|
|
82
|
+
(r'<a href="https://github\.com/[^"]+?">Repository</a>',
|
|
83
|
+
'<a href="https://github.com/{owner}/{repo}">Repository</a>',
|
|
84
|
+
"Footer repo link"),
|
|
85
|
+
# Footer releases link
|
|
86
|
+
(r'<a href="https://github\.com/[^"]+?/releases">Releases</a>',
|
|
87
|
+
'<a href="https://github.com/{owner}/{repo}/releases">Releases</a>',
|
|
88
|
+
"Footer releases link"),
|
|
89
|
+
# JS config: GIST_RAW_BASE
|
|
90
|
+
(r"const GIST_RAW_BASE = '[^']+';",
|
|
91
|
+
"const GIST_RAW_BASE = 'https://gist.githubusercontent.com/"
|
|
92
|
+
"{gh_username}/{badge_gist_id}/raw';",
|
|
93
|
+
"Gist raw base URL"),
|
|
94
|
+
# JS config: ARCHIVE_GIST_ID
|
|
95
|
+
(r"const ARCHIVE_GIST_ID = '[^']+';",
|
|
96
|
+
"const ARCHIVE_GIST_ID = '{archive_gist_id}';",
|
|
97
|
+
"Archive gist ID"),
|
|
98
|
+
# JS config: REPO_OWNER
|
|
99
|
+
(r"const REPO_OWNER = '[^']+';",
|
|
100
|
+
"const REPO_OWNER = '{owner}';",
|
|
101
|
+
"Repo owner"),
|
|
102
|
+
# JS config: REPO_NAME
|
|
103
|
+
(r"const REPO_NAME = '[^']+';",
|
|
104
|
+
"const REPO_NAME = '{repo}';",
|
|
105
|
+
"Repo name"),
|
|
106
|
+
# JS config: REPO_CREATED
|
|
107
|
+
(r"const REPO_CREATED = '[^']+';",
|
|
108
|
+
"const REPO_CREATED = '{created}';",
|
|
109
|
+
"Repo creation date"),
|
|
110
|
+
]
|
|
111
|
+
|
|
112
|
+
return apply_replacements(dashboard_path, replacements, config, dry_run)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def configure_readme(config, readme_path, dry_run=False):
|
|
116
|
+
"""Update the dashboard README.md with project-specific values.
|
|
117
|
+
|
|
118
|
+
Args:
|
|
119
|
+
config: Dict with owner, repo, display_name, gh_username,
|
|
120
|
+
badge_gist_id.
|
|
121
|
+
readme_path: Path to docs/stats/README.md.
|
|
122
|
+
dry_run: If True, only print what would happen.
|
|
123
|
+
|
|
124
|
+
Returns:
|
|
125
|
+
Count of successful replacements.
|
|
126
|
+
"""
|
|
127
|
+
print(f" Updating {readme_path}...")
|
|
128
|
+
|
|
129
|
+
config = dict(config) # copy to avoid mutating caller's dict
|
|
130
|
+
config["owner_lower"] = config["owner"].lower()
|
|
131
|
+
|
|
132
|
+
replacements = [
|
|
133
|
+
# Project name and link
|
|
134
|
+
(r'\[.*?\]\(https://github\.com/[^)]+\)\.',
|
|
135
|
+
'[{display_name}](https://github.com/{owner}/{repo}).',
|
|
136
|
+
"Project link"),
|
|
137
|
+
# Badge gist link
|
|
138
|
+
(r'\[Badge Gist\]\(https://gist\.github\.com/[^)]+\)',
|
|
139
|
+
'[Badge Gist](https://gist.github.com/{gh_username}/{badge_gist_id})',
|
|
140
|
+
"Badge gist link"),
|
|
141
|
+
# Dashboard URL
|
|
142
|
+
(r'\*\*https://[^*]+/stats/\*\*',
|
|
143
|
+
'**https://{owner_lower}.github.io/{repo}/stats/**',
|
|
144
|
+
"Dashboard URL"),
|
|
145
|
+
]
|
|
146
|
+
|
|
147
|
+
return apply_replacements(readme_path, replacements, config, dry_run)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def configure_workflow(config, workflow_path, dry_run=False):
|
|
151
|
+
"""Update the traffic-badges.yml workflow.
|
|
152
|
+
|
|
153
|
+
Args:
|
|
154
|
+
config: Dict with optional 'ci_workflows' list.
|
|
155
|
+
workflow_path: Path to .github/workflows/traffic-badges.yml.
|
|
156
|
+
dry_run: If True, only print what would happen.
|
|
157
|
+
|
|
158
|
+
Returns:
|
|
159
|
+
Count of changes made.
|
|
160
|
+
"""
|
|
161
|
+
print(f" Updating {workflow_path}...")
|
|
162
|
+
|
|
163
|
+
workflow_path = Path(workflow_path)
|
|
164
|
+
if not workflow_path.exists():
|
|
165
|
+
print_warn(f"File not found: {workflow_path}")
|
|
166
|
+
return 0
|
|
167
|
+
|
|
168
|
+
content = workflow_path.read_text(encoding="utf-8")
|
|
169
|
+
original = content
|
|
170
|
+
changes = 0
|
|
171
|
+
|
|
172
|
+
# Handle workflow_run trigger
|
|
173
|
+
if config.get("ci_workflows"):
|
|
174
|
+
names = json.dumps(config["ci_workflows"])
|
|
175
|
+
new_content = re.sub(
|
|
176
|
+
r'workflows: \[.*?\]',
|
|
177
|
+
f'workflows: {names}',
|
|
178
|
+
content
|
|
179
|
+
)
|
|
180
|
+
if new_content != content:
|
|
181
|
+
content = new_content
|
|
182
|
+
changes += 1
|
|
183
|
+
msg = f"workflow_run trigger: {names}"
|
|
184
|
+
print_dry(msg) if dry_run else print_ok(msg)
|
|
185
|
+
else:
|
|
186
|
+
new_content = re.sub(
|
|
187
|
+
r' workflow_run:.*?\n workflows:.*?\n types:.*?\n',
|
|
188
|
+
' # workflow_run: # Uncomment and set your CI workflow'
|
|
189
|
+
' name to run after CI\n'
|
|
190
|
+
' # workflows: ["CI"]\n'
|
|
191
|
+
' # types: [completed]\n',
|
|
192
|
+
content
|
|
193
|
+
)
|
|
194
|
+
if new_content != content:
|
|
195
|
+
content = new_content
|
|
196
|
+
changes += 1
|
|
197
|
+
msg = "workflow_run trigger: commented out (no CI workflows specified)"
|
|
198
|
+
print_dry(msg) if dry_run else print_ok(msg)
|
|
199
|
+
|
|
200
|
+
# Update archive version string
|
|
201
|
+
new_content = re.sub(
|
|
202
|
+
r'version: "[^"]+",',
|
|
203
|
+
'version: "0.1.0",',
|
|
204
|
+
content
|
|
205
|
+
)
|
|
206
|
+
if new_content != content:
|
|
207
|
+
content = new_content
|
|
208
|
+
changes += 1
|
|
209
|
+
msg = "Archive version: 0.1.0"
|
|
210
|
+
print_dry(msg) if dry_run else print_ok(msg)
|
|
211
|
+
|
|
212
|
+
if not dry_run and content != original:
|
|
213
|
+
workflow_path.write_text(content, encoding="utf-8")
|
|
214
|
+
|
|
215
|
+
return changes
|
ghtraf/gh.py
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
"""GitHub CLI (gh) wrapper utilities.
|
|
2
|
+
|
|
3
|
+
Provides authenticated access to the GitHub API via the gh CLI tool.
|
|
4
|
+
All ghtraf operations that touch GitHub go through this module.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import shutil
|
|
8
|
+
import subprocess
|
|
9
|
+
import sys
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def run_gh(args, input_data=None, check=True):
|
|
13
|
+
"""Run a gh CLI command, return stdout.
|
|
14
|
+
|
|
15
|
+
Args:
|
|
16
|
+
args: List of arguments to pass to gh.
|
|
17
|
+
input_data: Optional string to pipe to stdin.
|
|
18
|
+
check: If True (default), exit on failure.
|
|
19
|
+
|
|
20
|
+
Returns:
|
|
21
|
+
Stripped stdout string.
|
|
22
|
+
|
|
23
|
+
Raises:
|
|
24
|
+
SystemExit: If check=True and the command fails.
|
|
25
|
+
"""
|
|
26
|
+
result = subprocess.run(
|
|
27
|
+
["gh"] + args,
|
|
28
|
+
capture_output=True, text=True, encoding="utf-8",
|
|
29
|
+
input=input_data
|
|
30
|
+
)
|
|
31
|
+
if check and result.returncode != 0:
|
|
32
|
+
print(f" ERROR: gh {' '.join(args[:3])}...", file=sys.stderr)
|
|
33
|
+
print(f" {result.stderr.strip()}", file=sys.stderr)
|
|
34
|
+
sys.exit(1)
|
|
35
|
+
return result.stdout.strip()
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def check_gh_installed():
|
|
39
|
+
"""Verify gh CLI exists on PATH.
|
|
40
|
+
|
|
41
|
+
Returns:
|
|
42
|
+
Version string if found.
|
|
43
|
+
|
|
44
|
+
Raises:
|
|
45
|
+
SystemExit: If gh is not installed.
|
|
46
|
+
"""
|
|
47
|
+
if shutil.which("gh") is None:
|
|
48
|
+
print("ERROR: gh CLI not found.")
|
|
49
|
+
print()
|
|
50
|
+
print(" Install it from: https://cli.github.com")
|
|
51
|
+
print(" Or via package manager:")
|
|
52
|
+
print(" Windows: winget install GitHub.cli")
|
|
53
|
+
print(" macOS: brew install gh")
|
|
54
|
+
print(" Linux: See https://github.com/cli/cli/blob/trunk/docs/install_linux.md")
|
|
55
|
+
sys.exit(1)
|
|
56
|
+
|
|
57
|
+
version = subprocess.run(
|
|
58
|
+
["gh", "--version"], capture_output=True, text=True, encoding="utf-8"
|
|
59
|
+
).stdout.strip().split("\n")[0]
|
|
60
|
+
return version
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def check_gh_authenticated():
|
|
64
|
+
"""Verify gh auth status.
|
|
65
|
+
|
|
66
|
+
Returns:
|
|
67
|
+
Raw auth output string (for scope checking).
|
|
68
|
+
|
|
69
|
+
Raises:
|
|
70
|
+
SystemExit: If not authenticated.
|
|
71
|
+
"""
|
|
72
|
+
result = subprocess.run(
|
|
73
|
+
["gh", "auth", "status"],
|
|
74
|
+
capture_output=True, text=True, encoding="utf-8"
|
|
75
|
+
)
|
|
76
|
+
output = result.stdout + result.stderr
|
|
77
|
+
if result.returncode != 0:
|
|
78
|
+
print("ERROR: Not authenticated with GitHub CLI.")
|
|
79
|
+
print()
|
|
80
|
+
print(" Run: gh auth login")
|
|
81
|
+
print(" Then re-run this command.")
|
|
82
|
+
sys.exit(1)
|
|
83
|
+
return output
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def check_gh_scopes(auth_output):
|
|
87
|
+
"""Check if the gh token has gist scope.
|
|
88
|
+
|
|
89
|
+
Returns:
|
|
90
|
+
True if gist scope is available.
|
|
91
|
+
"""
|
|
92
|
+
result = subprocess.run(
|
|
93
|
+
["gh", "api", "gists", "--method", "GET", "-q", ".[0].id"],
|
|
94
|
+
capture_output=True, text=True, encoding="utf-8"
|
|
95
|
+
)
|
|
96
|
+
if result.returncode != 0 and "403" in result.stderr:
|
|
97
|
+
return False
|
|
98
|
+
return True
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def resolve_github_username():
|
|
102
|
+
"""Get the authenticated user's GitHub username."""
|
|
103
|
+
return run_gh(["api", "user", "--jq", ".login"])
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def set_repo_variable(name, value, gh_repo, dry_run=False):
|
|
107
|
+
"""Set a GitHub repository variable.
|
|
108
|
+
|
|
109
|
+
Args:
|
|
110
|
+
name: Variable name.
|
|
111
|
+
value: Variable value.
|
|
112
|
+
gh_repo: Repository in owner/repo format.
|
|
113
|
+
dry_run: If True, only print what would happen.
|
|
114
|
+
|
|
115
|
+
Returns:
|
|
116
|
+
True if successful (or dry run), False on failure.
|
|
117
|
+
"""
|
|
118
|
+
if dry_run:
|
|
119
|
+
return True
|
|
120
|
+
|
|
121
|
+
result = subprocess.run(
|
|
122
|
+
["gh", "variable", "set", name, "--body", value, "-R", gh_repo],
|
|
123
|
+
capture_output=True, text=True, encoding="utf-8"
|
|
124
|
+
)
|
|
125
|
+
if result.returncode != 0:
|
|
126
|
+
return False
|
|
127
|
+
return True
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def set_repo_secret(name, value, gh_repo):
|
|
131
|
+
"""Set a GitHub repository secret.
|
|
132
|
+
|
|
133
|
+
Args:
|
|
134
|
+
name: Secret name.
|
|
135
|
+
value: Secret value.
|
|
136
|
+
gh_repo: Repository in owner/repo format.
|
|
137
|
+
|
|
138
|
+
Returns:
|
|
139
|
+
True if successful, False on failure.
|
|
140
|
+
"""
|
|
141
|
+
result = subprocess.run(
|
|
142
|
+
["gh", "secret", "set", name, "-R", gh_repo],
|
|
143
|
+
capture_output=True, text=True, encoding="utf-8",
|
|
144
|
+
input=value
|
|
145
|
+
)
|
|
146
|
+
return result.returncode == 0
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def check_repo_exists(gh_repo):
|
|
150
|
+
"""Check if a repository exists on GitHub.
|
|
151
|
+
|
|
152
|
+
Returns:
|
|
153
|
+
The repo full_name if it exists, None otherwise.
|
|
154
|
+
"""
|
|
155
|
+
result = subprocess.run(
|
|
156
|
+
["gh", "api", f"repos/{gh_repo}", "--jq", ".full_name"],
|
|
157
|
+
capture_output=True, text=True, encoding="utf-8"
|
|
158
|
+
)
|
|
159
|
+
if result.returncode != 0:
|
|
160
|
+
return None
|
|
161
|
+
return result.stdout.strip()
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def get_repo_created_date(gh_repo):
|
|
165
|
+
"""Get the creation date of a repository.
|
|
166
|
+
|
|
167
|
+
Returns:
|
|
168
|
+
Date string (YYYY-MM-DD) or None.
|
|
169
|
+
"""
|
|
170
|
+
try:
|
|
171
|
+
result = subprocess.run(
|
|
172
|
+
["gh", "api", f"repos/{gh_repo}", "--jq", ".created_at"],
|
|
173
|
+
capture_output=True, text=True, encoding="utf-8",
|
|
174
|
+
)
|
|
175
|
+
if result.returncode != 0:
|
|
176
|
+
return None
|
|
177
|
+
raw = result.stdout.strip()
|
|
178
|
+
# Validate it looks like a date (YYYY-MM-DD...)
|
|
179
|
+
if raw and len(raw) >= 10 and raw[4] == "-" and raw[7] == "-":
|
|
180
|
+
return raw[:10]
|
|
181
|
+
except Exception:
|
|
182
|
+
pass
|
|
183
|
+
return None
|