git2tweet 0.1.0__tar.gz
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.
- git2tweet-0.1.0/PKG-INFO +66 -0
- git2tweet-0.1.0/README.md +50 -0
- git2tweet-0.1.0/pyproject.toml +29 -0
- git2tweet-0.1.0/pyproject.toml.orig +30 -0
- git2tweet-0.1.0/src/git2tweet/__init__.py +0 -0
- git2tweet-0.1.0/src/git2tweet/__main__.py +3 -0
- git2tweet-0.1.0/src/git2tweet/agent.py +119 -0
- git2tweet-0.1.0/src/git2tweet/cli.py +108 -0
- git2tweet-0.1.0/src/git2tweet/config.py +46 -0
- git2tweet-0.1.0/src/git2tweet/hooks.py +54 -0
- git2tweet-0.1.0/src/git2tweet/llm.py +22 -0
- git2tweet-0.1.0/src/git2tweet/repo.py +48 -0
- git2tweet-0.1.0/src/git2tweet/scrub.py +16 -0
- git2tweet-0.1.0/src/git2tweet/store.py +23 -0
git2tweet-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: git2tweet
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Turn your git commits into tweet drafts with an AI agent and human approval
|
|
5
|
+
Author: Vedant
|
|
6
|
+
Author-email: Vedant <vkowdiki@gmail.com>
|
|
7
|
+
Requires-Dist: langchain-core>=1.7.6
|
|
8
|
+
Requires-Dist: langchain-groq>=1.1.3
|
|
9
|
+
Requires-Dist: langgraph>=1.2.20
|
|
10
|
+
Requires-Dist: platformdirs>=4.12.4
|
|
11
|
+
Requires-Dist: python-dotenv>=1.2.4
|
|
12
|
+
Requires-Dist: langchain-ollama>=1.1.0 ; extra == 'ollama'
|
|
13
|
+
Requires-Python: >=3.10
|
|
14
|
+
Provides-Extra: ollama
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
|
|
17
|
+
# Git2Tweet
|
|
18
|
+
|
|
19
|
+
Turn your git commits into tweet drafts. You approve every one before it goes out.
|
|
20
|
+
|
|
21
|
+

