From 15d4351794adafa1fe8a75f39196637e75193146 Mon Sep 17 00:00:00 2001 From: joobert Date: Tue, 11 Jun 2024 02:09:37 -0400 Subject: [PATCH] Initialize project files --- .env.template | 3 + .gitignore | 2 + README.md | 85 +++++++++++++++++++ compose.yml | 10 +++ dockerfile | 5 ++ mitten.py | 213 +++++++++++++++++++++++++++++++++++++++++++++++ requirements.txt | 2 + 7 files changed, 320 insertions(+) create mode 100644 .env.template create mode 100644 .gitignore create mode 100644 README.md create mode 100644 compose.yml create mode 100644 dockerfile create mode 100644 mitten.py create mode 100644 requirements.txt diff --git a/.env.template b/.env.template new file mode 100644 index 0000000..29f3df2 --- /dev/null +++ b/.env.template @@ -0,0 +1,3 @@ +DISCORD_WEBHOOK_URL=your_webhook_url +REPOS=owner/repo1,owner/repo2,owner/repo3 # Comma-separated list of repositories, add as many as you'd like +CHECK_INTERVAL=60 # Adjust this at your own discretion (Default: 60 seconds) \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1d502a7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +.env +commit_log.json \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..67f4287 --- /dev/null +++ b/README.md @@ -0,0 +1,85 @@ +

+ Mitten +

+ +

+ +

