├── .env.example ├── .github └── workflows │ └── dcs-action.yml ├── .gitignore ├── LICENSE ├── README.md ├── pyproject.toml ├── src └── dcs │ ├── __init__.py │ └── main.py └── tests └── __init__.py /.env.example: -------------------------------------------------------------------------------- 1 | # Discord Webhook URL 2 | DISCORD_WEBHOOK_URL=YOUR_DISCORD_WEBHOOK_URL_HERE 3 | 4 | # Path to the local Git repository to monitor 5 | GIT_REPO_PATH=/path/to/your/local/repo 6 | 7 | # (Optional) AI Service API Key (e.g., Gemini) 8 | # AI_API_KEY=YOUR_AI_API_KEY_HERE 9 | GEMINI_API_KEY=YOUR_GEMINI_API_KEY_HERE 10 | 11 | # (Optional) Frequency of summaries (e.g., weekly, daily) - Default: weekly 12 | SUMMARY_FREQUENCY=weekly 13 | 14 | # --- Email Notification Settings (Optional) --- 15 | # Enable email notifications for critical script failures 16 | ENABLE_EMAIL_NOTIFICATION="true" # Set to "true" to enable, "false" or leave blank to disable 17 | 18 | # --- Gmail SMTP Configuration Example --- 19 | # For Gmail, use smtp.gmail.com and port 587 (TLS) or 465 (SSL) 20 | # IMPORTANT: You will likely need to generate an "App Password" for your Google Account 21 | # if you have 2-Factor Authentication enabled. Do NOT use your regular password here. 22 | # See: https://support.google.com/accounts/answer/185833 23 | SMTP_SERVER="smtp.gmail.com" 24 | SMTP_PORT="587" # Use 587 for TLS (recommended) or 465 for SSL 25 | SMTP_USER="your_gmail_address@gmail.com" # Your full Gmail address 26 | SMTP_PASSWORD="your_gmail_app_password" # The App Password you generated 27 | EMAIL_SENDER="your_gmail_address@gmail.com" # Email address the notification will be sent from 28 | EMAIL_RECEIVER="your_receiving_email@example.com" # Email address to receive the failure notifications 29 | 30 | # --- Other SMTP Provider Example --- 31 | # SMTP_SERVER="your_smtp_server.com" 32 | # SMTP_PORT="587" 33 | # SMTP_USER="your_email@example.com" 34 | # SMTP_PASSWORD="your_email_password" 35 | # EMAIL_SENDER="sender_email@example.com" 36 | # EMAIL_RECEIVER="your_receiving_email@example.com" 37 | -------------------------------------------------------------------------------- /.github/workflows/dcs-action.yml: -------------------------------------------------------------------------------- 1 | name: Discord Commit Summarizer 2 | 3 | on: 4 | workflow_call: 5 | inputs: 6 | frequency: 7 | description: 'Frequency of commit summaries (daily, weekly, monthly)' 8 | required: false 9 | default: 'weekly' 10 | type: string 11 | send_empty_summary: 12 | description: 'Whether to send a summary even if no new commits are found' 13 | required: false 14 | default: false 15 | type: boolean 16 | secrets: 17 | DISCORD_WEBHOOK_URL: 18 | description: 'Discord webhook URL to send summaries to' 19 | required: true 20 | GEMINI_API_KEY: 21 | description: 'Google Gemini API key for AI summaries' 22 | required: true 23 | 24 | workflow_dispatch: 25 | inputs: 26 | frequency: 27 | description: 'Frequency of commit summaries' 28 | required: true 29 | default: 'weekly' 30 | type: choice 31 | options: 32 | - daily 33 | - weekly 34 | - monthly 35 | send_empty_summary: 36 | description: 'Send summary even if no new commits' 37 | required: false 38 | default: false 39 | type: boolean 40 | 41 | schedule: 42 | # Run every Friday at 12:00 UTC 43 | - cron: '0 12 * * 5' 44 | 45 | jobs: 46 | summarize-commits: 47 | name: Generate and send commit summary 48 | runs-on: ubuntu-latest 49 | steps: 50 | - name: Checkout repository 51 | uses: actions/checkout@v4 52 | with: 53 | fetch-depth: 0 # Fetch all history for all branches and tags 54 | 55 | - name: Set up Python 56 | uses: actions/setup-python@v4 57 | with: 58 | python-version: '3.11' 59 | 60 | - name: Install dependencies 61 | run: | 62 | python -m pip install --upgrade pip 63 | # Install required dependencies directly 64 | pip install GitPython requests python-dotenv openai 65 | 66 | - name: Clone DCS Repository 67 | uses: actions/checkout@v4 68 | with: 69 | repository: miguel07alm/dcs 70 | ref: main 71 | path: dcs-tool 72 | 73 | - name: Install DCS 74 | working-directory: dcs-tool 75 | run: | 76 | # Install the DCS package 77 | pip install -e . 78 | 79 | - name: Run Discord Commit Summarizer 80 | env: 81 | DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL }} 82 | GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }} 83 | GIT_REPO_PATH: ${{ github.workspace }} 84 | SUMMARY_FREQUENCY: ${{ inputs.frequency || 'weekly' }} 85 | SEND_EMPTY_SUMMARY: ${{ inputs.send_empty_summary || 'false' }} 86 | working-directory: dcs-tool 87 | run: | 88 | python -m dcs.main 89 | 90 | - name: Upload logs 91 | if: always() 92 | uses: actions/upload-artifact@v4 93 | with: 94 | name: dcs-logs 95 | path: dcs-tool/logs/ 96 | retention-days: 7 97 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Python cache 2 | __pycache__/ 3 | *.pyc 4 | 5 | # Environment variables 6 | .env 7 | **.*env 8 | *.env 9 | 10 | # Logs 11 | logs/ 12 | 13 | # State file 14 | dcs_state.json 15 | 16 | # VS Code settings 17 | .vscode/ 18 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Miguel Ángel Simón Sierra 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # DCS - Discord Commit Summarizer 2 | 3 | A tool that monitors a local Git repository, generates AI-powered summaries of recent commits (daily, weekly, or monthly), and sends them to a Discord channel. Includes robust error handling and optional email notifications for critical failures. 4 | 5 | ## Motivation 6 | 7 | Keeping a community updated with the latest project developments often involves manually reading through Git commits and crafting announcements. This tool was born out of the desire to automate this process, making it effortless to generate clear, engaging updates from commit history and share them directly where the community gathers, like Discord. 8 | 9 | ## Features 10 | 11 | * Monitors a specified local Git repository. 12 | * Fetches commits based on a configurable frequency (`daily`, `weekly`, `monthly`). 13 | * Uses the Gemini API (via the OpenAI library interface) to generate user-friendly summaries focused on user impact and new capabilities, styled for Discord announcements. 14 | * Includes project context (reads the first 1000 characters of the repository's `README.md`) in the AI prompt for more relevant summaries. 15 | * Provides a basic commit list fallback if the AI summarization fails. 16 | * Sends summaries to a specified Discord webhook URL. 17 | * Automatically splits long messages to comply with Discord character limits. 18 | * Logs detailed run information to timestamped markdown files in the `logs/` directory. 19 | * Robust error handling for configuration issues, Git operations, AI API calls, and Discord sending. 20 | * Optional email notifications via SMTP (configurable for Gmail or other providers) for critical script failures, making it suitable for monitoring automated runs (e.g., via cron). 21 | 22 | ## Setup 23 | 24 | 1. **Clone the repository:** 25 | ```bash 26 | git clone 27 | cd dcs 28 | ``` 29 | 2. **Create and activate a virtual environment:** 30 | ```bash 31 | # Using venv (standard library) 32 | python -m venv .venv 33 | source .venv/bin/activate # On Windows use `.venv\Scripts\activate` 34 | 35 | # Or using uv (if installed) 36 | uv venv 37 | source .venv/bin/activate 38 | ``` 39 | 3. **Install dependencies:** 40 | ```bash 41 | # Using pip 42 | pip install -r requirements.txt 43 | # Or if you have configured pyproject.toml for the project: 44 | # pip install . 45 | 46 | # Or using uv 47 | uv pip install -r requirements.txt 48 | # Or uv pip install . 49 | ``` 50 | 4. **Configure environment variables:** 51 | * Copy the example environment file: 52 | ```bash 53 | cp .env.example .env 54 | ``` 55 | * Edit the `.env` file and fill in the required values: 56 | * `DISCORD_WEBHOOK_URL`: Your Discord channel webhook URL. 57 | * `GIT_REPO_PATH`: The absolute path to the local Git repository you want to monitor. 58 | * `GEMINI_API_KEY`: Your API key for the Google Gemini model. 59 | * (Optional) `SUMMARY_FREQUENCY`: Set to `daily`, `weekly` (default), or `monthly`. 60 | * (Optional) Email Notification Settings: If you want email alerts on failure: 61 | * Set `ENABLE_EMAIL_NOTIFICATION="true"`. 62 | * Configure `SMTP_SERVER`, `SMTP_PORT`, `SMTP_USER`, `SMTP_PASSWORD`, `EMAIL_SENDER`, `EMAIL_RECEIVER`. 63 | * **Important for Gmail:** Use `smtp.gmail.com` and port `587`. If you have 2-Factor Authentication enabled on your Google account, you **must** generate an "App Password" and use that for `SMTP_PASSWORD`. Do not use your regular Google account password. 64 | 65 | ## Usage 66 | 67 | * **Manual Run:** 68 | Execute the script directly from the project root: 69 | ```bash 70 | python src/dcs/main.py 71 | 72 | # Or using directly 73 | dcs 74 | ``` 75 | * **Automated Run (Cron Job):** 76 | Set up a cron job to run the script automatically. Edit your crontab (`crontab -e`) and add an entry like this (example runs daily at 3:00 AM): 77 | ```cron 78 | 0 3 * * * /usr/bin/python /path/to/your/dcs/src/dcs/main.py >> /path/to/your/dcs/logs/cron.log 2>&1 79 | ``` 80 | * **Important:** 81 | * Replace `/usr/bin/python` with the actual path to the python executable *within your virtual environment* (e.g., `/path/to/your/dcs/.venv/bin/python`) or ensure the script runs with the correct environment activated. 82 | * Replace `/path/to/your/dcs/` with the absolute path to the project directory. 83 | * Ensure the user running the cron job has the necessary permissions and environment variables set (cron jobs often run with a minimal environment; you might need to source variables or use absolute paths). 84 | 85 | ## Logging 86 | 87 | Check the `logs/` directory within the project folder. Each script run creates a detailed markdown file (e.g., `dcs_run_YYYYMMDD_HHMMSS.md`) containing information about the execution steps, fetched commits, AI prompts/responses, and any errors encountered. 88 | 89 | ## GitHub Action 90 | 91 | You can also use DCS as a GitHub Action to automatically generate summaries from your GitHub repository and post them to Discord. 92 | 93 | ### Setting up the GitHub Action 94 | 95 | 1. **Add required secrets to your GitHub repository:** 96 | - Go to your repository's Settings > Secrets and variables > Actions 97 | - Add the following secrets: 98 | - `DISCORD_WEBHOOK_URL`: Your Discord channel webhook URL 99 | - `GEMINI_API_KEY`: Your API key for the Google Gemini model 100 | 101 | 2. **Use as a workflow in your repository:** 102 | 103 | Create a file at `.github/workflows/discord-summary.yml` with the following content: 104 | 105 | ```yaml 106 | name: Discord Commit Summary 107 | 108 | on: 109 | schedule: 110 | - cron: '0 12 * * 5' # Run every Friday at 12:00 UTC 111 | workflow_dispatch: 112 | 113 | jobs: 114 | summarize: 115 | uses: miguel07alm/dcs/.github/workflows/dcs-action.yml@main 116 | with: 117 | frequency: weekly # Options: daily, weekly, monthly 118 | send_empty_summary: false # Whether to send a summary when no commits are found 119 | secrets: 120 | DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL }} 121 | GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }} 122 | ``` 123 | 124 | 3. **Customize as needed:** 125 | - Adjust the schedule to your preferred frequency 126 | - Change the `frequency` parameter to match your needs 127 | 128 | ### Manual Trigger 129 | 130 | You can also manually trigger the workflow by: 131 | 1. Going to your repository's "Actions" tab 132 | 2. Selecting the "Discord Commit Summary" workflow 133 | 3. Clicking "Run workflow" 134 | 4. Optionally adjusting the parameters before running 135 | 136 | ## License 137 | 138 | This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. 139 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["hatchling"] 3 | build-backend = "hatchling.build" 4 | 5 | [project] 6 | name = "dcs" 7 | version = "0.1.0" 8 | description = "Discord Commit Summarizer using AI" 9 | readme = "README.md" 10 | requires-python = ">=3.8" 11 | license = {text = "MIT"} 12 | authors = [ 13 | {name = "Miguel Ángel", email = "miguel07alm@protonmail.com"}, 14 | ] 15 | classifiers = [ 16 | "Programming Language :: Python :: 3", 17 | "License :: OSI Approved :: MIT License", 18 | "Operating System :: OS Independent", 19 | ] 20 | dependencies = [ 21 | "requests", # For sending requests to Discord webhook 22 | "python-dotenv", # For loading environment variables 23 | "GitPython", # For interacting with Git repositories 24 | # "google-generativeai", # Using OpenAI interface instead 25 | "openai", # For Gemini API via OpenAI interface 26 | ] 27 | 28 | [project.urls] 29 | Homepage = "https://github.com/miguel07alm/dcs" 30 | 31 | [project.scripts] 32 | dcs = "dcs.main:main" 33 | -------------------------------------------------------------------------------- /src/dcs/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Miguel07Alm/dcs/21cdc2ee0246f2b12146c59ce36d20061cbd9ff6/src/dcs/__init__.py -------------------------------------------------------------------------------- /src/dcs/main.py: -------------------------------------------------------------------------------- 1 | import os 2 | import requests 3 | from dotenv import load_dotenv 4 | from git import Repo, GitCommandError, Commit 5 | from typing import List, Dict, Any 6 | import time 7 | from datetime import datetime, timedelta 8 | import logging 9 | from openai import OpenAI, OpenAIError 10 | import smtplib 11 | from email.mime.text import MIMEText 12 | import traceback 13 | import sys 14 | 15 | 16 | LOG_DIR = "logs" 17 | RUN_TIMESTAMP = datetime.now().strftime("%Y%m%d_%H%M%S") 18 | LOG_FILE_PATH = os.path.join(LOG_DIR, f"dcs_run_{RUN_TIMESTAMP}.md") 19 | 20 | def ensure_log_dir_exists(): 21 | """Creates the log directory if it doesn't exist.""" 22 | try: 23 | os.makedirs(LOG_DIR, exist_ok=True) 24 | except Exception as e: 25 | logging.error(f"Failed to create log directory '{LOG_DIR}': {e}") 26 | 27 | def log_to_run_file(header, content): 28 | """Appends formatted content to the run-specific log file.""" 29 | try: 30 | ensure_log_dir_exists() 31 | with open(LOG_FILE_PATH, "a", encoding="utf-8") as f: 32 | f.write(f"## {header} ({datetime.now().strftime('%Y-%m-%d %H:%M:%S')})\n\n") 33 | if isinstance(content, (list, dict)): 34 | import json 35 | f.write(f"```json\n{json.dumps(content, indent=2, default=str)}\n```\n\n") 36 | else: 37 | f.write(f"```\n{str(content)}\n```\n\n") 38 | except Exception as e: 39 | logging.error(f"Failed to write to log file '{LOG_FILE_PATH}': {e}") 40 | 41 | 42 | SMTP_SERVER = os.getenv("SMTP_SERVER") 43 | SMTP_PORT = os.getenv("SMTP_PORT", 587) 44 | SMTP_USER = os.getenv("SMTP_USER") 45 | SMTP_PASSWORD = os.getenv("SMTP_PASSWORD") 46 | EMAIL_SENDER = os.getenv("EMAIL_SENDER", SMTP_USER) 47 | EMAIL_RECEIVER = os.getenv("EMAIL_RECEIVER") 48 | ENABLE_EMAIL_NOTIFICATION = os.getenv("ENABLE_EMAIL_NOTIFICATION", "false").lower() == "true" 49 | 50 | def send_failure_email(subject, error_details): 51 | """Sends an email notification about a critical failure.""" 52 | if not ENABLE_EMAIL_NOTIFICATION: 53 | logging.info("Email notifications are disabled. Skipping failure email.") 54 | return 55 | 56 | if not all([SMTP_SERVER, SMTP_PORT, SMTP_USER, SMTP_PASSWORD, EMAIL_SENDER, EMAIL_RECEIVER]): 57 | logging.error("Missing one or more required SMTP environment variables for email notification. Cannot send email.") 58 | missing_vars = [var for var, val in { 59 | "SMTP_SERVER": SMTP_SERVER, "SMTP_PORT": SMTP_PORT, "SMTP_USER": SMTP_USER, 60 | "SMTP_PASSWORD": "***", "EMAIL_SENDER": EMAIL_SENDER, "EMAIL_RECEIVER": EMAIL_RECEIVER 61 | }.items() if not val] 62 | logging.error(f"Missing email config variables: {', '.join(missing_vars)}") 63 | log_to_run_file("Email Sending Error", f"Missing config variables: {', '.join(missing_vars)}") 64 | return 65 | 66 | msg = MIMEText(f"The DCS script encountered a critical error and could not complete.\n\nError Details:\n{error_details}") 67 | msg['Subject'] = f"[DCS Failure] {subject}" 68 | msg['From'] = EMAIL_SENDER 69 | msg['To'] = EMAIL_RECEIVER 70 | 71 | try: 72 | logging.info(f"Attempting to send failure email to {EMAIL_RECEIVER} via {SMTP_SERVER}:{SMTP_PORT}") 73 | with smtplib.SMTP(SMTP_SERVER, int(SMTP_PORT)) as server: 74 | server.ehlo() 75 | server.starttls() 76 | server.ehlo() 77 | server.login(SMTP_USER, SMTP_PASSWORD) 78 | server.sendmail(EMAIL_SENDER, EMAIL_RECEIVER, msg.as_string()) 79 | logging.info("Failure email sent successfully.") 80 | log_to_run_file("Failure Email Sent", f"Sent notification for error: {subject}") 81 | except smtplib.SMTPAuthenticationError as e: 82 | logging.error(f"SMTP Authentication failed: {e}. Check SMTP_USER and SMTP_PASSWORD.") 83 | log_to_run_file("Email Sending Error", f"SMTP Authentication failed: {e}") 84 | except smtplib.SMTPServerDisconnected as e: 85 | logging.error(f"SMTP server disconnected unexpectedly: {e}. Check server/port and network.") 86 | log_to_run_file("Email Sending Error", f"SMTP server disconnected: {e}") 87 | except smtplib.SMTPException as e: 88 | logging.error(f"Failed to send failure email due to SMTP error: {e}") 89 | log_to_run_file("Email Sending Error", f"SMTP Error: {e}") 90 | except Exception as e: 91 | logging.error(f"An unexpected error occurred while sending the failure email: {e}") 92 | log_to_run_file("Email Sending Error", f"Unexpected Error: {e}") 93 | 94 | 95 | load_dotenv() 96 | 97 | GIT_REPO_PATH = os.getenv("GIT_REPO_PATH") 98 | DISCORD_WEBHOOK_URL = os.getenv("DISCORD_WEBHOOK_URL") 99 | GEMINI_API_KEY = os.getenv("GEMINI_API_KEY") 100 | SUMMARY_FREQUENCY = os.getenv("SUMMARY_FREQUENCY", "weekly").lower() 101 | 102 | DISCORD_CHAR_LIMIT = 2000 103 | 104 | logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s') 105 | 106 | def get_commits_since(repo_path, since_date) -> List[Dict[str, Any]]: 107 | """Fetches commits from the repository since a given date, including diff info.""" 108 | commits_data = [] 109 | try: 110 | repo = Repo(repo_path) 111 | commits = list(repo.iter_commits(rev='main', since=since_date.isoformat())) 112 | logging.info(f"Found {len(commits)} commits since {since_date.strftime('%Y-%m-%d')}") 113 | 114 | for commit in commits: 115 | diff_output = "Diff not available." 116 | try: 117 | if commit.parents: 118 | parent_sha = commit.parents[0].hexsha 119 | diff_output = repo.git.diff(parent_sha, commit.hexsha, '--shortstat') 120 | else: 121 | diff_output = repo.git.show(commit.hexsha, '--shortstat', '--pretty=format:') 122 | except Exception as diff_err: 123 | logging.warning(f"Could not get diff for commit {commit.hexsha[:7]}: {diff_err}") 124 | 125 | commits_data.append({ 126 | "commit": commit, 127 | "diff_summary": diff_output.strip() 128 | }) 129 | 130 | return commits_data 131 | except GitCommandError as e: 132 | logging.error(f"Error accessing Git repository at {repo_path}: {e}") 133 | return [] 134 | except Exception as e: 135 | logging.error(f"An unexpected error occurred while fetching commits: {e}") 136 | return [] 137 | 138 | def format_commits_for_prompt(commits_data: List[Dict[str, Any]]) -> str: 139 | """Formats commit messages and diff summaries into a single string for the AI prompt.""" 140 | commit_texts = [] 141 | for data in commits_data: 142 | commit = data["commit"] 143 | diff_summary = data["diff_summary"] 144 | commit_texts.append( 145 | f"- Commit: {commit.hexsha[:7]} by {commit.author.name}\n" 146 | f" Message: {commit.message.strip()}\n" 147 | f" Changes: {diff_summary if diff_summary else 'No changes summary available.'}" 148 | ) 149 | return "\n\n".join(commit_texts) 150 | 151 | def summarize_commits_with_ai(commits_data: List[Dict[str, Any]], project_context: str | None = None) -> str: 152 | """Generates a summary of commits using Gemini via OpenAI interface, including diff info and project context.""" 153 | if not commits_data: 154 | log_to_run_file("AI Summarization Skipped", "No new commits found.") 155 | return "No new commits found in the specified period." 156 | 157 | raw_commits = [data["commit"] for data in commits_data] 158 | 159 | log_to_run_file("Project Context Provided to AI", project_context if project_context else "None") 160 | 161 | if not GEMINI_API_KEY: 162 | logging.warning("GEMINI_API_KEY not found. Falling back to basic formatting.") 163 | log_to_run_file("AI Summarization Skipped", "GEMINI_API_KEY not found. Falling back to basic formatting.") 164 | return format_commits_basic(raw_commits) 165 | 166 | try: 167 | client = OpenAI( 168 | api_key=GEMINI_API_KEY, 169 | base_url="https://generativelanguage.googleapis.com/v1beta/", 170 | ) 171 | 172 | commit_details_with_diffs = format_commits_for_prompt(commits_data) 173 | log_to_run_file("Formatted Commits & Diffs for User Prompt", commit_details_with_diffs) 174 | 175 | context_str = "No project description available." 176 | if project_context: 177 | context_str = ' '.join(project_context.split())[:500] 178 | 179 | system_prompt = ( 180 | "You are a helpful assistant mimicking the style of a lead developer announcing updates on Discord. " 181 | "Your goal is to generate an engaging update message for end-users (non-technical audience) based on recent Git commits and project context, focusing on the *transformation* users experience. " 182 | "**VERY Strict Style Guidelines:**" 183 | "1. **Start:** Begin the entire response with *TWO emojis (one in the start and the other in the end of the header)* that best represents the update, followed *EXACTLY* by ` @everyone Major Update!`. Example: `🚀 @everyone Major Update!`. **DO NOT multiple headers.**" 184 | "2. **Tone:** Enthusiastic, direct, and personal (use 'I\'ve been working on...', 'You can now...'). Focus on excitement about **new capabilities** and **what users can achieve**." 185 | "3. **Formatting:** Use Discord markdown (`**bold**` for headings/key features). Group related changes under **bold headings** that hint at the **new capability or outcome**. Follow headings with a newline. **DO NOT use the em-dash character (—).** Use standard hyphens (-) or rephrase if necessary." 186 | "4. **Emojis:** Use ONLY the single chosen emoji at the very start. **NO OTHER EMOJIS** in the message body." 187 | "5. **Focus & Language:** **Sell the transformation!** Explain *what new ability or outcome the user gains*. Instead of just listing a feature ('Added X'), explain the result ('You can now achieve Y because I've added X' or 'Doing Z is now much easier/faster'). Use simple, non-technical language. **ABSOLUTELY NO mentioning internal code names, function names, component names, file names, or technical jargon.**" 188 | "6. **Conciseness:** Keep the entire message well under 2000 characters." 189 | "7. **Bug Fixes:** Only mention specific bug fixes if they resolved a very noticeable problem for users. Otherwise, group them generally under a heading like '**Smoother Experience**' and say something like 'I\'ve also fixed several smaller issues to make things run better.'." 190 | "8. **Example Style Reference (DO NOT COPY CONTENT, ONLY MIMIC STYLE, STRUCTURE & TRANSFORMATION FOCUS):**\n" 191 | " ```\n" 192 | " 🤖 @everyone Major Overhaul Update! 🤖\n\n" 193 | " Over the past two months, I've been working on a complete overhaul of the editor, optimizing the code (boring stuff) and adding exciting new features to enhance your experience. Here's what's new:\n\n" 194 | " **Edit Feature**\n\n" 195 | " You can now add new elements to your images by simply drawing on them and providing a prompt describing what you want to create.\n\n" 196 | " **Agent Mode**\n\n" 197 | " Two new modes have been added to the chat:\n\n" 198 | " * **Manual Mode:** Ideal for quick edits using commands. It's fast and perfect for implementing ideas on the fly.\n" 199 | " * **Agent Mode:** Allows you to create a list of edits for the agent to execute. While this prototype can't yet create full videos, it's a step toward smarter video editing.\n\n" 200 | " **Mentions (#) and Command Suggestions (@)**\n\n" 201 | " * **Mentions (#):** Easily select specific elements in the editor or groups of elements.\n" 202 | " * **Command Suggestions (@):** Quickly execute specific commands or combinations of commands to streamline your workflow.\n\n" 203 | " **Overlay Design**\n\n" 204 | " I redesigned the toolbar actions, such as crop, add blur region, background remover and AI upscaler into overlays. These overlays allow you to see changes directly on the canvas in real time, without needing to open a separate panel.\n\n" 205 | " **Feedback**\n\n" 206 | " Try out the new features and let me know what you think using the feedback dialog! I'm also open to suggestions for new features, feel free to submit them in ⁠feature-request.\n\n" 207 | " https://editfa.st/\n" 208 | " ```\n\n" 209 | f"**Project Context:** {context_str}\n" 210 | ) 211 | 212 | log_to_run_file("System Prompt Sent to AI", system_prompt) 213 | 214 | user_prompt = f"Generate the Discord update message based on these commits and changes from the last {SUMMARY_FREQUENCY}:\n\n{commit_details_with_diffs}" 215 | log_to_run_file("User Prompt Sent to AI", user_prompt) 216 | 217 | logging.info(f"Sending {len(commits_data)} commits with diff summaries and project context to Gemini for summarization (refined style)...") 218 | 219 | response = client.chat.completions.create( 220 | model="gemini-2.0-flash", 221 | messages=[ 222 | {"role": "system", "content": system_prompt}, 223 | {"role": "user", "content": user_prompt} 224 | ], 225 | temperature=0.65, 226 | max_tokens=550, 227 | n=1, 228 | ) 229 | log_to_run_file("Raw AI Response Object", response.model_dump_json(indent=2)) 230 | 231 | if response.choices and len(response.choices) > 0 and response.choices[0].message and response.choices[0].message.content: 232 | summary = response.choices[0].message.content.strip() 233 | log_to_run_file("Extracted AI Summary (Raw)", summary) 234 | logging.info("Successfully received summary from Gemini.") 235 | return summary 236 | else: 237 | logging.error(f"Unexpected response structure from Gemini API: {response}") 238 | log_to_run_file("Error: Unexpected AI Response Structure", response.model_dump_json(indent=2)) 239 | logging.warning("Falling back to basic formatting due to unexpected API response structure.") 240 | return format_commits_basic(raw_commits) 241 | 242 | except OpenAIError as e: 243 | logging.error(f"Error calling Gemini API: {e}") 244 | log_to_run_file("Error: OpenAI API Error", str(e)) 245 | logging.warning("Falling back to basic formatting due to API error.") 246 | return format_commits_basic(raw_commits) 247 | except Exception as e: 248 | logging.error(f"An unexpected error occurred during AI summarization: {e}") 249 | log_to_run_file("Error: Unexpected Summarization Error", str(e)) 250 | logging.warning("Falling back to basic formatting due to unexpected error.") 251 | return format_commits_basic(raw_commits) 252 | 253 | def format_commits_basic(commits: List[Commit]) -> str: 254 | """Formats commit messages into a basic string for fallback.""" 255 | if not commits: 256 | return "No new commits found in the specified period." 257 | 258 | summary = f"**Commit Summary ({datetime.now().strftime('%Y-%m-%d')})**\n\n" 259 | summary += f"Found {len(commits)} commits:\n" 260 | for commit in commits: 261 | short_hash = commit.hexsha[:7] 262 | message_first_line = commit.message.split('\n')[0] 263 | summary += f"- `{short_hash}`: {message_first_line} (by {commit.author.name})\n" 264 | return summary 265 | 266 | def split_message(message, limit=DISCORD_CHAR_LIMIT): 267 | """Splits a long message into chunks respecting Discord's limit, trying to split at newlines.""" 268 | if len(message) <= limit: 269 | return [message] 270 | 271 | chunks = [] 272 | current_chunk = "" 273 | lines = message.splitlines(keepends=True) 274 | 275 | for line in lines: 276 | if len(current_chunk) + len(line) > limit: 277 | if current_chunk: 278 | chunks.append(current_chunk) 279 | current_chunk = line 280 | else: 281 | current_chunk += line 282 | 283 | while len(current_chunk) > limit: 284 | split_at = current_chunk[:limit].rfind(" ") 285 | if split_at == -1 or split_at < limit // 2: 286 | split_at = limit 287 | 288 | chunks.append(current_chunk[:split_at]) 289 | current_chunk = current_chunk[split_at:].lstrip() 290 | 291 | if current_chunk: 292 | chunks.append(current_chunk) 293 | 294 | return [chunk.strip() for chunk in chunks if chunk.strip()] 295 | 296 | 297 | def send_to_discord(webhook_url, message): 298 | """Sends a message to the specified Discord webhook URL, splitting if necessary.""" 299 | if not webhook_url: 300 | logging.error("Discord webhook URL is not set. Cannot send message.") 301 | log_to_run_file("Discord Sending Skipped", "Webhook URL not set.") 302 | return False 303 | 304 | message_chunks = split_message(message, DISCORD_CHAR_LIMIT - 20) 305 | total_chunks = len(message_chunks) 306 | log_to_run_file(f"Message Splitting (Into {total_chunks} Chunks)", message_chunks) 307 | 308 | if total_chunks == 0: 309 | logging.warning("Attempted to send an empty or whitespace-only message.") 310 | log_to_run_file("Discord Sending Skipped", "Attempted to send empty message.") 311 | return False 312 | 313 | success = True 314 | for i, chunk in enumerate(message_chunks): 315 | part_indicator = f" (Part {i+1}/{total_chunks})" if total_chunks > 1 else "" 316 | 317 | if len(chunk) + len(part_indicator) > DISCORD_CHAR_LIMIT: 318 | chunk = chunk[:DISCORD_CHAR_LIMIT - len(part_indicator) - 3] + "..." 319 | 320 | final_chunk = chunk + part_indicator 321 | log_to_run_file(f"Sending Chunk {i+1}/{total_chunks} to Discord", final_chunk) 322 | 323 | data = {"content": final_chunk} 324 | try: 325 | response = requests.post(webhook_url, json=data) 326 | response.raise_for_status() 327 | logging.info(f"Successfully sent part {i+1}/{total_chunks} to Discord.") 328 | if total_chunks > 1 and i < total_chunks - 1: 329 | time.sleep(1.5) 330 | except requests.exceptions.RequestException as e: 331 | logging.error(f"Failed to send part {i+1}/{total_chunks} to Discord: {e}") 332 | log_to_run_file(f"Error Sending Chunk {i+1}/{total_chunks} to Discord", str(e)) 333 | success = False 334 | break 335 | 336 | return success 337 | 338 | def get_start_date(frequency): 339 | """Calculates the start date based on the frequency.""" 340 | now = datetime.now() 341 | if frequency == "daily": 342 | return now - timedelta(days=1) 343 | elif frequency == "weekly": 344 | return now - timedelta(weeks=1) 345 | elif frequency == "monthly": 346 | return now - timedelta(days=30) 347 | else: 348 | logging.warning(f"Unknown frequency '{frequency}', defaulting to weekly.") 349 | return now - timedelta(weeks=1) 350 | 351 | def main(): 352 | try: 353 | ensure_log_dir_exists() 354 | except Exception as e: 355 | print(f"CRITICAL: Failed to create log directory '{LOG_DIR}': {e}", file=sys.stderr) 356 | error_details = f"Failed to create log directory '{LOG_DIR}'.\n{traceback.format_exc()}" 357 | send_failure_email("Log Directory Creation Failed", error_details) 358 | sys.exit(1) 359 | 360 | log_to_run_file("Script Execution Started", f"Frequency: {SUMMARY_FREQUENCY}, Repo: {GIT_REPO_PATH}") 361 | logging.info("Starting DCS - Discord Commit Summarizer...") 362 | 363 | try: 364 | critical_error = False 365 | error_messages = [] 366 | 367 | if not GIT_REPO_PATH: 368 | error_messages.append("GIT_REPO_PATH environment variable is not set.") 369 | critical_error = True 370 | elif not os.path.isdir(GIT_REPO_PATH): 371 | error_messages.append(f"GIT_REPO_PATH '{GIT_REPO_PATH}' does not exist or is not a directory.") 372 | critical_error = True 373 | 374 | if not GEMINI_API_KEY: 375 | error_messages.append("GEMINI_API_KEY environment variable is not set. AI summarization will fail.") 376 | critical_error = True 377 | 378 | if not DISCORD_WEBHOOK_URL: 379 | logging.warning("DISCORD_WEBHOOK_URL environment variable is not set. Summary will only be logged.") 380 | log_to_run_file("Configuration Warning", "DISCORD_WEBHOOK_URL not set.") 381 | 382 | if critical_error: 383 | full_error_msg = "Critical configuration error(s):\n- " + "\n- ".join(error_messages) 384 | logging.error(full_error_msg) 385 | log_to_run_file("Script Execution Error", full_error_msg) 386 | send_failure_email("Configuration Error", full_error_msg) 387 | sys.exit(1) 388 | 389 | 390 | start_date = get_start_date(SUMMARY_FREQUENCY) 391 | log_to_run_file("Calculated Start Date", start_date.isoformat()) 392 | logging.info(f"Fetching commits since {start_date.strftime('%Y-%m-%d')} based on '{SUMMARY_FREQUENCY}' frequency.") 393 | 394 | commits_data = get_commits_since(GIT_REPO_PATH, start_date) 395 | commits_summary_for_log = [ 396 | { 397 | "hexsha": d["commit"].hexsha, 398 | "message": d["commit"].message.strip(), 399 | "author": d["commit"].author.name, 400 | "date": d["commit"].committed_datetime.isoformat(), 401 | "diff_summary": d["diff_summary"] 402 | } for d in commits_data 403 | ] 404 | log_to_run_file("Fetched Commits Data (Summary)", commits_summary_for_log) 405 | 406 | readme_content = None 407 | try: 408 | readme_path = os.path.join(GIT_REPO_PATH, 'README.md') 409 | if os.path.exists(readme_path): 410 | with open(readme_path, 'r', encoding='utf-8', errors='ignore') as f: 411 | readme_content = f.read(1000) 412 | logging.info("Read start of project README.md for context.") 413 | log_to_run_file("Read README Context (First 1000 chars)", readme_content if readme_content else "Empty") 414 | else: 415 | logging.info("Project README.md not found in GIT_REPO_PATH. Proceeding without project context.") 416 | log_to_run_file("README Context", "README.md not found.") 417 | except Exception as e: 418 | logging.error(f"Error reading project README.md: {e}") 419 | log_to_run_file("Error Reading README", str(e)) 420 | 421 | summary_message = summarize_commits_with_ai(commits_data, project_context=readme_content) 422 | log_to_run_file("Final Summary Message (Before Sending)", summary_message) 423 | 424 | if DISCORD_WEBHOOK_URL: 425 | success = send_to_discord(DISCORD_WEBHOOK_URL, summary_message) 426 | if not success: 427 | logging.error("Failed to send one or more message parts to Discord.") 428 | log_to_run_file("Discord Sending Issue", "Failed to send one or more message parts.") 429 | else: 430 | logging.info("Discord webhook URL not provided. Logging summary instead:") 431 | print("--- Summary Start ---") 432 | print(summary_message) 433 | print("--- Summary End ---") 434 | log_to_run_file("Discord Sending Skipped", "Webhook URL not provided. Logged to console.") 435 | 436 | log_to_run_file("Script Execution Finished Successfully", "------") 437 | logging.info("DCS script finished successfully.") 438 | 439 | except Exception as e: 440 | error_timestamp = datetime.now().strftime('%Y-%m-%d %H:%M:%S') 441 | error_type = type(e).__name__ 442 | error_traceback = traceback.format_exc() 443 | 444 | logging.critical(f"CRITICAL ERROR encountered at {error_timestamp}: {error_type} - {e}") 445 | logging.critical(f"Traceback:\n{error_traceback}") 446 | 447 | error_details_for_log = f"Timestamp: {error_timestamp}\nError Type: {error_type}\nMessage: {e}\n\nTraceback:\n{error_traceback}" 448 | log_to_run_file(f"CRITICAL SCRIPT FAILURE: {error_type}", error_details_for_log) 449 | 450 | email_subject = f"Critical Error: {error_type}" 451 | send_failure_email(email_subject, error_details_for_log) 452 | 453 | sys.exit(1) 454 | 455 | 456 | if __name__ == "__main__": 457 | main() 458 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Miguel07Alm/dcs/21cdc2ee0246f2b12146c59ce36d20061cbd9ff6/tests/__init__.py --------------------------------------------------------------------------------