-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy path_commitish_ai
More file actions
executable file
·248 lines (207 loc) · 10.1 KB
/
_commitish_ai
File metadata and controls
executable file
·248 lines (207 loc) · 10.1 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
#!/usr/bin/env zsh
# _commitish_ai: OpenAI integration for commitish
# This script provides AI-powered commit suggestions using OpenAI's API.
# It is intended for internal use by commitish only.
# Environment variable for API key
OPENAI_API_KEY="${OPENAI_API_KEY:-}"
verbose=false
user_instructions=""
# Check for flags
while [[ "$#" -gt 0 ]]; do
case $1 in
-v|--verbose)
verbose=true
shift
;;
--instructions)
shift
user_instructions="$1"
shift
;;
*)
shift
;;
esac
done
# Check if API key is set
if [ -z "$OPENAI_API_KEY" ]; then
echo "Error: OPENAI_API_KEY environment variable is not set" >&2
echo "Please set it with: export OPENAI_API_KEY=your_api_key" >&2
exit 1
fi
# Check if jq is installed (needed for JSON parsing)
if ! command -v jq &> /dev/null; then
echo "Error: jq is required for AI features but not installed."
exit 1
fi
# Get staged changes: full diff + file summary
staged_diff=$(git diff --cached 2>/dev/null)
staged_stat=$(git diff --cached --stat 2>/dev/null)
if [ -z "$staged_diff" ]; then
echo "Warning: No staged changes found"
staged_diff="No staged changes"
staged_stat="No staged changes"
fi
staged_changes="File summary:\n$staged_stat\n\nFull diff:\n$staged_diff"
# Append user-provided instructions if present
if [ -n "$user_instructions" ]; then
staged_changes="$staged_changes\n\nImportant and explicit instructions from user about the commit message:\n$user_instructions"
fi
# Get last 10 commit messages to provide context to OpenAI
recent_commits=$(git log --oneline -10 2>/dev/null | sed 's/^[a-f0-9]* //' || echo "")
# Function to make API call with retry - outputs to stdout
make_api_call() {
local system_prompt="$1"
local user_prompt="$2"
local call_name="${3:-unknown}"
local max_retries=3
local retry_count=0
# Build the JSON payload
local json_payload=$(jq -n --arg system "$system_prompt" --arg user "$user_prompt" '{
model: "gpt-5.4",
messages: [
{role: "system", content: $system},
{role: "user", content: $user}
]
}')
if $verbose; then
echo "[VERBOSE] Making API call: $call_name" >&2
echo "[VERBOSE] Request:" >&2
printf '%s\n' "$json_payload" | jq '.' >&2 2>/dev/null || printf '%s\n' "$json_payload" >&2
fi
while [ $retry_count -lt $max_retries ]; do
local response=$(curl -s --max-time 10 \
-X POST "https://api.openai.com/v1/chat/completions" \
-H "Authorization: Bearer $OPENAI_API_KEY" \
-H "Content-Type: application/json" \
-d "$json_payload")
# Check if response contains an error
if printf '%s\n' "$response" | jq -e '.error' > /dev/null 2>&1; then
local error_msg=$(printf '%s\n' "$response" | jq -r '.error.message // "Unknown error"')
retry_count=$((retry_count + 1))
if $verbose; then
echo "[VERBOSE] Error: $error_msg" >&2
echo "[VERBOSE] Retry $retry_count/$max_retries..." >&2
fi
sleep 2
continue
fi
if $verbose; then
echo "[VERBOSE] Response for $call_name:" >&2
printf '%s\n' "$response" | jq '.choices[0].message.content' >&2
fi
# Output just the response content to stdout
printf '%s\n' "$response" | jq -r '.choices[0].message.content // empty'
return 0
done
echo "[ERROR] Failed to get response from OpenAI after $max_retries attempts" >&2
return 1
}
# Escape string for JSON
json_escape() {
local str="$1"
if [ -z "$str" ]; then
echo ""
else
# Remove carriage returns and trim trailing newlines/spaces
printf "$str" | jq -Rs '.' | sed 's/^"//;s/"$//'
fi
}
# 0. First check if user typically uses scopes in their commits
uses_scope_prompt="Look at these commit messages and determine if the user typically includes a scope (like feat(api): or fix(ui):) in their commits.
Scope is the part in parentheses after the type. In the previous example, 'api' and 'ui' are scopes. If the commit messages are like 'feat: add button' without any parentheses, then the user doesn't use scopes.
Respond with ONLY 'yes' if they use scopes, or 'no' if they don't.
Commit messages:
$recent_commits"
uses_scope_response=$(make_api_call \
"You analyze commit message patterns. Respond with only 'yes' or 'no'." \
"$uses_scope_prompt" \
"uses-scope-check" | tr -d '\r' | tr '[:upper:]' '[:lower:]' | sed 's/[[:space:]]//g')
uses_scope=false
if echo "$uses_scope_response" | grep -q "yes"; then
uses_scope=true
fi
if $verbose; then
echo "[VERBOSE] User typically uses scopes: $uses_scope (response: '$uses_scope_response')" >&2
fi
# 1. Get commit type suggestion
type_prompt="Given the following staged changes, suggest the most appropriate conventional commit type.
Choose from: feat, fix, docs, style, refactor, perf, test, build, ci, chore, revert
Important: Focus on the PRIMARY purpose of the change. If both code and documentation changes are present, the type should reflect the code change (e.g. 'feat' or 'fix'), not the docs update. Only use 'docs' if the ONLY changes are documentation.
Respond with ONLY the type, nothing else."
# 2. Get scope suggestion only if user typically uses scopes
scope_prompt="Based on the staged changes, suggest a short scope (2-3 words max, lowercase, no spaces) for this commit.
If no specific scope makes sense, respond with 'none'.
Use one of the scopes that the user usually uses in their commits. You can create a new scope if needed.
The scope should reflect the main area of the codebase affected by the PRIMARY change, not secondary or ancillary files."
# 3. Get commit message suggestion
msg_prompt="Based on the staged changes, suggest a concise commit message (under 72 characters).
IMPORTANT GUIDELINES:
- Focus on the PRIMARY, most impactful change. If the commit includes both code/logic changes and documentation updates (like README), prioritize describing the code/logic change.
- Documentation, config, or README updates that accompany a code change are secondary — do NOT make them the focus of the message.
- Describe WHAT changed functionally, not which files were edited. For example, prefer 'add retry logic to API calls' over 'update api.js and README'.
- Use imperative mood (e.g. 'add', 'fix', 'remove', 'refactor'), lowercase, no period at the end.
- Do NOT include the type or scope prefix — just the message description.
- If the diff is too large to understand fully, focus on the most significant functional change visible in the file stats."
# 4. Get commit body - ONLY the body, not the commit message
body_prompt="Analyze the staged changes and decide if a commit body is needed.
GUIDELINES:
- If the change is straightforward and self-explanatory from the commit message alone (e.g. a small bug fix, renaming, adding a simple feature), respond with 'none'.
- If the change is complex, involves trade-offs, or has non-obvious reasoning, write a brief commit body (2-3 sentences max).
- The body should explain WHY the change was made or HOW it works at a high level — do NOT repeat WHAT changed (that's the commit message's job).
- Focus on the core code/logic changes. If documentation was also updated, you don't need to mention it in the body unless the documentation change itself is the main point.
- Do NOT include any headers, labels, or prefixes like 'Body:' or 'Brief commit body:'.
- Return ONLY the body text, or 'none'."
# Run API calls in parallel, capturing output into variables via temp file descriptors
exec {type_fd}< <(make_api_call \
"You are a commit message expert following conventional commits format. You analyze diffs to determine the primary intent of a change, prioritizing code and logic changes over ancillary updates like documentation or config files." \
"Staged changes:\n$staged_changes\n\n$type_prompt" \
"commit-type")
if $uses_scope; then
exec {scope_fd}< <(make_api_call \
"You suggest concise, meaningful commit scopes that reflect the primary area of the codebase affected. You match the user's existing scope conventions when possible. Default to 'none' if unsure." \
"Commit messages: \n$recent_commits\n\nStaged changes:\n$staged_changes\n\n$scope_prompt" \
"scope-suggestion")
fi
exec {msg_fd}< <(make_api_call \
"You write clear, concise conventional commit messages that capture the primary functional change. You prioritize describing code and logic changes over documentation or config updates. When a commit contains both, the message should reflect the core change. Return ONLY the message text, no prefixes." \
"Staged changes:\n$staged_changes\n\n$msg_prompt" \
"commit-message")
exec {body_fd}< <(make_api_call \
"You write helpful commit body text that explains the reasoning and context behind changes. You focus on WHY a change was made and any non-obvious technical decisions, not on listing which files were changed. You keep bodies brief and only suggest one when the change truly warrants explanation. Return ONLY the body content, no prefixes or labels." \
"Staged changes:\n$staged_changes\n\n$body_prompt" \
"commit-body")
# Read results from file descriptors (cat blocks until subprocess finishes)
ai_type=$(cat <&$type_fd | tr -d '\r')
exec {type_fd}<&-
# Sanitize commit type: extract the first matching valid type from the response
ai_type_lower=$(printf '%s' "$ai_type" | tr '[:upper:]' '[:lower:]')
ai_type=""
for t in feat fix docs style refactor perf test build ci chore revert; do
if [[ "$ai_type_lower" == *"$t"* ]]; then
ai_type="$t"
break
fi
done
if $uses_scope; then
ai_scope=$(cat <&$scope_fd | tr -d '\r')
exec {scope_fd}<&-
else
ai_scope="none"
fi
ai_msg=$(cat <&$msg_fd | tr -d '\r')
exec {msg_fd}<&-
ai_body=$(cat <&$body_fd | tr -d '\r')
exec {body_fd}<&-
# Sanitize body: if it's just "none" (possibly repeated/whitespace-padded), treat as empty
ai_body_trimmed=$(printf '%s' "$ai_body" | tr -d '[:space:]' | tr '[:upper:]' '[:lower:]')
if [[ "$ai_body_trimmed" == "none" || "$ai_body_trimmed" == "nonenone" || "$ai_body_trimmed" == "none." ]]; then
ai_body="none"
fi
# Output as JSON for easy parsing by commitish
printf '{\n'
printf ' "type": "%s",\n' "$(json_escape "$ai_type")"
printf ' "scope": "%s",\n' "$(json_escape "$ai_scope")"
printf ' "message": "%s",\n' "$(json_escape "$ai_msg")"
printf ' "body": "%s"\n' "$(json_escape "$ai_body")"
printf '}\n'