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/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