git-alchemist 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- git_alchemist-1.0.0.dist-info/METADATA +99 -0
- git_alchemist-1.0.0.dist-info/RECORD +17 -0
- git_alchemist-1.0.0.dist-info/WHEEL +5 -0
- git_alchemist-1.0.0.dist-info/entry_points.txt +2 -0
- git_alchemist-1.0.0.dist-info/licenses/LICENSE +21 -0
- git_alchemist-1.0.0.dist-info/top_level.txt +1 -0
- src/architect.py +149 -0
- src/audit.py +61 -0
- src/cli.py +84 -0
- src/committer.py +70 -0
- src/core.py +59 -0
- src/issue_gen.py +76 -0
- src/profile_gen.py +197 -0
- src/promote.py +74 -0
- src/repo_tools.py +111 -0
- src/sage.py +71 -0
- src/utils.py +47 -0
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: git-alchemist
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: A unified AI stack to optimize, describe, and architect your GitHub repositories.
|
|
5
|
+
Author: abduznik
|
|
6
|
+
License: MIT
|
|
7
|
+
Requires-Python: >=3.10
|
|
8
|
+
Description-Content-Type: text/markdown
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Requires-Dist: google-genai
|
|
11
|
+
Requires-Dist: rich
|
|
12
|
+
Requires-Dist: python-dotenv
|
|
13
|
+
Requires-Dist: requests
|
|
14
|
+
Dynamic: license-file
|
|
15
|
+
|
|
16
|
+
# Git-Alchemist ⚗️
|
|
17
|
+
|
|
18
|
+
**Git-Alchemist ⚗️** is a unified AI-powered CLI tool for automating GitHub repository management. It consolidates multiple technical utilities into a single, intelligent system powered by Google's Gemini 3 and Gemma 3 models.
|
|
19
|
+
|
|
20
|
+
### 🌐 [Visit the Official Site](https://abduznik.github.io/Git-Alchemist/)
|
|
21
|
+
|
|
22
|
+
---
|
|
23
|
+
|
|
24
|
+
## Features
|
|
25
|
+
|
|
26
|
+
* **Smart Profile Generator:** Intelligently generates or updates your GitHub Profile README.
|
|
27
|
+
* **Topic Generator:** Auto-tag your repositories with AI-suggested topics for better discoverability.
|
|
28
|
+
* **Description Refiner:** Automatically generates repository descriptions by analyzing your README content.
|
|
29
|
+
* **Issue Drafter:** Translates loose ideas into structured, technical GitHub Issue drafts.
|
|
30
|
+
* **Architect (Scaffold):** Generates and executes project scaffolding commands in a safe, temporary workspace.
|
|
31
|
+
* **Fix & Explain:** Apply AI-powered patches to specific files or get concise technical explanations for complex code.
|
|
32
|
+
* **Gold Score Audit:** Measure your repository's professional quality and health.
|
|
33
|
+
* **The Sage:** Contextual codebase chat to answer deep technical questions about your code.
|
|
34
|
+
* **Commit Alchemist:** Automated semantic commit message suggestions from staged changes.
|
|
35
|
+
|
|
36
|
+
## Model Tiers
|
|
37
|
+
|
|
38
|
+
Git-Alchemist features a dynamic fallback system to ensure you never hit a quota wall:
|
|
39
|
+
|
|
40
|
+
* **Fast Mode (Default):** Utilizes **Gemma 3 (27B)** and **Gemini 3 Flash**. Optimized for speed and high-volume tasks.
|
|
41
|
+
* **Smart Mode (`--smart`):** Utilizes **Gemini 3 Pro** and **Gemini 2.5 Pro**. Optimized for complex architecture and deep code analysis.
|
|
42
|
+
|
|
43
|
+
## Installation
|
|
44
|
+
|
|
45
|
+
1. **Clone the repository:**
|
|
46
|
+
```bash
|
|
47
|
+
git clone https://github.com/abduznik/Git-Alchemist.git
|
|
48
|
+
cd Git-Alchemist
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
2. **Install as a Global Library:**
|
|
52
|
+
```bash
|
|
53
|
+
pip install .
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
3. **Set up your Environment:**
|
|
57
|
+
Create a `.env` file in the directory or export it in your shell:
|
|
58
|
+
```env
|
|
59
|
+
GEMINI_API_KEY=your_actual_api_key_here
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
## Usage
|
|
63
|
+
|
|
64
|
+
Once installed, you can run the `alchemist` command from **any directory**:
|
|
65
|
+
|
|
66
|
+
```bash
|
|
67
|
+
# Audit a repository
|
|
68
|
+
alchemist audit
|
|
69
|
+
|
|
70
|
+
# Optimize repository topics
|
|
71
|
+
alchemist topics
|
|
72
|
+
|
|
73
|
+
# Generate semantic commit messages
|
|
74
|
+
alchemist commit
|
|
75
|
+
|
|
76
|
+
# Ask the Sage a question
|
|
77
|
+
alchemist sage "How does the audit scoring work?"
|
|
78
|
+
|
|
79
|
+
# Scaffold a new project (Safe Mode)
|
|
80
|
+
alchemist scaffold "A FastAPI backend with a React frontend" --smart
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
## Requirements
|
|
84
|
+
|
|
85
|
+
* Python 3.10+
|
|
86
|
+
* GitHub CLI (`gh`) installed and authenticated (`gh auth login`).
|
|
87
|
+
* A Google Gemini API Key.
|
|
88
|
+
|
|
89
|
+
## Migration Note
|
|
90
|
+
|
|
91
|
+
This tool replaces and consolidates the following legacy scripts:
|
|
92
|
+
* `AI-Gen-Profile`
|
|
93
|
+
* `AI-Gen-Topics`
|
|
94
|
+
* `AI-Gen-Description`
|
|
95
|
+
* `AI-Gen-Issue`
|
|
96
|
+
* `Ai-Pro-Arch`
|
|
97
|
+
|
|
98
|
+
---
|
|
99
|
+
*Created by [abduznik](https://github.com/abduznik)*
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
git_alchemist-1.0.0.dist-info/licenses/LICENSE,sha256=m0_AWYEhbGuv2DeqHpVotJvfJSbWafyGQwivleB3tUI,1065
|
|
2
|
+
src/architect.py,sha256=zt9nqjeLJhj_98rT5GHDH1rj0s0-j1CQav7NbVPPkQA,5481
|
|
3
|
+
src/audit.py,sha256=fuIuoxRk1yC4-2vy2qqgz1EkTavQvyHHFRxuJQjpc3s,2788
|
|
4
|
+
src/cli.py,sha256=-_NksBB4jG2NKZmg-5OYfkPbqIQKhOKFORfdWiu0czQ,3688
|
|
5
|
+
src/committer.py,sha256=O9KFavCgJa8mbsFGZoLBdtMEccwlN7yKNuaxhPtdQ-c,2249
|
|
6
|
+
src/core.py,sha256=mwz5QgTIyIMKVHvfcP2Ndol_DHdZz5azx-zT_FE3VOY,1806
|
|
7
|
+
src/issue_gen.py,sha256=Sdn2DKnihWIDwRJIRmrql-9q8mqLy7LscEuk4celt0c,2474
|
|
8
|
+
src/profile_gen.py,sha256=i6N-gTE0rM1hIxWfN8sEBLItm1gu2_6xE94YaeHRa7g,6772
|
|
9
|
+
src/promote.py,sha256=nSmFywPI5mmHJO15dTpwZ2TO_IZT9w04XOJWF3BE0xM,3224
|
|
10
|
+
src/repo_tools.py,sha256=QGkCDYRBsl4g1DAecNJtOj91nLYZkJriadY3rCLfzE4,3929
|
|
11
|
+
src/sage.py,sha256=nxUEc146pKO_6TR4nBONKkRdREY08NEa2ir4BF--vX8,2227
|
|
12
|
+
src/utils.py,sha256=Ncz4vczfIXcQGgXaFa-8bL3SD3Sykg46yiHDScV8-CA,1187
|
|
13
|
+
git_alchemist-1.0.0.dist-info/METADATA,sha256=McyH9frbTwtCOm1B22_fpGZ012iDyPmisr8mk92oBEI,3219
|
|
14
|
+
git_alchemist-1.0.0.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
|
|
15
|
+
git_alchemist-1.0.0.dist-info/entry_points.txt,sha256=OcOmJI8khwZFsdRSUGGppfKdVfdGOKRRkwcSlZ-rJTI,43
|
|
16
|
+
git_alchemist-1.0.0.dist-info/top_level.txt,sha256=74rtVfumQlgAPzR5_2CgYN24MB0XARCg0t-gzk6gTrM,4
|
|
17
|
+
git_alchemist-1.0.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 abduznik
|
|
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.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
src
|
src/architect.py
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import json
|
|
3
|
+
import shutil
|
|
4
|
+
import tempfile
|
|
5
|
+
import subprocess
|
|
6
|
+
from rich.console import Console
|
|
7
|
+
from rich.prompt import Confirm
|
|
8
|
+
from .core import generate_content
|
|
9
|
+
from .utils import run_shell
|
|
10
|
+
|
|
11
|
+
console = Console()
|
|
12
|
+
|
|
13
|
+
def scaffold_project(instruction, mode="fast"):
|
|
14
|
+
"""
|
|
15
|
+
Generates shell commands to scaffold a project in a temporary directory.
|
|
16
|
+
"""
|
|
17
|
+
console.print(f"[cyan]Architecting solution for: {instruction} ({mode} mode)...[/cyan]")
|
|
18
|
+
|
|
19
|
+
# Create temp env
|
|
20
|
+
temp_dir = tempfile.mkdtemp(prefix="git_alchemist_scaffold_")
|
|
21
|
+
console.print(f"[gray]Created temporary workspace: {temp_dir}[/gray]")
|
|
22
|
+
|
|
23
|
+
prompt = f"""
|
|
24
|
+
Task: Project Scaffolding.
|
|
25
|
+
User Goal: "{instruction}"
|
|
26
|
+
Operating System: Linux/Unix (Termux)
|
|
27
|
+
Constraint: Return ONLY a JSON object with a single key "commands" containing an array of shell strings.
|
|
28
|
+
The commands should assume they are running INSIDE the project root.
|
|
29
|
+
Example: {{"commands": ["mkdir src", "touch src/main.py", "echo 'print(1)' > src/main.py"]}}
|
|
30
|
+
Do NOT use markdown blocks.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
result = generate_content(prompt, mode=mode)
|
|
34
|
+
if not result:
|
|
35
|
+
shutil.rmtree(temp_dir)
|
|
36
|
+
return
|
|
37
|
+
|
|
38
|
+
try:
|
|
39
|
+
clean_result = result.replace("```json", "").replace("```", "").strip()
|
|
40
|
+
data = json.loads(clean_result)
|
|
41
|
+
commands = data.get("commands", [])
|
|
42
|
+
|
|
43
|
+
console.print("[green]Generated Plan:[/green]")
|
|
44
|
+
for cmd in commands:
|
|
45
|
+
console.print(f" > {cmd}")
|
|
46
|
+
|
|
47
|
+
if Confirm.ask("Execute these commands in the temporary workspace?"):
|
|
48
|
+
# Execute in temp dir
|
|
49
|
+
cwd = os.getcwd()
|
|
50
|
+
os.chdir(temp_dir)
|
|
51
|
+
try:
|
|
52
|
+
for cmd in commands:
|
|
53
|
+
console.print(f"[cyan]Running:[/cyan] {cmd}")
|
|
54
|
+
run_shell(cmd)
|
|
55
|
+
|
|
56
|
+
console.print("[green]Scaffolding complete in temporary workspace.[/green]")
|
|
57
|
+
console.print(f"[gray]Contents of {temp_dir}:[/gray]")
|
|
58
|
+
run_shell("ls -R")
|
|
59
|
+
|
|
60
|
+
if Confirm.ask("Keep these files? (Moves them to current directory)"):
|
|
61
|
+
# Move files from temp_dir to cwd
|
|
62
|
+
# We iterate over items in temp_dir and move them
|
|
63
|
+
for item in os.listdir(temp_dir):
|
|
64
|
+
s = os.path.join(temp_dir, item)
|
|
65
|
+
d = os.path.join(cwd, item)
|
|
66
|
+
if os.path.exists(d):
|
|
67
|
+
console.print(f"[yellow]Warning:[/yellow] {item} already exists in current directory. Skipping.")
|
|
68
|
+
else:
|
|
69
|
+
shutil.move(s, d)
|
|
70
|
+
console.print("[green]Files moved successfully.[/green]")
|
|
71
|
+
else:
|
|
72
|
+
console.print("[yellow]Discarding workspace.[/yellow]")
|
|
73
|
+
|
|
74
|
+
except Exception as e:
|
|
75
|
+
console.print(f"[red]Execution failed:[/red] {e}")
|
|
76
|
+
finally:
|
|
77
|
+
os.chdir(cwd)
|
|
78
|
+
|
|
79
|
+
except json.JSONDecodeError:
|
|
80
|
+
console.print(f"[red]Failed to parse AI response:[/red] {result}")
|
|
81
|
+
finally:
|
|
82
|
+
if os.path.exists(temp_dir):
|
|
83
|
+
shutil.rmtree(temp_dir)
|
|
84
|
+
console.print("[gray]Temporary workspace cleaned up.[/gray]")
|
|
85
|
+
|
|
86
|
+
def fix_code(file_path, instruction, mode="fast"):
|
|
87
|
+
"""
|
|
88
|
+
Reads a file, applies an AI fix, and optionally creates a PR.
|
|
89
|
+
"""
|
|
90
|
+
if not os.path.exists(file_path):
|
|
91
|
+
console.print(f"[red]File not found:[/red] {file_path}")
|
|
92
|
+
return
|
|
93
|
+
|
|
94
|
+
console.print(f"[cyan]Reading {file_path} ({mode} mode)...[/cyan]")
|
|
95
|
+
with open(file_path, "r", encoding="utf-8") as f:
|
|
96
|
+
content = f.read()
|
|
97
|
+
|
|
98
|
+
prompt = f"""
|
|
99
|
+
Task: Fix/Modify Code.
|
|
100
|
+
User Instructions:
|
|
101
|
+
'''
|
|
102
|
+
{instruction}
|
|
103
|
+
'''
|
|
104
|
+
|
|
105
|
+
Target File Content:
|
|
106
|
+
'''
|
|
107
|
+
{content}
|
|
108
|
+
'''
|
|
109
|
+
|
|
110
|
+
Goal: Return ONLY the complete, corrected file content based on the User Instructions. Do not use markdown blocks.
|
|
111
|
+
"""
|
|
112
|
+
|
|
113
|
+
console.print(f"[magenta]Consulting Gemini ({mode} mode)...[/magenta]")
|
|
114
|
+
result = generate_content(prompt, mode=mode)
|
|
115
|
+
if not result:
|
|
116
|
+
return
|
|
117
|
+
|
|
118
|
+
clean_result = result.replace("```python", "").replace("```", "").strip() # Generic cleanup
|
|
119
|
+
|
|
120
|
+
# Backup
|
|
121
|
+
backup_path = f"{file_path}.bak"
|
|
122
|
+
shutil.copy(file_path, backup_path)
|
|
123
|
+
console.print(f"[gray]Backup created: {backup_path}[/gray]")
|
|
124
|
+
|
|
125
|
+
with open(file_path, "w", encoding="utf-8") as f:
|
|
126
|
+
f.write(clean_result)
|
|
127
|
+
|
|
128
|
+
console.print(f"[green]File updated.[/green]")
|
|
129
|
+
|
|
130
|
+
if Confirm.ask("Create a PR for this fix?"):
|
|
131
|
+
# This assumes we are in a git repo
|
|
132
|
+
try:
|
|
133
|
+
branch_name = f"fix/ai-{os.urandom(4).hex()}"
|
|
134
|
+
run_shell(f"git checkout -b {branch_name}")
|
|
135
|
+
run_shell(f"git add {file_path}")
|
|
136
|
+
run_shell(f'git commit -m "AI Fix: {instruction}"')
|
|
137
|
+
run_shell(f"git push -u origin {branch_name}")
|
|
138
|
+
run_shell(f'gh pr create --title "AI Fix: {instruction}" --body "Automated fix." --web')
|
|
139
|
+
except Exception as e:
|
|
140
|
+
console.print(f"[red]PR creation failed:[/red] {e}")
|
|
141
|
+
|
|
142
|
+
def explain_code(context, mode="fast"):
|
|
143
|
+
"""
|
|
144
|
+
Explains a concept or code snippet.
|
|
145
|
+
"""
|
|
146
|
+
prompt = f"Task: Explain Concept/Code. Context: '{context}'. Keep it concise and technical."
|
|
147
|
+
result = generate_content(prompt, mode=mode)
|
|
148
|
+
if result:
|
|
149
|
+
console.print(f"\n[bold white]--- Explanation ---[/bold white]\n{result}\n[bold white]-------------------[/bold white]")
|
src/audit.py
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import json
|
|
3
|
+
from rich.console import Console
|
|
4
|
+
from rich.table import Table
|
|
5
|
+
from rich.progress import Progress
|
|
6
|
+
from .utils import run_shell, check_gh_auth
|
|
7
|
+
|
|
8
|
+
console = Console()
|
|
9
|
+
|
|
10
|
+
def run_audit(user=None, repo_name=None):
|
|
11
|
+
"""
|
|
12
|
+
Audits a repository for 'Gold Standard' items and returns a score.
|
|
13
|
+
"""
|
|
14
|
+
username = user or check_gh_auth()
|
|
15
|
+
if not username:
|
|
16
|
+
console.print("[red]Not authenticated with gh CLI.[/red]")
|
|
17
|
+
return
|
|
18
|
+
|
|
19
|
+
# Use current directory if no repo specified
|
|
20
|
+
target_repo = repo_name or run_shell("gh repo view --json name --jq .name", check=False)
|
|
21
|
+
if not target_repo:
|
|
22
|
+
console.print("[yellow]Not inside a Git repository. Auditing current directory files only.[/yellow]")
|
|
23
|
+
repo_data = {}
|
|
24
|
+
else:
|
|
25
|
+
console.print(f"[cyan]Auditing Repository:[/cyan] [bold]{username}/{target_repo}[/bold]")
|
|
26
|
+
repo_data_raw = run_shell(f"gh repo view {username}/{target_repo} --json description,repositoryTopics,licenseInfo", check=False)
|
|
27
|
+
repo_data = json.loads(repo_data_raw) if repo_data_raw else {}
|
|
28
|
+
|
|
29
|
+
checks = {
|
|
30
|
+
"README.md": {"score": 20, "found": os.path.exists("README.md")},
|
|
31
|
+
"LICENSE": {"score": 10, "found": bool(repo_data.get("licenseInfo")) or any(os.path.exists(f) for f in ["LICENSE", "LICENSE.md", "LICENSE.txt"])},
|
|
32
|
+
"CONTRIBUTING.md": {"score": 10, "found": any(os.path.exists(f) for f in ["CONTRIBUTING.md", "CONTRIBUTING"])},
|
|
33
|
+
"Metadata: Description": {"score": 20, "found": bool(repo_data.get("description"))},
|
|
34
|
+
"Metadata: Topics": {"score": 20, "found": len(repo_data.get("repositoryTopics", [])) >= 3},
|
|
35
|
+
"CI/CD: GitHub Actions": {"score": 20, "found": os.path.exists(".github/workflows")},
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
total_score = sum(c["score"] for c in checks.values() if c["found"])
|
|
39
|
+
|
|
40
|
+
# Display Table
|
|
41
|
+
table = Table(title=f"Repository Audit: {target_repo or 'Local'}", border_style="blue")
|
|
42
|
+
table.add_column("Criterion", style="cyan")
|
|
43
|
+
table.add_column("Status", justify="center")
|
|
44
|
+
table.add_column("Weight", justify="right")
|
|
45
|
+
|
|
46
|
+
for name, data in checks.items():
|
|
47
|
+
status = "[green]GOLD[/green]" if data["found"] else "[red]LEAD[/red]"
|
|
48
|
+
table.add_row(name, status, f"{data['score']}")
|
|
49
|
+
|
|
50
|
+
console.print(table)
|
|
51
|
+
|
|
52
|
+
# Final Score Display
|
|
53
|
+
color = "green" if total_score >= 80 else "yellow" if total_score >= 50 else "red"
|
|
54
|
+
console.print(f"\n[bold]Transmutation Score:[/bold] [{color}]{total_score}%[/{color}]")
|
|
55
|
+
|
|
56
|
+
if total_score < 100:
|
|
57
|
+
console.print(f"\n[italic gray]The Alchemist suggests adding the missing components to reach 100% Gold.[/italic gray]")
|
|
58
|
+
else:
|
|
59
|
+
console.print(f"\n[bold yellow]✨ Pure Gold! This repository is optimized for the community.[/bold yellow]")
|
|
60
|
+
|
|
61
|
+
return total_score
|
src/cli.py
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
import sys
|
|
3
|
+
from rich.console import Console
|
|
4
|
+
from .profile_gen import generate_profile
|
|
5
|
+
from .architect import scaffold_project, fix_code, explain_code
|
|
6
|
+
from .repo_tools import optimize_topics, generate_descriptions
|
|
7
|
+
from .issue_gen import create_issue
|
|
8
|
+
from .audit import run_audit
|
|
9
|
+
from .sage import ask_sage
|
|
10
|
+
from .committer import suggest_commits
|
|
11
|
+
|
|
12
|
+
console = Console()
|
|
13
|
+
|
|
14
|
+
def main():
|
|
15
|
+
parser = argparse.ArgumentParser(description="Git-Alchemist: AI-powered Git Operations")
|
|
16
|
+
parser.add_argument("--smart", action="store_true", help="Use high-end Gemini Pro models (slower/lower quota)")
|
|
17
|
+
subparsers = parser.add_subparsers(dest="command", help="Available commands")
|
|
18
|
+
|
|
19
|
+
# Commit Command
|
|
20
|
+
commit_parser = subparsers.add_parser("commit", help="Generate semantic commit messages from changes")
|
|
21
|
+
|
|
22
|
+
# Sage Command
|
|
23
|
+
sage_parser = subparsers.add_parser("sage", help="Ask the Sage questions about your codebase")
|
|
24
|
+
sage_parser.add_argument("question", help="The question about your code")
|
|
25
|
+
|
|
26
|
+
# Audit Command
|
|
27
|
+
audit_parser = subparsers.add_parser("audit", help="Check repository 'Gold' status and metadata")
|
|
28
|
+
audit_parser.add_argument("--repo", help="Specific repository name to audit")
|
|
29
|
+
|
|
30
|
+
# Profile Generator Command
|
|
31
|
+
profile_parser = subparsers.add_parser("profile", help="Generate or update GitHub Profile README")
|
|
32
|
+
profile_parser.add_argument("--force", action="store_true", help="Force full regeneration")
|
|
33
|
+
profile_parser.add_argument("--user", help="GitHub username (optional, detects automatically)")
|
|
34
|
+
|
|
35
|
+
# Repo Tools
|
|
36
|
+
topics_parser = subparsers.add_parser("topics", help="Optimize repository topics/tags")
|
|
37
|
+
topics_parser.add_argument("--user", help="GitHub username")
|
|
38
|
+
|
|
39
|
+
describe_parser = subparsers.add_parser("describe", help="Generate missing repository descriptions")
|
|
40
|
+
describe_parser.add_argument("--user", help="GitHub username")
|
|
41
|
+
|
|
42
|
+
# Issue Generator
|
|
43
|
+
issue_parser = subparsers.add_parser("issue", help="Draft a technical issue from an idea")
|
|
44
|
+
issue_parser.add_argument("idea", help="The feature or bug idea")
|
|
45
|
+
|
|
46
|
+
# Architect Commands
|
|
47
|
+
scaffold_parser = subparsers.add_parser("scaffold", help="Generate a new project structure (safe mode)")
|
|
48
|
+
scaffold_parser.add_argument("instruction", help="What to build (e.g., 'A Flask app with Docker')")
|
|
49
|
+
|
|
50
|
+
fix_parser = subparsers.add_parser("fix", help="Modify a file using AI")
|
|
51
|
+
fix_parser.add_argument("file", help="Path to the file to fix")
|
|
52
|
+
fix_parser.add_argument("instruction", help="What to change")
|
|
53
|
+
|
|
54
|
+
explain_parser = subparsers.add_parser("explain", help="Explain code or concepts")
|
|
55
|
+
explain_parser.add_argument("context", help="The code or concept to explain")
|
|
56
|
+
|
|
57
|
+
args = parser.parse_args()
|
|
58
|
+
mode = "smart" if args.smart else "fast"
|
|
59
|
+
|
|
60
|
+
if args.command == "profile":
|
|
61
|
+
generate_profile(args.user, args.force, mode=mode)
|
|
62
|
+
elif args.command == "topics":
|
|
63
|
+
optimize_topics(args.user, mode=mode)
|
|
64
|
+
elif args.command == "describe":
|
|
65
|
+
generate_descriptions(args.user, mode=mode)
|
|
66
|
+
elif args.command == "issue":
|
|
67
|
+
create_issue(args.idea, mode=mode)
|
|
68
|
+
elif args.command == "scaffold":
|
|
69
|
+
scaffold_project(args.instruction, mode=mode)
|
|
70
|
+
elif args.command == "fix":
|
|
71
|
+
fix_code(args.file, args.instruction, mode=mode)
|
|
72
|
+
elif args.command == "explain":
|
|
73
|
+
explain_code(args.context, mode=mode)
|
|
74
|
+
elif args.command == "audit":
|
|
75
|
+
run_audit(repo_name=args.repo)
|
|
76
|
+
elif args.command == "sage":
|
|
77
|
+
ask_sage(args.question, mode=mode)
|
|
78
|
+
elif args.command == "commit":
|
|
79
|
+
suggest_commits(mode=mode)
|
|
80
|
+
else:
|
|
81
|
+
parser.print_help()
|
|
82
|
+
|
|
83
|
+
if __name__ == "__main__":
|
|
84
|
+
main()
|
src/committer.py
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from rich.console import Console
|
|
3
|
+
from rich.prompt import Prompt
|
|
4
|
+
from .core import generate_content
|
|
5
|
+
from .utils import run_shell
|
|
6
|
+
|
|
7
|
+
console = Console()
|
|
8
|
+
|
|
9
|
+
def get_staged_diff():
|
|
10
|
+
"""Returns the diff of staged changes."""
|
|
11
|
+
return run_shell("git diff --cached", check=False)
|
|
12
|
+
|
|
13
|
+
def suggest_commits(mode="fast"):
|
|
14
|
+
"""
|
|
15
|
+
Analyzes staged changes and suggests 3 semantic commit messages.
|
|
16
|
+
"""
|
|
17
|
+
diff = get_staged_diff()
|
|
18
|
+
|
|
19
|
+
if not diff:
|
|
20
|
+
console.print("[yellow]No staged changes found.[/yellow]")
|
|
21
|
+
if Prompt.ask("Stage all changes now? (git add .)", choices=["y", "n"], default="y") == "y":
|
|
22
|
+
run_shell("git add .")
|
|
23
|
+
diff = get_staged_diff()
|
|
24
|
+
else:
|
|
25
|
+
return
|
|
26
|
+
|
|
27
|
+
console.print("[cyan]Analyzing changes for the perfect commit message...[/cyan]")
|
|
28
|
+
|
|
29
|
+
prompt = f"""
|
|
30
|
+
Task: Suggest 3 professional, semantic commit messages based on the git diff below.
|
|
31
|
+
Format: <type>(<scope>): <subject>
|
|
32
|
+
Types: feat, fix, docs, style, refactor, test, chore
|
|
33
|
+
|
|
34
|
+
DIFF:
|
|
35
|
+
'''
|
|
36
|
+
{diff[:5000]}
|
|
37
|
+
'''
|
|
38
|
+
|
|
39
|
+
Instructions:
|
|
40
|
+
1. Return ONLY a numbered list of 3 options.
|
|
41
|
+
2. No explanations or extra text.
|
|
42
|
+
3. Ensure they are concise and accurate.
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
result = generate_content(prompt, mode=mode)
|
|
46
|
+
if not result:
|
|
47
|
+
return
|
|
48
|
+
|
|
49
|
+
options = [line.strip() for line in result.strip().split("\n") if line.strip()]
|
|
50
|
+
# Remove numbering if AI added it (e.g., "1. feat: ...")
|
|
51
|
+
clean_options = []
|
|
52
|
+
for opt in options:
|
|
53
|
+
# Match "1. ", "1) ", etc.
|
|
54
|
+
import re
|
|
55
|
+
clean_opt = re.sub(r'^\d+[\.\)]\s*', '', opt).strip()
|
|
56
|
+
if clean_opt:
|
|
57
|
+
clean_options.append(clean_opt)
|
|
58
|
+
|
|
59
|
+
console.print("\n[bold green]Recommended Transmutations:[/bold green]")
|
|
60
|
+
for i, opt in enumerate(clean_options, 1):
|
|
61
|
+
console.print(f" [bold cyan]{i}.[/bold cyan] {opt}")
|
|
62
|
+
|
|
63
|
+
choice = Prompt.ask("\nSelect a message to commit (or 'c' to cancel)", choices=[str(i) for i in range(1, len(clean_options)+1)] + ["c"])
|
|
64
|
+
|
|
65
|
+
if choice != "c":
|
|
66
|
+
selected_msg = clean_options[int(choice)-1]
|
|
67
|
+
console.print(f"[green]Committing with message:[/green] {selected_msg}")
|
|
68
|
+
run_shell(f'git commit -m "{selected_msg}"')
|
|
69
|
+
else:
|
|
70
|
+
console.print("[yellow]Commit aborted.[/yellow]")
|
src/core.py
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import sys
|
|
3
|
+
import time
|
|
4
|
+
from google import genai
|
|
5
|
+
from dotenv import load_dotenv
|
|
6
|
+
from rich.console import Console
|
|
7
|
+
|
|
8
|
+
console = Console()
|
|
9
|
+
|
|
10
|
+
# Define model tiers based on your available list
|
|
11
|
+
SMART_MODELS = [
|
|
12
|
+
"gemini-3-pro-preview",
|
|
13
|
+
"gemini-2.5-pro",
|
|
14
|
+
"gemini-1.5-pro", # Fallback to stable if preview fails
|
|
15
|
+
]
|
|
16
|
+
|
|
17
|
+
FAST_MODELS = [
|
|
18
|
+
"gemma-3-27b-it",
|
|
19
|
+
"gemma-3-12b-it",
|
|
20
|
+
"gemini-3-flash-preview",
|
|
21
|
+
"gemini-2.0-flash",
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
def get_gemini_client():
|
|
25
|
+
load_dotenv()
|
|
26
|
+
api_key = os.getenv("GEMINI_API_KEY")
|
|
27
|
+
if not api_key:
|
|
28
|
+
console.print("[bold red]Error:[/bold red] GEMINI_API_KEY not found.")
|
|
29
|
+
sys.exit(1)
|
|
30
|
+
return genai.Client(api_key=api_key, http_options={'api_version':'v1alpha'})
|
|
31
|
+
|
|
32
|
+
def generate_content(prompt, mode="fast"):
|
|
33
|
+
"""
|
|
34
|
+
Generates content with automatic fallback.
|
|
35
|
+
Mode: 'fast' (Gemma/Flash) or 'smart' (Pro/3-Pro)
|
|
36
|
+
"""
|
|
37
|
+
client = get_gemini_client()
|
|
38
|
+
models = SMART_MODELS if mode == "smart" else FAST_MODELS
|
|
39
|
+
|
|
40
|
+
for model_name in models:
|
|
41
|
+
try:
|
|
42
|
+
console.print(f"[gray]Attempting with {model_name}...[/gray]")
|
|
43
|
+
response = client.models.generate_content(
|
|
44
|
+
model=model_name,
|
|
45
|
+
contents=prompt
|
|
46
|
+
)
|
|
47
|
+
if response and response.text:
|
|
48
|
+
return response.text
|
|
49
|
+
except Exception as e:
|
|
50
|
+
err_msg = str(e)
|
|
51
|
+
if "429" in err_msg or "RESOURCE_EXHAUSTED" in err_msg:
|
|
52
|
+
console.print(f"[yellow]Quota hit for {model_name}. Trying next...[/yellow]")
|
|
53
|
+
continue
|
|
54
|
+
else:
|
|
55
|
+
console.print(f"[red]Error with {model_name}:[/red] {err_msg}")
|
|
56
|
+
continue
|
|
57
|
+
|
|
58
|
+
console.print("[bold red]Critical:[/bold red] All models exhausted or failed.")
|
|
59
|
+
return None
|
src/issue_gen.py
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import tempfile
|
|
3
|
+
import os
|
|
4
|
+
from rich.console import Console
|
|
5
|
+
from .core import generate_content
|
|
6
|
+
from .utils import run_shell
|
|
7
|
+
|
|
8
|
+
console = Console()
|
|
9
|
+
|
|
10
|
+
def create_issue(idea, mode="fast"):
|
|
11
|
+
"""
|
|
12
|
+
Translates an idea into a technical GitHub issue.
|
|
13
|
+
"""
|
|
14
|
+
console.print(f"[cyan]Drafting technical issue for: {idea} ({mode} mode)...[/cyan]")
|
|
15
|
+
|
|
16
|
+
prompt = f"""
|
|
17
|
+
You are a Senior Tech Lead.
|
|
18
|
+
User Input: '{idea}'
|
|
19
|
+
TASK: Translate this into a technical implementation plan.
|
|
20
|
+
STRUCTURE:
|
|
21
|
+
- **Context**: 1 sentence explaining WHY.
|
|
22
|
+
- **Directives**: Bullet points of EXACTLY what to change.
|
|
23
|
+
OUTPUT FORMAT: Return ONLY a JSON object with keys: "title", "body", "label", "easy" (boolean).
|
|
24
|
+
No markdown blocks.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
result = generate_content(prompt, mode=mode)
|
|
28
|
+
if not result: return
|
|
29
|
+
|
|
30
|
+
try:
|
|
31
|
+
clean_json = result.replace("```json", "").replace("```", "").strip()
|
|
32
|
+
issue = json.loads(clean_json)
|
|
33
|
+
|
|
34
|
+
title = f"[DRAFT] {issue['title']}"
|
|
35
|
+
body = f"{issue['body']}\n\n> Automated by Git-Alchemist"
|
|
36
|
+
label = issue.get('label', 'enhancement')
|
|
37
|
+
|
|
38
|
+
console.print(f"[yellow]Uploading Draft: {title}[/yellow]")
|
|
39
|
+
|
|
40
|
+
# Create labels if they don't exist
|
|
41
|
+
run_shell('gh label create "automated" --color "505050" 2>/dev/null', check=False)
|
|
42
|
+
run_shell('gh label create "status: draft" --color "333333" 2>/dev/null', check=False)
|
|
43
|
+
run_shell(f'gh label create "{label}" 2>/dev/null', check=False)
|
|
44
|
+
|
|
45
|
+
with tempfile.NamedTemporaryFile(mode='w', suffix='.md', delete=False) as tf:
|
|
46
|
+
tf.write(body)
|
|
47
|
+
temp_name = tf.name
|
|
48
|
+
|
|
49
|
+
cmd = [
|
|
50
|
+
'gh',
|
|
51
|
+
'issue',
|
|
52
|
+
'create',
|
|
53
|
+
'--title',
|
|
54
|
+
f'"{title}"',
|
|
55
|
+
'--body-file',
|
|
56
|
+
f'"{temp_name}"',
|
|
57
|
+
'--label',
|
|
58
|
+
'"status: draft"',
|
|
59
|
+
'--label',
|
|
60
|
+
'"automated"',
|
|
61
|
+
'--label',
|
|
62
|
+
f'"{label}"'
|
|
63
|
+
]
|
|
64
|
+
|
|
65
|
+
if issue.get('easy'):
|
|
66
|
+
run_shell('gh label create "good first issue" --color "7057ff" 2>/dev/null', check=False)
|
|
67
|
+
cmd.extend(['--label', '"good first issue"'])
|
|
68
|
+
|
|
69
|
+
run_shell(" ".join(cmd))
|
|
70
|
+
os.unlink(temp_name)
|
|
71
|
+
|
|
72
|
+
console.print("[green]Success! Issue created as draft.[/green]")
|
|
73
|
+
|
|
74
|
+
except Exception as e:
|
|
75
|
+
console.print(f"[red]Failed to create issue:[/red] {e}")
|
|
76
|
+
console.print(f"[gray]Raw Output:[/gray] {result}")
|
src/profile_gen.py
ADDED
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import json
|
|
3
|
+
import tempfile
|
|
4
|
+
import shutil
|
|
5
|
+
import re
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from rich.console import Console
|
|
8
|
+
from rich.prompt import Confirm
|
|
9
|
+
from .core import generate_content
|
|
10
|
+
from .utils import run_shell, check_gh_auth, get_user_email
|
|
11
|
+
|
|
12
|
+
console = Console()
|
|
13
|
+
|
|
14
|
+
def fetch_repos(username):
|
|
15
|
+
"""
|
|
16
|
+
Fetches public repositories for the user.
|
|
17
|
+
"""
|
|
18
|
+
console.print("[cyan]Fetching repositories...[/cyan]")
|
|
19
|
+
cmd = 'gh repo list --visibility=public --limit 100 --json name,description,url,isPrivate,isArchived,stargazerCount'
|
|
20
|
+
try:
|
|
21
|
+
output = run_shell(cmd)
|
|
22
|
+
return json.loads(output)
|
|
23
|
+
except Exception as e:
|
|
24
|
+
console.print(f"[red]Failed to fetch repos:[/red] {e}")
|
|
25
|
+
return []
|
|
26
|
+
|
|
27
|
+
def filter_repos(repos, username, strategy="FULL_GEN", existing_content=""):
|
|
28
|
+
"""
|
|
29
|
+
Filters out junk, private, archived, and irrelevant repos (like Awesome lists).
|
|
30
|
+
"""
|
|
31
|
+
candidates = []
|
|
32
|
+
|
|
33
|
+
# Blocklist for low-value repos
|
|
34
|
+
junk_patterns = ["test", "export", "WPy64", "PROFILE_DRAFT.md", "temp", "awesome-"]
|
|
35
|
+
|
|
36
|
+
for r in repos:
|
|
37
|
+
name = r['name']
|
|
38
|
+
|
|
39
|
+
# Basic filters
|
|
40
|
+
if name == username: continue
|
|
41
|
+
if r.get('isPrivate'): continue
|
|
42
|
+
if r.get('isArchived'): continue
|
|
43
|
+
if any(p in name.lower() for p in junk_patterns): continue
|
|
44
|
+
if name.endswith(".exe"): continue
|
|
45
|
+
|
|
46
|
+
# Strategy filter
|
|
47
|
+
if strategy == "SMART_UPDATE":
|
|
48
|
+
# Check if name or URL exists in current profile content
|
|
49
|
+
if name in existing_content or r['url'] in existing_content:
|
|
50
|
+
continue
|
|
51
|
+
|
|
52
|
+
candidates.append(r)
|
|
53
|
+
|
|
54
|
+
return candidates
|
|
55
|
+
|
|
56
|
+
def generate_profile(username, force=False, mode="fast"):
|
|
57
|
+
"""
|
|
58
|
+
Main function to generate or update the profile.
|
|
59
|
+
"""
|
|
60
|
+
if not username:
|
|
61
|
+
username = check_gh_auth()
|
|
62
|
+
if not username:
|
|
63
|
+
console.print("[red]Not logged into GitHub CLI. Run 'gh auth login' first.[/red]")
|
|
64
|
+
return
|
|
65
|
+
|
|
66
|
+
console.print(f"[green]Authenticated as: {username}[/green]")
|
|
67
|
+
|
|
68
|
+
# Discovery
|
|
69
|
+
current_content = ""
|
|
70
|
+
strategy = "FULL_GEN"
|
|
71
|
+
|
|
72
|
+
try:
|
|
73
|
+
console.print("[cyan]Checking for existing profile...[/cyan]")
|
|
74
|
+
current_content = run_shell(f'gh api "repos/{username}/{username}/readme" --headers "Accept: application/vnd.github.raw"', check=False)
|
|
75
|
+
if current_content and len(current_content) > 200 and not force:
|
|
76
|
+
console.print("[green]Found existing robust profile. Switching to SMART_UPDATE.[/green]")
|
|
77
|
+
strategy = "SMART_UPDATE"
|
|
78
|
+
else:
|
|
79
|
+
console.print("[yellow]Profile basic or missing. Using FULL_GEN.[/yellow]")
|
|
80
|
+
except:
|
|
81
|
+
pass
|
|
82
|
+
|
|
83
|
+
# Fetch & Filter
|
|
84
|
+
repos = fetch_repos(username)
|
|
85
|
+
candidates = filter_repos(repos, username, strategy, current_content)
|
|
86
|
+
|
|
87
|
+
if not candidates:
|
|
88
|
+
console.print("[green]No new repositories to add.[/green]")
|
|
89
|
+
return
|
|
90
|
+
|
|
91
|
+
# Prompt Engineering
|
|
92
|
+
candidates_str = "\n".join([f"- Name: {r['name']}\n Desc: {r['description'] or ''}\n URL: {r['url']}" for r in candidates])
|
|
93
|
+
|
|
94
|
+
prompt = ""
|
|
95
|
+
if strategy == "SMART_UPDATE":
|
|
96
|
+
prompt = f"""
|
|
97
|
+
Task: Update a GitHub Profile README.
|
|
98
|
+
Existing Markdown:
|
|
99
|
+
'''
|
|
100
|
+
{current_content}
|
|
101
|
+
'''
|
|
102
|
+
|
|
103
|
+
New Projects to Add:
|
|
104
|
+
{candidates_str}
|
|
105
|
+
|
|
106
|
+
Instructions:
|
|
107
|
+
1. Integrate the 'New Projects' into the 'Existing Markdown' naturally.
|
|
108
|
+
2. Place them under appropriate categories.
|
|
109
|
+
3. PRESERVE THE EXISTING STRUCTURE: Do not delete existing sections, badges, or headers.
|
|
110
|
+
4. MANDATORY LINKING: Use the provided URL for each project in the format '- **[Name](URL)** - Description'.
|
|
111
|
+
5. MATCH THE STYLE: If the existing list uses bullets, stick to it.
|
|
112
|
+
6. STRICTLY NO EMOJIS in new additions.
|
|
113
|
+
7. Output the FULL updated Markdown content.
|
|
114
|
+
"""
|
|
115
|
+
else:
|
|
116
|
+
# Full Gen
|
|
117
|
+
prompt = f"""
|
|
118
|
+
Task: Generate a professional Project Showcase for a GitHub Profile.
|
|
119
|
+
Username: {username}
|
|
120
|
+
|
|
121
|
+
Projects List:
|
|
122
|
+
{candidates_str}
|
|
123
|
+
|
|
124
|
+
Instructions:
|
|
125
|
+
1. Group projects into 3-6 meaningful categories (e.g., '## AI & Automation', '## Hardware').
|
|
126
|
+
2. MANDATORY LINKING: Use the format '- **[Name](URL)** - Description'.
|
|
127
|
+
3. Use a clean, professional header at the top.
|
|
128
|
+
4. STRICTLY NO EMOJIS.
|
|
129
|
+
5. Output the FULL Markdown.
|
|
130
|
+
"""
|
|
131
|
+
|
|
132
|
+
console.print(f"[magenta]Generating content with Gemini ({mode} mode)...[/magenta]")
|
|
133
|
+
result = generate_content(prompt, mode=mode)
|
|
134
|
+
|
|
135
|
+
if not result:
|
|
136
|
+
return
|
|
137
|
+
|
|
138
|
+
# Post-processing
|
|
139
|
+
final_md = result.replace("```markdown", "").replace("```", "").strip()
|
|
140
|
+
|
|
141
|
+
# Ensure branding is preserved but clean
|
|
142
|
+
footer = "\n\n---\n*Generated by Git-Alchemist ⚗️*"
|
|
143
|
+
if "*Generated by Git-Alchemist*" not in final_md:
|
|
144
|
+
final_md += footer
|
|
145
|
+
|
|
146
|
+
# Save Draft
|
|
147
|
+
with open("PROFILE_DRAFT.md", "w", encoding="utf-8") as f:
|
|
148
|
+
f.write(final_md)
|
|
149
|
+
console.print(f"[magenta]Draft saved to PROFILE_DRAFT.md[/magenta]")
|
|
150
|
+
|
|
151
|
+
# Deploy (Strictly PR)
|
|
152
|
+
if Confirm.ask("Deploy these changes via Pull Request?"):
|
|
153
|
+
deploy_profile(username, final_md)
|
|
154
|
+
|
|
155
|
+
def deploy_profile(username, content):
|
|
156
|
+
"""
|
|
157
|
+
Clones the profile repo, updates README, and opens a PR.
|
|
158
|
+
"""
|
|
159
|
+
temp_dir = tempfile.mkdtemp(prefix="git_alchemist_")
|
|
160
|
+
try:
|
|
161
|
+
console.print("[cyan]Cloning profile repository...[/cyan]")
|
|
162
|
+
run_shell(f'gh repo clone {username}/{username} {temp_dir}')
|
|
163
|
+
|
|
164
|
+
# Determine the target dir (sometimes gh clones into a subdir, sometimes not)
|
|
165
|
+
repo_dir = Path(temp_dir)
|
|
166
|
+
if (repo_dir / username).exists():
|
|
167
|
+
repo_dir = repo_dir / username
|
|
168
|
+
|
|
169
|
+
# Write file
|
|
170
|
+
readme_path = repo_dir / "README.md"
|
|
171
|
+
with open(readme_path, "w", encoding="utf-8") as f:
|
|
172
|
+
f.write(content)
|
|
173
|
+
|
|
174
|
+
# Git ops
|
|
175
|
+
cwd = os.getcwd()
|
|
176
|
+
os.chdir(repo_dir)
|
|
177
|
+
|
|
178
|
+
branch_name = f"profile-update-{os.urandom(2).hex()}"
|
|
179
|
+
run_shell(f'git checkout -b {branch_name}')
|
|
180
|
+
|
|
181
|
+
user_email = get_user_email() or f"{username}@users.noreply.github.com"
|
|
182
|
+
run_shell(f'git config user.name "{username}"')
|
|
183
|
+
run_shell(f'git config user.email "{user_email}"')
|
|
184
|
+
|
|
185
|
+
run_shell('git add README.md')
|
|
186
|
+
run_shell(f'git commit -m "docs: Update profile README via Git-Alchemist"')
|
|
187
|
+
run_shell(f'git push -u origin {branch_name} --force')
|
|
188
|
+
|
|
189
|
+
console.print("[green]Opening PR...[/green]")
|
|
190
|
+
run_shell(f'gh pr create --title "AI Profile Update" --body "Automated profile update generated by Git-Alchemist. Added missing repo links and organized new projects."')
|
|
191
|
+
|
|
192
|
+
os.chdir(cwd)
|
|
193
|
+
|
|
194
|
+
except Exception as e:
|
|
195
|
+
console.print(f"[red]Deployment failed:[/red] {e}")
|
|
196
|
+
finally:
|
|
197
|
+
shutil.rmtree(temp_dir, ignore_errors=True)
|
src/promote.py
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import requests
|
|
3
|
+
from rich.console import Console
|
|
4
|
+
|
|
5
|
+
console = Console()
|
|
6
|
+
|
|
7
|
+
STORY = """
|
|
8
|
+
I’ve been working on this tool for a couple of months now. It actually started as a
|
|
9
|
+
collection of loose PowerShell scripts I wrote to handle my own GitHub maintenance
|
|
10
|
+
(updating my profile, tagging repos, etc.).
|
|
11
|
+
|
|
12
|
+
I finally decided to combine them all into a unified project. I ported everything to
|
|
13
|
+
Python and built Git-Alchemist. It uses Gemini 3 and Gemma 3 to automate the boring
|
|
14
|
+
parts of Git management—like writing descriptions or scaffolding project structures
|
|
15
|
+
in safe, temporary workspaces. I'm really happy with how the consolidation turned out
|
|
16
|
+
and wanted to share it with the community.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
def post_to_devto(api_key):
|
|
20
|
+
"""Automates posting to Dev.to"""
|
|
21
|
+
url = "https://dev.to/api/articles"
|
|
22
|
+
headers = {"api-key": api_key}
|
|
23
|
+
|
|
24
|
+
article = {
|
|
25
|
+
"article": {
|
|
26
|
+
"title": "I consolidated my Git automation scripts into a unified AI stack: Git-Alchemist",
|
|
27
|
+
"published": True,
|
|
28
|
+
"body_markdown": f"# Git-Alchemist ⚗️\n\n{STORY}\n\nCheck it out here: [https://github.com/abduznik/Git-Alchemist](https://github.com/abduznik/Git-Alchemist)\n\nLanding Page: [https://abduznik.github.io/Git-Alchemist/](https://abduznik.github.io/Git-Alchemist/)",
|
|
29
|
+
"tags": ["python", "github", "ai", "opensource"],
|
|
30
|
+
"series": "Git Automation"
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
try:
|
|
35
|
+
response = requests.post(url, json=article, headers=headers)
|
|
36
|
+
if response.status_code == 201:
|
|
37
|
+
console.print("[green]Successfully posted to Dev.to![/green]")
|
|
38
|
+
else:
|
|
39
|
+
console.print(f"[red]Failed to post to Dev.to:[/red] {response.text}")
|
|
40
|
+
except Exception as e:
|
|
41
|
+
console.print(f"[red]Error:[/red] {e}")
|
|
42
|
+
|
|
43
|
+
def generate_manual_submissions():
|
|
44
|
+
"""Generates text for manual form submissions"""
|
|
45
|
+
submissions = {
|
|
46
|
+
"Hackaday (https://hackaday.com/submit-a-tip/)": {
|
|
47
|
+
"Subject": "AI-Powered GitHub Maintenance: Git-Alchemist",
|
|
48
|
+
"Message": f"Hi Hackaday! I thought you might find this interesting. {STORY}\nRepo: https://github.com/abduznik/Git-Alchemist"
|
|
49
|
+
},
|
|
50
|
+
"TLDR Newsletter (https://tldr.tech/submit)": {
|
|
51
|
+
"Link": "https://github.com/abduznik/Git-Alchemist",
|
|
52
|
+
"Description": "A unified AI stack for GitHub repository management and project scaffolding."
|
|
53
|
+
},
|
|
54
|
+
"Console.dev (Newsletter for Dev Tools)": {
|
|
55
|
+
"Email": "hello@console.dev",
|
|
56
|
+
"Subject": "Tool Submission: Git-Alchemist",
|
|
57
|
+
"Body": f"Hi Console team, {STORY}\nRepo: https://github.com/abduznik/Git-Alchemist"
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
console.print("\n[bold cyan]--- Manual Submission Drafts ---[/bold cyan]")
|
|
62
|
+
for platform, data in submissions.items():
|
|
63
|
+
console.print(f"\n[bold yellow]{platform}[/bold yellow]")
|
|
64
|
+
for k, v in data.items():
|
|
65
|
+
console.print(f"[bold]{k}:[/bold] {v}")
|
|
66
|
+
|
|
67
|
+
if __name__ == "__main__":
|
|
68
|
+
devto_key = os.getenv("DEVTO_API_KEY")
|
|
69
|
+
if devto_key:
|
|
70
|
+
post_to_devto(devto_key)
|
|
71
|
+
else:
|
|
72
|
+
console.print("[yellow]No DEVTO_API_KEY found. Skipping automated post.[/yellow]")
|
|
73
|
+
|
|
74
|
+
generate_manual_submissions()
|
src/repo_tools.py
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import time
|
|
3
|
+
from rich.console import Console
|
|
4
|
+
from .core import generate_content
|
|
5
|
+
from .utils import run_shell, check_gh_auth
|
|
6
|
+
|
|
7
|
+
console = Console()
|
|
8
|
+
|
|
9
|
+
def optimize_topics(user=None, mode="fast"):
|
|
10
|
+
"""
|
|
11
|
+
Analyzes repositories and adds relevant topics using Gemini.
|
|
12
|
+
"""
|
|
13
|
+
username = user or check_gh_auth()
|
|
14
|
+
if not username:
|
|
15
|
+
console.print("[red]Not authenticated with gh CLI.[/red]")
|
|
16
|
+
return
|
|
17
|
+
|
|
18
|
+
console.print(f"[cyan]Optimizing topics for {username} ({mode} mode)...[/cyan]")
|
|
19
|
+
repos_raw = run_shell('gh repo list --visibility=public --limit 100 --json name,description,repositoryTopics')
|
|
20
|
+
repos = json.loads(repos_raw)
|
|
21
|
+
|
|
22
|
+
count = 0
|
|
23
|
+
for repo in repos:
|
|
24
|
+
name = repo['name']
|
|
25
|
+
desc = repo.get('description') or "No description provided"
|
|
26
|
+
|
|
27
|
+
# Safe access to topics
|
|
28
|
+
raw_topics = repo.get('repositoryTopics')
|
|
29
|
+
if raw_topics is None:
|
|
30
|
+
existing = []
|
|
31
|
+
else:
|
|
32
|
+
existing = [t['name'] for t in raw_topics]
|
|
33
|
+
|
|
34
|
+
if len(existing) >= 5:
|
|
35
|
+
continue
|
|
36
|
+
|
|
37
|
+
console.print(f"[white]Analyzing {name}...[/white]")
|
|
38
|
+
|
|
39
|
+
prompt = f"""
|
|
40
|
+
Task: Suggest search-friendly GitHub topics for project "{name}".
|
|
41
|
+
Description: "{desc}".
|
|
42
|
+
Existing Topics: {existing}.
|
|
43
|
+
Return ONLY a JSON array of strings (max 5 total topics).
|
|
44
|
+
Focus on technical keywords like 'python', 'api', 'automation', 'cli'.
|
|
45
|
+
Output Example: ["python", "automation"]
|
|
46
|
+
"""
|
|
47
|
+
result = generate_content(prompt, mode=mode)
|
|
48
|
+
if not result: continue
|
|
49
|
+
|
|
50
|
+
try:
|
|
51
|
+
clean_json = result.replace("```json", "").replace("```", "").strip()
|
|
52
|
+
new_tags = json.loads(clean_json)
|
|
53
|
+
|
|
54
|
+
# Filter out existing
|
|
55
|
+
to_add = [t for t in new_tags if t not in existing]
|
|
56
|
+
|
|
57
|
+
if to_add:
|
|
58
|
+
tag_str = ",".join(to_add)
|
|
59
|
+
console.print(f" [green]Adding tags:[/green] {tag_str}")
|
|
60
|
+
run_shell(f'gh repo edit {username}/{name} --add-topic "{tag_str}"')
|
|
61
|
+
count += 1
|
|
62
|
+
time.sleep(0.5)
|
|
63
|
+
except:
|
|
64
|
+
console.print(f" [red]Failed to parse topics for {name}[/red]")
|
|
65
|
+
|
|
66
|
+
console.print(f"[cyan]Done! Optimized {count} repositories.[/cyan]")
|
|
67
|
+
|
|
68
|
+
def generate_descriptions(user=None, mode="fast"):
|
|
69
|
+
"""
|
|
70
|
+
Generates descriptions for repositories that are missing them.
|
|
71
|
+
"""
|
|
72
|
+
username = user or check_gh_auth()
|
|
73
|
+
if not username: return
|
|
74
|
+
|
|
75
|
+
console.print(f"[cyan]Generating descriptions for {username} ({mode} mode)...[/cyan]")
|
|
76
|
+
repos_raw = run_shell('gh repo list --visibility=public --limit 100 --json name,description')
|
|
77
|
+
repos = json.loads(repos_raw)
|
|
78
|
+
|
|
79
|
+
count = 0
|
|
80
|
+
for repo in repos:
|
|
81
|
+
name = repo['name']
|
|
82
|
+
if name == username: continue # Skip profile repo
|
|
83
|
+
if repo.get('description'): continue # Skip if already has desc
|
|
84
|
+
|
|
85
|
+
console.print(f"[white]Analyzing {name}...[/white]")
|
|
86
|
+
|
|
87
|
+
# Fetch Readme
|
|
88
|
+
try:
|
|
89
|
+
readme = run_shell(f'gh repo view {username}/{name} --json body -q .body', check=False)
|
|
90
|
+
context = readme[:1500] if readme else "No readme available."
|
|
91
|
+
except:
|
|
92
|
+
context = "No readme available."
|
|
93
|
+
|
|
94
|
+
prompt = f"""
|
|
95
|
+
Task: Generate a GitHub repository description for project "{name}".
|
|
96
|
+
Readme Context: "{context}".
|
|
97
|
+
Constraint: Max 20 words. Start with an action verb.
|
|
98
|
+
Output ONLY the description. No quotes.
|
|
99
|
+
"""
|
|
100
|
+
result = generate_content(prompt, mode=mode)
|
|
101
|
+
if not result: continue
|
|
102
|
+
|
|
103
|
+
new_desc = result.strip().replace('"', '').replace("'", "")
|
|
104
|
+
if len(new_desc) > 200: new_desc = new_desc[:197] + "..."
|
|
105
|
+
|
|
106
|
+
console.print(f" [green]New Desc:[/green] {new_desc}")
|
|
107
|
+
run_shell(f'gh repo edit {username}/{name} --description "{new_desc}"')
|
|
108
|
+
count += 1
|
|
109
|
+
time.sleep(0.5)
|
|
110
|
+
|
|
111
|
+
console.print(f"[cyan]Done! Updated {count} descriptions.[/cyan]")
|
src/sage.py
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from rich.console import Console
|
|
3
|
+
from .core import generate_content
|
|
4
|
+
from .utils import run_shell
|
|
5
|
+
|
|
6
|
+
console = Console()
|
|
7
|
+
|
|
8
|
+
def get_codebase_context():
|
|
9
|
+
"""
|
|
10
|
+
Scans the repository and aggregates source code into a single context string.
|
|
11
|
+
"""
|
|
12
|
+
context = []
|
|
13
|
+
# Extensions to include
|
|
14
|
+
extensions = {'.py', '.md', '.ps1', '.sh', '.js', '.ts', '.c', '.cpp', '.h', '.yml', '.yaml', '.Dockerfile'}
|
|
15
|
+
# Folders to ignore
|
|
16
|
+
ignore_dirs = {'__pycache__', '.git', 'venv', 'node_modules', '.tmp', 'docs'}
|
|
17
|
+
|
|
18
|
+
for root, dirs, files in os.walk("."):
|
|
19
|
+
# Filter directories in-place
|
|
20
|
+
dirs[:] = [d for d in dirs if d not in ignore_dirs]
|
|
21
|
+
|
|
22
|
+
for file in files:
|
|
23
|
+
ext = os.path.splitext(file)[1]
|
|
24
|
+
if ext in extensions:
|
|
25
|
+
path = os.path.join(root, file)
|
|
26
|
+
try:
|
|
27
|
+
with open(path, "r", encoding="utf-8") as f:
|
|
28
|
+
content = f.read()
|
|
29
|
+
context.append(f"--- FILE: {path} ---\n{content}\n")
|
|
30
|
+
except Exception:
|
|
31
|
+
continue
|
|
32
|
+
|
|
33
|
+
return "\n".join(context)
|
|
34
|
+
|
|
35
|
+
def ask_sage(question, mode="fast"):
|
|
36
|
+
"""
|
|
37
|
+
Queries Gemini using the aggregated codebase as context.
|
|
38
|
+
"""
|
|
39
|
+
console.print("[cyan]The Sage is meditating on your codebase...[/cyan]")
|
|
40
|
+
|
|
41
|
+
code_context = get_codebase_context()
|
|
42
|
+
|
|
43
|
+
if not code_context:
|
|
44
|
+
console.print("[yellow]Warning: No source files found to analyze.[/yellow]")
|
|
45
|
+
code_context = "No code found in repository."
|
|
46
|
+
|
|
47
|
+
prompt = f"""
|
|
48
|
+
You are "The Sage", an expert software architect and technical lead.
|
|
49
|
+
Below is the source code context for the current project.
|
|
50
|
+
Use this context to answer the user's question precisely and technically.
|
|
51
|
+
|
|
52
|
+
CONTEXT:
|
|
53
|
+
'''
|
|
54
|
+
{code_context}
|
|
55
|
+
'''
|
|
56
|
+
|
|
57
|
+
USER QUESTION:
|
|
58
|
+
{question}
|
|
59
|
+
|
|
60
|
+
Instructions:
|
|
61
|
+
1. Base your answer ONLY on the provided code.
|
|
62
|
+
2. Be concise but deep.
|
|
63
|
+
3. If the answer isn't in the code, say so.
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
result = generate_content(prompt, mode=mode)
|
|
67
|
+
|
|
68
|
+
if result:
|
|
69
|
+
console.print("\n[bold fuchsia]--- The Sage's Wisdom ---[/bold fuchsia]")
|
|
70
|
+
console.print(result)
|
|
71
|
+
console.print("[bold fuchsia]-----------------------[/bold fuchsia]")
|
src/utils.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import subprocess
|
|
2
|
+
import json
|
|
3
|
+
import shutil
|
|
4
|
+
from rich.console import Console
|
|
5
|
+
|
|
6
|
+
console = Console()
|
|
7
|
+
|
|
8
|
+
def run_shell(command, check=True, capture_output=True):
|
|
9
|
+
"""
|
|
10
|
+
Runs a shell command and returns the result.
|
|
11
|
+
"""
|
|
12
|
+
try:
|
|
13
|
+
result = subprocess.run(
|
|
14
|
+
command,
|
|
15
|
+
shell=True,
|
|
16
|
+
check=check,
|
|
17
|
+
capture_output=capture_output,
|
|
18
|
+
text=True
|
|
19
|
+
)
|
|
20
|
+
return result.stdout.strip()
|
|
21
|
+
except subprocess.CalledProcessError as e:
|
|
22
|
+
if check:
|
|
23
|
+
console.print(f"[bold red]Command Failed:[/bold red] {command}")
|
|
24
|
+
console.print(f"[red]Error:[/red] {e.stderr}")
|
|
25
|
+
raise e
|
|
26
|
+
return None
|
|
27
|
+
|
|
28
|
+
def check_gh_auth():
|
|
29
|
+
"""
|
|
30
|
+
Checks if the user is authenticated with GitHub CLI.
|
|
31
|
+
Returns the username if authenticated, else None.
|
|
32
|
+
"""
|
|
33
|
+
try:
|
|
34
|
+
user_login = run_shell('gh api user -q ".login"')
|
|
35
|
+
return user_login
|
|
36
|
+
except:
|
|
37
|
+
return None
|
|
38
|
+
|
|
39
|
+
def get_user_email():
|
|
40
|
+
"""
|
|
41
|
+
Gets the user email from GitHub CLI.
|
|
42
|
+
"""
|
|
43
|
+
try:
|
|
44
|
+
email = run_shell('gh api user -q ".email"', check=False)
|
|
45
|
+
return email if email else None
|
|
46
|
+
except:
|
|
47
|
+
return None
|