umpyre 0.1.7__tar.gz → 0.1.9__tar.gz
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.
Potentially problematic release.
This version of umpyre might be problematic. Click here for more details.
- {umpyre-0.1.7 → umpyre-0.1.9}/PKG-INFO +6 -6
- {umpyre-0.1.7 → umpyre-0.1.9}/README.md +4 -4
- umpyre-0.1.9/actions/track-metrics/action.yml +90 -0
- {umpyre-0.1.7 → umpyre-0.1.9}/misc/docs/FAILURE_PROTECTION.md +4 -7
- {umpyre-0.1.7 → umpyre-0.1.9}/misc/docs/PHASE_2_PLAN.md +21 -22
- {umpyre-0.1.7 → umpyre-0.1.9}/misc/docs/PHASE_3_PLAN.md +54 -47
- {umpyre-0.1.7 → umpyre-0.1.9}/misc/docs/STORAGE_STRUCTURE.md +14 -8
- {umpyre-0.1.7 → umpyre-0.1.9}/pyproject.toml +1 -1
- {umpyre-0.1.7 → umpyre-0.1.9}/setup.cfg +1 -1
- umpyre-0.1.9/tests/test_git_branch_storage.py +77 -0
- {umpyre-0.1.7 → umpyre-0.1.9}/umpyre/cli.py +9 -3
- {umpyre-0.1.7 → umpyre-0.1.9}/umpyre/python_code_stats.py +0 -9
- {umpyre-0.1.7 → umpyre-0.1.9}/umpyre/storage/git_branch.py +68 -4
- umpyre-0.1.7/actions/track-metrics/action.yml +0 -74
- {umpyre-0.1.7 → umpyre-0.1.9}/.gitattributes +0 -0
- {umpyre-0.1.7 → umpyre-0.1.9}/.github/umpyre-config.yml +0 -0
- {umpyre-0.1.7 → umpyre-0.1.9}/.github/workflows/ci.yml +0 -0
- {umpyre-0.1.7 → umpyre-0.1.9}/.gitignore +0 -0
- {umpyre-0.1.7 → umpyre-0.1.9}/LICENSE +0 -0
- {umpyre-0.1.7 → umpyre-0.1.9}/QUICK_START.md +0 -0
- {umpyre-0.1.7 → umpyre-0.1.9}/json +0 -0
- {umpyre-0.1.7 → umpyre-0.1.9}/misc/docs/CHANGELOG.md +0 -0
- {umpyre-0.1.7 → umpyre-0.1.9}/misc/docs/IMPLEMENTATION_STATUS.md +0 -0
- {umpyre-0.1.7 → umpyre-0.1.9}/misc/docs/IMPLEMENTATION_SUMMARY.md +0 -0
- {umpyre-0.1.7 → umpyre-0.1.9}/misc/docs/TESTING_GUIDE.md +0 -0
- {umpyre-0.1.7 → umpyre-0.1.9}/misc/docs/TEST_RESULTS.md +0 -0
- {umpyre-0.1.7 → umpyre-0.1.9}/test_on_astate.py +0 -0
- {umpyre-0.1.7 → umpyre-0.1.9}/tests/test_base_collector.py +0 -0
- {umpyre-0.1.7 → umpyre-0.1.9}/tests/test_config.py +0 -0
- {umpyre-0.1.7 → umpyre-0.1.9}/tests/test_coverage_collector.py +0 -0
- {umpyre-0.1.7 → umpyre-0.1.9}/tests/test_schema.py +0 -0
- {umpyre-0.1.7 → umpyre-0.1.9}/tests/test_umpyre_collector.py +0 -0
- {umpyre-0.1.7 → umpyre-0.1.9}/umpyre/__init__.py +0 -0
- {umpyre-0.1.7 → umpyre-0.1.9}/umpyre/collectors/__init__.py +0 -0
- {umpyre-0.1.7 → umpyre-0.1.9}/umpyre/collectors/base.py +0 -0
- {umpyre-0.1.7 → umpyre-0.1.9}/umpyre/collectors/coverage_collector.py +0 -0
- {umpyre-0.1.7 → umpyre-0.1.9}/umpyre/collectors/umpyre_collector.py +0 -0
- {umpyre-0.1.7 → umpyre-0.1.9}/umpyre/collectors/wily_collector.py +0 -0
- {umpyre-0.1.7 → umpyre-0.1.9}/umpyre/collectors/workflow_status.py +0 -0
- {umpyre-0.1.7 → umpyre-0.1.9}/umpyre/config.py +0 -0
- {umpyre-0.1.7 → umpyre-0.1.9}/umpyre/schema.py +0 -0
- {umpyre-0.1.7 → umpyre-0.1.9}/umpyre/storage/__init__.py +0 -0
- {umpyre-0.1.7 → umpyre-0.1.9}/umpyre/storage/formats.py +0 -0
- {umpyre-0.1.7 → umpyre-0.1.9}/umpyre/storage/query_utils.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
2
|
Name: umpyre
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.9
|
|
4
4
|
Summary: Code analysis and quality metrics tracking system for CI/CD
|
|
5
5
|
Project-URL: Homepage, https://github.com/i2mint/umpyre
|
|
6
6
|
License: Apache-2.0
|
|
@@ -144,9 +144,9 @@ history_dir = Path("history")
|
|
|
144
144
|
|
|
145
145
|
# Parse filename
|
|
146
146
|
info = parse_metric_filename("2025_11_14_22_45_00__700e012__0.1.0.json")
|
|
147
|
-
print(info[
|
|
148
|
-
print(info[
|
|
149
|
-
print(info[
|
|
147
|
+
print(info["commit_sha"]) # '700e012'
|
|
148
|
+
print(info["pypi_version"]) # '0.1.0'
|
|
149
|
+
print(info["timestamp"]) # datetime object
|
|
150
150
|
|
|
151
151
|
# Find by commit
|
|
152
152
|
metrics = find_metrics_by_commit(history_dir, "700e012")
|
|
@@ -318,7 +318,7 @@ import collections
|
|
|
318
318
|
modules_info_df(collections)
|
|
319
319
|
|
|
320
320
|
# Compare multiple packages
|
|
321
|
-
stats_of([
|
|
321
|
+
stats_of(["urllib", "json", "collections"])
|
|
322
322
|
```
|
|
323
323
|
|
|
324
324
|
See original README examples above for detailed usage.
|
|
@@ -129,9 +129,9 @@ history_dir = Path("history")
|
|
|
129
129
|
|
|
130
130
|
# Parse filename
|
|
131
131
|
info = parse_metric_filename("2025_11_14_22_45_00__700e012__0.1.0.json")
|
|
132
|
-
print(info[
|
|
133
|
-
print(info[
|
|
134
|
-
print(info[
|
|
132
|
+
print(info["commit_sha"]) # '700e012'
|
|
133
|
+
print(info["pypi_version"]) # '0.1.0'
|
|
134
|
+
print(info["timestamp"]) # datetime object
|
|
135
135
|
|
|
136
136
|
# Find by commit
|
|
137
137
|
metrics = find_metrics_by_commit(history_dir, "700e012")
|
|
@@ -303,7 +303,7 @@ import collections
|
|
|
303
303
|
modules_info_df(collections)
|
|
304
304
|
|
|
305
305
|
# Compare multiple packages
|
|
306
|
-
stats_of([
|
|
306
|
+
stats_of(["urllib", "json", "collections"])
|
|
307
307
|
```
|
|
308
308
|
|
|
309
309
|
See original README examples above for detailed usage.
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
name: Track Code Metrics
|
|
2
|
+
description: Collect and store code quality metrics for CI/CD pipelines
|
|
3
|
+
|
|
4
|
+
inputs:
|
|
5
|
+
config-path:
|
|
6
|
+
description: 'Path to umpyre config file'
|
|
7
|
+
default: '.github/umpyre-config.yml'
|
|
8
|
+
required: false
|
|
9
|
+
|
|
10
|
+
storage-branch:
|
|
11
|
+
description: 'Branch to store metrics on (the default leaves the choice to storage.branch in the config file)'
|
|
12
|
+
default: 'code-metrics'
|
|
13
|
+
required: false
|
|
14
|
+
|
|
15
|
+
github-token:
|
|
16
|
+
description: 'GitHub token: read by the workflow_status collector and used to push the metrics branch (needs contents: write; with a read-only token the push fails, non-fatally)'
|
|
17
|
+
required: true
|
|
18
|
+
|
|
19
|
+
force-run:
|
|
20
|
+
description: "Run even if an earlier step of the action failed ('true' or 'false'). To run after a failed step of the calling job, also give the calling step `if: always()`"
|
|
21
|
+
default: 'false'
|
|
22
|
+
required: false
|
|
23
|
+
|
|
24
|
+
python-version:
|
|
25
|
+
description: 'Python version to use'
|
|
26
|
+
default: '3.10'
|
|
27
|
+
required: false
|
|
28
|
+
|
|
29
|
+
runs:
|
|
30
|
+
using: composite
|
|
31
|
+
steps:
|
|
32
|
+
- name: Setup Python
|
|
33
|
+
uses: actions/setup-python@v5
|
|
34
|
+
with:
|
|
35
|
+
python-version: ${{ inputs.python-version }}
|
|
36
|
+
|
|
37
|
+
- name: Install umpyre and dependencies
|
|
38
|
+
shell: bash
|
|
39
|
+
run: |
|
|
40
|
+
pip install umpyre coverage wily bandit interrogate
|
|
41
|
+
|
|
42
|
+
- name: Collect and Store Metrics
|
|
43
|
+
# Run on success, or on failure too when `force-run` is 'true'.
|
|
44
|
+
if: ${{ success() || (inputs.force-run == 'true' && !cancelled()) }}
|
|
45
|
+
shell: bash
|
|
46
|
+
env:
|
|
47
|
+
# Inputs reach the script as environment variables, never pasted into it.
|
|
48
|
+
CONFIG_PATH: ${{ inputs.config-path }}
|
|
49
|
+
STORAGE_BRANCH: ${{ inputs.storage-branch }}
|
|
50
|
+
# Used by the workflow_status collector and to push the metrics branch.
|
|
51
|
+
GITHUB_TOKEN: ${{ inputs.github-token }}
|
|
52
|
+
run: |
|
|
53
|
+
echo "::group::Collecting Code Metrics"
|
|
54
|
+
|
|
55
|
+
# Set up error handling - continue even if collection fails
|
|
56
|
+
set +e
|
|
57
|
+
|
|
58
|
+
# Build the command. Run from $RUNNER_TEMP in isolated mode (-I) so that a
|
|
59
|
+
# file in the checked-out repo (umpyre/, yaml.py, ...) can't shadow an
|
|
60
|
+
# installed package; the repository is passed explicitly.
|
|
61
|
+
cmd=(python -I -m umpyre.cli collect --repo-path "$GITHUB_WORKSPACE")
|
|
62
|
+
if [ -n "$CONFIG_PATH" ] && [ -f "$GITHUB_WORKSPACE/$CONFIG_PATH" ]; then
|
|
63
|
+
cmd+=(--config "$GITHUB_WORKSPACE/$CONFIG_PATH")
|
|
64
|
+
elif [ -n "$CONFIG_PATH" ] && [ -f "$CONFIG_PATH" ]; then
|
|
65
|
+
cmd+=(--config "$CONFIG_PATH")
|
|
66
|
+
fi
|
|
67
|
+
# The input's default leaves the choice to the config file (storage.branch).
|
|
68
|
+
if [ -n "$STORAGE_BRANCH" ] && [ "$STORAGE_BRANCH" != "code-metrics" ]; then
|
|
69
|
+
cmd+=(--storage-branch "$STORAGE_BRANCH")
|
|
70
|
+
fi
|
|
71
|
+
|
|
72
|
+
(cd "${RUNNER_TEMP:-/tmp}" && "${cmd[@]}")
|
|
73
|
+
EXIT_CODE=$?
|
|
74
|
+
|
|
75
|
+
# Report status but don't fail
|
|
76
|
+
if [ $EXIT_CODE -eq 0 ]; then
|
|
77
|
+
echo "✅ Metrics collection completed successfully"
|
|
78
|
+
else
|
|
79
|
+
echo "⚠️ Metrics collection failed with exit code $EXIT_CODE"
|
|
80
|
+
echo "This is informational only - CI pipeline continues"
|
|
81
|
+
fi
|
|
82
|
+
|
|
83
|
+
echo "::endgroup::"
|
|
84
|
+
|
|
85
|
+
# Always exit successfully to not break CI
|
|
86
|
+
exit 0
|
|
87
|
+
|
|
88
|
+
branding:
|
|
89
|
+
icon: 'bar-chart-2'
|
|
90
|
+
color: 'blue'
|
|
@@ -59,12 +59,12 @@ def cmd_collect(args):
|
|
|
59
59
|
return 0
|
|
60
60
|
except Exception as e:
|
|
61
61
|
print(f"❌ Error during metrics collection: {e}")
|
|
62
|
-
|
|
62
|
+
|
|
63
63
|
# In CI mode, log but don't fail
|
|
64
|
-
if os.getenv(
|
|
64
|
+
if os.getenv("CI") or os.getenv("GITHUB_ACTIONS"):
|
|
65
65
|
print("⚠️ Running in CI - treating as non-fatal")
|
|
66
66
|
return 0
|
|
67
|
-
|
|
67
|
+
|
|
68
68
|
return 1 # Fail in local development
|
|
69
69
|
```
|
|
70
70
|
|
|
@@ -136,10 +136,7 @@ def collect(self) -> dict:
|
|
|
136
136
|
# ... collect metrics ...
|
|
137
137
|
return metrics
|
|
138
138
|
except Exception as e:
|
|
139
|
-
return {
|
|
140
|
-
"num_functions": 0,
|
|
141
|
-
"error": str(e)
|
|
142
|
-
}
|
|
139
|
+
return {"num_functions": 0, "error": str(e)}
|
|
143
140
|
```
|
|
144
141
|
|
|
145
142
|
This ensures one broken collector doesn't fail the entire collection.
|
|
@@ -27,12 +27,13 @@ Generate visual charts showing metric trends over time.
|
|
|
27
27
|
```python
|
|
28
28
|
# umpyre/visualization/plots.py
|
|
29
29
|
|
|
30
|
+
|
|
30
31
|
class PlotGenerator:
|
|
31
32
|
"""Generate time-series plots from metrics history."""
|
|
32
|
-
|
|
33
|
+
|
|
33
34
|
def __init__(self, metrics_branch: str = "code-metrics"):
|
|
34
35
|
self.branch = metrics_branch
|
|
35
|
-
|
|
36
|
+
|
|
36
37
|
def generate_coverage_plot(self) -> Path:
|
|
37
38
|
"""Generate coverage trend plot."""
|
|
38
39
|
# Parse history/*.json files
|
|
@@ -40,11 +41,11 @@ class PlotGenerator:
|
|
|
40
41
|
# Plot with matplotlib/plotly
|
|
41
42
|
# Save as PNG/SVG
|
|
42
43
|
pass
|
|
43
|
-
|
|
44
|
+
|
|
44
45
|
def generate_maintainability_plot(self) -> Path:
|
|
45
46
|
"""Generate maintainability trend plot."""
|
|
46
47
|
pass
|
|
47
|
-
|
|
48
|
+
|
|
48
49
|
def generate_all_plots(self) -> dict[str, Path]:
|
|
49
50
|
"""Generate all standard plots."""
|
|
50
51
|
pass
|
|
@@ -132,21 +133,22 @@ Version: 0.1.0
|
|
|
132
133
|
```python
|
|
133
134
|
# umpyre/visualization/readme_generator.py
|
|
134
135
|
|
|
136
|
+
|
|
135
137
|
class ReadmeGenerator:
|
|
136
138
|
"""Generate METRICS.md from metrics history."""
|
|
137
|
-
|
|
139
|
+
|
|
138
140
|
def generate_summary_table(self) -> str:
|
|
139
141
|
"""Create latest metrics table."""
|
|
140
142
|
pass
|
|
141
|
-
|
|
143
|
+
|
|
142
144
|
def generate_trend_section(self) -> str:
|
|
143
145
|
"""Embed plot images."""
|
|
144
146
|
pass
|
|
145
|
-
|
|
147
|
+
|
|
146
148
|
def generate_badges(self) -> str:
|
|
147
149
|
"""Create shields.io badges."""
|
|
148
150
|
pass
|
|
149
|
-
|
|
151
|
+
|
|
150
152
|
def generate_full_readme(self) -> str:
|
|
151
153
|
"""Generate complete METRICS.md."""
|
|
152
154
|
pass
|
|
@@ -179,18 +181,14 @@ Aggregate metrics across multiple repositories to provide organization-wide insi
|
|
|
179
181
|
```python
|
|
180
182
|
# umpyre/collectors/aggregation_collector.py
|
|
181
183
|
|
|
184
|
+
|
|
182
185
|
class AggregationCollector(MetricCollector):
|
|
183
186
|
"""Aggregate metrics across multiple repositories."""
|
|
184
|
-
|
|
185
|
-
def __init__(
|
|
186
|
-
self,
|
|
187
|
-
org: str,
|
|
188
|
-
repos: list[str],
|
|
189
|
-
github_token: Optional[str] = None
|
|
190
|
-
):
|
|
187
|
+
|
|
188
|
+
def __init__(self, org: str, repos: list[str], github_token: Optional[str] = None):
|
|
191
189
|
"""
|
|
192
190
|
Initialize aggregator.
|
|
193
|
-
|
|
191
|
+
|
|
194
192
|
Args:
|
|
195
193
|
org: GitHub organization name
|
|
196
194
|
repos: List of repository names
|
|
@@ -199,11 +197,11 @@ class AggregationCollector(MetricCollector):
|
|
|
199
197
|
self.org = org
|
|
200
198
|
self.repos = repos
|
|
201
199
|
self.token = github_token
|
|
202
|
-
|
|
200
|
+
|
|
203
201
|
def collect(self) -> dict:
|
|
204
202
|
"""
|
|
205
203
|
Aggregate metrics from multiple repos.
|
|
206
|
-
|
|
204
|
+
|
|
207
205
|
Returns:
|
|
208
206
|
Aggregated metrics dictionary
|
|
209
207
|
"""
|
|
@@ -211,7 +209,7 @@ class AggregationCollector(MetricCollector):
|
|
|
211
209
|
# 1. Clone code-metrics branch
|
|
212
210
|
# 2. Read latest metrics.json
|
|
213
211
|
# 3. Aggregate
|
|
214
|
-
|
|
212
|
+
|
|
215
213
|
return {
|
|
216
214
|
"total_repos": len(self.repos),
|
|
217
215
|
"avg_coverage": 82.5,
|
|
@@ -267,16 +265,17 @@ Create interactive HTML dashboard with organization-wide metrics.
|
|
|
267
265
|
```python
|
|
268
266
|
# umpyre/visualization/dashboard.py
|
|
269
267
|
|
|
268
|
+
|
|
270
269
|
class DashboardGenerator:
|
|
271
270
|
"""Generate interactive HTML dashboard."""
|
|
272
|
-
|
|
271
|
+
|
|
273
272
|
def generate_html(self, aggregated_metrics: dict) -> str:
|
|
274
273
|
"""
|
|
275
274
|
Generate dashboard HTML.
|
|
276
|
-
|
|
275
|
+
|
|
277
276
|
Args:
|
|
278
277
|
aggregated_metrics: Output from AggregationCollector
|
|
279
|
-
|
|
278
|
+
|
|
280
279
|
Returns:
|
|
281
280
|
HTML string
|
|
282
281
|
"""
|
|
@@ -32,30 +32,33 @@ Enforce quality standards by validating metrics against configurable thresholds.
|
|
|
32
32
|
```python
|
|
33
33
|
# umpyre/validation/thresholds.py
|
|
34
34
|
|
|
35
|
+
|
|
35
36
|
@dataclass
|
|
36
37
|
class Threshold:
|
|
37
38
|
"""Threshold definition."""
|
|
39
|
+
|
|
38
40
|
metric_path: str # e.g., "metrics.coverage.line_coverage"
|
|
39
|
-
operator: str
|
|
41
|
+
operator: str # ">=", "<=", "==", "!=", ">", "<"
|
|
40
42
|
value: float
|
|
41
|
-
action: str
|
|
43
|
+
action: str # "warn", "fail", "notify", "comment"
|
|
42
44
|
message: Optional[str] = None
|
|
43
45
|
|
|
46
|
+
|
|
44
47
|
class ThresholdValidator:
|
|
45
48
|
"""Validate metrics against thresholds."""
|
|
46
|
-
|
|
49
|
+
|
|
47
50
|
def __init__(self, thresholds: list[Threshold]):
|
|
48
51
|
self.thresholds = thresholds
|
|
49
|
-
|
|
52
|
+
|
|
50
53
|
def validate(self, metrics: dict) -> list[ThresholdViolation]:
|
|
51
54
|
"""
|
|
52
55
|
Validate metrics against all thresholds.
|
|
53
|
-
|
|
56
|
+
|
|
54
57
|
Returns:
|
|
55
58
|
List of violations
|
|
56
59
|
"""
|
|
57
60
|
violations = []
|
|
58
|
-
|
|
61
|
+
|
|
59
62
|
for threshold in self.thresholds:
|
|
60
63
|
value = self._extract_metric(metrics, threshold.metric_path)
|
|
61
64
|
if not self._check_threshold(value, threshold):
|
|
@@ -66,9 +69,9 @@ class ThresholdValidator:
|
|
|
66
69
|
expected_value=threshold.value,
|
|
67
70
|
)
|
|
68
71
|
)
|
|
69
|
-
|
|
72
|
+
|
|
70
73
|
return violations
|
|
71
|
-
|
|
74
|
+
|
|
72
75
|
def _check_threshold(self, value: float, threshold: Threshold) -> bool:
|
|
73
76
|
"""Check if value passes threshold."""
|
|
74
77
|
ops = {
|
|
@@ -119,17 +122,18 @@ thresholds:
|
|
|
119
122
|
```python
|
|
120
123
|
# umpyre/validators.py
|
|
121
124
|
|
|
125
|
+
|
|
122
126
|
def check_docstring_ratio(metrics: dict) -> tuple[bool, str]:
|
|
123
127
|
"""
|
|
124
128
|
Custom validator: check if docstring ratio is acceptable.
|
|
125
|
-
|
|
129
|
+
|
|
126
130
|
Returns:
|
|
127
131
|
(passed, message)
|
|
128
132
|
"""
|
|
129
133
|
docs = metrics["metrics"]["umpyre_stats"]["docs_lines"]
|
|
130
134
|
total = metrics["metrics"]["umpyre_stats"]["total_lines"]
|
|
131
135
|
ratio = docs / total if total > 0 else 0
|
|
132
|
-
|
|
136
|
+
|
|
133
137
|
if ratio < 0.1:
|
|
134
138
|
return False, f"Docstring ratio {ratio:.1%} is below 10%"
|
|
135
139
|
return True, "Docstring ratio acceptable"
|
|
@@ -158,13 +162,14 @@ python -m umpyre.cli validate --violations-only
|
|
|
158
162
|
```python
|
|
159
163
|
# umpyre/collectors/bandit_collector.py
|
|
160
164
|
|
|
165
|
+
|
|
161
166
|
class BanditCollector(MetricCollector):
|
|
162
167
|
"""Collect security metrics using Bandit."""
|
|
163
|
-
|
|
168
|
+
|
|
164
169
|
def collect(self) -> dict:
|
|
165
170
|
"""
|
|
166
171
|
Run bandit security scan.
|
|
167
|
-
|
|
172
|
+
|
|
168
173
|
Returns:
|
|
169
174
|
{
|
|
170
175
|
"total_issues": 12,
|
|
@@ -206,13 +211,14 @@ collectors:
|
|
|
206
211
|
```python
|
|
207
212
|
# umpyre/collectors/interrogate_collector.py
|
|
208
213
|
|
|
214
|
+
|
|
209
215
|
class InterrogateCollector(MetricCollector):
|
|
210
216
|
"""Collect docstring coverage using interrogate."""
|
|
211
|
-
|
|
217
|
+
|
|
212
218
|
def collect(self) -> dict:
|
|
213
219
|
"""
|
|
214
220
|
Run interrogate to check docstring coverage.
|
|
215
|
-
|
|
221
|
+
|
|
216
222
|
Returns:
|
|
217
223
|
{
|
|
218
224
|
"coverage": 78.5,
|
|
@@ -237,13 +243,14 @@ class InterrogateCollector(MetricCollector):
|
|
|
237
243
|
```python
|
|
238
244
|
# umpyre/collectors/mypy_collector.py
|
|
239
245
|
|
|
246
|
+
|
|
240
247
|
class MyPyCollector(MetricCollector):
|
|
241
248
|
"""Collect type checking results using mypy."""
|
|
242
|
-
|
|
249
|
+
|
|
243
250
|
def collect(self) -> dict:
|
|
244
251
|
"""
|
|
245
252
|
Run mypy type checker.
|
|
246
|
-
|
|
253
|
+
|
|
247
254
|
Returns:
|
|
248
255
|
{
|
|
249
256
|
"total_errors": 15,
|
|
@@ -273,13 +280,14 @@ class MyPyCollector(MetricCollector):
|
|
|
273
280
|
```python
|
|
274
281
|
# umpyre/collectors/pylint_collector.py
|
|
275
282
|
|
|
283
|
+
|
|
276
284
|
class PylintCollector(MetricCollector):
|
|
277
285
|
"""Collect code quality score using Pylint."""
|
|
278
|
-
|
|
286
|
+
|
|
279
287
|
def collect(self) -> dict:
|
|
280
288
|
"""
|
|
281
289
|
Run pylint code analysis.
|
|
282
|
-
|
|
290
|
+
|
|
283
291
|
Returns:
|
|
284
292
|
{
|
|
285
293
|
"score": 8.5, # Out of 10
|
|
@@ -314,13 +322,14 @@ Manage metrics history efficiently (pruning, compression, rotation).
|
|
|
314
322
|
```python
|
|
315
323
|
# umpyre/storage/pruning.py
|
|
316
324
|
|
|
325
|
+
|
|
317
326
|
class MetricsPruner:
|
|
318
327
|
"""Prune old metrics to save space."""
|
|
319
|
-
|
|
328
|
+
|
|
320
329
|
def prune(self, branch: str = "code-metrics"):
|
|
321
330
|
"""
|
|
322
331
|
Prune metrics history based on retention policy.
|
|
323
|
-
|
|
332
|
+
|
|
324
333
|
Strategy:
|
|
325
334
|
- Keep all metrics < 30 days old
|
|
326
335
|
- Keep weekly snapshots for 30-90 days
|
|
@@ -353,17 +362,16 @@ storage:
|
|
|
353
362
|
```python
|
|
354
363
|
# umpyre/storage/compression.py
|
|
355
364
|
|
|
365
|
+
|
|
356
366
|
class MetricsCompressor:
|
|
357
367
|
"""Compress old metrics files."""
|
|
358
|
-
|
|
368
|
+
|
|
359
369
|
def compress_old_files(
|
|
360
|
-
self,
|
|
361
|
-
branch: str = "code-metrics",
|
|
362
|
-
older_than_days: int = 90
|
|
370
|
+
self, branch: str = "code-metrics", older_than_days: int = 90
|
|
363
371
|
):
|
|
364
372
|
"""
|
|
365
373
|
Compress metrics older than N days.
|
|
366
|
-
|
|
374
|
+
|
|
367
375
|
Changes:
|
|
368
376
|
- 2024_01_15_10_30_00__abc1234__0.1.0.json
|
|
369
377
|
→ 2024_01_15_10_30_00__abc1234__0.1.0.json.gz
|
|
@@ -383,14 +391,11 @@ class MetricsCompressor:
|
|
|
383
391
|
```python
|
|
384
392
|
# umpyre/storage/rotation.py
|
|
385
393
|
|
|
394
|
+
|
|
386
395
|
class MetricsRotator:
|
|
387
396
|
"""Rotate out very old metrics."""
|
|
388
|
-
|
|
389
|
-
def rotate(
|
|
390
|
-
self,
|
|
391
|
-
branch: str = "code-metrics",
|
|
392
|
-
max_age_days: int = 365
|
|
393
|
-
):
|
|
397
|
+
|
|
398
|
+
def rotate(self, branch: str = "code-metrics", max_age_days: int = 365):
|
|
394
399
|
"""Delete metrics older than max_age_days."""
|
|
395
400
|
# Parse timestamps
|
|
396
401
|
# Delete files older than threshold
|
|
@@ -418,47 +423,48 @@ Handle schema version changes gracefully with automatic migrations.
|
|
|
418
423
|
```python
|
|
419
424
|
# umpyre/schema.py (enhanced)
|
|
420
425
|
|
|
426
|
+
|
|
421
427
|
class MetricSchema:
|
|
422
428
|
"""Versioned schema with migration support."""
|
|
423
|
-
|
|
429
|
+
|
|
424
430
|
version: str = "1.1" # Bump version
|
|
425
|
-
|
|
431
|
+
|
|
426
432
|
@classmethod
|
|
427
433
|
def migrate(cls, data: dict, from_version: str) -> dict:
|
|
428
434
|
"""
|
|
429
435
|
Migrate data from old schema to current.
|
|
430
|
-
|
|
436
|
+
|
|
431
437
|
Migration chain:
|
|
432
438
|
1.0 → 1.1 → 1.2 → ...
|
|
433
439
|
"""
|
|
434
440
|
if from_version == cls.current_version():
|
|
435
441
|
return data
|
|
436
|
-
|
|
442
|
+
|
|
437
443
|
# Migration registry
|
|
438
444
|
migrations = {
|
|
439
445
|
"1.0": cls._migrate_1_0_to_1_1,
|
|
440
446
|
"1.1": cls._migrate_1_1_to_1_2,
|
|
441
447
|
}
|
|
442
|
-
|
|
448
|
+
|
|
443
449
|
# Apply migrations in sequence
|
|
444
450
|
current_version = from_version
|
|
445
451
|
current_data = data
|
|
446
|
-
|
|
452
|
+
|
|
447
453
|
while current_version != cls.current_version():
|
|
448
454
|
if current_version not in migrations:
|
|
449
455
|
raise ValueError(f"No migration path from {current_version}")
|
|
450
|
-
|
|
456
|
+
|
|
451
457
|
migrator = migrations[current_version]
|
|
452
458
|
current_data = migrator(current_data)
|
|
453
459
|
current_version = cls._next_version(current_version)
|
|
454
|
-
|
|
460
|
+
|
|
455
461
|
return current_data
|
|
456
|
-
|
|
462
|
+
|
|
457
463
|
@classmethod
|
|
458
464
|
def _migrate_1_0_to_1_1(cls, data: dict) -> dict:
|
|
459
465
|
"""
|
|
460
466
|
Migrate from schema 1.0 to 1.1.
|
|
461
|
-
|
|
467
|
+
|
|
462
468
|
Changes in 1.1:
|
|
463
469
|
- Added pypi_version field
|
|
464
470
|
- Added collection_duration_seconds
|
|
@@ -468,10 +474,10 @@ class MetricSchema:
|
|
|
468
474
|
for collector_name, metrics in data["metrics"].items():
|
|
469
475
|
if "pypi_version" not in metrics:
|
|
470
476
|
metrics["pypi_version"] = None
|
|
471
|
-
|
|
477
|
+
|
|
472
478
|
data["schema_version"] = "1.1"
|
|
473
479
|
return data
|
|
474
|
-
|
|
480
|
+
|
|
475
481
|
@classmethod
|
|
476
482
|
def _migrate_1_1_to_1_2(cls, data: dict) -> dict:
|
|
477
483
|
"""Future migration example."""
|
|
@@ -484,22 +490,23 @@ class MetricSchema:
|
|
|
484
490
|
```python
|
|
485
491
|
# umpyre/storage/formats.py (enhanced)
|
|
486
492
|
|
|
493
|
+
|
|
487
494
|
def load_metrics(filepath: Path) -> dict:
|
|
488
495
|
"""
|
|
489
496
|
Load metrics with automatic schema migration.
|
|
490
|
-
|
|
497
|
+
|
|
491
498
|
If old schema detected, automatically migrates to current.
|
|
492
499
|
"""
|
|
493
500
|
with open(filepath) as f:
|
|
494
501
|
data = json.load(f)
|
|
495
|
-
|
|
502
|
+
|
|
496
503
|
schema_version = data.get("schema_version", "1.0")
|
|
497
504
|
current_version = MetricSchema.current_version()
|
|
498
|
-
|
|
505
|
+
|
|
499
506
|
if schema_version != current_version:
|
|
500
507
|
print(f"Migrating from schema {schema_version} to {current_version}")
|
|
501
508
|
data = MetricSchema.migrate(data, from_version=schema_version)
|
|
502
|
-
|
|
509
|
+
|
|
503
510
|
return data
|
|
504
511
|
```
|
|
505
512
|
|
|
@@ -128,10 +128,11 @@ ls -t history/*__0.1.0.json | head -1
|
|
|
128
128
|
import re
|
|
129
129
|
from pathlib import Path
|
|
130
130
|
|
|
131
|
+
|
|
131
132
|
def parse_metric_filename(filename: str) -> dict:
|
|
132
133
|
"""
|
|
133
134
|
Parse metric filename into components.
|
|
134
|
-
|
|
135
|
+
|
|
135
136
|
Example: "2025_11_14_22_45_00__700e012__0.1.0.json"
|
|
136
137
|
Returns: {
|
|
137
138
|
"timestamp": "2025-11-14T22:45:00",
|
|
@@ -139,27 +140,32 @@ def parse_metric_filename(filename: str) -> dict:
|
|
|
139
140
|
"pypi_version": "0.1.0",
|
|
140
141
|
}
|
|
141
142
|
"""
|
|
142
|
-
pattern = r
|
|
143
|
+
pattern = r"(\d{4}_\d{2}_\d{2}_\d{2}_\d{2}_\d{2})__(\w{7})__(.+)\.json"
|
|
143
144
|
match = re.match(pattern, filename)
|
|
144
|
-
|
|
145
|
+
|
|
145
146
|
if not match:
|
|
146
147
|
raise ValueError(f"Invalid filename format: {filename}")
|
|
147
|
-
|
|
148
|
+
|
|
148
149
|
timestamp_str, sha, version = match.groups()
|
|
149
|
-
|
|
150
|
+
|
|
150
151
|
# Convert timestamp to ISO format
|
|
151
|
-
timestamp =
|
|
152
|
-
|
|
152
|
+
timestamp = (
|
|
153
|
+
timestamp_str.replace("_", "-", 2).replace("_", ":", 2).replace("_", "T", 1)
|
|
154
|
+
)
|
|
155
|
+
|
|
153
156
|
return {
|
|
154
157
|
"timestamp": timestamp,
|
|
155
158
|
"commit_sha": sha,
|
|
156
159
|
"pypi_version": None if version == "none" else version,
|
|
157
160
|
}
|
|
158
161
|
|
|
162
|
+
|
|
159
163
|
# Usage
|
|
160
164
|
for file in Path("history").glob("*.json"):
|
|
161
165
|
info = parse_metric_filename(file.name)
|
|
162
|
-
print(
|
|
166
|
+
print(
|
|
167
|
+
f"Commit {info['commit_sha']} at {info['timestamp']} (v{info['pypi_version']})"
|
|
168
|
+
)
|
|
163
169
|
```
|
|
164
170
|
|
|
165
171
|
## Metrics Schema
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""GitBranchStorage pushes to the repository's real remote, not to the local workspace (umpyre#8)."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import subprocess
|
|
5
|
+
|
|
6
|
+
import pytest
|
|
7
|
+
|
|
8
|
+
from umpyre.storage.git_branch import GitBranchStorage
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _git(*args, cwd):
|
|
12
|
+
return subprocess.run(
|
|
13
|
+
["git", *args], cwd=cwd, capture_output=True, text=True, check=True
|
|
14
|
+
).stdout.strip()
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@pytest.fixture
|
|
18
|
+
def workspace(tmp_path):
|
|
19
|
+
"""A workspace whose ``origin`` is a bare repo standing in for GitHub."""
|
|
20
|
+
remote = tmp_path / "remote.git"
|
|
21
|
+
work = tmp_path / "work"
|
|
22
|
+
_git("init", "--bare", "-q", str(remote), cwd=tmp_path)
|
|
23
|
+
_git("init", "-q", str(work), cwd=tmp_path)
|
|
24
|
+
for k, v in (("user.name", "t"), ("user.email", "t@t")):
|
|
25
|
+
_git("config", k, v, cwd=work)
|
|
26
|
+
(work / "a.txt").write_text("a")
|
|
27
|
+
_git("add", ".", cwd=work)
|
|
28
|
+
_git("commit", "-qm", "init", cwd=work)
|
|
29
|
+
_git("remote", "add", "origin", str(remote), cwd=work)
|
|
30
|
+
return work, remote
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def test_metrics_land_on_the_remote_and_accumulate(workspace):
|
|
34
|
+
work, remote = workspace
|
|
35
|
+
storage = GitBranchStorage(str(work))
|
|
36
|
+
storage.store_metrics({"metrics": {"n": 1}}, "a" * 40, branch="code-metrics")
|
|
37
|
+
# on the (bare) remote, not merely in the local workspace
|
|
38
|
+
assert _git("rev-parse", "--verify", "code-metrics", cwd=remote)
|
|
39
|
+
storage.store_metrics({"metrics": {"n": 2}}, "b" * 40, branch="code-metrics")
|
|
40
|
+
log = _git("log", "--format=%s", "code-metrics", cwd=remote).splitlines()
|
|
41
|
+
assert log == [f"Update metrics for {'b' * 7}", f"Update metrics for {'a' * 7}"]
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def test_token_becomes_a_host_scoped_auth_header_not_a_url(monkeypatch):
|
|
45
|
+
monkeypatch.delenv("GIT_CONFIG_COUNT", raising=False)
|
|
46
|
+
monkeypatch.delenv("GITHUB_SERVER_URL", raising=False)
|
|
47
|
+
storage = GitBranchStorage.__new__(GitBranchStorage)
|
|
48
|
+
storage.token = "s3cret"
|
|
49
|
+
env = storage._git_env("https://user@github.com/org/repo.git")
|
|
50
|
+
assert int(env["GIT_CONFIG_COUNT"]) == 2
|
|
51
|
+
key = "http.https://github.com/.extraheader"
|
|
52
|
+
assert [env["GIT_CONFIG_KEY_0"], env["GIT_CONFIG_KEY_1"]] == [key, key]
|
|
53
|
+
# an empty header first clears the one actions/checkout leaves in the config
|
|
54
|
+
assert env["GIT_CONFIG_VALUE_0"] == ""
|
|
55
|
+
assert env["GIT_CONFIG_VALUE_1"].startswith("AUTHORIZATION: basic ")
|
|
56
|
+
assert "s3cret" not in env["GIT_CONFIG_VALUE_1"] # base64, not plain
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def test_token_header_appends_to_existing_git_config_env(monkeypatch):
|
|
60
|
+
monkeypatch.setenv("GIT_CONFIG_COUNT", "1")
|
|
61
|
+
monkeypatch.delenv("GITHUB_SERVER_URL", raising=False)
|
|
62
|
+
storage = GitBranchStorage.__new__(GitBranchStorage)
|
|
63
|
+
storage.token = "t"
|
|
64
|
+
env = storage._git_env("https://github.com/o/r")
|
|
65
|
+
assert env["GIT_CONFIG_COUNT"] == "3" and "GIT_CONFIG_KEY_2" in env
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def test_no_header_without_token_for_local_ssh_or_foreign_hosts(monkeypatch):
|
|
69
|
+
monkeypatch.delenv("GITHUB_SERVER_URL", raising=False)
|
|
70
|
+
storage = GitBranchStorage.__new__(GitBranchStorage)
|
|
71
|
+
storage.token = "t"
|
|
72
|
+
for url in ("/some/local/path", "git@github.com:o/r.git", None):
|
|
73
|
+
assert storage._git_env(url) == dict(os.environ)
|
|
74
|
+
monkeypatch.setenv("GITHUB_SERVER_URL", "https://github.com")
|
|
75
|
+
assert storage._git_env("https://evil.example/o/r") == dict(os.environ)
|
|
76
|
+
storage.token = None
|
|
77
|
+
assert storage._git_env("https://github.com/o/r") == dict(os.environ)
|
|
@@ -106,14 +106,15 @@ def cmd_collect(args):
|
|
|
106
106
|
|
|
107
107
|
print(json.dumps(metric_data, indent=2))
|
|
108
108
|
else:
|
|
109
|
-
|
|
109
|
+
branch = args.storage_branch or config.storage_branch
|
|
110
|
+
storage = GitBranchStorage(repo_path, token=os.getenv("GITHUB_TOKEN"))
|
|
110
111
|
storage.store_metrics(
|
|
111
112
|
metric_data,
|
|
112
113
|
commit_sha,
|
|
113
|
-
branch=
|
|
114
|
+
branch=branch,
|
|
114
115
|
formats=config.storage_formats,
|
|
115
116
|
)
|
|
116
|
-
print(f"Metrics stored to branch '{
|
|
117
|
+
print(f"Metrics stored to branch '{branch}'")
|
|
117
118
|
|
|
118
119
|
print(f"Collection completed in {duration:.2f}s")
|
|
119
120
|
return 0
|
|
@@ -202,6 +203,11 @@ def main():
|
|
|
202
203
|
collect_parser.add_argument(
|
|
203
204
|
"--message", help="Commit message (default: auto-detect from git)"
|
|
204
205
|
)
|
|
206
|
+
collect_parser.add_argument(
|
|
207
|
+
"--storage-branch",
|
|
208
|
+
help="Branch to store metrics on (default: storage.branch of the config, "
|
|
209
|
+
"else 'code-metrics')",
|
|
210
|
+
)
|
|
205
211
|
collect_parser.add_argument(
|
|
206
212
|
"--no-store", action="store_true", help="Collect but don't store (dry-run)"
|
|
207
213
|
)
|
|
@@ -341,12 +341,3 @@ def stats_of(
|
|
|
341
341
|
put_at_the_front = [x for x in df.index if x not in put_at_the_end]
|
|
342
342
|
df = df.loc[put_at_the_front + put_at_the_end]
|
|
343
343
|
return df
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
if __name__ == "__main__":
|
|
347
|
-
from dol.util import ModuleNotFoundErrorNiceMessage
|
|
348
|
-
|
|
349
|
-
with ModuleNotFoundErrorNiceMessage():
|
|
350
|
-
import argh
|
|
351
|
-
|
|
352
|
-
argh.dispatch_commands([modules_info_df, modules_info_df_stats, stats_of])
|
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
"""Git branch-based storage operations."""
|
|
2
2
|
|
|
3
|
+
import base64
|
|
4
|
+
import os
|
|
3
5
|
import subprocess
|
|
4
6
|
import tempfile
|
|
5
7
|
from datetime import datetime
|
|
6
8
|
from pathlib import Path
|
|
7
9
|
from typing import Optional
|
|
10
|
+
from urllib.parse import urlsplit
|
|
8
11
|
|
|
9
12
|
from umpyre.storage.formats import serialize_metrics
|
|
10
13
|
|
|
@@ -22,16 +25,25 @@ class GitBranchStorage:
|
|
|
22
25
|
>>> storage.store_metrics(metrics, "abc123", branch="code-metrics") #doctest: +SKIP
|
|
23
26
|
"""
|
|
24
27
|
|
|
25
|
-
def __init__(
|
|
28
|
+
def __init__(
|
|
29
|
+
self, repo_path: str, remote: str = "origin", *, token: Optional[str] = None
|
|
30
|
+
):
|
|
26
31
|
"""
|
|
27
32
|
Initialize storage.
|
|
28
33
|
|
|
34
|
+
Metrics are pushed to the URL of ``remote`` in ``repo_path`` (e.g. GitHub),
|
|
35
|
+
not to ``repo_path`` itself.
|
|
36
|
+
|
|
29
37
|
Args:
|
|
30
38
|
repo_path: Path to git repository
|
|
31
39
|
remote: Git remote name
|
|
40
|
+
token: Access token used (as an HTTP auth header scoped to the remote's
|
|
41
|
+
host, never written to a URL or config) to push to an https remote.
|
|
42
|
+
Without it, the push uses whatever credentials git already has.
|
|
32
43
|
"""
|
|
33
44
|
self.repo_path = Path(repo_path)
|
|
34
45
|
self.remote = remote
|
|
46
|
+
self.token = token
|
|
35
47
|
|
|
36
48
|
if not (self.repo_path / ".git").exists():
|
|
37
49
|
raise ValueError(f"Not a git repository: {repo_path}")
|
|
@@ -91,9 +103,46 @@ class GitBranchStorage:
|
|
|
91
103
|
elif fmt == "csv":
|
|
92
104
|
serialize_metrics(metrics, tmpdir / "metrics.csv", format="csv")
|
|
93
105
|
|
|
94
|
-
# Commit and push
|
|
106
|
+
# Commit and push (to the real remote, not to the local workspace)
|
|
95
107
|
self._commit_and_push(tmpdir, branch, commit_sha)
|
|
96
108
|
|
|
109
|
+
def _remote_url(self) -> Optional[str]:
|
|
110
|
+
"""URL of the real remote of ``repo_path``, or ``None`` if it has none."""
|
|
111
|
+
result = subprocess.run(
|
|
112
|
+
["git", "remote", "get-url", self.remote],
|
|
113
|
+
cwd=self.repo_path,
|
|
114
|
+
capture_output=True,
|
|
115
|
+
text=True,
|
|
116
|
+
check=False,
|
|
117
|
+
)
|
|
118
|
+
return result.stdout.strip() or None if result.returncode == 0 else None
|
|
119
|
+
|
|
120
|
+
def _git_env(self, url: Optional[str]) -> dict:
|
|
121
|
+
"""Environment for git commands that talk to the remote (auth header, if any)."""
|
|
122
|
+
env = dict(os.environ)
|
|
123
|
+
parts = urlsplit(url or "")
|
|
124
|
+
host = parts.netloc.rsplit("@", 1)[-1]
|
|
125
|
+
server_host = urlsplit(env.get("GITHUB_SERVER_URL", "")).netloc
|
|
126
|
+
if (
|
|
127
|
+
self.token
|
|
128
|
+
and parts.scheme in ("http", "https")
|
|
129
|
+
and host
|
|
130
|
+
and (not server_host or host == server_host)
|
|
131
|
+
):
|
|
132
|
+
env["GIT_TERMINAL_PROMPT"] = "0" # fail, don't wait for a password
|
|
133
|
+
key = f"http.{parts.scheme}://{host}/.extraheader"
|
|
134
|
+
credentials = base64.b64encode(f"x-access-token:{self.token}".encode())
|
|
135
|
+
n = int(env.get("GIT_CONFIG_COUNT") or 0)
|
|
136
|
+
# actions/checkout leaves its own Authorization header in the repo config,
|
|
137
|
+
# and GitHub rejects two (HTTP 400): an empty value clears earlier ones.
|
|
138
|
+
for i, value in enumerate(
|
|
139
|
+
("", f"AUTHORIZATION: basic {credentials.decode()}")
|
|
140
|
+
):
|
|
141
|
+
env[f"GIT_CONFIG_KEY_{n + i}"] = key
|
|
142
|
+
env[f"GIT_CONFIG_VALUE_{n + i}"] = value
|
|
143
|
+
env["GIT_CONFIG_COUNT"] = str(n + 2)
|
|
144
|
+
return env
|
|
145
|
+
|
|
97
146
|
def _fetch_branch(self, branch: str):
|
|
98
147
|
"""Fetch metrics branch from remote."""
|
|
99
148
|
try:
|
|
@@ -103,6 +152,7 @@ class GitBranchStorage:
|
|
|
103
152
|
capture_output=True,
|
|
104
153
|
check=False, # May not exist yet
|
|
105
154
|
timeout=30,
|
|
155
|
+
env=self._git_env(self._remote_url()),
|
|
106
156
|
)
|
|
107
157
|
except subprocess.TimeoutExpired:
|
|
108
158
|
pass
|
|
@@ -158,6 +208,17 @@ class GitBranchStorage:
|
|
|
158
208
|
|
|
159
209
|
def _commit_and_push(self, work_dir: Path, branch: str, commit_sha: str):
|
|
160
210
|
"""Commit changes and push to remote."""
|
|
211
|
+
# The clone's origin is the local workspace; point it at the real remote.
|
|
212
|
+
remote_url = self._remote_url()
|
|
213
|
+
if remote_url:
|
|
214
|
+
subprocess.run(
|
|
215
|
+
["git", "remote", "set-url", "origin", remote_url],
|
|
216
|
+
cwd=work_dir,
|
|
217
|
+
capture_output=True,
|
|
218
|
+
check=True,
|
|
219
|
+
)
|
|
220
|
+
env = self._git_env(remote_url)
|
|
221
|
+
|
|
161
222
|
# Configure git
|
|
162
223
|
subprocess.run(
|
|
163
224
|
["git", "config", "user.name", "umpyre-bot"],
|
|
@@ -202,21 +263,24 @@ class GitBranchStorage:
|
|
|
202
263
|
for attempt in range(3):
|
|
203
264
|
try:
|
|
204
265
|
subprocess.run(
|
|
205
|
-
["git", "push",
|
|
266
|
+
["git", "push", "origin", branch],
|
|
206
267
|
cwd=work_dir,
|
|
207
268
|
capture_output=True,
|
|
208
269
|
check=True,
|
|
209
270
|
timeout=60,
|
|
271
|
+
env=env,
|
|
210
272
|
)
|
|
211
273
|
break
|
|
212
274
|
except subprocess.CalledProcessError:
|
|
213
275
|
if attempt < 2:
|
|
214
276
|
# Pull and try again
|
|
215
277
|
subprocess.run(
|
|
216
|
-
["git", "pull", "--rebase",
|
|
278
|
+
["git", "pull", "--rebase", "origin", branch],
|
|
217
279
|
cwd=work_dir,
|
|
218
280
|
capture_output=True,
|
|
219
281
|
check=False,
|
|
282
|
+
timeout=60,
|
|
283
|
+
env=env,
|
|
220
284
|
)
|
|
221
285
|
else:
|
|
222
286
|
raise
|
|
@@ -1,74 +0,0 @@
|
|
|
1
|
-
name: Track Code Metrics
|
|
2
|
-
description: Collect and store code quality metrics for CI/CD pipelines
|
|
3
|
-
|
|
4
|
-
inputs:
|
|
5
|
-
config-path:
|
|
6
|
-
description: 'Path to umpyre config file'
|
|
7
|
-
default: '.github/umpyre-config.yml'
|
|
8
|
-
required: false
|
|
9
|
-
|
|
10
|
-
storage-branch:
|
|
11
|
-
description: 'Branch to store metrics'
|
|
12
|
-
default: 'code-metrics'
|
|
13
|
-
required: false
|
|
14
|
-
|
|
15
|
-
github-token:
|
|
16
|
-
description: 'GitHub token for API access'
|
|
17
|
-
required: true
|
|
18
|
-
|
|
19
|
-
force-run:
|
|
20
|
-
description: 'Run even if workflow failed'
|
|
21
|
-
default: 'false'
|
|
22
|
-
required: false
|
|
23
|
-
|
|
24
|
-
python-version:
|
|
25
|
-
description: 'Python version to use'
|
|
26
|
-
default: '3.10'
|
|
27
|
-
required: false
|
|
28
|
-
|
|
29
|
-
runs:
|
|
30
|
-
using: composite
|
|
31
|
-
steps:
|
|
32
|
-
- name: Setup Python
|
|
33
|
-
uses: actions/setup-python@v5
|
|
34
|
-
with:
|
|
35
|
-
python-version: ${{ inputs.python-version }}
|
|
36
|
-
|
|
37
|
-
- name: Install umpyre and dependencies
|
|
38
|
-
shell: bash
|
|
39
|
-
run: |
|
|
40
|
-
pip install umpyre coverage wily bandit interrogate
|
|
41
|
-
|
|
42
|
-
- name: Collect and Store Metrics
|
|
43
|
-
shell: bash
|
|
44
|
-
run: |
|
|
45
|
-
echo "::group::Collecting Code Metrics"
|
|
46
|
-
|
|
47
|
-
# Set up error handling - continue even if collection fails
|
|
48
|
-
set +e
|
|
49
|
-
|
|
50
|
-
# Run umpyre collect
|
|
51
|
-
if [ -f "${{ inputs.config-path }}" ]; then
|
|
52
|
-
python -m umpyre.cli collect --config "${{ inputs.config-path }}"
|
|
53
|
-
EXIT_CODE=$?
|
|
54
|
-
else
|
|
55
|
-
python -m umpyre.cli collect
|
|
56
|
-
EXIT_CODE=$?
|
|
57
|
-
fi
|
|
58
|
-
|
|
59
|
-
# Report status but don't fail
|
|
60
|
-
if [ $EXIT_CODE -eq 0 ]; then
|
|
61
|
-
echo "✅ Metrics collection completed successfully"
|
|
62
|
-
else
|
|
63
|
-
echo "⚠️ Metrics collection failed with exit code $EXIT_CODE"
|
|
64
|
-
echo "This is informational only - CI pipeline continues"
|
|
65
|
-
fi
|
|
66
|
-
|
|
67
|
-
echo "::endgroup::"
|
|
68
|
-
|
|
69
|
-
# Always exit successfully to not break CI
|
|
70
|
-
exit 0
|
|
71
|
-
|
|
72
|
-
branding:
|
|
73
|
-
icon: 'bar-chart-2'
|
|
74
|
-
color: 'blue'
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|