From e4a73bc2dc0e1021f48abb625d40bfc0aa2ad956 Mon Sep 17 00:00:00 2001
From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com>
Date: Thu, 21 May 2026 02:28:45 +0000
Subject: [PATCH 1/5] Bump requests from 2.34.1 to 2.34.2
Bumps [requests](https://github.com/psf/requests) from 2.34.1 to 2.34.2.
- [Release notes](https://github.com/psf/requests/releases)
- [Changelog](https://github.com/psf/requests/blob/main/HISTORY.md)
- [Commits](https://github.com/psf/requests/compare/v2.34.1...v2.34.2)
---
updated-dependencies:
- dependency-name: requests
dependency-version: 2.34.2
dependency-type: direct:production
update-type: version-update:semver-patch
...
Signed-off-by: dependabot[bot]
---
requirements.txt | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/requirements.txt b/requirements.txt
index 8f16493..5b1f21f 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -7,7 +7,7 @@ APScheduler==3.11.2
PyGithub==2.9.1
gitpython==3.1.50
python-dotenv==1.2.2
-requests==2.34.1
+requests==2.34.2
sqlalchemy==2.0.49
flask-sqlalchemy==3.1.1
wtforms==3.2.2
From d7f7a5ed47c7a4fd73e4457cba57e8dc467ac250 Mon Sep 17 00:00:00 2001
From: stuyk
Date: Sat, 23 May 2026 18:11:13 -0700
Subject: [PATCH 2/5] feat: backup all user repositories
---
app.py | 242 ++++++++++++++++++++++++++++++++-
backup_service.py | 33 ++++-
templates/add_by_username.html | 190 ++++++++++++++++++++++++++
templates/dashboard.html | 11 +-
templates/repositories.html | 215 ++++++++++++++++++++++++++++-
5 files changed, 680 insertions(+), 11 deletions(-)
create mode 100644 templates/add_by_username.html
diff --git a/app.py b/app.py
index 0dbb620..43c82c2 100644
--- a/app.py
+++ b/app.py
@@ -446,7 +446,33 @@ def reset_password():
@login_required
def repositories():
repos = Repository.query.filter_by(user_id=current_user.id).all()
- return render_template('repositories.html', repositories=repos)
+
+ # Get backup job status
+ running_jobs = BackupJob.query.filter_by(user_id=current_user.id, status='running').all()
+ pending_jobs = BackupJob.query.filter_by(user_id=current_user.id, status='pending').all()
+ completed_jobs = BackupJob.query.filter_by(user_id=current_user.id, status='completed').all()
+ failed_jobs = BackupJob.query.filter_by(user_id=current_user.id, status='failed').all()
+
+ # Calculate status
+ total_repos = len(repos)
+ running_count = len(running_jobs)
+ pending_count = len(pending_jobs)
+
+ # Status percentage (based on active backups vs total)
+ active_backups = running_count + pending_count
+
+ backup_status = {
+ 'running': running_count,
+ 'pending': pending_count,
+ 'completed': len(completed_jobs),
+ 'failed': len(failed_jobs),
+ 'total_repos': total_repos,
+ 'active': active_backups > 0,
+ 'running_jobs': running_jobs,
+ 'pending_jobs': pending_jobs
+ }
+
+ return render_template('repositories.html', repositories=repos, backup_status=backup_status)
@app.route('/repositories/add', methods=['GET', 'POST'])
@login_required
@@ -525,6 +551,154 @@ def add_repository():
return render_template('add_repository.html')
+@app.route('/repositories/add-by-username', methods=['GET', 'POST'])
+@login_required
+def add_repositories_by_username():
+ """Add all repositories from a GitHub user"""
+ if request.method == 'POST':
+ github_username = request.form.get('github_username', '').strip()
+ github_token = request.form.get('github_token', '').strip()
+ backup_format = request.form.get('backup_format', 'folder')
+ schedule_type = request.form.get('schedule_type', 'daily')
+ retention_count = int(request.form.get('retention_count', 5))
+
+ if not github_username:
+ flash('Please provide a GitHub username', 'error')
+ return render_template('add_by_username.html')
+
+ try:
+ from github import Github, GithubException
+
+ # Initialize GitHub API
+ if github_token:
+ g = Github(github_token)
+ else:
+ g = Github() # Unauthenticated (limited rate limit)
+
+ # Fetch the user
+ try:
+ user = g.get_user(github_username)
+ except GithubException as e:
+ flash(f'GitHub user "{github_username}" not found or API error: {str(e)}', 'error')
+ logger.warning(f"Failed to fetch GitHub user {github_username}: {str(e)}")
+ return render_template('add_by_username.html')
+
+ # Get all repositories
+ try:
+ repos = user.get_repos(type='all') # all, public, private
+ repos_list = list(repos)
+ except GithubException as e:
+ flash(f'Error fetching repositories: {str(e)}', 'error')
+ logger.warning(f"Failed to fetch repos for {github_username}: {str(e)}")
+ return render_template('add_by_username.html')
+
+ if not repos_list:
+ flash(f'No repositories found for user "{github_username}"', 'info')
+ return redirect(url_for('repositories'))
+
+ added_count = 0
+ skipped_count = 0
+ failed_repos = []
+
+ for repo in repos_list:
+ try:
+ # Skip if repo is a fork (optional - change if you want to include forks)
+ if repo.fork:
+ logger.info(f"Skipping forked repository: {repo.name}")
+ skipped_count += 1
+ continue
+
+ repo_name = repo.name
+ repo_url = repo.clone_url # Uses HTTPS URL
+
+ # Check if this repository already exists for this user
+ existing = Repository.query.filter_by(
+ user_id=current_user.id,
+ name=repo_name,
+ url=repo_url
+ ).first()
+
+ if existing:
+ logger.info(f"Repository {repo_name} already exists for user, skipping")
+ skipped_count += 1
+ continue
+
+ # Create new repository record
+ new_repo = Repository(
+ user_id=current_user.id,
+ name=repo_name,
+ url=repo_url,
+ github_token=github_token if repo.private else '', # Only store token for private repos
+ backup_format=backup_format,
+ schedule_type=schedule_type,
+ retention_count=retention_count,
+ is_active=True
+ )
+
+ db.session.add(new_repo)
+ added_count += 1
+ logger.info(f"Added repository: {repo_name}")
+
+ except Exception as e:
+ failed_repos.append((repo.name, str(e)))
+ logger.error(f"Failed to add repository {repo.name}: {str(e)}")
+ continue
+
+ # Commit all new repositories
+ if added_count > 0:
+ try:
+ db.session.commit()
+ logger.info(f"Committed {added_count} new repositories for user {current_user.id}")
+
+ # Now schedule backup jobs for newly added repositories
+ new_repos = Repository.query.filter_by(
+ user_id=current_user.id,
+ name=repo_name # This will get the last one, but we'll schedule all active ones
+ ).filter(Repository.schedule_type != 'manual').all()
+
+ # Actually, let's schedule all added repos from this batch
+ # Get repos added in last few seconds
+ cutoff_time = datetime.utcnow() - timedelta(seconds=5)
+ recently_added = Repository.query.filter_by(
+ user_id=current_user.id,
+ is_active=True
+ ).filter(Repository.created_at > cutoff_time).all()
+
+ for repo in recently_added:
+ if repo.schedule_type != 'manual':
+ try:
+ schedule_backup_job(repo)
+ logger.info(f"Scheduled backup for {repo.name}")
+ except Exception as e:
+ logger.error(f"Failed to schedule backup for {repo.name}: {e}")
+
+ except Exception as e:
+ db.session.rollback()
+ flash(f'Error saving repositories: {str(e)}', 'error')
+ logger.error(f"Failed to commit repositories: {str(e)}")
+ return render_template('add_by_username.html')
+
+ # Build success message
+ message = f'Successfully added {added_count} repositories'
+ if skipped_count > 0:
+ message += f' ({skipped_count} skipped - already exist or are forks)'
+ if failed_repos:
+ message += f' ({len(failed_repos)} failed)'
+
+ flash(message, 'success')
+
+ if failed_repos:
+ logger.warning(f"Failed to add {len(failed_repos)} repositories: {failed_repos}")
+
+ return redirect(url_for('repositories'))
+
+ except Exception as e:
+ flash(f'Unexpected error: {str(e)}', 'error')
+ logger.error(f"Unexpected error in add_repositories_by_username: {str(e)}", exc_info=True)
+ return render_template('add_by_username.html')
+
+ return render_template('add_by_username.html')
+
@app.route('/repositories//edit', methods=['GET', 'POST'])
@login_required
def edit_repository(repo_id):
@@ -621,6 +795,43 @@ def delete_repository(repo_id):
flash('Repository deleted successfully', 'success')
return redirect(url_for('repositories'))
+@app.route('/repositories/delete-all', methods=['POST'])
+@login_required
+def delete_all_repositories():
+ """Delete all repositories for the current user"""
+ repositories = Repository.query.filter_by(user_id=current_user.id).all()
+
+ if not repositories:
+ flash('No repositories to delete', 'info')
+ return redirect(url_for('repositories'))
+
+ deleted_count = 0
+
+ for repository in repositories:
+ try:
+ # Remove scheduled job
+ try:
+ scheduler.remove_job(f'backup_{repository.id}')
+ logger.info(f"Removed scheduled job for repository {repository.id}")
+ except:
+ pass
+
+ db.session.delete(repository)
+ deleted_count += 1
+ logger.info(f"Deleted repository: {repository.name}")
+ except Exception as e:
+ logger.error(f"Failed to delete repository {repository.id}: {str(e)}")
+ continue
+
+ if deleted_count > 0:
+ db.session.commit()
+ flash(f'Deleted {deleted_count} repository/repositories successfully', 'success')
+ logger.info(f"Deleted {deleted_count} repositories for user {current_user.id}")
+ else:
+ flash('Failed to delete repositories', 'error')
+
+ return redirect(url_for('repositories'))
+
@app.route('/repositories//backup', methods=['POST'])
@login_required
def manual_backup(repo_id):
@@ -636,6 +847,35 @@ def manual_backup(repo_id):
return redirect(url_for('repositories'))
+@app.route('/repositories/backup-all', methods=['POST'])
+@login_required
+def backup_all_repositories():
+ """Trigger backups for all active repositories"""
+ repositories = Repository.query.filter_by(user_id=current_user.id, is_active=True).all()
+
+ if not repositories:
+ flash('No active repositories to backup', 'info')
+ return redirect(url_for('repositories'))
+
+ backup_count = 0
+ error_count = 0
+
+ for repository in repositories:
+ try:
+ backup_service.backup_repository(repository)
+ backup_count += 1
+ logger.info(f"Triggered backup for repository: {repository.name}")
+ except Exception as e:
+ error_count += 1
+ logger.error(f"Failed to trigger backup for {repository.name}: {str(e)}")
+
+ if error_count == 0:
+ flash(f'Started backup for {backup_count} repositories', 'success')
+ else:
+ flash(f'Started backup for {backup_count} repositories ({error_count} failed)', 'warning')
+
+ return redirect(url_for('repositories'))
+
@app.route('/jobs')
@login_required
def backup_jobs():
diff --git a/backup_service.py b/backup_service.py
index 4a1cbf0..7e8cca2 100644
--- a/backup_service.py
+++ b/backup_service.py
@@ -17,6 +17,29 @@ def __init__(self):
self.backup_base_dir = Path('/app/backups')
self.backup_base_dir.mkdir(exist_ok=True)
+ def _extract_github_username(self, repo_url):
+ """Extract GitHub username from repository URL
+
+ Handles both formats:
+ - https://github.com/username/repo
+ - git@github.com:username/repo.git
+ """
+ try:
+ # Parse the URL
+ if repo_url.startswith('git@'):
+ # git@github.com:username/repo.git
+ parts = repo_url.split(':')[1].split('/')
+ username = parts[0]
+ else:
+ # https://github.com/username/repo
+ parts = repo_url.rstrip('/').split('/')
+ username = parts[-2] # Second to last part
+
+ return username.strip()
+ except (IndexError, AttributeError):
+ logger.warning(f"Could not extract username from URL: {repo_url}, using 'unknown'")
+ return 'unknown'
+
def backup_repository(self, repository):
"""Backup a repository according to its settings"""
logger.info(f"Starting backup for repository: {repository.name}")
@@ -44,7 +67,8 @@ def backup_repository(self, repository):
return
# Auto-cleanup: Check for and clean up any orphaned temp directories
- user_backup_dir = self.backup_base_dir / f"user_{repository.user_id}"
+ github_username = self._extract_github_username(repository.url)
+ user_backup_dir = self.backup_base_dir / github_username
repo_backup_dir = user_backup_dir / repository.name
if repo_backup_dir.exists():
self._cleanup_temp_directories(repo_backup_dir)
@@ -69,8 +93,11 @@ def backup_repository(self, repository):
temp_clone_dir = None
try:
- # Create user-specific backup directory
- user_backup_dir = self.backup_base_dir / f"user_{repository.user_id}"
+ # Extract GitHub username from repository URL
+ github_username = self._extract_github_username(repository.url)
+
+ # Create GitHub-username-specific backup directory
+ user_backup_dir = self.backup_base_dir / github_username
user_backup_dir.mkdir(exist_ok=True)
# Create repository-specific backup directory
diff --git a/templates/add_by_username.html b/templates/add_by_username.html
new file mode 100644
index 0000000..aa0189b
--- /dev/null
+++ b/templates/add_by_username.html
@@ -0,0 +1,190 @@
+{% extends "base.html" %}
+
+{% block page_title %}Add Repositories by Username{% endblock %}
+
+{% block content %}
+
+
+
+
+
+
+
+
+
+ Discovering repositories...
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ - Public Repositories: Can be added without a token, but you'll have limited API rate limits (60 requests/hour)
+ - Private Repositories: Requires a GitHub Personal Access Token with repository read access
+ - Rate Limiting: GitHub API limits unauthenticated requests. Use a token for higher limits (5000 requests/hour)
+ - Forks: Currently skipped, but you can add them manually if needed
+ - Large Accounts: Accounts with many repositories may take longer to process
+
+
+
+
+
+{% endblock %}
+
+{% block scripts %}
+
+{% endblock %}
diff --git a/templates/dashboard.html b/templates/dashboard.html
index 93ca62e..0239471 100644
--- a/templates/dashboard.html
+++ b/templates/dashboard.html
@@ -78,9 +78,14 @@ {{ recent_jobs|selectattr('status', 'equalto', 'failed')|list|length }}
Repositories
-
- Add Repository
-
+
{% if repositories %}
diff --git a/templates/repositories.html b/templates/repositories.html
index e981a39..01797c2 100644
--- a/templates/repositories.html
+++ b/templates/repositories.html
@@ -3,11 +3,136 @@
{% block page_title %}Repositories{% endblock %}
{% block content %}
+
+
+
+
+
+ {% if backup_status.active %}
+ Backup Status: In Progress
+ {% else %}
+ No Active Backups
+ {% endif %}
+
+
+
+ {{ backup_status.total_repos }} Repositories
+
+
+
+ {% if backup_status.active %}
+
+
+
+
+
+
+
+
Running:
+
{{ backup_status.running }}/{{ backup_status.total_repos }}
+ {% if backup_status.running > 0 %}
+
+ {% for job in backup_status.running_jobs[:3] %}
+ {{ job.repository.name }}{% if not loop.last %},{% endif %}
+ {% endfor %}
+ {% if backup_status.running > 3 %}
+ and {{ backup_status.running - 3 }} more
+ {% endif %}
+
+ {% endif %}
+
+
+
+
+
+
+
+
+
+
+ Pending:
+ {{ backup_status.pending }}/{{ backup_status.total_repos }}
+
+
+
+
+
+
+
+ {% if backup_status.total_repos > 0 %}
+
+ {% if backup_status.running > 0 %}
+ Running
+ {% endif %}
+
+
+ {% if backup_status.pending > 0 %}
+ Pending
+ {% endif %}
+
+ {% endif %}
+
+ {% else %}
+
+
+
+
+
+
+
+ Completed Today:
+ {{ backup_status.completed }}
+
+
+
+
+
+
+
+
+
+
+ Failed:
+ {{ backup_status.failed }}
+
+
+
+
+
+
+ {% endif %}
+
+
{% if repositories %}
@@ -70,7 +195,7 @@
{% endif %}
-
{% endfor %}
+
+
+
+
+
+
+
+
+
+ This action cannot be undone!
+
+
Are you sure you want to delete all {{ repositories|length }} repositories?
+
This will:
+
+ - Delete all repository records from the system
+ - Cancel all scheduled backups
+ - Preserve all existing backup files in storage
+ - This cannot be undone
+
+
+
+
+
+
+
{% else %}
@@ -123,4 +283,51 @@
No Repositories
{% endif %}
+
+
+
+
+
+ Backup Started — Your backup has been queued and will start shortly.
+
+
+
+{% endblock %}
+
+{% block scripts %}
+
{% endblock %}
From 47adc9f4e8e128ff2a5888371d3535dd5bafdc97 Mon Sep 17 00:00:00 2001
From: stuyk
Date: Sat, 23 May 2026 18:13:28 -0700
Subject: [PATCH 3/5] docs: update readme
---
README.md | 16 ++++++++++------
1 file changed, 10 insertions(+), 6 deletions(-)
diff --git a/README.md b/README.md
index 9e84be6..b345e5b 100644
--- a/README.md
+++ b/README.md
@@ -41,6 +41,10 @@ A comprehensive web-based solution for backing up GitHub repositories with sched
+- **Seamless Backup Experience**:
+ - Non-blocking backups without page refreshes
+ - Stay in place while operations run in the background
+ - Quick repository bulk import via "Add by Username" feature
- **User Settings**: Change username and password functionality
- **Docker Ready**: Fully containerized with health checks and proper user permissions
@@ -89,12 +93,12 @@ docker run -d \
### Environment Variables
-| Variable | Description | Default |
-|----------|-------------|---------|
-| `SECRET_KEY` | Flask secret key for sessions | `dev-secret-key-change-in-production` |
-| `DATABASE_URL` | SQLite database file path | `sqlite:////app/data/github_backup.db` |
-| `PUID` | User ID for file permissions | `1000` |
-| `PGID` | Group ID for file permissions | `1000` |
+| Variable | Description | Default |
+| -------------- | ----------------------------- | -------------------------------------- |
+| `SECRET_KEY` | Flask secret key for sessions | `dev-secret-key-change-in-production` |
+| `DATABASE_URL` | SQLite database file path | `sqlite:////app/data/github_backup.db` |
+| `PUID` | User ID for file permissions | `1000` |
+| `PGID` | Group ID for file permissions | `1000` |
### GitHub Token Setup
From ad5ed5809d53d5b80628d1fa81d9d4a78f8535dd Mon Sep 17 00:00:00 2001
From: Timeraider <57343973+GitTimeraider@users.noreply.github.com>
Date: Tue, 26 May 2026 14:08:20 +0200
Subject: [PATCH 4/5] Docker build push
---
README.md | 1 +
1 file changed, 1 insertion(+)
diff --git a/README.md b/README.md
index b345e5b..ae1848e 100644
--- a/README.md
+++ b/README.md
@@ -14,6 +14,7 @@ A comprehensive web-based solution for backing up GitHub repositories with sched
+
## Features
- **Web UI with Authentication**: Secure login system with automatic admin user creation
From 26d9887e7c6ee03ead81eb58f640d85b162d1ece Mon Sep 17 00:00:00 2001
From: Timeraider <57343973+GitTimeraider@users.noreply.github.com>
Date: Tue, 26 May 2026 15:01:58 +0200
Subject: [PATCH 5/5] Docker build push
---
README.md | 1 -
1 file changed, 1 deletion(-)
diff --git a/README.md b/README.md
index ae1848e..b345e5b 100644
--- a/README.md
+++ b/README.md
@@ -14,7 +14,6 @@ A comprehensive web-based solution for backing up GitHub repositories with sched
-
## Features
- **Web UI with Authentication**: Secure login system with automatic admin user creation