Skip to content

Commit

Permalink
Initialize project files
Browse files Browse the repository at this point in the history
  • Loading branch information
joobert committed Jun 11, 2024
1 parent b1e545a commit 15d4351
Show file tree
Hide file tree
Showing 7 changed files with 320 additions and 0 deletions.
3 changes: 3 additions & 0 deletions .env.template
Original file line number Diff line number Diff line change
@@ -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)
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
.env
commit_log.json
85 changes: 85 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
<h1 align="center">
Mitten
</h1>

<p align="center">
<img width="180" height="180" src="https://i.imgur.com/ptCgBYk.png">
</p>

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/)
10 changes: 10 additions & 0 deletions compose.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
services:
mitten:
build: .
container_name: mitten
env_file:
- .env
volumes:
- ./commit_log.json:/app/commit_log.json
restart: unless-stopped
5 changes: 5 additions & 0 deletions dockerfile
Original file line number Diff line number Diff line change
@@ -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"]
213 changes: 213 additions & 0 deletions mitten.py
Original file line number Diff line number Diff line change
@@ -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()
2 changes: 2 additions & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
requests
python-dotenv

0 comments on commit 15d4351

Please sign in to comment.