+ +Mitten is a Python script designed to monitor GitHub repositories for new commits and send notifications to a specified Discord channel. The script leverages the GitHub API to fetch commit information and Discord Webhooks to post notifications. + +## Features + +- Fetches commits from specified GitHub repositories. +- Sends commit notifications to Discord with detailed commit information. +- Supports multiple repositories concurrently using threading. +- Logs notified commits to avoid duplicate notifications. +- Fetches commits pushed since the last runtime of the script, ensuring that commits pushed during downtime are still fetched in the next run. +- Configurable through environment variables. + +## Requirements + +- Python 3.7+ +- `requests` library +- `python-dotenv` library + +### Installation + +1. Clone the repository: + ```sh + git clone https://github.com/joobert/mitten.git + cd Mitten + ``` + +2. Install dependencies: + ```sh + pip install -r requirements.txt + ``` + +3. Create a `.env` file with the following content: + ```env + DISCORD_WEBHOOK_URL=your_webhook_url + REPOS=owner/repo1,owner/repo2,owner/repo3 + CHECK_INTERVAL=60 + ``` + +4. Run the script: + ```sh + python mitten.py + ``` + +### (Optional) Running with Docker + +###### Ensure you have both Docker and Docker Compose installed on your machine. + +1. Clone the repository: + ```sh + git clone https://github.com/joobert/mitten.git + cd Mitten + ``` + +2. Create a `.env` file with the following content: + ```env + DISCORD_WEBHOOK_URL=your_webhook_url + REPOS=owner/repo1,owner/repo2,owner/repo3 + CHECK_INTERVAL=60 + ``` + +3. Start the service with Docker Compose: + ```sh + docker-compose up -d + ``` + +## Configuration + +- **DISCORD_WEBHOOK_URL**: Your Discord webhook URL. +- **REPOS**: Comma-separated list of repositories to monitor. +- **CHECK_INTERVAL**: Interval in seconds between checks. + +## Contributing + +Contributions are welcome! Please feel free to submit a Pull Request or open an Issue. + +## License + +[MIT](https://choosealicense.com/licenses/mit/) diff --git a/compose.yml b/compose.yml new file mode 100644 index 0000000..7c0e3f5 --- /dev/null +++ b/compose.yml @@ -0,0 +1,10 @@ +--- +services: + mitten: + build: . + container_name: mitten + env_file: + - .env + volumes: + - ./commit_log.json:/app/commit_log.json + restart: unless-stopped diff --git a/dockerfile b/dockerfile new file mode 100644 index 0000000..3e4b05d --- /dev/null +++ b/dockerfile @@ -0,0 +1,5 @@ +FROM python:3.9-slim +WORKDIR /app +COPY . /app +RUN pip install --no-cache-dir -r requirements.txt +CMD ["python", "mitten.py"] diff --git a/mitten.py b/mitten.py new file mode 100644 index 0000000..54a2fdb --- /dev/null +++ b/mitten.py @@ -0,0 +1,213 @@ +import requests +import time +import json +import os +import logging +from dotenv import load_dotenv +from concurrent.futures import ThreadPoolExecutor, as_completed + +# Load environment variables from a .env file +load_dotenv() + +# Set up logging +logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s') + +# Discord webhook URL from environment variables +discord_webhook_url = os.getenv('DISCORD_WEBHOOK_URL') + +# Stores the timestamp of the latest commit seen for each repo +latest_commits = {} + +# Fetch all commits of a repository +def fetch_all_commits(repo): + url = f'https://api.github.com/repos/{repo}/commits' + commits = [] + while url: + response = requests.get(url) + response.raise_for_status() + batch = response.json() + commits.extend(batch) + url = response.links.get('next', {}).get('url') + return commits + +# Fetch commits of a repository since the last known commit +def fetch_repo_info(repo, last_seen_timestamp=None): + url = f'https://api.github.com/repos/{repo}/commits' + if last_seen_timestamp: + url += f'?since={last_seen_timestamp}' + response = requests.get(url) + response.raise_for_status() + commits = response.json() + + # Fetch repository information + repo_info_url = f'https://api.github.com/repos/{repo}' + repo_info_response = requests.get(repo_info_url) + repo_info_response.raise_for_status() + repo_info = repo_info_response.json() + + # Extract name and avatar_url from owner + repo_name = repo_info['name'] + owner_avatar_url = repo_info['owner']['avatar_url'] + + # Add name and avatar_url to each commit + for commit in commits: + commit['repo_name'] = repo_name + commit['owner_avatar_url'] = owner_avatar_url + + return commits + +# Load existing log data +def load_commit_log(): + if os.path.exists('commit_log.json'): + with open('commit_log.json', 'r') as file: + return json.load(file) + else: + return {} + +# Save log data +def save_commit_log(commit_log): + with open('commit_log.json', 'w') as file: + json.dump(commit_log, file, indent=4) + +# Check if a commit has been notified +def has_been_notified(repo, commit_sha, commit_log): + return commit_log.get(repo, {}).get(commit_sha, False) + +# Log a notified commit +def log_notified_commit(repo, commit_sha, commit_log): + if repo not in commit_log: + commit_log[repo] = {} + commit_log[repo][commit_sha] = True + save_commit_log(commit_log) + +# Initialize any new repository with all of its commits to avoid spamming notifications +def initialize_repo_log(repo, commit_log): + logging.info(f"Initializing log for new repository: {repo}") + commits = fetch_all_commits(repo) + if repo not in commit_log: + commit_log[repo] = {} + for commit in commits: + commit_sha = commit['sha'] + commit_log[repo][commit_sha] = True + save_commit_log(commit_log) + if commits: + latest_commits[repo] = commits[0]['commit']['committer']['date'] + logging.info(f"Initialized {len(commits)} commits for repository: {repo}") + +# Send a notification to Discord about the new commit +def notify_discord(repo, commit): + commit_sha = commit['sha'] + commit_message = commit['commit']['message'] + commit_log = load_commit_log() + + # Check if already notified + if has_been_notified(repo, commit_sha, commit_log): + logging.info(f"Commit #{commit_sha} in {repo} has already been logged. Watching for new commits...") + return + + if '\n\n' in commit_message: + simple_commit_message, commit_description = commit_message.split('\n\n', 1) + elif '\n' in commit_message: + simple_commit_message, commit_description = commit_message.split('\n', 1) + else: + simple_commit_message = commit_message + commit_description = 'No description provided' + + commit_url = commit['html_url'] + pushed_at = commit['commit']['committer']['date'] + owner_avatar_url = commit['owner_avatar_url'] + repo_url = f"https://github.com/{repo}" + + # Log the notified commit + log_notified_commit(repo, commit_sha, commit_log) + + # Construct the Discord embed + if commit_description == 'No description provided': + discord_embed = { + "embeds": [ + { + "author": { + "name": commit['repo_name'], + "icon_url": owner_avatar_url + }, + "title": f"New commit in {repo}", + "url": repo_url, + "timestamp": pushed_at, + "fields": [ + { + "name": "Commit", + "value": f"[`{commit_sha[:7]}`]({commit_url}) {simple_commit_message}" + } + ] + } + ] + } + else: + discord_embed = { + "embeds": [ + { + "author": { + "name": commit['repo_name'], + "icon_url": owner_avatar_url + }, + "title": f"New commit in {repo}", + "url": repo_url, + "timestamp": pushed_at, + "fields": [ + { + "name": "Commit", + "value": f"[`{commit_sha[:7]}`]({commit_url}) {simple_commit_message}" + }, + { + "name": "Description", + "value": commit_description + } + ] + } + ] + } + + logging.info(f"Sending message to Discord for new commit in {repo}:\n" + f" Commit SHA: {commit_sha}\n" + f" Commit Message: {simple_commit_message}\n" + f" Description: {commit_description[:50]}...\n" + f" Commit URL: {commit_url}") + + response = requests.post(discord_webhook_url, json=discord_embed) + response.raise_for_status() + +# Check each repository for new commits +def check_repo(repo): + try: + commit_log = load_commit_log() + if repo not in commit_log: + initialize_repo_log(repo, commit_log) + logging.info(f"Skipping initial fetch for {repo} following initialization") + return # Failsafe to avoid spam notifications, temporarily skip fetching new commits right after initialization + last_seen_timestamp = latest_commits.get(repo) + new_commits = fetch_repo_info(repo, last_seen_timestamp) + if new_commits: + # Sort commits by timestamp to ensure correct order + new_commits.sort(key=lambda commit: commit['commit']['committer']['date']) + # Update the latest commit timestamp for the repo to the most recent commit + latest_commit_timestamp = new_commits[-1]['commit']['committer']['date'] + latest_commits[repo] = latest_commit_timestamp + # Notify for each new commit + for commit in new_commits: + notify_discord(repo, commit) + except requests.RequestException as e: + logging.error(f"Error fetching commits for {repo}: {e}") + +# Main function to orchestrate the checking and notification process +def main(): + repos = os.getenv('REPOS').split(',') + interval = int(os.getenv('CHECK_INTERVAL')) + while True: + with ThreadPoolExecutor() as executor: + futures = [executor.submit(check_repo, repo) for repo in repos] + for future in as_completed(futures): + future.result() + time.sleep(interval) + +if __name__ == "__main__": + main() diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..df7458c --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +requests +python-dotenv