|
|
22
|
+
|
|
23
|
+
## Install
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
pipx install git2tweet # or: uv tool install git2tweet
|
|
27
|
+
git2tweet config # pick a model provider, paste your API key (once)
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## Use it
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
git commit -m "feat: add retry with backoff to the webhook handler"
|
|
34
|
+
git tweet # drafts a tweet, you approve or edit, X opens prefilled
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Want a reminder after every commit? Run `git2tweet init` once per repo.
|
|
38
|
+
|
|
39
|
+
| Command | What it does |
|
|
40
|
+
|---|---|
|
|
41
|
+
| `git tweet` / `g2t` | Draft a tweet for your latest commit |
|
|
42
|
+
| `git tweet <sha>` | Draft for a specific commit |
|
|
43
|
+
| `git tweet --force` | Draft again even if the commit was already handled |
|
|
44
|
+
| `git2tweet config` | Set provider, model and API key |
|
|
45
|
+
| `git2tweet init` | Install the post-commit reminder hook (`--remove` to undo) |
|
|
46
|
+
|
|
47
|
+
## How it works
|
|
48
|
+
|
|
49
|
+
An agent (built with LangGraph) reads your commit and diff, decides if it's worth
|
|
50
|
+
sharing, and drafts a tweet. Trivial commits (typos, formatting) are skipped.
|
|
51
|
+
You then approve, edit, or reject it. Approved tweets open in X with the text
|
|
52
|
+
prefilled, so you press Post yourself. Git2Tweet never holds your X credentials.
|
|
53
|
+
|
|
54
|
+
## Privacy
|
|
55
|
+
|
|
56
|
+
- The commit message and diff are sent to the model provider you choose (Groq by default).
|
|
57
|
+
- Known secret patterns (API keys, tokens, `password=...`) are redacted first.
|
|
58
|
+
This is best effort, so don't rely on it for sensitive repos.
|
|
59
|
+
- For fully local use, pick `ollama` in `git2tweet config`: nothing leaves your machine.
|
|
60
|
+
- Git2Tweet sends no telemetry.
|
|
61
|
+
|
|
62
|
+
## License
|
|
63
|
+
|
|
64
|
+
MIT
|
|
65
|
+
|
|
66
|
+
[View full license](https://github.com/Vex-15/git2tweet?tab=MIT-1-ov-file)
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# Git2Tweet
|
|
2
|
+
|
|
3
|
+
Turn your git commits into tweet drafts. You approve every one before it goes out.
|
|
4
|
+
|
|
5
|
+

|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
pipx install git2tweet # or: uv tool install git2tweet
|
|
11
|
+
git2tweet config # pick a model provider, paste your API key (once)
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
## Use it
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
git commit -m "feat: add retry with backoff to the webhook handler"
|
|
18
|
+
git tweet # drafts a tweet, you approve or edit, X opens prefilled
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
Want a reminder after every commit? Run `git2tweet init` once per repo.
|
|
22
|
+
|
|
23
|
+
| Command | What it does |
|
|
24
|
+
|---|---|
|
|
25
|
+
| `git tweet` / `g2t` | Draft a tweet for your latest commit |
|
|
26
|
+
| `git tweet <sha>` | Draft for a specific commit |
|
|
27
|
+
| `git tweet --force` | Draft again even if the commit was already handled |
|
|
28
|
+
| `git2tweet config` | Set provider, model and API key |
|
|
29
|
+
| `git2tweet init` | Install the post-commit reminder hook (`--remove` to undo) |
|
|
30
|
+
|
|
31
|
+
## How it works
|
|
32
|
+
|
|
33
|
+
An agent (built with LangGraph) reads your commit and diff, decides if it's worth
|
|
34
|
+
sharing, and drafts a tweet. Trivial commits (typos, formatting) are skipped.
|
|
35
|
+
You then approve, edit, or reject it. Approved tweets open in X with the text
|
|
36
|
+
prefilled, so you press Post yourself. Git2Tweet never holds your X credentials.
|
|
37
|
+
|
|
38
|
+
## Privacy
|
|
39
|
+
|
|
40
|
+
- The commit message and diff are sent to the model provider you choose (Groq by default).
|
|
41
|
+
- Known secret patterns (API keys, tokens, `password=...`) are redacted first.
|
|
42
|
+
This is best effort, so don't rely on it for sensitive repos.
|
|
43
|
+
- For fully local use, pick `ollama` in `git2tweet config`: nothing leaves your machine.
|
|
44
|
+
- Git2Tweet sends no telemetry.
|
|
45
|
+
|
|
46
|
+
## License
|
|
47
|
+
|
|
48
|
+
MIT
|
|
49
|
+
|
|
50
|
+
[View full license](https://github.com/Vex-15/git2tweet?tab=MIT-1-ov-file)
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "git2tweet"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Turn your git commits into tweet drafts with an AI agent and human approval"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.10"
|
|
7
|
+
dependencies = [
|
|
8
|
+
"langchain-core>=1.7.6",
|
|
9
|
+
"langchain-groq>=1.1.3",
|
|
10
|
+
"langgraph>=1.2.20",
|
|
11
|
+
"platformdirs>=4.12.4",
|
|
12
|
+
"python-dotenv>=1.2.4",
|
|
13
|
+
]
|
|
14
|
+
|
|
15
|
+
[[project.authors]]
|
|
16
|
+
name = "Vedant"
|
|
17
|
+
email = "vkowdiki@gmail.com"
|
|
18
|
+
|
|
19
|
+
[project.scripts]
|
|
20
|
+
git2tweet = "git2tweet.cli:main"
|
|
21
|
+
g2t = "git2tweet.cli:main"
|
|
22
|
+
git-tweet = "git2tweet.cli:main"
|
|
23
|
+
|
|
24
|
+
[project.optional-dependencies]
|
|
25
|
+
ollama = ["langchain-ollama>=1.1.0"]
|
|
26
|
+
|
|
27
|
+
[build-system]
|
|
28
|
+
requires = ["uv_build>=0.13.5,<0.14.0"]
|
|
29
|
+
build-backend = "uv_build"
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "git2tweet"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Turn your git commits into tweet drafts with an AI agent and human approval"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
authors = [
|
|
7
|
+
{ name = "Vedant", email = "vkowdiki@gmail.com" }
|
|
8
|
+
]
|
|
9
|
+
requires-python = ">=3.10"
|
|
10
|
+
dependencies = [
|
|
11
|
+
"langchain-core>=1.7.6",
|
|
12
|
+
"langchain-groq>=1.1.3",
|
|
13
|
+
"langgraph>=1.2.20",
|
|
14
|
+
"platformdirs>=4.12.4",
|
|
15
|
+
"python-dotenv>=1.2.4",
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
[project.scripts]
|
|
19
|
+
git2tweet = "git2tweet.cli:main"
|
|
20
|
+
g2t = "git2tweet.cli:main"
|
|
21
|
+
git-tweet = "git2tweet.cli:main"
|
|
22
|
+
|
|
23
|
+
[project.optional-dependencies]
|
|
24
|
+
ollama = [
|
|
25
|
+
"langchain-ollama>=1.1.0",
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
[build-system]
|
|
29
|
+
requires = ["uv_build>=0.13.5,<0.14.0"]
|
|
30
|
+
build-backend = "uv_build"
|
|
File without changes
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import webbrowser
|
|
3
|
+
from typing import Annotated, TypedDict
|
|
4
|
+
from urllib.parse import urlencode
|
|
5
|
+
|
|
6
|
+
from langchain_core.messages import HumanMessage, SystemMessage
|
|
7
|
+
from langchain_core.tools import tool
|
|
8
|
+
from langgraph.checkpoint.memory import MemorySaver
|
|
9
|
+
from langgraph.graph import END, START, StateGraph
|
|
10
|
+
from langgraph.graph.message import add_messages
|
|
11
|
+
from langgraph.prebuilt import ToolNode, tools_condition
|
|
12
|
+
from langgraph.types import Command, interrupt
|
|
13
|
+
|
|
14
|
+
from . import repo, store
|
|
15
|
+
from .llm import make_llm
|
|
16
|
+
|
|
17
|
+
SYSTEM = """You write tweets for a developer who builds in public.
|
|
18
|
+
Look at the recent commits, pick the most tweet-worthy one, read its diff,
|
|
19
|
+
then write ONE tweet under 280 characters (first person, casual, max 1 emoji,
|
|
20
|
+
max 2 hashtags, never include secrets).
|
|
21
|
+
If every commit is trivial (typos, formatting, merges), reply with exactly: SKIP
|
|
22
|
+
Output only the tweet text or SKIP."""
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@tool
|
|
26
|
+
def list_recent_commits(n: int = 3) -> str:
|
|
27
|
+
"""List the most recent commits (sha + first line of message)."""
|
|
28
|
+
return json.dumps(repo.list_commits(n))
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@tool
|
|
32
|
+
def get_commit_diff(sha: str) -> str:
|
|
33
|
+
"""Get the full message and code diff for one commit by its sha."""
|
|
34
|
+
return f"Message: {repo.commit_message(sha)}\n\n{repo.commit_diff(sha)}"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
TOOLS = [list_recent_commits, get_commit_diff]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class State(TypedDict):
|
|
41
|
+
messages: Annotated[list, add_messages]
|
|
42
|
+
tweet: str
|
|
43
|
+
status: str
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def approve(state: State):
|
|
47
|
+
draft = state["messages"][-1].content.strip()
|
|
48
|
+
if draft.strip(" .\"'").upper() == "SKIP":
|
|
49
|
+
return {"status": "skipped"}
|
|
50
|
+
|
|
51
|
+
answer = interrupt({"draft": draft, "chars": len(draft)}).strip()
|
|
52
|
+
|
|
53
|
+
if answer.lower() == "y":
|
|
54
|
+
return {"tweet": draft, "status": "approved"}
|
|
55
|
+
if answer.lower() == "n":
|
|
56
|
+
return {"status": "rejected"}
|
|
57
|
+
return {"tweet": answer, "status": "approved"} # anything else = your edit
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def post(state: State):
|
|
61
|
+
url = "https://twitter.com/intent/tweet?" + urlencode({"text": state["tweet"]})
|
|
62
|
+
webbrowser.open(url)
|
|
63
|
+
return {"status": "opened"}
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def after_approve(state: State):
|
|
67
|
+
return "post" if state["status"] == "approved" else END
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def build_graph():
|
|
71
|
+
llm = make_llm().bind_tools(TOOLS)
|
|
72
|
+
|
|
73
|
+
def agent(state: State):
|
|
74
|
+
reply = llm.invoke([SystemMessage(SYSTEM)] + state["messages"])
|
|
75
|
+
return {"messages": [reply]}
|
|
76
|
+
|
|
77
|
+
builder = StateGraph(State)
|
|
78
|
+
builder.add_node("agent", agent)
|
|
79
|
+
builder.add_node("tools", ToolNode(TOOLS))
|
|
80
|
+
builder.add_node("approve", approve)
|
|
81
|
+
builder.add_node("post", post)
|
|
82
|
+
|
|
83
|
+
builder.add_edge(START, "agent")
|
|
84
|
+
builder.add_conditional_edges(
|
|
85
|
+
"agent", tools_condition, {"tools": "tools", END: "approve"}
|
|
86
|
+
)
|
|
87
|
+
builder.add_edge("tools", "agent")
|
|
88
|
+
builder.add_conditional_edges(
|
|
89
|
+
"approve", after_approve, {"post": "post", END: END}
|
|
90
|
+
)
|
|
91
|
+
builder.add_edge("post", END)
|
|
92
|
+
return builder.compile(checkpointer=MemorySaver())
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def run(sha: str) -> None:
|
|
96
|
+
if sha in store.load():
|
|
97
|
+
print(f"Already handled {sha[:7]}, skipping.")
|
|
98
|
+
return
|
|
99
|
+
|
|
100
|
+
graph = build_graph()
|
|
101
|
+
config = {"configurable": {"thread_id": sha}, "recursion_limit": 10}
|
|
102
|
+
task = f"Write a tweet about commit {sha}. Read its diff with get_commit_diff first."
|
|
103
|
+
|
|
104
|
+
result = graph.invoke({"messages": [HumanMessage(task)]}, config)
|
|
105
|
+
|
|
106
|
+
while "__interrupt__" in result:
|
|
107
|
+
info = result["__interrupt__"][0].value
|
|
108
|
+
print(f"\nDRAFT ({info['chars']} chars):\n{info['draft']}\n")
|
|
109
|
+
answer = input("Post? [y] yes / [n] no / or type your edited tweet: ")
|
|
110
|
+
result = graph.invoke(Command(resume=answer), config)
|
|
111
|
+
|
|
112
|
+
status = result["status"]
|
|
113
|
+
print("Status:", status)
|
|
114
|
+
|
|
115
|
+
if status == "opened":
|
|
116
|
+
if input("Did you press Post on X? [y/n] ").lower() == "y":
|
|
117
|
+
store.mark(sha)
|
|
118
|
+
elif status in ("skipped", "rejected"):
|
|
119
|
+
store.mark(sha)
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
import getpass
|
|
3
|
+
import json
|
|
4
|
+
import subprocess
|
|
5
|
+
import sys
|
|
6
|
+
|
|
7
|
+
from dotenv import load_dotenv
|
|
8
|
+
|
|
9
|
+
from . import config, hooks, repo, store
|
|
10
|
+
|
|
11
|
+
COMMANDS = {"draft", "config", "init", "hook"}
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def configure(show: bool) -> None:
|
|
15
|
+
cfg = config.load()
|
|
16
|
+
|
|
17
|
+
if show:
|
|
18
|
+
shown = dict(cfg)
|
|
19
|
+
if "groq_api_key" in shown:
|
|
20
|
+
shown["groq_api_key"] = "****" + shown["groq_api_key"][-4:]
|
|
21
|
+
print(json.dumps(shown, indent=2))
|
|
22
|
+
print(f"\nConfig file: {config.CONFIG_FILE}")
|
|
23
|
+
return
|
|
24
|
+
|
|
25
|
+
current = cfg.get("provider", "groq")
|
|
26
|
+
provider = input(f"Provider [groq/ollama] ({current}): ").strip().lower() or current
|
|
27
|
+
if provider not in config.DEFAULT_MODELS:
|
|
28
|
+
sys.exit(f"Unknown provider '{provider}'. Choose: {', '.join(config.DEFAULT_MODELS)}")
|
|
29
|
+
|
|
30
|
+
default_model = (cfg.get("model") if provider == cfg.get("provider") else None) \
|
|
31
|
+
or config.DEFAULT_MODELS[provider]
|
|
32
|
+
cfg["provider"] = provider
|
|
33
|
+
cfg["model"] = input(f"Model ({default_model}): ").strip() or default_model
|
|
34
|
+
|
|
35
|
+
if provider == "groq":
|
|
36
|
+
key = getpass.getpass("Groq API key (hidden, Enter keeps current): ").strip()
|
|
37
|
+
if key:
|
|
38
|
+
cfg["groq_api_key"] = key
|
|
39
|
+
|
|
40
|
+
config.save(cfg)
|
|
41
|
+
print(f"Saved to {config.CONFIG_FILE}")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def draft(sha: str) -> None:
|
|
45
|
+
from .agent import run # imported here so --help, config and hook stay fast
|
|
46
|
+
|
|
47
|
+
try:
|
|
48
|
+
full_sha = repo.resolve(sha)
|
|
49
|
+
except (subprocess.CalledProcessError, FileNotFoundError):
|
|
50
|
+
sys.exit("Run this inside a git repository, with a valid commit.")
|
|
51
|
+
run(full_sha)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def init(remove: bool) -> None:
|
|
55
|
+
try:
|
|
56
|
+
msg = hooks.remove() if remove else hooks.install()
|
|
57
|
+
except (subprocess.CalledProcessError, FileNotFoundError):
|
|
58
|
+
sys.exit("Run this inside a git repository.")
|
|
59
|
+
print(msg)
|
|
60
|
+
if msg.startswith("Installed"):
|
|
61
|
+
print("After each commit you'll see a reminder. Run `git2tweet` to draft a tweet.")
|
|
62
|
+
|
|
63
|
+
def hook() -> None:
|
|
64
|
+
"""Called by the post-commit hook: must be fast, quiet, and never fail."""
|
|
65
|
+
try:
|
|
66
|
+
sha = repo.resolve("HEAD")
|
|
67
|
+
if sha in store.load() or repo.is_merge(sha):
|
|
68
|
+
return
|
|
69
|
+
subject = repo.list_commits(1)[0]["message"]
|
|
70
|
+
print(f"git2tweet: tweet this? Run `git tweet` ({sha[:7]}: {subject})")
|
|
71
|
+
except Exception:
|
|
72
|
+
pass
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def main() -> None:
|
|
76
|
+
load_dotenv() # dev convenience: reads a .env in the current folder
|
|
77
|
+
|
|
78
|
+
parser = argparse.ArgumentParser(
|
|
79
|
+
prog="git2tweet",
|
|
80
|
+
description="Turn a git commit into a tweet draft you approve before posting.",
|
|
81
|
+
epilog="Run `git2tweet` alone to draft a tweet for your latest commit.",
|
|
82
|
+
)
|
|
83
|
+
sub = parser.add_subparsers(dest="command", metavar="{draft,config,init}")
|
|
84
|
+
|
|
85
|
+
p_draft = sub.add_parser("draft", help="draft a tweet for a commit (the default)")
|
|
86
|
+
p_draft.add_argument("sha", nargs="?", default="HEAD", help="commit to use (default: latest)")
|
|
87
|
+
|
|
88
|
+
p_config = sub.add_parser("config", help="set provider, model and API key")
|
|
89
|
+
p_config.add_argument("--show", action="store_true", help="show current settings")
|
|
90
|
+
|
|
91
|
+
p_init = sub.add_parser("init", help="install a git hook that reminds you after each commit")
|
|
92
|
+
p_init.add_argument("--remove", action="store_true", help="uninstall the hook")
|
|
93
|
+
|
|
94
|
+
sub.add_parser("hook", help=argparse.SUPPRESS) # internal, used by the hook itself
|
|
95
|
+
|
|
96
|
+
argv = sys.argv[1:]
|
|
97
|
+
if not argv or (argv[0] not in COMMANDS and not argv[0].startswith("-")):
|
|
98
|
+
argv = ["draft", *argv]
|
|
99
|
+
args = parser.parse_args(argv)
|
|
100
|
+
|
|
101
|
+
if args.command == "config":
|
|
102
|
+
configure(args.show)
|
|
103
|
+
elif args.command == "init":
|
|
104
|
+
init(args.remove)
|
|
105
|
+
elif args.command == "hook":
|
|
106
|
+
hook()
|
|
107
|
+
else:
|
|
108
|
+
draft(args.sha)
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import os
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from platformdirs import user_config_dir
|
|
6
|
+
|
|
7
|
+
CONFIG_FILE = Path(user_config_dir("git2tweet")) / "config.json"
|
|
8
|
+
|
|
9
|
+
DEFAULT_MODELS = {
|
|
10
|
+
"groq": "openai/gpt-oss-120b",
|
|
11
|
+
"ollama": "llama3.1",
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def load() -> dict:
|
|
16
|
+
if not CONFIG_FILE.exists():
|
|
17
|
+
return {}
|
|
18
|
+
try:
|
|
19
|
+
return json.loads(CONFIG_FILE.read_text(encoding="utf-8"))
|
|
20
|
+
except json.JSONDecodeError:
|
|
21
|
+
return {}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def save(cfg: dict) -> None:
|
|
25
|
+
CONFIG_FILE.parent.mkdir(parents=True, exist_ok=True)
|
|
26
|
+
CONFIG_FILE.write_text(json.dumps(cfg, indent=2), encoding="utf-8")
|
|
27
|
+
try:
|
|
28
|
+
CONFIG_FILE.chmod(0o600) # owner-only on Mac/Linux
|
|
29
|
+
except OSError:
|
|
30
|
+
pass
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def get_settings() -> dict:
|
|
34
|
+
"""Environment variables win over the config file, which wins over defaults."""
|
|
35
|
+
cfg = load()
|
|
36
|
+
provider = os.environ.get("GIT2TWEET_PROVIDER") or cfg.get("provider", "groq")
|
|
37
|
+
model = (
|
|
38
|
+
os.environ.get("GIT2TWEET_MODEL")
|
|
39
|
+
or cfg.get("model")
|
|
40
|
+
or DEFAULT_MODELS.get(provider, "")
|
|
41
|
+
)
|
|
42
|
+
return {"provider": provider, "model": model}
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def get_groq_key() -> str | None:
|
|
46
|
+
return os.environ.get("GROQ_API_KEY") or load().get("groq_api_key")
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import stat
|
|
2
|
+
|
|
3
|
+
from . import repo
|
|
4
|
+
|
|
5
|
+
MARKER_START = "# >>> git2tweet >>>"
|
|
6
|
+
MARKER_END = "# <<< git2tweet <<<"
|
|
7
|
+
BLOCK = (
|
|
8
|
+
f"{MARKER_START}\n"
|
|
9
|
+
"command -v git2tweet >/dev/null 2>&1 && git2tweet hook || true\n"
|
|
10
|
+
f"{MARKER_END}\n"
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _path():
|
|
15
|
+
return repo.hooks_dir() / "post-commit"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def install() -> str:
|
|
19
|
+
path = _path()
|
|
20
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
21
|
+
|
|
22
|
+
if path.exists():
|
|
23
|
+
text = path.read_text(encoding="utf-8")
|
|
24
|
+
if MARKER_START in text:
|
|
25
|
+
return f"Already installed ({path})"
|
|
26
|
+
first = text.splitlines()[0] if text else ""
|
|
27
|
+
if not (first.startswith("#!") and first.rstrip().endswith("sh")):
|
|
28
|
+
return "A non-shell post-commit hook already exists. Add `git2tweet hook` to it manually."
|
|
29
|
+
text = text.rstrip("\n") + "\n\n" + BLOCK
|
|
30
|
+
else:
|
|
31
|
+
text = "#!/bin/sh\n\n" + BLOCK
|
|
32
|
+
|
|
33
|
+
path.write_text(text, encoding="utf-8", newline="\n") # LF only, or sh breaks on Windows
|
|
34
|
+
path.chmod(path.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
|
|
35
|
+
return f"Installed post-commit hook ({path})"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def remove() -> str:
|
|
39
|
+
path = _path()
|
|
40
|
+
if not path.exists():
|
|
41
|
+
return "No git2tweet hook found."
|
|
42
|
+
text = path.read_text(encoding="utf-8")
|
|
43
|
+
if MARKER_START not in text:
|
|
44
|
+
return "No git2tweet hook found."
|
|
45
|
+
|
|
46
|
+
start = text.index(MARKER_START)
|
|
47
|
+
end = text.index(MARKER_END) + len(MARKER_END)
|
|
48
|
+
rest = (text[:start] + text[end:]).strip()
|
|
49
|
+
|
|
50
|
+
if rest in ("", "#!/bin/sh"):
|
|
51
|
+
path.unlink() # the hook was only ours, so delete the file
|
|
52
|
+
else:
|
|
53
|
+
path.write_text(rest + "\n", encoding="utf-8", newline="\n")
|
|
54
|
+
return "Removed git2tweet hook."
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
from . import config
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def make_llm():
|
|
5
|
+
s = config.get_settings()
|
|
6
|
+
|
|
7
|
+
if s["provider"] == "groq":
|
|
8
|
+
from langchain_groq import ChatGroq
|
|
9
|
+
|
|
10
|
+
key = config.get_groq_key()
|
|
11
|
+
if not key:
|
|
12
|
+
raise SystemExit("No Groq API key found. Run `git2tweet config` or set GROQ_API_KEY.")
|
|
13
|
+
return ChatGroq(model=s["model"], api_key=key)
|
|
14
|
+
|
|
15
|
+
if s["provider"] == "ollama":
|
|
16
|
+
try:
|
|
17
|
+
from langchain_ollama import ChatOllama
|
|
18
|
+
except ImportError:
|
|
19
|
+
raise SystemExit('Ollama support is not installed. Run: pip install "git2tweet[ollama]"')
|
|
20
|
+
return ChatOllama(model=s["model"])
|
|
21
|
+
|
|
22
|
+
raise SystemExit(f"Unknown provider '{s['provider']}'. Use groq or ollama.")
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import subprocess
|
|
2
|
+
|
|
3
|
+
from .scrub import scrub
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def _git(*args: str) -> str:
|
|
7
|
+
result = subprocess.run(
|
|
8
|
+
["git", *args],
|
|
9
|
+
capture_output=True, text=True, encoding="utf-8", errors="replace",
|
|
10
|
+
check=True,
|
|
11
|
+
)
|
|
12
|
+
return result.stdout.strip()
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def resolve(ref: str = "HEAD") -> str:
|
|
16
|
+
"""Turn HEAD, a short sha, or a branch name into a full sha."""
|
|
17
|
+
return _git("rev-parse", ref)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def list_commits(n: int = 3) -> list[dict]:
|
|
21
|
+
out = _git("log", f"-{n}", "--pretty=format:%H%x09%s")
|
|
22
|
+
rows = [line.split("\t", 1) for line in out.splitlines()]
|
|
23
|
+
return [{"sha": sha, "message": scrub(msg)} for sha, msg in rows]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def commit_message(sha: str) -> str:
|
|
27
|
+
return scrub(_git("show", "-s", "--format=%B", sha))
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def commit_diff(sha: str, max_chars: int = 6000) -> str:
|
|
31
|
+
return scrub(_git("show", "--format=", "--patch", sha))[:max_chars]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
from pathlib import Path
|
|
35
|
+
|
|
36
|
+
def git_dir() -> Path:
|
|
37
|
+
"""The repo's .git folder (works from any subfolder, worktrees too)."""
|
|
38
|
+
return Path(_git("rev-parse", "--absolute-git-dir"))
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def hooks_dir() -> Path:
|
|
42
|
+
"""Where this repo's hooks live (respects a custom core.hooksPath)."""
|
|
43
|
+
return Path(_git("rev-parse", "--git-path", "hooks")).resolve()
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def is_merge(sha: str) -> bool:
|
|
47
|
+
parts = _git("rev-list", "--parents", "-n", "1", sha).split()
|
|
48
|
+
return len(parts) > 2 # sha + 2 or more parents
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import re
|
|
2
|
+
|
|
3
|
+
PATTERNS = [
|
|
4
|
+
r"ghp_[A-Za-z0-9]{36}",
|
|
5
|
+
r"github_pat_[A-Za-z0-9_]{20,}",
|
|
6
|
+
r"gsk_[A-Za-z0-9]{20,}",
|
|
7
|
+
r"sk-[A-Za-z0-9_-]{20,}",
|
|
8
|
+
r"AKIA[0-9A-Z]{16}",
|
|
9
|
+
r"(?i)(api[_-]?key|token|secret|password)\s*[=:]\s*\S+",
|
|
10
|
+
]
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def scrub(text: str) -> str:
|
|
14
|
+
for p in PATTERNS:
|
|
15
|
+
text = re.sub(p, "[REDACTED]", text)
|
|
16
|
+
return text
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import json
|
|
2
|
+
|
|
3
|
+
from . import repo
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def _file():
|
|
7
|
+
return repo.git_dir() / "git2tweet.json"
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def load() -> set[str]:
|
|
11
|
+
f = _file()
|
|
12
|
+
if not f.exists():
|
|
13
|
+
return set()
|
|
14
|
+
try:
|
|
15
|
+
return set(json.loads(f.read_text(encoding="utf-8")))
|
|
16
|
+
except (json.JSONDecodeError, TypeError):
|
|
17
|
+
return set()
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def mark(sha: str) -> None:
|
|
21
|
+
shas = load()
|
|
22
|
+
shas.add(sha)
|
|
23
|
+
_file().write_text(json.dumps(sorted(shas)), encoding="utf-8")
|