Compare commits

..

16 Commits

Author SHA1 Message Date
coderabbitai[bot]
2703f04a0d 📝 Add docstrings to main
Docstrings generation was requested by @AlexeyMal.

* https://github.com/wled/WLED/pull/5000#issuecomment-3418606053

The following files were modified:

* `wled00/json.cpp`
2025-10-18 15:54:16 +00:00
Benjam Welker
ca5debef32 Fix blank area issue with Twinkle (#5005)
* Fix blank area issue with Twinkle
2025-10-17 07:31:00 +02:00
Damian Schneider
3bc728e068 fix low brightness gradient "jumpyness"
during testing at low brightness I noticed that gradients can be "jumping" in colors quite wildly, turning a smooth gradient into a flickering mess. This is due to the color hue preservation being inaccurate and a bit too aggressive. This can be seen for example using a gradient palette and "Running" FX.
Removing the hue preservation completely fixes it but leaves color artefacts for example visible in PS Fire at very low brightness: the bright part of the flames gets a pink hue. This change is a compromise to fix both problems to a "good enough" state
2025-10-12 15:18:48 +02:00
Damian Schneider
7f1f986f13 safety check for bootloop action tracker: bring it back on track if out of bounds 2025-10-09 22:08:18 +02:00
Will Tatam
91fdb5822b Merge pull request #4985 from wled-compile/patch-1
Correct broken esp32dev_8M enviroment
2025-10-07 07:42:33 +01:00
Will Tatam
e4cabf8de6 Merge pull request #4987 from wled/copilot/fix-e93598cf-9ce2-4b3d-82dd-393eaf538463
Fix copilot-instructions.md to require mandatory hardware build validation
2025-10-05 16:23:00 +01:00
copilot-swe-agent[bot]
f034601512 Reference Hardware Compilation section for common environments list
Co-authored-by: netmindz <442066+netmindz@users.noreply.github.com>
2025-10-05 15:12:07 +00:00
copilot-swe-agent[bot]
151a974607 Fix copilot-instructions.md to require mandatory build validation
Co-authored-by: netmindz <442066+netmindz@users.noreply.github.com>
2025-10-05 15:04:44 +00:00
copilot-swe-agent[bot]
9f583f16f8 Initial plan 2025-10-05 14:54:28 +00:00
wled-compile
4c4436f48c Update platformio.ini
esp32dev_8M: add flash_mode
2025-10-05 16:32:22 +02:00
Damian Schneider
3562fa264e Bugfix for gif player WRT inactive segment and bugfix in copy FX
- if a segment is destroyed or turned inactive, disable the gif player: only one gif player instance can run at a time, if a inactive or destroyed segment uses it, the effect is broken for other segments.

- copy FX ironically copied the source segment on each call, should use reference not a copy!
2025-10-02 20:40:43 +02:00
Damian Schneider
359d46c3e1 Bugfix for gif playback and segment destruction, bugfix in copy FX
- if a segment is destroyed or turned inactive, disable the gif player: only one gif player instance can run at a time, if a inactive or destroyed segment uses it, the effect is broken for other segments.
- copy FX ironically copied the source segment on each call, should use reference not a copy!
2025-10-02 20:06:01 +02:00
Damian Schneider
2b73a349dd Bugfix for FX: Tri Fade
Bugfix for FX: Tri Fade
2025-09-28 19:52:20 +02:00
Damian Schneider
d86ae7db40 Merge pull request #4968 from MathijsG/patch-1
Fix typo changable > changeable
2025-09-28 17:08:14 +02:00
Mathijs Groothuis
f096320e63 Fix typo changable > changeable
Fix typo that I discovered while tinkering in Wled.
2025-09-28 16:55:03 +02:00
Blaž Kristan
649d43b581 Bugfix for FX: Tri Fade
- incorrectly calculated counter and progress
2025-09-08 11:37:43 +02:00
15 changed files with 89 additions and 1720 deletions

View File

@@ -30,6 +30,27 @@ The build has two main phases:
- Common environments: `nodemcuv2`, `esp32dev`, `esp8266_2m`
- List all targets: `pio run --list-targets`
## Before Finishing Work
**CRITICAL: You MUST complete ALL of these steps before marking your work as complete:**
1. **Run the test suite**: `npm test` -- Set timeout to 2+ minutes. NEVER CANCEL.
- All tests MUST pass
- If tests fail, fix the issue before proceeding
2. **Build at least one hardware environment**: `pio run -e esp32dev` -- Set timeout to 30+ minutes. NEVER CANCEL.
- Choose `esp32dev` as it's a common, representative environment
- See "Hardware Compilation" section above for the full list of common environments
- The build MUST complete successfully without errors
- If the build fails, fix the issue before proceeding
- **DO NOT skip this step** - it validates that firmware compiles with your changes
3. **For web UI changes only**: Manually test the interface
- See "Manual Testing Scenarios" section below
- Verify the UI loads and functions correctly
**If any of these validation steps fail, you MUST fix the issues before finishing. Do NOT mark work as complete with failing builds or tests.**
## Validation and Testing
### Web UI Testing
@@ -44,7 +65,7 @@ The build has two main phases:
- **Code style**: Use tabs for web files (.html/.css/.js), spaces (2 per level) for C++ files
- **C++ formatting available**: `clang-format` is installed but not in CI
- **Always run tests before finishing**: `npm test`
- **Always run a build for the common environment before finishing**
- **MANDATORY: Always run a hardware build before finishing** (see "Before Finishing Work" section below)
### Manual Testing Scenarios
After making changes to web UI, always test:
@@ -100,10 +121,16 @@ package.json # Node.js dependencies and scripts
## Build Timing and Timeouts
- **Web UI build**: 3 seconds - Set timeout to 30 seconds minimum
- **Test suite**: 40 seconds - Set timeout to 2 minutes minimum
- **Hardware builds**: 15+ minutes - Set timeout to 30+ minutes minimum
- **NEVER CANCEL long-running builds** - PlatformIO downloads and compilation can take significant time
**IMPORTANT: Use these timeout values when running builds:**
- **Web UI build** (`npm run build`): 3 seconds typical - Set timeout to 30 seconds minimum
- **Test suite** (`npm test`): 40 seconds typical - Set timeout to 120 seconds (2 minutes) minimum
- **Hardware builds** (`pio run -e [target]`): 15-20 minutes typical for first build - Set timeout to 1800 seconds (30 minutes) minimum
- Subsequent builds are faster due to caching
- First builds download toolchains and dependencies which takes significant time
- **NEVER CANCEL long-running builds** - PlatformIO downloads and compilation require patience
**When validating your changes before finishing, you MUST wait for the hardware build to complete successfully. Set the timeout appropriately and be patient.**
## Troubleshooting
@@ -129,11 +156,17 @@ package.json # Node.js dependencies and scripts
- **Hardware builds require appropriate ESP32/ESP8266 development board**
## CI/CD Pipeline
The GitHub Actions workflow:
**The GitHub Actions CI workflow will:**
1. Installs Node.js and Python dependencies
2. Runs `npm test` to validate build system
3. Builds web UI with `npm run build`
4. Compiles firmware for multiple hardware targets
2. Runs `npm test` to validate build system (MUST pass)
3. Builds web UI with `npm run build` (automatically run by PlatformIO)
4. Compiles firmware for ALL hardware targets listed in `default_envs` (MUST succeed for all)
5. Uploads build artifacts
Match this workflow in your local development to ensure CI success.
**To ensure CI success, you MUST locally:**
- Run `npm test` and ensure it passes
- Run `pio run -e esp32dev` (or another common environment from "Hardware Compilation" section) and ensure it completes successfully
- If either fails locally, it WILL fail in CI
**Match this workflow in your local development to ensure CI success. Do not mark work complete until you have validated builds locally.**

3
.gitignore vendored
View File

@@ -25,6 +25,3 @@ wled-update.sh
/wled00/Release
/wled00/wled00.ino.cpp
/wled00/html_*.h
# Temporary fork statistics results
tempresults.json

View File

@@ -450,7 +450,7 @@ board_build.partitions = ${esp32.large_partitions}
board_upload.flash_size = 8MB
board_upload.maximum_size = 8388608
; board_build.f_flash = 80000000L
; board_build.flash_mode = qio
board_build.flash_mode = dio
[env:esp32dev_16M]
board = esp32dev

View File

@@ -1,201 +0,0 @@
# Fork Statistics Analysis Tool
This tool analyzes GitHub repository forks to provide insights into fork activity and health for the WLED project.
## Features
The script analyzes and reports on:
- **Branch Analysis**: Which forks have branches that do not exist in the main repo
- **Recency Analysis**: Which forks have recent versions of main vs outdated forks
- **Contribution Analysis**: Which fork repos have been the source of PRs into the main repo
- **Activity Detection**: Which forks have active development but haven't contributed PRs
- **Owner Commit Analysis**: Statistics about commits made by fork owners to their own repositories
- **Age Statistics**: Distribution of how far behind forks are (1 month, 3 months, 6 months, 1 year, 2+ years)
- **Incremental Saving**: Automatically saves intermediate results every 10 forks to prevent data loss
## Requirements
- Python 3.7+
- `requests` library (included in WLED requirements.txt)
- GitHub personal access token (recommended for analyzing large numbers of forks)
## Usage
### Quick Demo
To see what the output looks like with sample data:
```bash
python3 tools/fork_stats.py --demo
```
### Basic Analysis (Rate Limited)
Analyze the first 10 forks without a token (uses GitHub's unauthenticated API with 60 requests/hour limit):
```bash
python3 tools/fork_stats.py --max-forks 10
```
### Full Analysis with Token
For comprehensive analysis, create a GitHub personal access token:
1. Go to GitHub Settings > Developer settings > Personal access tokens > Tokens (classic)
2. Generate a new token with `public_repo` scope
3. Set the token as an environment variable:
```bash
export GITHUB_TOKEN="your_token_here"
python3 tools/fork_stats.py
```
Or pass it directly:
```bash
python3 tools/fork_stats.py --token "your_token_here"
```
### Advanced Options
```bash
# Analyze specific repository
python3 tools/fork_stats.py --repo owner/repo
# Limit number of forks analyzed
python3 tools/fork_stats.py --max-forks 50
# Fast mode: skip detailed analysis of very old forks for better performance
python3 tools/fork_stats.py --fast --max-forks 100
# Save detailed JSON results
python3 tools/fork_stats.py --output results.json
# Check what would be analyzed without making API calls
python3 tools/fork_stats.py --dry-run
# Different output format
python3 tools/fork_stats.py --format json
```
## Output
### Summary Format (Default)
The tool provides a human-readable summary including:
- Repository statistics (total forks, stars, watchers)
- Fork age distribution showing staleness
- Activity analysis showing contribution patterns
- Key insights about fork health
### JSON Format
Detailed machine-readable output including:
- Complete fork metadata for each analyzed fork
- Branch information and unique branches
- Contribution history and activity metrics
- Owner commit statistics for each fork
- Full statistical breakdown
- Intermediate results are automatically saved to `tempresults.json` every 10 forks to prevent data loss on interruption
### Visualization
For advanced visualization and analysis of the JSON results, use the companion visualizer tool:
```bash
# Generate visualizations from collected data
python3 tools/fork_stats_visualizer.py results.json --save-plots
# Text-only statistics (no graphs)
python3 tools/fork_stats_visualizer.py results.json --no-graphs
```
See [README_fork_stats_visualizer.md](README_fork_stats_visualizer.md) for complete documentation.
## Performance Considerations
### Execution Speed
- **Without Token**: 60 requests/hour (very slow, only suitable for testing)
- **With Token**: 5000 requests/hour (much faster, recommended for real analysis)
- **Each fork requires 3-8 API requests** depending on the fork's complexity
### Fast Mode
Use `--fast` flag to improve performance:
- Skips detailed analysis of forks inactive for 3+ years
- Reduces API calls for very old forks by ~80%
- Maintains statistical accuracy while dramatically improving speed
- Recommended for initial analysis or large repository scans
### Progress Tracking
The tool provides detailed progress information including:
- Current fork being analyzed
- Time taken per fork analysis
- API requests made and remaining rate limit
- Estimated completion time
## Example Output
```
============================================================
FORK ANALYSIS SUMMARY FOR wled/WLED
============================================================
Repository Details:
- Total Forks: 1,243
- Analyzed: 100
- Stars: 15,500
- Watchers: 326
Fork Age Distribution:
- Last updated ≤ 1 month: 8 ( 8.0%)
- Last updated ≤ 3 months: 12 ( 12.0%)
- Last updated ≤ 6 months: 15 ( 15.0%)
- Last updated ≤ 1 year: 23 ( 23.0%)
- Last updated ≤ 2 years: 25 ( 25.0%)
- Last updated > 5 years: 17 ( 17.0%)
Fork Activity Analysis:
- Forks with unique branches: 34 (34.0%)
- Forks with recent main branch: 42 (42.0%)
- Forks that contributed PRs: 18 (18.0%)
- Active forks (no PR contributions): 23 (23.0%)
Owner Commit Analysis:
- Forks with owner commits: 67 (67.0%)
- Total commits by fork owners: 2845
- Average commits per fork: 28.5
Key Insights:
- Most forks are significantly behind main branch
- Significant number of forks have custom development
- Majority of forks show some owner development activity
```
## Use Cases
- **Project Maintenance**: Identify which forks are actively maintained
- **Community Engagement**: Find potential contributors who haven't submitted PRs
- **Code Discovery**: Locate interesting custom features in fork branches
- **Health Assessment**: Monitor overall ecosystem health of the project
- **Outreach Planning**: Target active fork maintainers for collaboration
## Implementation Details
The script uses the GitHub REST API v3 and implements:
- Rate limiting with automatic backoff
- Error handling for private/deleted repositories
- Efficient pagination for large fork lists
- Branch comparison algorithms
- PR attribution analysis
- Commit recency detection
## Troubleshooting
- **Rate Limit Errors**: Use a GitHub token or reduce `--max-forks`
- **Permission Errors**: Ensure token has `public_repo` scope
- **Network Errors**: Check internet connection and GitHub status
- **Large Repository Timeouts**: Use `--max-forks` to limit analysis scope

View File

@@ -1,240 +0,0 @@
# Fork Statistics Visualizer
A Python script that loads JSON data generated by `fork_stats.py` and displays detailed statistics both as formatted text lists and visual graphs.
## Features
- **Text-based Statistics**: Formatted tables with visual bars showing percentages
- **Interactive Graphs**: Pie charts, bar charts, histograms, and combined dashboards
- **Top Forks Lists**: Shows top forks by unique branches, owner commits, and activity
- **Export Capabilities**: Save all visualizations as high-quality PNG images
- **Works Without Graphics**: Can run in text-only mode without matplotlib
## Installation
### Basic Installation (Text-only)
The visualizer works without additional dependencies for text-based output:
```bash
python3 tools/fork_stats_visualizer.py results.json --no-graphs
```
### Full Installation (With Graphics)
For graphical visualizations, install matplotlib:
```bash
pip install -r tools/fork_stats_visualizer_requirements.txt
```
## Usage
### Basic Usage
Display text statistics and interactive graphs:
```bash
python3 tools/fork_stats_visualizer.py results.json
```
### Text-Only Mode
Skip graphs and only show text statistics:
```bash
python3 tools/fork_stats_visualizer.py results.json --no-graphs
```
### Save Plots to Files
Save all plots as PNG files instead of displaying them:
```bash
python3 tools/fork_stats_visualizer.py results.json --save-plots
```
### Custom Output Directory
Specify where to save the plots:
```bash
python3 tools/fork_stats_visualizer.py results.json --save-plots --output-dir ./my_plots
```
### Show Top N Forks
Display top 30 forks instead of default 20:
```bash
python3 tools/fork_stats_visualizer.py results.json --top-n 30
```
## Output
### Text Statistics
The script displays:
1. **Repository Information**
- Repository name, total forks, stars, watchers
- Number of forks analyzed
- Analysis timestamp
2. **Fork Age Distribution**
- Breakdown by age categories (≤1 month, ≤3 months, ≤6 months, ≤1 year, ≤2 years, >5 years)
- Count and percentage for each category
- Visual bars showing proportions
3. **Fork Activity Analysis**
- Forks with unique branches
- Forks with recent main branch
- Forks that contributed PRs
- Active forks without PR contributions
- Visual bars for each metric
4. **Owner Commit Analysis**
- Number of forks with owner commits
- Total commits by all fork owners
- Average commits per fork
5. **Top Forks Lists**
- Top N forks by unique branches
- Top N forks by owner commits
- Active forks without PR contributions
### Visual Graphs
When matplotlib is available, the script generates:
1. **Age Distribution Pie Chart**
- Shows the distribution of fork ages
- Color-coded by recency (green for recent, red for old)
2. **Activity Metrics Bar Chart**
- Compares different activity metrics side-by-side
- Shows percentages for each metric
3. **Owner Commits Distribution Histogram**
- Shows the distribution of owner commits across forks
- Includes mean and max statistics
4. **Combined Dashboard**
- All-in-one view with multiple charts
- Summary statistics panel
- Perfect for presentations and reports
## Example Output
### Text Output
```
================================================================================
REPOSITORY INFORMATION
================================================================================
Repository: wled/WLED
Total Forks: 1,243
Stars: 15,500
Watchers: 326
Analyzed Forks: 100
================================================================================
FORK AGE DISTRIBUTION
================================================================================
Age Category Count Percentage
------------------------------------------------------------
Last updated ≤ 1 month 8 8.0% ████
Last updated ≤ 3 months 12 12.0% ██████
Last updated ≤ 6 months 15 15.0% ███████
Last updated ≤ 1 year 23 23.0% ███████████
Last updated ≤ 2 years 25 25.0% ████████████
Last updated > 5 years 17 17.0% ████████
```
### Saved Files
When using `--save-plots`, the following files are created:
- `age_distribution.png` - Pie chart of fork ages
- `activity_metrics.png` - Bar chart of activity metrics
- `owner_commits_distribution.png` - Histogram of owner commits
- `dashboard.png` - Combined dashboard view
## Integration with fork_stats.py
### Complete Workflow
1. **Collect Statistics**
```bash
python3 tools/fork_stats.py --output results.json --max-forks 100
```
2. **Visualize Results**
```bash
python3 tools/fork_stats_visualizer.py results.json --save-plots
```
3. **View Dashboard**
Open `fork_plots/dashboard.png` for a complete overview
### Automated Analysis
Create a bash script to run both tools:
```bash
#!/bin/bash
# analyze_forks.sh
# Set your GitHub token
export GITHUB_TOKEN="your_token_here"
# Collect statistics
python3 tools/fork_stats.py \
--repo wled/WLED \
--max-forks 200 \
--fast \
--output fork_analysis.json
# Generate visualizations
python3 tools/fork_stats_visualizer.py \
fork_analysis.json \
--save-plots \
--output-dir ./fork_analysis_plots \
--top-n 25
echo "Analysis complete! Check fork_analysis_plots/ for visualizations."
```
## Troubleshooting
### "matplotlib not installed" Warning
This is normal if you haven't installed matplotlib. The script will still work in text-only mode. To enable graphs:
```bash
pip install -r tools/fork_stats_visualizer_requirements.txt
```
### "No detailed fork data available"
The JSON file doesn't contain individual fork details. This happens when:
- Using demo mode in fork_stats.py
- The analysis was interrupted before collecting fork data
- The JSON file is incomplete
The visualizer will still show aggregate statistics from the statistics section.
### Plots Not Saving
Ensure the output directory is writable:
```bash
mkdir -p ./fork_plots
chmod 755 ./fork_plots
python3 tools/fork_stats_visualizer.py results.json --save-plots --output-dir ./fork_plots
```
## Command-Line Options
| Option | Description | Default |
|--------|-------------|---------|
| `json_file` | Path to JSON file with fork statistics | Required |
| `--save-plots` | Save plots to files instead of displaying | False |
| `--output-dir DIR` | Directory to save plots | `./fork_plots` |
| `--top-n N` | Number of top forks to display | 20 |
| `--no-graphs` | Skip graph generation, text only | False |
## Dependencies
### Required
- Python 3.7+
- json (built-in)
- argparse (built-in)
### Optional (for graphs)
- matplotlib >= 3.5.0
Install optional dependencies:
```bash
pip install -r tools/fork_stats_visualizer_requirements.txt
```

View File

@@ -1,782 +0,0 @@
#!/usr/bin/env python3
"""
Fork Statistics Analysis Tool for WLED Repository
This script analyzes the forks of the WLED repository to provide insights on:
- Which forks have branches that do not exist in the main repo
- Which forks have a recent version of main (vs outdated forks)
- Which fork repos have been the source of PRs into the main repo
- Which forks have active development but haven't contributed PRs
- Statistics on how far behind forks are (1 month, 3 months, 6 months, 1+ years)
Usage:
python3 tools/fork_stats.py [--token GITHUB_TOKEN] [--repo owner/repo] [--output OUTPUT_FILE]
Environment Variables:
GITHUB_TOKEN: GitHub personal access token for API access
"""
import argparse
import json
import os
import sys
import time
from datetime import datetime, timedelta, timezone
from typing import Dict, List, Optional, Set, Tuple
import requests
from dataclasses import dataclass
@dataclass
class ForkInfo:
"""Information about a repository fork."""
name: str
full_name: str
owner: str
html_url: str
updated_at: datetime
pushed_at: datetime
default_branch: str
branches: List[str]
unique_branches: List[str]
behind_main_by_commits: int
behind_main_by_days: int
has_contributed_prs: bool
recent_commits: int
is_active: bool
owner_commits: int
class GitHubAPIError(Exception):
"""Custom exception for GitHub API errors."""
pass
class ForkStatsAnalyzer:
"""Analyzes fork statistics for a GitHub repository."""
def __init__(self, token: Optional[str] = None):
self.token = token or os.getenv('GITHUB_TOKEN')
if not self.token:
print("Warning: No GitHub token provided. API rate limits will be severely restricted.")
self.session = requests.Session()
if self.token:
self.session.headers.update({'Authorization': f'token {self.token}'})
self.session.headers.update({
'Accept': 'application/vnd.github.v3+json',
'User-Agent': 'WLED-Fork-Stats-Analyzer/1.0'
})
# Rate limiting
self.requests_made = 0
self.rate_limit_remaining = None
self.rate_limit_reset = None
def _make_request(self, url: str, params: Optional[Dict] = None) -> Dict:
"""Make a GitHub API request with rate limiting."""
if self.rate_limit_remaining is not None and self.rate_limit_remaining <= 5:
if self.rate_limit_reset:
wait_time = self.rate_limit_reset - time.time()
if wait_time > 0:
print(f"Rate limit low ({self.rate_limit_remaining} remaining). Waiting {wait_time:.1f} seconds...")
time.sleep(wait_time + 1)
try:
response = self.session.get(url, params=params)
# Update rate limit info
self.rate_limit_remaining = int(response.headers.get('X-RateLimit-Remaining', 0))
reset_timestamp = response.headers.get('X-RateLimit-Reset')
if reset_timestamp:
self.rate_limit_reset = int(reset_timestamp)
self.requests_made += 1
if self.requests_made % 50 == 0:
print(f"API requests made: {self.requests_made}, remaining: {self.rate_limit_remaining}")
if response.status_code == 403:
if 'rate limit' in response.text.lower():
raise GitHubAPIError("Rate limit exceeded")
else:
raise GitHubAPIError("API access forbidden (check token permissions)")
response.raise_for_status()
return response.json()
except requests.exceptions.RequestException as e:
raise GitHubAPIError(f"API request failed: {e}")
def get_repository_info(self, repo: str) -> Dict:
"""Get basic repository information."""
url = f"https://api.github.com/repos/{repo}"
return self._make_request(url)
def get_forks(self, repo: str, max_forks: Optional[int] = None) -> List[Dict]:
"""Get all forks of a repository."""
forks = []
page = 1
per_page = 100
while True:
url = f"https://api.github.com/repos/{repo}/forks"
params = {'page': page, 'per_page': per_page, 'sort': 'newest'}
print(f"Fetching forks page {page}...")
data = self._make_request(url, params)
if not data:
break
forks.extend(data)
if len(data) < per_page:
break
if max_forks and len(forks) >= max_forks:
forks = forks[:max_forks]
break
page += 1
return forks
def get_branches(self, repo: str, max_branches: int = 500) -> List[str]:
"""Get all branches for a repository (with limits for performance)."""
branches = []
page = 1
per_page = 100
max_pages = max_branches // 100 + 1
while page <= max_pages:
url = f"https://api.github.com/repos/{repo}/branches"
params = {'page': page, 'per_page': per_page}
try:
data = self._make_request(url, params)
if not data:
break
branches.extend([branch['name'] for branch in data])
if len(data) < per_page:
break
# Limit total branches to avoid excessive API calls
if len(branches) >= max_branches:
branches = branches[:max_branches]
break
page += 1
except GitHubAPIError as e:
if "404" in str(e): # Repository might be empty or deleted
break
raise
return branches
def get_pull_requests_from_fork(self, main_repo: str, fork_owner: str) -> List[Dict]:
"""Get pull requests created from a specific fork (optimized)."""
prs = []
page = 1
per_page = 100
max_pages = 10 # Limit to first 1000 PRs to avoid excessive API calls
print(f" Checking PRs from {fork_owner}...")
while page <= max_pages:
url = f"https://api.github.com/repos/{main_repo}/pulls"
params = {
'state': 'all',
'head': f'{fork_owner}:',
'page': page,
'per_page': per_page
}
try:
data = self._make_request(url, params)
if not data:
break
# Filter PRs that are actually from this fork owner
fork_prs = [pr for pr in data if pr['head']['repo'] and
pr['head']['repo']['owner']['login'] == fork_owner]
prs.extend(fork_prs)
if len(data) < per_page:
break
page += 1
# Early exit if we found PRs (for performance - we just need to know if any exist)
if fork_prs:
break
except GitHubAPIError:
break # Some API limitations or permissions issues
return prs
def get_commits_by_author(self, repo: str, author: str, branch: str = None, max_commits: int = 500) -> int:
"""Get number of commits by a specific author (optimized for performance)."""
url = f"https://api.github.com/repos/{repo}/commits"
params = {
'author': author,
'per_page': 100
}
if branch:
params['sha'] = branch
try:
commits_count = 0
page = 1
max_pages = max_commits // 100 + 1 # Limit pages to avoid excessive API calls
while page <= max_pages:
params['page'] = page
print(f" Fetching commits by {author}, page {page}...")
response = self.session.get(url, params=params)
if response.status_code != 200:
break
# Update rate limit tracking
self.rate_limit_remaining = int(response.headers.get('X-RateLimit-Remaining', 0))
reset_timestamp = response.headers.get('X-RateLimit-Reset')
if reset_timestamp:
self.rate_limit_reset = int(reset_timestamp)
self.requests_made += 1
data = response.json()
if not data:
break
commits_count += len(data)
# If we got less than per_page results, we're done
if len(data) < 100:
break
page += 1
# Early exit if we hit our limit
if commits_count >= max_commits:
commits_count = max_commits # Cap at max to indicate truncation
break
# Add small delay between requests to be nice to API
time.sleep(0.1)
return commits_count
except Exception as e:
print(f" Error fetching commits for {author}: {e}")
return 0
def get_commits_since_date(self, repo: str, since_date: datetime, branch: str = None) -> int:
"""Get number of commits since a specific date."""
url = f"https://api.github.com/repos/{repo}/commits"
params = {
'since': since_date.isoformat(),
'per_page': 1
}
if branch:
params['sha'] = branch
try:
response = self.session.get(url, params=params)
if response.status_code != 200:
return 0
# Get total count from Link header if available
link_header = response.headers.get('Link')
if link_header and 'rel="last"' in link_header:
# Parse the last page number to estimate total commits
import re
match = re.search(r'page=(\d+).*rel="last"', link_header)
if match:
return min(int(match.group(1)) * 30, 1000) # Rough estimate, cap at 1000
data = response.json()
return len(data) if data else 0
except:
return 0
def compare_repositories(self, base_repo: str, head_repo: str) -> Dict:
"""Compare two repositories to see how far behind head is from base."""
url = f"https://api.github.com/repos/{base_repo}/compare/main...{head_repo}:main"
try:
return self._make_request(url)
except GitHubAPIError:
return {}
def analyze_fork(self, fork: Dict, main_repo: str, main_branches: Set[str]) -> ForkInfo:
"""Analyze a single fork and return detailed information."""
fork_name = fork['full_name']
fork_owner = fork['owner']['login']
start_time = time.time()
print(f"Analyzing fork: {fork_name}")
# Get fork branches
try:
print(f" Fetching branches...")
fork_branches = self.get_branches(fork_name)
print(f" Found {len(fork_branches)} branches")
except GitHubAPIError as e:
print(f" Error fetching branches: {e}")
fork_branches = []
# Find unique branches (branches in fork but not in main repo)
unique_branches = [branch for branch in fork_branches if branch not in main_branches]
if unique_branches:
print(f" Found {len(unique_branches)} unique branches: {unique_branches[:5]}")
# Check if fork has contributed PRs
prs_from_fork = self.get_pull_requests_from_fork(main_repo, fork_owner)
has_contributed = len(prs_from_fork) > 0
if has_contributed:
print(f" Found {len(prs_from_fork)} PRs from this fork")
# Compare with main repository
print(f" Comparing with main repository...")
comparison = self.compare_repositories(main_repo, fork_name)
behind_commits = comparison.get('behind_by', 0)
# Calculate days behind based on last push
pushed_at = datetime.fromisoformat(fork['pushed_at'].replace('Z', '+00:00'))
now = datetime.now(timezone.utc)
days_behind = (now - pushed_at).days
print(f" Last pushed {days_behind} days ago")
# Check for recent activity
print(f" Checking recent activity...")
thirty_days_ago = now - timedelta(days=30)
recent_commits = self.get_commits_since_date(fork_name, thirty_days_ago)
is_active = recent_commits > 0 or days_behind < 30
# Get commits by fork owner
print(f" Analyzing owner commits...")
owner_commits = self.get_commits_by_author(fork_name, fork_owner, max_commits=200)
if owner_commits >= 200:
print(f" Owner has 200+ commits (truncated for performance)")
else:
print(f" Owner has {owner_commits} commits")
elapsed_time = time.time() - start_time
print(f" Analysis completed in {elapsed_time:.1f} seconds")
return ForkInfo(
name=fork['name'],
full_name=fork_name,
owner=fork_owner,
html_url=fork['html_url'],
updated_at=datetime.fromisoformat(fork['updated_at'].replace('Z', '+00:00')),
pushed_at=pushed_at,
default_branch=fork['default_branch'],
branches=fork_branches,
unique_branches=unique_branches,
behind_main_by_commits=behind_commits,
behind_main_by_days=days_behind,
has_contributed_prs=has_contributed,
recent_commits=recent_commits,
is_active=is_active,
owner_commits=owner_commits
)
def analyze_repository_forks(self, repo: str, max_forks: Optional[int] = None, fast_mode: bool = False) -> Dict:
"""Main analysis function for repository forks."""
start_time = time.time()
print(f"Starting fork analysis for {repo}")
if fast_mode:
print("Fast mode enabled: Skipping detailed analysis of forks inactive for 3+ years")
# Get main repository info
main_repo_info = self.get_repository_info(repo)
print(f"Repository: {main_repo_info['full_name']}")
print(f"Forks count: {main_repo_info['forks_count']}")
# Get main repository branches
print("Fetching main repository branches...")
main_branches = set(self.get_branches(repo))
print(f"Main repository has {len(main_branches)} branches")
# Get all forks
forks = self.get_forks(repo, max_forks)
print(f"Found {len(forks)} forks to analyze")
print(f"Estimated API requests needed: {len(forks) * (3 if fast_mode else 8)}")
if self.rate_limit_remaining:
print(f"Current rate limit: {self.rate_limit_remaining} requests remaining")
if not forks:
return {
'main_repo': main_repo_info,
'total_forks': 0,
'analyzed_forks': [],
'statistics': {}
}
# Analyze each fork
analyzed_forks = []
temp_results_file = "tempresults.json"
for i, fork in enumerate(forks, 1):
try:
print(f"Progress: {i}/{len(forks)} - {fork['full_name']}")
# Skip very old forks to improve performance (if fast mode enabled)
if fast_mode:
pushed_at = datetime.fromisoformat(fork['pushed_at'].replace('Z', '+00:00'))
days_since_push = (datetime.now(timezone.utc) - pushed_at).days
if days_since_push > 1095: # Skip forks not updated in 3+ years
print(f" Skipping fork (not updated in {days_since_push} days)")
# Create minimal fork info for very old forks
fork_info = ForkInfo(
name=fork['name'],
full_name=fork['full_name'],
owner=fork['owner']['login'],
html_url=fork['html_url'],
updated_at=datetime.fromisoformat(fork['updated_at'].replace('Z', '+00:00')),
pushed_at=pushed_at,
default_branch=fork['default_branch'],
branches=[],
unique_branches=[],
behind_main_by_commits=0,
behind_main_by_days=days_since_push,
has_contributed_prs=False,
recent_commits=0,
is_active=False,
owner_commits=0
)
analyzed_forks.append(fork_info)
continue
fork_info = self.analyze_fork(fork, repo, main_branches)
analyzed_forks.append(fork_info)
except Exception as e:
print(f"Error analyzing fork {fork['full_name']}: {e}")
continue
# Save intermediate results every 10 forks
if i % 10 == 0:
print(f"Saving intermediate results to {temp_results_file}...")
temp_results = {
'main_repo': main_repo_info,
'total_forks': len(forks),
'analyzed_so_far': i,
'analyzed_forks': [
{
'name': fork.name,
'full_name': fork.full_name,
'owner': fork.owner,
'html_url': fork.html_url,
'updated_at': fork.updated_at.isoformat(),
'pushed_at': fork.pushed_at.isoformat(),
'default_branch': fork.default_branch,
'branches': fork.branches,
'unique_branches': fork.unique_branches,
'behind_main_by_commits': fork.behind_main_by_commits,
'behind_main_by_days': fork.behind_main_by_days,
'has_contributed_prs': fork.has_contributed_prs,
'recent_commits': fork.recent_commits,
'is_active': fork.is_active,
'owner_commits': fork.owner_commits
}
for fork in analyzed_forks
],
'statistics': self._calculate_statistics(analyzed_forks),
'analysis_timestamp': datetime.now(timezone.utc).isoformat()
}
try:
with open(temp_results_file, 'w') as f:
json.dump(temp_results, f, indent=2)
except Exception as save_error:
print(f"Warning: Failed to save intermediate results: {save_error}")
# Be nice to the API
time.sleep(1)
# Calculate statistics
statistics = self._calculate_statistics(analyzed_forks)
# Clean up temporary results file on successful completion
temp_results_file = "tempresults.json"
if os.path.exists(temp_results_file):
try:
os.remove(temp_results_file)
print(f"Cleaned up temporary file: {temp_results_file}")
except Exception as e:
print(f"Warning: Could not remove temporary file {temp_results_file}: {e}")
return {
'main_repo': main_repo_info,
'total_forks': len(forks),
'analyzed_forks': analyzed_forks,
'statistics': statistics,
'analysis_timestamp': datetime.now(timezone.utc).isoformat()
}
def _calculate_statistics(self, forks: List[ForkInfo]) -> Dict:
"""Calculate summary statistics from analyzed forks."""
if not forks:
return {}
total_forks = len(forks)
# Categorize by age
now = datetime.now(timezone.utc)
age_categories = {
'1_month': 0,
'3_months': 0,
'6_months': 0,
'1_year': 0,
'2_years': 0,
'5_plus_years': 0
}
for fork in forks:
days_old = (now - fork.pushed_at).days
if days_old <= 30:
age_categories['1_month'] += 1
elif days_old <= 90:
age_categories['3_months'] += 1
elif days_old <= 180:
age_categories['6_months'] += 1
elif days_old <= 365:
age_categories['1_year'] += 1
elif days_old <= 730:
age_categories['2_years'] += 1
else:
age_categories['5_plus_years'] += 1
# Other statistics
forks_with_unique_branches = len([f for f in forks if f.unique_branches])
forks_with_recent_main = len([f for f in forks if f.behind_main_by_days <= 365])
forks_with_contributed_prs = len([f for f in forks if f.has_contributed_prs])
active_non_contributing = len([f for f in forks if f.is_active and not f.has_contributed_prs])
# Owner commit statistics
forks_with_owner_commits = len([f for f in forks if f.owner_commits > 0])
total_owner_commits = sum(f.owner_commits for f in forks)
avg_owner_commits = total_owner_commits / total_forks if total_forks > 0 else 0
return {
'total_analyzed': total_forks,
'age_distribution': age_categories,
'forks_with_unique_branches': forks_with_unique_branches,
'forks_with_recent_main': forks_with_recent_main,
'forks_that_contributed_prs': forks_with_contributed_prs,
'active_non_contributing_forks': active_non_contributing,
'forks_with_owner_commits': forks_with_owner_commits,
'total_owner_commits': total_owner_commits,
'avg_owner_commits_per_fork': round(avg_owner_commits, 1),
'percentage_with_unique_branches': (forks_with_unique_branches / total_forks) * 100,
'percentage_with_recent_main': (forks_with_recent_main / total_forks) * 100,
'percentage_contributed_prs': (forks_with_contributed_prs / total_forks) * 100,
'percentage_active_non_contributing': (active_non_contributing / total_forks) * 100,
'percentage_with_owner_commits': (forks_with_owner_commits / total_forks) * 100
}
def main():
"""Main entry point."""
parser = argparse.ArgumentParser(description='Analyze GitHub repository fork statistics')
parser.add_argument('--token', help='GitHub personal access token')
parser.add_argument('--repo', default='wled/WLED', help='Repository in owner/repo format')
parser.add_argument('--output', help='Output file for JSON results')
parser.add_argument('--max-forks', type=int, help='Maximum number of forks to analyze')
parser.add_argument('--format', choices=['json', 'summary'], default='summary',
help='Output format')
parser.add_argument('--demo', action='store_true',
help='Run with sample data for demonstration (no API calls)')
parser.add_argument('--dry-run', action='store_true',
help='Show what would be analyzed without making API calls')
parser.add_argument('--fast', action='store_true',
help='Fast mode: skip detailed analysis of very old forks (3+ years) for better performance')
args = parser.parse_args()
# Validate repository format
if '/' not in args.repo:
print("Error: Repository must be in 'owner/repo' format")
sys.exit(1)
# Create analyzer
analyzer = ForkStatsAnalyzer(args.token)
if args.demo:
# Create sample data for demonstration
print("DEMO MODE: Using sample data for demonstration")
print("This shows what the output would look like for WLED repository analysis\n")
sample_results = {
'main_repo': {
'full_name': 'wled/WLED',
'forks_count': 1243,
'stargazers_count': 15500,
'watchers_count': 326
},
'total_forks': 100, # Sample size
'analyzed_forks': [], # Not needed for summary
'statistics': {
'total_analyzed': 100,
'age_distribution': {
'1_month': 8,
'3_months': 12,
'6_months': 15,
'1_year': 23,
'2_years': 25,
'5_plus_years': 17
},
'forks_with_unique_branches': 34,
'forks_with_recent_main': 42,
'forks_that_contributed_prs': 18,
'active_non_contributing_forks': 23,
'forks_with_owner_commits': 67,
'total_owner_commits': 2845,
'avg_owner_commits_per_fork': 28.5,
'percentage_with_unique_branches': 34.0,
'percentage_with_recent_main': 42.0,
'percentage_contributed_prs': 18.0,
'percentage_active_non_contributing': 23.0,
'percentage_with_owner_commits': 67.0
}
}
if args.output:
# Save sample results to JSON for demo
with open(args.output, 'w') as f:
json.dump(sample_results, f, indent=2)
print(f"Sample JSON results saved to {args.output}")
if args.format == 'summary' or not args.output:
print_summary(sample_results)
return
if args.dry_run:
try:
# Just get basic repository info for dry run
print(f"DRY RUN: Analyzing repository {args.repo}")
repo_info = analyzer.get_repository_info(args.repo)
print(f"Repository: {repo_info['full_name']}")
print(f"Total forks: {repo_info['forks_count']:,}")
forks_to_analyze = args.max_forks or min(repo_info['forks_count'], 100)
print(f"Would analyze: {forks_to_analyze} forks")
print(f"Estimated API requests: {forks_to_analyze * 3 + 10}")
print(f"Rate limit status: {analyzer.rate_limit_remaining or 'Unknown'} requests remaining")
if not analyzer.token:
print("WARNING: No token provided. Rate limit is 60 requests/hour for unauthenticated requests.")
if repo_info['forks_count'] > 20:
print("Consider using a GitHub token for analyzing larger repositories.")
else:
print("GitHub token provided. Rate limit is 5000 requests/hour.")
return
except GitHubAPIError as e:
print(f"Error accessing repository: {e}")
sys.exit(1)
try:
# Run analysis
results = analyzer.analyze_repository_forks(args.repo, args.max_forks, args.fast)
if args.output:
# Save detailed results as JSON
with open(args.output, 'w') as f:
# Convert ForkInfo objects to dicts for JSON serialization
serializable_results = results.copy()
serializable_results['analyzed_forks'] = [
{
'name': fork.name,
'full_name': fork.full_name,
'owner': fork.owner,
'html_url': fork.html_url,
'updated_at': fork.updated_at.isoformat(),
'pushed_at': fork.pushed_at.isoformat(),
'default_branch': fork.default_branch,
'branches': fork.branches,
'unique_branches': fork.unique_branches,
'behind_main_by_commits': fork.behind_main_by_commits,
'behind_main_by_days': fork.behind_main_by_days,
'has_contributed_prs': fork.has_contributed_prs,
'recent_commits': fork.recent_commits,
'is_active': fork.is_active,
'owner_commits': fork.owner_commits
}
for fork in results['analyzed_forks']
]
json.dump(serializable_results, f, indent=2)
print(f"\nDetailed results saved to {args.output}")
# Print summary
if args.format == 'summary' or not args.output:
print_summary(results)
except KeyboardInterrupt:
print("\nAnalysis interrupted by user")
sys.exit(1)
except Exception as e:
print(f"Error: {e}")
sys.exit(1)
def print_summary(results: Dict):
"""Print a human-readable summary of the analysis."""
stats = results['statistics']
main_repo = results['main_repo']
print("\n" + "="*60)
print(f"FORK ANALYSIS SUMMARY FOR {main_repo['full_name']}")
print("="*60)
print(f"\nRepository Details:")
print(f" - Total Forks: {main_repo['forks_count']:,}")
print(f" - Analyzed: {stats.get('total_analyzed', 0):,}")
print(f" - Stars: {main_repo['stargazers_count']:,}")
print(f" - Watchers: {main_repo['watchers_count']:,}")
if 'age_distribution' in stats:
print(f"\nFork Age Distribution:")
age_dist = stats['age_distribution']
total = stats['total_analyzed']
print(f" - Last updated ≤ 1 month: {age_dist['1_month']:4d} ({age_dist['1_month']/total*100:5.1f}%)")
print(f" - Last updated ≤ 3 months: {age_dist['3_months']:4d} ({age_dist['3_months']/total*100:5.1f}%)")
print(f" - Last updated ≤ 6 months: {age_dist['6_months']:4d} ({age_dist['6_months']/total*100:5.1f}%)")
print(f" - Last updated ≤ 1 year: {age_dist['1_year']:4d} ({age_dist['1_year']/total*100:5.1f}%)")
print(f" - Last updated ≤ 2 years: {age_dist['2_years']:4d} ({age_dist['2_years']/total*100:5.1f}%)")
print(f" - Last updated > 5 years: {age_dist['5_plus_years']:4d} ({age_dist['5_plus_years']/total*100:5.1f}%)")
print(f"\nFork Activity Analysis:")
print(f" - Forks with unique branches: {stats.get('forks_with_unique_branches', 0):4d} ({stats.get('percentage_with_unique_branches', 0):.1f}%)")
print(f" - Forks with recent main branch: {stats.get('forks_with_recent_main', 0):4d} ({stats.get('percentage_with_recent_main', 0):.1f}%)")
print(f" - Forks that contributed PRs: {stats.get('forks_that_contributed_prs', 0):4d} ({stats.get('percentage_contributed_prs', 0):.1f}%)")
print(f" - Active forks (no PR contributions): {stats.get('active_non_contributing_forks', 0):4d} ({stats.get('percentage_active_non_contributing', 0):.1f}%)")
print(f"\nOwner Commit Analysis:")
print(f" - Forks with owner commits: {stats.get('forks_with_owner_commits', 0):4d} ({stats.get('percentage_with_owner_commits', 0):.1f}%)")
print(f" - Total commits by fork owners: {stats.get('total_owner_commits', 0):4d}")
print(f" - Average commits per fork: {stats.get('avg_owner_commits_per_fork', 0):4.1f}")
print(f"\nKey Insights:")
if stats.get('percentage_with_recent_main', 0) < 50:
print(f" - Most forks are significantly behind main branch")
if stats.get('percentage_contributed_prs', 0) < 10:
print(f" - Very few forks have contributed back to main repository")
if stats.get('percentage_with_unique_branches', 0) > 20:
print(f" - Significant number of forks have custom development")
if stats.get('percentage_with_owner_commits', 0) > 60:
print(f" - Majority of forks show some owner development activity")
print("\n" + "="*60)
if __name__ == '__main__':
main()

View File

@@ -1,466 +0,0 @@
#!/usr/bin/env python3
"""
Fork Statistics Visualizer
This script loads JSON data generated by fork_stats.py and displays
detailed statistics both as formatted lists and visual graphs.
Usage:
python3 tools/fork_stats_visualizer.py results.json
python3 tools/fork_stats_visualizer.py results.json --save-plots
python3 tools/fork_stats_visualizer.py results.json --output-dir ./plots
"""
import argparse
import json
import sys
from pathlib import Path
from typing import Dict, List
from datetime import datetime
# Try to import matplotlib, but allow running without it
try:
import matplotlib.pyplot as plt
import matplotlib.patches as mpatches
HAS_MATPLOTLIB = True
except ImportError:
HAS_MATPLOTLIB = False
print("Warning: matplotlib not installed. Graphical visualizations will be disabled.")
print("Install with: pip install -r tools/fork_stats_visualizer_requirements.txt")
print()
def load_json_data(filepath: str) -> Dict:
"""Load the JSON data file."""
try:
with open(filepath, 'r') as f:
return json.load(f)
except FileNotFoundError:
print(f"Error: File '{filepath}' not found.")
sys.exit(1)
except json.JSONDecodeError as e:
print(f"Error: Invalid JSON in '{filepath}': {e}")
sys.exit(1)
def print_section_header(title: str):
"""Print a formatted section header."""
print("\n" + "=" * 80)
print(f" {title}")
print("=" * 80)
def print_repository_info(data: Dict):
"""Print repository information."""
print_section_header("REPOSITORY INFORMATION")
main_repo = data.get('main_repo', {})
print(f"\nRepository: {main_repo.get('full_name', 'Unknown')}")
print(f"Total Forks: {main_repo.get('forks_count', 0):,}")
print(f"Stars: {main_repo.get('stargazers_count', 0):,}")
print(f"Watchers: {main_repo.get('watchers_count', 0):,}")
print(f"\nAnalyzed Forks: {data.get('total_forks', 0)}")
if 'analysis_timestamp' in data:
timestamp = data['analysis_timestamp']
try:
dt = datetime.fromisoformat(timestamp.replace('Z', '+00:00'))
print(f"Analysis Date: {dt.strftime('%Y-%m-%d %H:%M:%S UTC')}")
except:
print(f"Analysis Date: {timestamp}")
def print_age_distribution(stats: Dict):
"""Print fork age distribution statistics."""
print_section_header("FORK AGE DISTRIBUTION")
age_dist = stats.get('age_distribution', {})
total = stats.get('total_analyzed', 1)
age_labels = {
'1_month': 'Last updated ≤ 1 month',
'3_months': 'Last updated ≤ 3 months',
'6_months': 'Last updated ≤ 6 months',
'1_year': 'Last updated ≤ 1 year',
'2_years': 'Last updated ≤ 2 years',
'5_plus_years': 'Last updated > 5 years'
}
print("\nAge Category Count Percentage")
print("-" * 60)
for key, label in age_labels.items():
count = age_dist.get(key, 0)
pct = (count / total * 100) if total > 0 else 0
bar = '' * int(pct / 2) # Visual bar
print(f"{label:30} {count:5d} {pct:5.1f}% {bar}")
def print_activity_analysis(stats: Dict):
"""Print fork activity analysis."""
print_section_header("FORK ACTIVITY ANALYSIS")
total = stats.get('total_analyzed', 1)
metrics = [
('Forks with unique branches', stats.get('forks_with_unique_branches', 0),
stats.get('percentage_with_unique_branches', 0)),
('Forks with recent main branch', stats.get('forks_with_recent_main', 0),
stats.get('percentage_with_recent_main', 0)),
('Forks that contributed PRs', stats.get('forks_that_contributed_prs', 0),
stats.get('percentage_contributed_prs', 0)),
('Active forks (no PR contributions)', stats.get('active_non_contributing_forks', 0),
stats.get('percentage_active_non_contributing', 0)),
]
print("\nMetric Count Percentage")
print("-" * 65)
for label, count, pct in metrics:
bar = '' * int(pct / 2) # Visual bar
print(f"{label:35} {count:5d} {pct:5.1f}% {bar}")
def print_owner_commits(stats: Dict):
"""Print owner commit statistics."""
print_section_header("OWNER COMMIT ANALYSIS")
total_forks = stats.get('total_analyzed', 0)
forks_with_commits = stats.get('forks_with_owner_commits', 0)
total_commits = stats.get('total_owner_commits', 0)
avg_commits = stats.get('avg_owner_commits_per_fork', 0)
pct_with_commits = stats.get('percentage_with_owner_commits', 0)
print(f"\nForks with owner commits: {forks_with_commits:5d} / {total_forks:5d} ({pct_with_commits:.1f}%)")
print(f"Total commits by fork owners: {total_commits:5d}")
print(f"Average commits per fork: {avg_commits:5.1f}")
def print_top_forks(data: Dict, n: int = 20):
"""Print top forks by various metrics."""
analyzed_forks = data.get('analyzed_forks', [])
if not analyzed_forks:
print("\nNo detailed fork data available in JSON file.")
return
# Top forks by unique branches
print_section_header(f"TOP {n} FORKS BY UNIQUE BRANCHES")
forks_by_branches = sorted(analyzed_forks,
key=lambda x: len(x.get('unique_branches', [])),
reverse=True)[:n]
print(f"\n{'Rank':<6} {'Fork':<45} {'Unique Branches':<20} {'Owner Commits'}")
print("-" * 90)
for i, fork in enumerate(forks_by_branches, 1):
unique_count = len(fork.get('unique_branches', []))
owner_commits = fork.get('owner_commits', 0)
print(f"{i:<6} {fork.get('full_name', 'Unknown'):<45} {unique_count:<20} {owner_commits}")
# Top forks by owner commits
print_section_header(f"TOP {n} FORKS BY OWNER COMMITS")
forks_by_commits = sorted(analyzed_forks,
key=lambda x: x.get('owner_commits', 0),
reverse=True)[:n]
print(f"\n{'Rank':<6} {'Fork':<45} {'Owner Commits':<20} {'Active'}")
print("-" * 90)
for i, fork in enumerate(forks_by_commits, 1):
commits = fork.get('owner_commits', 0)
is_active = "Yes" if fork.get('is_active', False) else "No"
print(f"{i:<6} {fork.get('full_name', 'Unknown'):<45} {commits:<20} {is_active}")
# Active forks that haven't contributed
print_section_header("ACTIVE FORKS WITHOUT PR CONTRIBUTIONS")
active_no_pr = [f for f in analyzed_forks
if f.get('is_active', False) and not f.get('has_contributed_prs', False)][:n]
if active_no_pr:
print(f"\n{'Fork':<45} {'Recent Commits':<20} {'Owner Commits':<20} {'Days Behind'}")
print("-" * 110)
for fork in active_no_pr:
recent = fork.get('recent_commits', 0)
owner = fork.get('owner_commits', 0)
days = fork.get('behind_main_by_days', 0)
print(f"{fork.get('full_name', 'Unknown'):<45} {recent:<20} {owner:<20} {days}")
else:
print("\nNo active forks without PR contributions found.")
def create_age_distribution_chart(stats: Dict, save_path: str = None):
"""Create a pie chart for fork age distribution."""
if not HAS_MATPLOTLIB:
print("Skipping age distribution chart (matplotlib not available)")
return
age_dist = stats.get('age_distribution', {})
labels = ['≤ 1 month', '≤ 3 months', '≤ 6 months', '≤ 1 year', '≤ 2 years', '> 5 years']
sizes = [
age_dist.get('1_month', 0),
age_dist.get('3_months', 0),
age_dist.get('6_months', 0),
age_dist.get('1_year', 0),
age_dist.get('2_years', 0),
age_dist.get('5_plus_years', 0)
]
colors = ['#2ecc71', '#27ae60', '#f39c12', '#e67e22', '#e74c3c', '#95a5a6']
fig, ax = plt.subplots(figsize=(10, 8))
wedges, texts, autotexts = ax.pie(sizes, labels=labels, autopct='%1.1f%%',
colors=colors, startangle=90)
ax.set_title('Fork Age Distribution (Last Update)', fontsize=16, fontweight='bold', pad=20)
# Make percentage text more readable
for autotext in autotexts:
autotext.set_color('white')
autotext.set_fontsize(10)
autotext.set_fontweight('bold')
plt.tight_layout()
if save_path:
plt.savefig(save_path, dpi=300, bbox_inches='tight')
print(f"Saved: {save_path}")
else:
plt.show()
plt.close()
def create_activity_bar_chart(stats: Dict, save_path: str = None):
"""Create a bar chart for fork activity metrics."""
if not HAS_MATPLOTLIB:
print("Skipping activity bar chart (matplotlib not available)")
return
metrics = [
'Unique\nBranches',
'Recent\nMain',
'Contributed\nPRs',
'Active\nNo PRs',
'Owner\nCommits'
]
values = [
stats.get('percentage_with_unique_branches', 0),
stats.get('percentage_with_recent_main', 0),
stats.get('percentage_contributed_prs', 0),
stats.get('percentage_active_non_contributing', 0),
stats.get('percentage_with_owner_commits', 0)
]
colors = ['#3498db', '#2ecc71', '#9b59b6', '#e67e22', '#e74c3c']
fig, ax = plt.subplots(figsize=(12, 7))
bars = ax.bar(metrics, values, color=colors, alpha=0.8, edgecolor='black', linewidth=1.5)
ax.set_ylabel('Percentage of Forks (%)', fontsize=12, fontweight='bold')
ax.set_title('Fork Activity Metrics', fontsize=16, fontweight='bold', pad=20)
ax.set_ylim(0, 100)
ax.grid(axis='y', alpha=0.3, linestyle='--')
# Add value labels on bars
for bar in bars:
height = bar.get_height()
ax.text(bar.get_x() + bar.get_width()/2., height + 1,
f'{height:.1f}%', ha='center', va='bottom', fontweight='bold')
plt.tight_layout()
if save_path:
plt.savefig(save_path, dpi=300, bbox_inches='tight')
print(f"Saved: {save_path}")
else:
plt.show()
plt.close()
def create_owner_commits_distribution(data: Dict, save_path: str = None):
"""Create a histogram of owner commits distribution."""
if not HAS_MATPLOTLIB:
print("Skipping owner commits distribution chart (matplotlib not available)")
return
analyzed_forks = data.get('analyzed_forks', [])
if not analyzed_forks:
print("No detailed fork data for commits distribution chart.")
return
commits = [fork.get('owner_commits', 0) for fork in analyzed_forks if fork.get('owner_commits', 0) > 0]
if not commits:
print("No owner commits data available.")
return
fig, ax = plt.subplots(figsize=(12, 7))
# Create histogram with bins
n, bins, patches = ax.hist(commits, bins=20, color='#3498db', alpha=0.7, edgecolor='black')
# Color code the bins
cm = plt.cm.RdYlGn_r
bin_centers = 0.5 * (bins[:-1] + bins[1:])
col = bin_centers - min(bin_centers)
col /= max(col)
for c, p in zip(col, patches):
plt.setp(p, 'facecolor', cm(c))
ax.set_xlabel('Number of Owner Commits', fontsize=12, fontweight='bold')
ax.set_ylabel('Number of Forks', fontsize=12, fontweight='bold')
ax.set_title('Distribution of Owner Commits Across Forks', fontsize=16, fontweight='bold', pad=20)
ax.grid(axis='y', alpha=0.3, linestyle='--')
# Add statistics text
stats_text = f'Total Forks: {len(commits)}\nMean: {sum(commits)/len(commits):.1f}\nMax: {max(commits)}'
ax.text(0.95, 0.95, stats_text, transform=ax.transAxes,
verticalalignment='top', horizontalalignment='right',
bbox=dict(boxstyle='round', facecolor='wheat', alpha=0.5),
fontsize=10, fontweight='bold')
plt.tight_layout()
if save_path:
plt.savefig(save_path, dpi=300, bbox_inches='tight')
print(f"Saved: {save_path}")
else:
plt.show()
plt.close()
def create_combined_dashboard(stats: Dict, data: Dict, save_path: str = None):
"""Create a combined dashboard with multiple charts."""
if not HAS_MATPLOTLIB:
print("Skipping combined dashboard (matplotlib not available)")
return
fig = plt.figure(figsize=(16, 10))
# Age distribution pie chart
ax1 = plt.subplot(2, 2, 1)
age_dist = stats.get('age_distribution', {})
labels = ['≤1mo', '≤3mo', '≤6mo', '≤1yr', '≤2yr', '>5yr']
sizes = [
age_dist.get('1_month', 0),
age_dist.get('3_months', 0),
age_dist.get('6_months', 0),
age_dist.get('1_year', 0),
age_dist.get('2_years', 0),
age_dist.get('5_plus_years', 0)
]
colors = ['#2ecc71', '#27ae60', '#f39c12', '#e67e22', '#e74c3c', '#95a5a6']
ax1.pie(sizes, labels=labels, autopct='%1.1f%%', colors=colors, startangle=90)
ax1.set_title('Fork Age Distribution', fontweight='bold')
# Activity metrics bar chart
ax2 = plt.subplot(2, 2, 2)
metrics = ['Unique\nBranches', 'Recent\nMain', 'PRs', 'Active\nNo PRs', 'Owner\nCommits']
values = [
stats.get('percentage_with_unique_branches', 0),
stats.get('percentage_with_recent_main', 0),
stats.get('percentage_contributed_prs', 0),
stats.get('percentage_active_non_contributing', 0),
stats.get('percentage_with_owner_commits', 0)
]
colors_bar = ['#3498db', '#2ecc71', '#9b59b6', '#e67e22', '#e74c3c']
bars = ax2.bar(metrics, values, color=colors_bar, alpha=0.8)
ax2.set_ylabel('Percentage (%)')
ax2.set_title('Activity Metrics', fontweight='bold')
ax2.set_ylim(0, 100)
ax2.grid(axis='y', alpha=0.3)
# Owner commits histogram
ax3 = plt.subplot(2, 2, 3)
analyzed_forks = data.get('analyzed_forks', [])
commits = [fork.get('owner_commits', 0) for fork in analyzed_forks if fork.get('owner_commits', 0) > 0]
if commits:
ax3.hist(commits, bins=15, color='#3498db', alpha=0.7, edgecolor='black')
ax3.set_xlabel('Owner Commits')
ax3.set_ylabel('Frequency')
ax3.set_title('Owner Commits Distribution', fontweight='bold')
ax3.grid(axis='y', alpha=0.3)
# Summary statistics
ax4 = plt.subplot(2, 2, 4)
ax4.axis('off')
main_repo = data.get('main_repo', {})
summary_text = f"""
REPOSITORY STATISTICS
{'='*35}
Repository: {main_repo.get('full_name', 'Unknown')}
Total Forks: {main_repo.get('forks_count', 0):,}
Analyzed: {stats.get('total_analyzed', 0)}
KEY METRICS:
• Unique Branches: {stats.get('forks_with_unique_branches', 0)} ({stats.get('percentage_with_unique_branches', 0):.1f}%)
• PR Contributors: {stats.get('forks_that_contributed_prs', 0)} ({stats.get('percentage_contributed_prs', 0):.1f}%)
• Owner Commits: {stats.get('total_owner_commits', 0):,}
• Avg Commits/Fork: {stats.get('avg_owner_commits_per_fork', 0):.1f}
INSIGHTS:
• Recent Forks: {age_dist.get('1_month', 0) + age_dist.get('3_months', 0)}
• Very Old (>5yr): {age_dist.get('5_plus_years', 0)}
• Active No PRs: {stats.get('active_non_contributing_forks', 0)}
"""
ax4.text(0.1, 0.9, summary_text, transform=ax4.transAxes,
verticalalignment='top', fontsize=11, fontfamily='monospace',
bbox=dict(boxstyle='round', facecolor='lightblue', alpha=0.3))
fig.suptitle('Fork Statistics Dashboard', fontsize=18, fontweight='bold', y=0.98)
plt.tight_layout(rect=[0, 0.03, 1, 0.96])
if save_path:
plt.savefig(save_path, dpi=300, bbox_inches='tight')
print(f"Saved: {save_path}")
else:
plt.show()
plt.close()
def main():
parser = argparse.ArgumentParser(description='Visualize fork statistics data')
parser.add_argument('json_file', help='Path to the JSON file with fork statistics')
parser.add_argument('--save-plots', action='store_true', help='Save plots to files instead of displaying')
parser.add_argument('--output-dir', default='./fork_plots', help='Directory to save plots (default: ./fork_plots)')
parser.add_argument('--top-n', type=int, default=20, help='Number of top forks to display (default: 20)')
parser.add_argument('--no-graphs', action='store_true', help='Skip graph generation, only show text statistics')
args = parser.parse_args()
# Load data
data = load_json_data(args.json_file)
stats = data.get('statistics', {})
# Print text statistics
print_repository_info(data)
print_age_distribution(stats)
print_activity_analysis(stats)
print_owner_commits(stats)
print_top_forks(data, args.top_n)
# Generate graphs if not disabled
if not args.no_graphs:
print_section_header("GENERATING VISUAL CHARTS")
if args.save_plots:
output_dir = Path(args.output_dir)
output_dir.mkdir(parents=True, exist_ok=True)
print(f"\nSaving plots to: {output_dir}")
create_age_distribution_chart(stats, output_dir / "age_distribution.png")
create_activity_bar_chart(stats, output_dir / "activity_metrics.png")
create_owner_commits_distribution(data, output_dir / "owner_commits_distribution.png")
create_combined_dashboard(stats, data, output_dir / "dashboard.png")
print(f"\nAll plots saved to: {output_dir}")
else:
print("\nDisplaying interactive plots...")
print("Close each plot window to see the next one.")
create_age_distribution_chart(stats)
create_activity_bar_chart(stats)
create_owner_commits_distribution(data)
create_combined_dashboard(stats, data)
print_section_header("VISUALIZATION COMPLETE")
print(f"\nData source: {args.json_file}")
print(f"Total forks analyzed: {stats.get('total_analyzed', 0)}")
if __name__ == '__main__':
main()

View File

@@ -1,4 +0,0 @@
# Optional requirements for fork statistics visualization tool
# Install with: pip install -r tools/fork_stats_visualizer_requirements.txt
matplotlib>=3.5.0

View File

@@ -135,7 +135,8 @@ uint16_t mode_copy_segment(void) {
SEGMENT.fadeToBlackBy(5); // fade out
return FRAMETIME;
}
Segment sourcesegment = strip.getSegment(sourceid);
Segment& sourcesegment = strip.getSegment(sourceid);
if (sourcesegment.isActive()) {
uint32_t sourcecolor;
uint32_t destcolor;
@@ -677,7 +678,7 @@ uint16_t mode_twinkle(void) {
SEGENV.step = it;
}
unsigned PRNG16 = SEGENV.aux1;
uint16_t PRNG16 = SEGENV.aux1;
for (unsigned i = 0; i < SEGENV.aux0; i++)
{
@@ -1714,8 +1715,8 @@ static const char _data_FX_MODE_TRICOLOR_WIPE[] PROGMEM = "Tri Wipe@!;1,2,3;!";
* Modified by Aircoookie
*/
uint16_t mode_tricolor_fade(void) {
unsigned counter = strip.now * ((SEGMENT.speed >> 3) +1);
uint16_t prog = (counter * 768) >> 16;
uint16_t counter = strip.now * ((SEGMENT.speed >> 3) +1);
uint32_t prog = (counter * 768) >> 16;
uint32_t color1 = 0, color2 = 0;
unsigned stage = 0;

View File

@@ -625,6 +625,9 @@ class Segment {
DEBUGFX_PRINTLN();
#endif
clearName();
#ifdef WLED_ENABLE_GIF
endImagePlayback(this);
#endif
deallocateData();
p_free(pixels);
}

View File

@@ -448,6 +448,9 @@ void Segment::setGeometry(uint16_t i1, uint16_t i2, uint8_t grp, uint8_t spc, ui
// apply change immediately
if (i2 <= i1) { //disable segment
#ifdef WLED_ENABLE_GIF
endImagePlayback(this);
#endif
deallocateData();
p_free(pixels);
pixels = nullptr;
@@ -466,6 +469,9 @@ void Segment::setGeometry(uint16_t i1, uint16_t i2, uint8_t grp, uint8_t spc, ui
#endif
// safety check
if (start >= stop || startY >= stopY) {
#ifdef WLED_ENABLE_GIF
endImagePlayback(this);
#endif
deallocateData();
p_free(pixels);
pixels = nullptr;
@@ -479,6 +485,9 @@ void Segment::setGeometry(uint16_t i1, uint16_t i2, uint8_t grp, uint8_t spc, ui
pixels = static_cast<uint32_t*>(allocate_buffer(length() * sizeof(uint32_t), BFRALLOC_PREFER_PSRAM | BFRALLOC_NOBYTEACCESS));
if (!pixels) {
DEBUGFX_PRINTLN(F("!!! Not enough RAM for pixel buffer !!!"));
#ifdef WLED_ENABLE_GIF
endImagePlayback(this);
#endif
deallocateData();
errorFlag = ERR_NORAM_PX;
stop = 0;

View File

@@ -72,11 +72,10 @@ uint32_t IRAM_ATTR color_fade(uint32_t c1, uint8_t amount, bool video) {
// video scaling: make sure colors do not dim to zero if they started non-zero unless they distort the hue
uint8_t r = byte(c1>>16), g = byte(c1>>8), b = byte(c1), w = byte(c1>>24); // extract r, g, b, w channels
uint8_t maxc = (r > g) ? ((r > b) ? r : b) : ((g > b) ? g : b); // determine dominant channel for hue preservation
uint8_t quarterMax = maxc >> 2; // note: using half of max results in color artefacts
addRemains = r && r > quarterMax ? 0x00010000 : 0;
addRemains |= g && g > quarterMax ? 0x00000100 : 0;
addRemains |= b && b > quarterMax ? 0x00000001 : 0;
addRemains |= w ? 0x01000000 : 0;
addRemains = r && (r<<5) > maxc ? 0x00010000 : 0; // note: setting color preservation threshold too high results in flickering and
addRemains |= g && (g<<5) > maxc ? 0x00000100 : 0; // jumping colors in low brightness gradients. Multiplying the color preserves
addRemains |= b && (b<<5) > maxc ? 0x00000001 : 0; // better accuracy than dividing the maxc. Shifting by 5 is a good compromise
addRemains |= w ? 0x01000000 : 0; // i.e. remove color channel if <13% of max
}
const uint32_t TWO_CHANNEL_MASK = 0x00FF00FF;
uint32_t rb = (((c1 & TWO_CHANNEL_MASK) * amount) >> 8) & TWO_CHANNEL_MASK; // scale red and blue

View File

@@ -48,7 +48,7 @@
To enable OTA, for security reasons you need to also enter the correct password!<br>
The password should be changed when OTA is enabled.<br>
<b>Disable OTA when not in use, otherwise an attacker can reflash device software!</b><br>
<i>Settings on this page are only changable if OTA lock is disabled!</i><br>
<i>Settings on this page are only changeable if OTA lock is disabled!</i><br>
Deny access to WiFi settings if locked: <input type="checkbox" name="OW"><br><br>
Factory reset: <input type="checkbox" name="RS"><br>
All settings and presets will be erased.<br><br>

View File

@@ -64,6 +64,22 @@ namespace {
}
}
/**
* Deserialize a segment description from a JSON object and apply it to the specified segment slot.
*
* Parses and applies geometry, naming, grouping/spacing/offset, 2D bounds, mode, palette, colors
* (supports kelvin, hex, "r" random, object or array formats), per-LED assignments, options (on/frz/sel/rev/mi),
* speed/intensity, custom channels, checks, blend mode, and LOXONE mappings. The function may append a new
* segment, delete a segment, perform a repeat expansion to create multiple segments, and mark global state
* as changed when segment parameters differ.
*
* @param elem JSON object describing the segment (API format).
* @param it Default segment index to use when `elem["id"]` is not provided.
* @param presetId Optional preset identifier; when nonzero, preset-related side effects (e.g., playlist unloading)
* are suppressed or handled differently.
* @return true if the JSON was valid for the target id and the segment was applied (or created); false if the
* target id is out of range or the descriptor is invalid (e.g., attempting to create an empty segment).
*/
static bool deserializeSegment(JsonObject elem, byte it, byte presetId = 0)
{
byte id = elem["id"] | it;
@@ -205,7 +221,7 @@ static bool deserializeSegment(JsonObject elem, byte it, byte presetId = 0)
// JSON "col" array can contain the following values for each of segment's colors (primary, background, custom):
// "col":[int|string|object|array, int|string|object|array, int|string|object|array]
// int = Kelvin temperature or 0 for black
// string = hex representation of [WW]RRGGBB
// string = hex representation of [WW]RRGGBB or "r" for random color
// object = individual channel control {"r":0,"g":127,"b":255,"w":255}, each being optional (valid to send {})
// array = direct channel values [r,g,b,w] (w element being optional)
int rgbw[] = {0,0,0,0};
@@ -229,6 +245,9 @@ static bool deserializeSegment(JsonObject elem, byte it, byte presetId = 0)
if (kelvin == 0) seg.setColor(i, 0);
if (kelvin > 0) colorKtoRGB(kelvin, brgbw);
colValid = true;
} else if (hexCol[0] == 'r' && hexCol[1] == '\0') { // Random colors via JSON API in Segment object like col=["r","r","r"] · Issue #4996
setRandomColor(brgbw);
colValid = true;
} else { //HEX string, e.g. "FFAA00"
colValid = colorFromHexString(brgbw, hexCol);
}
@@ -1240,4 +1259,4 @@ bool serveLiveLeds(AsyncWebServerRequest* request, uint32_t wsClient)
#endif
return true;
}
#endif
#endif

View File

@@ -896,7 +896,8 @@ static bool detectBootLoop() {
bl_crashcounter++;
if (bl_crashcounter >= BOOTLOOP_THRESHOLD) {
DEBUG_PRINTLN(F("!BOOTLOOP DETECTED!"));
bl_crashcounter = 0;
bl_crashcounter = 0;
if(bl_actiontracker > BOOTLOOP_ACTION_DUMP) bl_actiontracker = BOOTLOOP_ACTION_RESTORE; // reset action tracker if out of bounds
result = true;
}
} else {