diff --git a/submissions/git-narrate/.gitignore b/submissions/git-narrate/.gitignore new file mode 100644 index 00000000..09365ff8 --- /dev/null +++ b/submissions/git-narrate/.gitignore @@ -0,0 +1,37 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.pyc +*.pyd +*.pyo +*.egg-info/ + +# Distribution / packaging +.Python +build/ +dist/ +wheels/ +*.egg-info/ +.tox/ +.venv/ +venv/ +env/ + +# Editors +.vscode/ +.idea/ + +# OS +.DS_Store +.Trashes +Thumbs.db + +# Logs and data +*.log +*.sqlite3 +*.db + +# AI related +.env +history.html +timeline.png +contributors.png diff --git a/submissions/git-narrate/LICENSE b/submissions/git-narrate/LICENSE new file mode 100644 index 00000000..34a691ef --- /dev/null +++ b/submissions/git-narrate/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 Sithum Sathsara Rajapakshe | 000x + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/submissions/git-narrate/README.md b/submissions/git-narrate/README.md new file mode 100644 index 00000000..fec0c313 --- /dev/null +++ b/submissions/git-narrate/README.md @@ -0,0 +1,76 @@ +# Git-Narrate User Guide 📖 + +Welcome to Git-Narrate! This guide will help you get started with turning your project's history into an exciting story. + +## What is Git-Narrate? + +Imagine your project is like a movie, and every change you make is a scene. Git-Narrate is like a movie director that watches all these scenes and creates a story about how your project was made. It looks at your project's `git` history (the log of all your changes) and writes a narrative about it. + +## Key Features + +* **Comprehensive Repository Analysis**: Git-Narrate delves into your Git repository to extract detailed information about commits, branches, tags, and contributors. +* **AI-Powered Storytelling**: Leverage the power of AI to transform raw Git data into a rich, engaging, and accurate narrative of your project's development journey. +* **Flexible Output Formats**: Generate your project's story in Markdown, HTML, or plain text, suitable for various uses like documentation, web display, or simple readability. +* **Visual Insights**: Create insightful visualizations, including a commit activity timeline and a contributor activity chart, to better understand your project's evolution and team contributions. +* **Interactive Command-Line Interface**: A user-friendly CLI guides you through the process with clear prompts for repository path, output preferences, and visualization options. + +## Getting Started + +### 1. Installation + +To use Git-Narrate, you first need to install it on your computer. Open your terminal or command prompt and type the following command: + +```bash +pip install git-narrate +``` + +This will download and install Git-Narrate so you can use it from anywhere on your computer. + +### 2. Running Git-Narrate + +Once installed, you can run Git-Narrate on any of your projects that use `git`. + +1. **Navigate to your project folder:** + Open your terminal and go to the folder of the project you want to analyze. For example: + ```bash + cd /path/to/your/project + ``` + +2. **Run the command:** + Now, simply run the `git-narrate` command: + ```bash + git-narrate + ``` + The application will then guide you through the process by asking for the following inputs: + * **Path to your Git repository**: You can enter the path to your repository (e.g., `/path/to/your/project`) or simply press Enter to use the current directory (`.`). + * **Output format**: Choose between Markdown, HTML, or plain text for your story. + * **Output file path**: Specify where you want to save the generated story file. + * **Generate visualization charts**: Confirm if you want to create `timeline.png` and `contributors.png` charts. + + After you provide these inputs, Git-Narrate will generate the story and any requested visualizations. + +## Fun Things You Can Do + +Git-Narrate will prompt you for your preferences, allowing you to: + +* **Choose Output Format**: Select `html` to generate a story that looks like a webpage (e.g., `git_story.html`). +* **Generate Visualizations**: Opt to create `timeline.png` (commit activity over time) and `contributors.png` (contributor activity) charts. + + +## For Developers: A Quick Look Under the Hood + +If you're a developer and want to contribute to Git-Narrate, here's a quick overview of how it works: + +* **`analyzer.py`**: This is the heart of the tool. It uses `GitPython` to read the `.git` folder and extract all the data about commits, branches, tags, and contributors. +* **`narrator.py`**: This module takes the data from the analyzer and turns it into a story. It has different functions to create Markdown, HTML, or plain text stories. +* **`ai_narrator.py`**: This module sends the project data to the Z.ai API and gets back a more detailed story. +* **`visualizer.py`**: This module uses `matplotlib` to create the timeline and contributor charts. +* **`cli.py`**: This file defines the command-line interface using `click`, so you can run `git-narrate` with different options. + +### Contributing + +We welcome contributions! If you want to help make Git-Narrate even better, please check out our [Contributing Guide](https://github.com/000xs/Git-Narrate/blob/main/CONTRIBUTING.md). + +### License + +Git-Narrate is licensed under the MIT License. You can find more details in the [LICENSE](https://github.com/000xs/Git-Narrate/blob/main/LICENSE) file. diff --git a/submissions/git-narrate/git_narrate/__init__.py b/submissions/git-narrate/git_narrate/__init__.py new file mode 100644 index 00000000..32d1feb4 --- /dev/null +++ b/submissions/git-narrate/git_narrate/__init__.py @@ -0,0 +1,9 @@ +""" +Git-Narrate: The Repository Storyteller + +A tool that analyzes a git repository and generates a human-readable story of its development. +""" + +__version__ = "1.0.3" +__author__ = "Sithum Sathsara Rajapakshe | 000x" +__email__ = "SITHUMSS9122@gmail.com" \ No newline at end of file diff --git a/submissions/git-narrate/git_narrate/analyzer.py b/submissions/git-narrate/git_narrate/analyzer.py new file mode 100644 index 00000000..823b722b --- /dev/null +++ b/submissions/git-narrate/git_narrate/analyzer.py @@ -0,0 +1,149 @@ +import git +from datetime import datetime +from typing import List, Dict, Any +from pathlib import Path +from collections import defaultdict + +class RepoAnalyzer: + def __init__(self, repo_path: Path): + self.repo_path = repo_path + self.repo = git.Repo(repo_path) + + def analyze(self) -> Dict[str, Any]: + """Perform complete repository analysis.""" + return { + "commits": self._get_commits(), + "branches": self._get_branches(), + "tags": self._get_tags(), + "contributors": self._get_contributors(), + "repo_name": self._get_project_name_from_readme() or self.repo_path.name, + "readme_content": self._get_readme_content() + } + + def _get_commits(self) -> List[Dict[str, Any]]: + """Extract commit history.""" + commits = [] + for commit in self.repo.iter_commits("--all"): + changed_files = list(commit.stats.files.keys()) + + # Get full content of changed files + file_contents = {} + for file_path in changed_files: + try: + file_contents[file_path] = self.repo.git.show(f"{commit.hexsha}:{file_path}") + except git.exc.GitCommandError: + # This can happen for deleted files, etc. + file_contents[file_path] = None + + commits.append({ + "sha": commit.hexsha, + "author": commit.author.name, + "email": commit.author.email, + "date": datetime.fromtimestamp(commit.committed_date), + "message": commit.message.strip(), + "files_changed": len(changed_files), + "insertions": commit.stats.total["insertions"], + "deletions": commit.stats.total["deletions"], + "is_merge": len(commit.parents) > 1, + "file_contents": file_contents, + "category": self._categorize_commit(commit.message.strip()) + }) + return commits + + def _categorize_commit(self, message: str) -> str: + """Categorize commit based on its message.""" + message = message.lower() + if message.startswith("feat"): + return "feature" + if message.startswith("fix"): + return "fix" + if message.startswith("docs"): + return "documentation" + if message.startswith("style"): + return "style" + if message.startswith("refactor"): + return "refactor" + if message.startswith("test"): + return "test" + if message.startswith("chore"): + return "chore" + return "other" + + def _get_branches(self) -> List[Dict[str, Any]]: + """Get branch information.""" + branches = [] + for branch in self.repo.branches: + branches.append({ + "name": branch.name, + "commit": branch.commit.hexsha, + "is_remote": branch.is_remote + }) + return branches + + def _get_tags(self) -> List[Dict[str, Any]]: + """Get tag information.""" + tags = [] + for tag in self.repo.tags: + tags.append({ + "name": tag.name, + "commit": tag.commit.hexsha, + "date": datetime.fromtimestamp(tag.tag.tagged_date) if tag.tag else None + }) + return tags + + def _get_contributors(self) -> Dict[str, Dict[str, Any]]: + """Get contributor statistics.""" + contributors = defaultdict(lambda: { + "commits": 0, + "insertions": 0, + "deletions": 0, + "first_commit": None, + "last_commit": None + }) + + for commit in self._get_commits(): + author = commit["author"] + contributors[author]["commits"] += 1 + contributors[author]["insertions"] += commit["insertions"] + contributors[author]["deletions"] += commit["deletions"] + + if not contributors[author]["first_commit"] or commit["date"] < contributors[author]["first_commit"]: + contributors[author]["first_commit"] = commit["date"] + if not contributors[author]["last_commit"] or commit["date"] > contributors[author]["last_commit"]: + contributors[author]["last_commit"] = commit["date"] + + return dict(contributors) + + def _get_readme_content(self) -> str: + """Extract content from README file if it exists.""" + readme_paths = [ + self.repo_path / "README.md", + self.repo_path / "README.rst", + self.repo_path / "README.txt", + self.repo_path / "readme.md", + self.repo_path / "readme.rst", + self.repo_path / "readme.txt" + ] + + for path in readme_paths: + if path.exists() and path.is_file(): + try: + with open(path, 'r', encoding='utf-8') as f: + return f.read() + except Exception: + continue + + return "" + + def _get_project_name_from_readme(self) -> str: + """Extract project name from the first H1 heading in README.md.""" + readme = self._get_readme_content() + if not readme: + return "" + + lines = readme.split('\n') + for line in lines: + stripped_line = line.strip() + if stripped_line.startswith('# '): + return stripped_line.lstrip('# ').strip() + return "" diff --git a/submissions/git-narrate/git_narrate/cli.py b/submissions/git-narrate/git_narrate/cli.py new file mode 100644 index 00000000..bf099b7d --- /dev/null +++ b/submissions/git-narrate/git_narrate/cli.py @@ -0,0 +1,84 @@ +import click +import questionary +from pathlib import Path +from rich.console import Console +from rich.status import Status +import git # Import git for exception handling +from .analyzer import RepoAnalyzer +from .narrator import RepoNarrator +from .visualizer import create_html_story +import pyfiglet + +console = Console(force_terminal=True) + +@click.command() +def main(): + """Generate a human-readable story of a git repository's development.""" + + # Display ASCII art welcome + ascii_art = pyfiglet.figlet_format("Git-Narrate") + console.print(f"[bold green]{ascii_art}[/bold green]") + console.print("[bold green]Welcome to Git-Narrate! Let's create your project's story.[/bold green]") + + repo_path_str = console.input( + "[bold cyan]Enter the path to your Git repository('/path/repo')[/bold cyan] [default: .]: " + ) or "." + repo_path = Path(repo_path_str) + + output_format = questionary.select( + "Choose output format:", + choices=["markdown", "html", "text"], + default="html" + ).ask() + + output_extension = "md" if output_format == "markdown" else "html" if output_format == "html" else "txt" if output_format == "text" else "txt" + output_default = repo_path / "git_story" + output_str = questionary.text( + f"Enter output file path(/path/[filename])", + default=str(output_default) + ).ask() + output_path = Path(f"{output_str}.{output_extension}") + + # Analyze repository + console.print("[bold blue]Please be patient while the repository content is being examined.[/bold blue]") + try: + with click.progressbar( + length=100, + label=f"Analyzing repository at {repo_path}...", + show_percent=False, + show_eta=False, + bar_template="%(label)s %(bar)s | %(info)s", + fill_char=click.style("█", fg="green"), + empty_char=" ", + ) as bar: + analyzer = RepoAnalyzer(repo_path) + repo_data = analyzer.analyze() + bar.update(100) # Complete the progress bar once analysis is done + except git.exc.NoSuchPathError: + console.print(f"[bold red]Error: Repository not found at '{repo_path}'. Please ensure the path is correct and it's a valid Git repository.[/bold red]") + return + except Exception as e: + console.print(f"[bold red]An unexpected error occurred during repository analysis: {e}[/bold red]") + return + + # Generate narrative (always AI-powered) + with Status(f"[bold green]Generating AI-powered narrative...", spinner="dots", console=console) as status: + narrator = RepoNarrator(repo_data) + story_md = narrator.generate_story() + status.stop() + + # Save narrative + try: + if output_format == 'html': + create_html_story(story_md, output_path, repo_data["repo_name"]) + else: + with open(output_path, "w", encoding="utf-8") as f: + f.write(story_md) + console.print(f"[bold green]Narrative saved to {output_path}[/bold green]") + except PermissionError: + console.print(f"[bold red]Error: Permission denied to write to '{output_path}'. Please check file permissions or choose a different path.[/bold red]") + except Exception as e: + console.print(f"[bold red]An unexpected error occurred: {e}[/bold red]") + +if __name__ == "__main__": + main() diff --git a/submissions/git-narrate/git_narrate/narrator.py b/submissions/git-narrate/git_narrate/narrator.py new file mode 100644 index 00000000..129f3a3d --- /dev/null +++ b/submissions/git-narrate/git_narrate/narrator.py @@ -0,0 +1,137 @@ +import json +import os +from typing import Dict, Any, List +import requests +from .utils import format_date, clean_commit_message +from dotenv import load_dotenv + +class RepoNarrator: + def __init__(self, repo_data: Dict[str, Any]): + self.repo_data = repo_data + load_dotenv() + self.api_key = os.getenv("OPENAI_API_KEY") + if not self.api_key or self.api_key.strip() == "": + self.api_key = None + + def generate_story(self) -> str: + """Generate an AI-powered narrative of the repository's development.""" + if not self.api_key: + return "An OpenAI API key is required to generate a story. Please set the OPENAI_API_KEY environment variable." + + # Step 1: Structure the story into chapters with detailed beats + chapters = self._structure_chapters() + if not chapters: + return "Could not generate a story from the repository history." + + # Step 2: Get a thematic summary for each chapter from the AI + chapter_summaries = [] + for chapter in chapters: + summary = self._get_chapter_summary(chapter) + if "Error" in summary: + return summary # Propagate error + chapter_summaries.append(f"## {chapter['title']}\n{summary}") + + # Step 3: Weave the chapter summaries into a final, cohesive narrative + return self._weave_final_story(chapter_summaries) + + def _structure_chapters(self) -> List[Dict[str, Any]]: + """Group commits into chapters.""" + commits = sorted(self.repo_data["commits"], key=lambda c: c["date"]) + if not commits: + return [] + + num_commits = len(commits) + chapter_size = max(1, num_commits // 4) + + chapters = [ + {"title": "The Dawn of the Project", "commits": commits[0:chapter_size]}, + {"title": "Building the Foundation", "commits": commits[chapter_size:2*chapter_size]}, + {"title": "Trials and Triumphs", "commits": commits[2*chapter_size:3*chapter_size]}, + {"title": "The Horizon Beyond", "commits": commits[3*chapter_size:]} + ] + + return [c for c in chapters if c["commits"]] + + def _get_chapter_summary(self, chapter: Dict[str, Any]) -> str: + """Use AI to generate a thematic summary for a single chapter.""" + + # Sanitize and limit data for the prompt + commits_json = self._get_limited_json(chapter["commits"]) + + prompt = f""" +You are a technical writer. Your task is to write a short, thematic summary of the following list of git commits. This is one chapter in a larger story. Focus on the main events and the overall theme of this period. + +Chapter Title: {chapter['title']} +Commit Data: +```json +{commits_json} +``` + +Based on the data, write a concise summary of this chapter in the project's history. +""" + return self._call_ai(prompt, 500) + + def _weave_final_story(self, chapter_summaries: List[str]) -> str: + """Use AI to weave chapter summaries into a final narrative.""" + + full_summary = "\n\n".join(chapter_summaries) + + prompt = f""" +You are a master storyteller. I have a series of chapter summaries from a project's history. Your task is to weave them into a single, cohesive, and engaging narrative. Smooth out the transitions between chapters and give the story a consistent, compelling voice. + +Here are the chapter summaries: +--- +{full_summary} +--- + +Now, write the final, complete story of the project. +""" + return self._call_ai(prompt, 3000) + + def _get_limited_json(self, commits: List[Dict[str, Any]]) -> str: + """Create a JSON string from commits, limited by character count.""" + sampled_commits = [] + total_chars = 0 + max_chars = 75000 # A safe limit for each chapter's data + + for commit in commits: + # A simplified representation for the chapter summary prompt + simplified_commit = { + "author": commit["author"], + "message": commit["message"], + "category": commit["category"], + "files_changed": len(commit["file_contents"]), + "insertions": commit["insertions"], + "deletions": commit["deletions"] + } + commit_str = json.dumps(simplified_commit, default=str) + if total_chars + len(commit_str) > max_chars: + break + sampled_commits.append(simplified_commit) + total_chars += len(commit_str) + + return json.dumps(sampled_commits, indent=2, default=str) + + def _call_ai(self, prompt: str, max_tokens: int) -> str: + """A helper function to call the AI API.""" + try: + headers = { + "Content-Type": "application/json", + "Authorization": f"Bearer {self.api_key}", + } + payload = { + "model": "glm-4.5-flash", + "messages": [{"role": "user", "content": prompt}], + "temperature": 0.7, + "max_tokens": max_tokens, + } + + response = requests.post( + "https://api.z.ai/api/paas/v4/chat/completions", + headers=headers, + json=payload + ) + response.raise_for_status() + return response.json()["choices"][0]["message"]["content"] + except Exception as e: + return f"Error: An error occurred while communicating with the AI: {str(e)}" diff --git a/submissions/git-narrate/git_narrate/utils.py b/submissions/git-narrate/git_narrate/utils.py new file mode 100644 index 00000000..17b90167 --- /dev/null +++ b/submissions/git-narrate/git_narrate/utils.py @@ -0,0 +1,40 @@ +from datetime import datetime +from typing import List, Dict, Any +import re + +def format_date(date: datetime) -> str: + """Format a datetime object for display.""" + return date.strftime("%Y-%m-%d") + +def format_duration(start: datetime, end: datetime) -> str: + """Calculate and format a duration between two dates.""" + delta = end - start + days = delta.days + months, days = divmod(days, 30) + years, months = divmod(months, 12) + + parts = [] + if years > 0: + parts.append(f"{years} year{'s' if years != 1 else ''}") + if months > 0: + parts.append(f"{months} month{'s' if months != 1 else ''}") + if days > 0 and not (years > 0 or months > 0): + parts.append(f"{days} day{'s' if days != 1 else ''}") + + return ", ".join(parts) if parts else "0 days" + +def get_top_contributors(contributors: Dict[str, Dict[str, Any]], limit: int = 5) -> List[Dict[str, Any]]: + """Get the top contributors by commit count.""" + sorted_contributors = sorted( + contributors.items(), + key=lambda x: x[1]["commits"], + reverse=True + ) + return [{"name": name, **stats} for name, stats in sorted_contributors[:limit]] + +def clean_commit_message(message: str) -> str: + """Clean up a commit message for display.""" + # Remove common prefixes + message = re.sub(r'^(feat|fix|docs|style|refactor|test|chore)(\([^)]+\))?:\s*', '', message, flags=re.IGNORECASE) + # Remove trailing whitespace and newlines + return message.strip() \ No newline at end of file diff --git a/submissions/git-narrate/git_narrate/visualizer.py b/submissions/git-narrate/git_narrate/visualizer.py new file mode 100644 index 00000000..b1fcbc73 --- /dev/null +++ b/submissions/git-narrate/git_narrate/visualizer.py @@ -0,0 +1,75 @@ +import markdown2 +from pathlib import Path + +def create_html_story(markdown_content: str, output_path: Path, project_name: str): + """Converts a markdown story into a styled HTML file.""" + + html_body = markdown2.markdown(markdown_content, extras=["fenced-code-blocks", "tables"]) + + css_style = """ + body { + font-family: 'Georgia', serif; + line-height: 1.6; + color: #333; + max-width: 800px; + margin: 40px auto; + padding: 20px; + background-color: #f9f9f9; + border-left: 2px solid #ddd; + } + h1, h2, h3 { + font-family: 'Helvetica Neue', sans-serif; + color: #2c3e50; + border-bottom: 1px solid #eaecef; + padding-bottom: 0.3em; + } + h1 { + font-size: 2.5em; + text-align: center; + border-bottom: none; + } + h2 { + font-size: 1.75em; + } + p { + margin-bottom: 1em; + } + code { + background-color: #ecf0f1; + padding: 0.2em 0.4em; + border-radius: 3px; + font-family: 'Courier New', monospace; + } + pre { + background-color: #2c3e50; + color: #ecf0f1; + padding: 1em; + border-radius: 5px; + overflow-x: auto; + } + pre code { + background-color: transparent; + padding: 0; + } + """ + + html_template = f""" + + +
+ + +