commiti 1.2.3
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.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +206 -0
- data/bin/commiti +64 -0
- data/lib/commiti.rb +21 -0
- data/lib/flows/base_flow.rb +98 -0
- data/lib/flows/commit_flow.rb +29 -0
- data/lib/flows/pr_flow.rb +45 -0
- data/lib/services/diff_summarization/batch_runner.rb +148 -0
- data/lib/services/diff_summarization/diff_summarizer.rb +89 -0
- data/lib/services/diff_summarization/fallback_builder.rb +61 -0
- data/lib/services/flow_context_builder.rb +38 -0
- data/lib/services/git/commit/commit_execution.rb +80 -0
- data/lib/services/git/commit/commit_staging.rb +37 -0
- data/lib/services/git/diff_parser.rb +63 -0
- data/lib/services/git/git_reader.rb +189 -0
- data/lib/services/git/git_writer.rb +58 -0
- data/lib/services/git/pr/pr_opener.rb +238 -0
- data/lib/services/google_client.rb +134 -0
- data/lib/services/helpers/clipboard.rb +44 -0
- data/lib/services/helpers/config_loader.rb +79 -0
- data/lib/services/helpers/interactive_prompt.rb +129 -0
- data/lib/services/helpers/prompt_builder.rb +122 -0
- data/lib/services/helpers/spinner.rb +49 -0
- data/lib/services/message_generator.rb +174 -0
- data/lib/services/message_presenter.rb +52 -0
- metadata +99 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 55dfe17b32ed45b600920943c16b99f39b0acff0c9c66e516db63e6fa7a0debf
|
|
4
|
+
data.tar.gz: 9f70caaeef73f63211e93bd28ae2ae69236c0e777153c48a765066a0825e4e49
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: a627866611dcc0974b30a005814b38226833412a1789a57f4f3559c010849313731d361dd0d0e192722577e0f1353d8fa55c2bf9487c4d83bbe596e7d5a04e16
|
|
7
|
+
data.tar.gz: 0b8e9679be21a6f74ea419ffb4968f883fb564990eeab845c63f525190d459a151cebc17d2d852a3eba9f8293093107bd146e76cf37cae8087af88bd06f53e5e
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Setoju
|
|
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.
|
data/README.md
ADDED
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
# Commiti
|
|
2
|
+
|
|
3
|
+
AI-powered commit message and pull request description generator for Git repositories, using Google AI models.
|
|
4
|
+
|
|
5
|
+
## What It Does
|
|
6
|
+
|
|
7
|
+
Commiti helps you:
|
|
8
|
+
|
|
9
|
+
- Generate conventional commit messages from staged changes.
|
|
10
|
+
- Generate structured pull request descriptions from branch diffs.
|
|
11
|
+
- Review and optionally edit generated commit messages before writing to Git history.
|
|
12
|
+
- Open a prefilled PR/MR page in your browser for GitHub, GitLab, or GitBucket (no provider API token required).
|
|
13
|
+
- Preserve semantic diff quality on large changes using file-aware clipping that keeps file headers and hunk markers.
|
|
14
|
+
|
|
15
|
+
## Requirements
|
|
16
|
+
|
|
17
|
+
- Ruby 3.2+
|
|
18
|
+
- Git
|
|
19
|
+
- Google AI API key
|
|
20
|
+
- A Git repository as your working directory
|
|
21
|
+
|
|
22
|
+
## Install Dependencies (from source)
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
bundle install
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## CLI Usage
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
bundle exec ruby -Ilib bin/commiti [options]
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Or after gem installation:
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
commiti [options]
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## Configuration
|
|
41
|
+
|
|
42
|
+
Commiti uses a single configuration approach: environment variables.
|
|
43
|
+
|
|
44
|
+
Set variables in your shell, CI secret manager, or local `.env` file (in your project):
|
|
45
|
+
|
|
46
|
+
```dotenv
|
|
47
|
+
GOOGLE_API_KEY=your_google_ai_key
|
|
48
|
+
|
|
49
|
+
# Optional overrides:
|
|
50
|
+
# COMMITI_MODEL=gemma-4-31b-it
|
|
51
|
+
# COMMITI_CANDIDATES=1
|
|
52
|
+
# COMMITI_BASE_BRANCH=main
|
|
53
|
+
# COMMITI_NO_COPY=false
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
`GEMINI_API_KEY` is also accepted as an alias for `GOOGLE_API_KEY`.
|
|
57
|
+
|
|
58
|
+
You can copy `.env.example` as a starting point.
|
|
59
|
+
|
|
60
|
+
Your API key is sent directly from your local process to Google's API.
|
|
61
|
+
Commiti does not store it and does not proxy requests through any Commiti server.
|
|
62
|
+
Never commit `.env` to git.
|
|
63
|
+
|
|
64
|
+
### Options
|
|
65
|
+
|
|
66
|
+
- `--type TYPE` where `TYPE` is `commit` or `pr` (default: `commit`)
|
|
67
|
+
- `--base BRANCH` base branch for PR diff (default: `main`)
|
|
68
|
+
- `--no-copy` print output only, skip clipboard copy
|
|
69
|
+
- `--candidates N` generate `N` output candidates (`1`-`5`, default: `1`)
|
|
70
|
+
- `-h`, `--help` show help
|
|
71
|
+
|
|
72
|
+
## Commit Flow (`--type commit`)
|
|
73
|
+
|
|
74
|
+
1. Shows `git status --short`.
|
|
75
|
+
2. Asks for confirmation before staging (`git add -A`).
|
|
76
|
+
3. Ensures there are staged changes.
|
|
77
|
+
4. Reads staged diff and generates commit message candidate(s).
|
|
78
|
+
- If the AI draft misses a conventional commit prefix, Commiti auto-normalizes it to a valid conventional subject.
|
|
79
|
+
5. If `--candidates` is greater than `1`, shows numbered candidates and asks you to select one.
|
|
80
|
+
6. Shows selected message and asks: `Commit with this message? [y/e/N]`
|
|
81
|
+
- `y`: commit now
|
|
82
|
+
- `e`: open editor, then validate and re-confirm
|
|
83
|
+
- `N`: skip commit
|
|
84
|
+
7. Writes commit with `git commit --file <tempfile>`.
|
|
85
|
+
|
|
86
|
+
### Commit Message Validation
|
|
87
|
+
|
|
88
|
+
- First line must use a conventional commit prefix (e.g. `feat:`, `fix:`).
|
|
89
|
+
- First line must be 100 characters or fewer.
|
|
90
|
+
|
|
91
|
+
### Why `--file` instead of `-m`
|
|
92
|
+
|
|
93
|
+
Multi-line messages and special characters are safer with `git commit --file`, avoiding shell quoting edge cases.
|
|
94
|
+
|
|
95
|
+
### Editor Selection
|
|
96
|
+
|
|
97
|
+
Commit edit mode uses:
|
|
98
|
+
|
|
99
|
+
1. `VISUAL`
|
|
100
|
+
2. `EDITOR`
|
|
101
|
+
3. Fallback: `notepad` on Windows, `vi` on non-Windows
|
|
102
|
+
|
|
103
|
+
## PR Flow (`--type pr`)
|
|
104
|
+
|
|
105
|
+
1. Reads branch diff: `git diff <base>...HEAD`.
|
|
106
|
+
2. Generates PR description with these sections:
|
|
107
|
+
- `## Summary`
|
|
108
|
+
- `## Motivation`
|
|
109
|
+
- `## Changes Made`
|
|
110
|
+
- `## Testing Notes`
|
|
111
|
+
3. Builds provider compare/MR URL with prefilled title/body using query params.
|
|
112
|
+
- GitHub/GitBucket: compare URL
|
|
113
|
+
- GitLab: new merge request URL
|
|
114
|
+
- If the URL would exceed safe browser/provider limits, Commiti drops description prefill automatically and keeps the shortest usable URL.
|
|
115
|
+
4. Asks before opening browser.
|
|
116
|
+
|
|
117
|
+
The tool opens a browser URL only. It does not call provider APIs.
|
|
118
|
+
|
|
119
|
+
### Diff Context Protocol
|
|
120
|
+
|
|
121
|
+
When a diff exceeds internal size limits, Commiti clips and summarizes using file-aware rules:
|
|
122
|
+
|
|
123
|
+
- Keeps full `diff --git` file headers where possible.
|
|
124
|
+
- Preserves `@@ ... @@` hunk headers before clipping hunk bodies.
|
|
125
|
+
- Includes as many complete files/hunks as fit in the limit, then appends a clipping notice.
|
|
126
|
+
- Summarizes large chunks asynchronously and in batches to reduce total LLM round trips.
|
|
127
|
+
- Falls back to deterministic file-level summaries if model summarization times out.
|
|
128
|
+
|
|
129
|
+
This improves semantic quality for AI generation compared with naive truncation.
|
|
130
|
+
|
|
131
|
+
## Examples
|
|
132
|
+
|
|
133
|
+
Generate commit message and commit interactively:
|
|
134
|
+
|
|
135
|
+
```bash
|
|
136
|
+
bundle exec ruby -Ilib bin/commiti --type commit
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
Generate multiple commit message candidates and pick one:
|
|
140
|
+
|
|
141
|
+
```bash
|
|
142
|
+
bundle exec ruby -Ilib bin/commiti --type commit --candidates 3
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
Generate PR description against `develop`:
|
|
146
|
+
|
|
147
|
+
```bash
|
|
148
|
+
bundle exec ruby -Ilib bin/commiti --type pr --base develop
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
Print only, do not copy to clipboard:
|
|
152
|
+
|
|
153
|
+
```bash
|
|
154
|
+
bundle exec ruby -Ilib bin/commiti --type pr --no-copy
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
## Implementation Overview
|
|
158
|
+
|
|
159
|
+
Main entrypoint and flow orchestration:
|
|
160
|
+
|
|
161
|
+
- `bin/commiti`: CLI parsing and flow dispatch
|
|
162
|
+
- `lib/flows/base_flow.rb`: shared generation pipeline and quality checks
|
|
163
|
+
- `lib/flows/commit_flow.rb`: commit-specific staging/edit/commit interactions
|
|
164
|
+
- `lib/flows/pr_flow.rb`: PR-specific URL generation/open flow
|
|
165
|
+
|
|
166
|
+
Core services:
|
|
167
|
+
|
|
168
|
+
- `lib/services/git_reader.rb`
|
|
169
|
+
- `lib/services/git/git_reader.rb`: Reads staged diff and branch diff, applies file-aware clipping, provides recent commits helper.
|
|
170
|
+
- `lib/services/git/git_writer.rb`: Reads status/staged state, stages (`git add -A`), commits with message file (`git commit --file`), reads branch and origin remote.
|
|
171
|
+
- `lib/services/git/diff_parser.rb`: Parses diff blocks and derives change metadata.
|
|
172
|
+
- `lib/services/git/pr/pr_opener.rb`: Parses GitHub/GitLab/GitBucket remotes, builds provider-specific PR/MR URL, opens browser cross-platform.
|
|
173
|
+
- `lib/services/google_client.rb`: Sends generation requests to Google Generative Language API.
|
|
174
|
+
- `lib/services/diff_summarization/diff_summarizer.rb`: Orchestrates large-diff summarization and summary combine.
|
|
175
|
+
- `lib/services/diff_summarization/batch_runner.rb`: Runs asynchronous, batched per-file summarization jobs.
|
|
176
|
+
- `lib/services/diff_summarization/fallback_builder.rb`: Builds deterministic summaries when model summarization fails or times out.
|
|
177
|
+
- `lib/services/helpers/config_loader.rb`: Loads configuration from environment variables.
|
|
178
|
+
- `lib/services/helpers/prompt_builder.rb`: Builds strict system/user prompts for commit and PR modes.
|
|
179
|
+
- `lib/services/helpers/interactive_prompt.rb`: Handles confirmation prompts, candidate selection, editor loop, and commit message validation.
|
|
180
|
+
- `lib/services/helpers/clipboard.rb`: Provides cross-platform clipboard support.
|
|
181
|
+
- `lib/services/helpers/spinner.rb`: Displays a spinner for long-running operations.
|
|
182
|
+
- `lib/services/message_generator.rb`: Generates commit and PR messages with quality checks.
|
|
183
|
+
- `lib/services/message_presenter.rb`: Presents generated messages to the user.
|
|
184
|
+
- `lib/services/flow_context_builder.rb`: Builds the context for different Commiti flows.
|
|
185
|
+
- `lib/services/git/commit/commit_staging.rb`: Handles staging changes for a commit.
|
|
186
|
+
- `lib/services/git/commit/commit_execution.rb`: Executes the git commit command.
|
|
187
|
+
|
|
188
|
+
Service loading:
|
|
189
|
+
|
|
190
|
+
- `lib/commiti.rb` requires all service modules.
|
|
191
|
+
|
|
192
|
+
## Error Handling
|
|
193
|
+
|
|
194
|
+
The CLI reports user-friendly errors for common cases such as:
|
|
195
|
+
|
|
196
|
+
- No changes/staged changes
|
|
197
|
+
- Invalid or missing Git data
|
|
198
|
+
- Google AI API connectivity/authentication failures
|
|
199
|
+
- Summarization timeouts on large diffs (automatically falls back to a deterministic summary and continues)
|
|
200
|
+
- Browser open failures
|
|
201
|
+
|
|
202
|
+
## Notes
|
|
203
|
+
|
|
204
|
+
- The default model is `gemma-4-31b-it` in `GoogleClient`.
|
|
205
|
+
- PR browser URL body payloads are URL-encoded with `URI.encode_www_form`.
|
|
206
|
+
- You can tune summarization worker concurrency with `DIFF_SUMMARY_WORKERS`.
|
data/bin/commiti
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require 'optparse'
|
|
5
|
+
require 'dotenv/load'
|
|
6
|
+
require 'commiti'
|
|
7
|
+
|
|
8
|
+
options = {
|
|
9
|
+
type: :commit
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
OptionParser.new do |opts|
|
|
13
|
+
opts.banner = <<~BANNER
|
|
14
|
+
|
|
15
|
+
AI Commit - Powered by Google AI (#{Commiti::GoogleClient::DEFAULT_MODEL})
|
|
16
|
+
Usage: commiti [options]
|
|
17
|
+
|
|
18
|
+
BANNER
|
|
19
|
+
|
|
20
|
+
opts.on('--type TYPE', %i[commit pr], 'Message type: commit or pr (default: commit)') do |t|
|
|
21
|
+
options[:type] = t
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
opts.on('--base BRANCH', 'Base branch for PR diff (default: main)') do |b|
|
|
25
|
+
options[:base_branch] = b
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
opts.on('--no-copy', 'Print output only, skip clipboard copy') do
|
|
29
|
+
options[:no_copy] = true
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
opts.on('--candidates N', Integer, 'Number of output candidates to generate (1-5, default: 1)') do |n|
|
|
33
|
+
raise OptionParser::InvalidArgument, 'candidates must be between 1 and 5' unless n.between?(1, 5)
|
|
34
|
+
|
|
35
|
+
options[:candidates] = n
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
opts.on('-h', '--help', 'Show this help') do
|
|
39
|
+
puts opts
|
|
40
|
+
exit
|
|
41
|
+
end
|
|
42
|
+
end.parse!
|
|
43
|
+
|
|
44
|
+
begin
|
|
45
|
+
flow = if options[:type] == :pr
|
|
46
|
+
Commiti::Flows::PrFlow.new(options: options)
|
|
47
|
+
else
|
|
48
|
+
Commiti::Flows::CommitFlow.new(options: options)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
flow.run
|
|
52
|
+
rescue RuntimeError => e
|
|
53
|
+
puts "\nError: #{e.message}\n\n"
|
|
54
|
+
exit 1
|
|
55
|
+
rescue SocketError, Errno::ECONNREFUSED
|
|
56
|
+
puts "\nError: Could not connect to Google AI API. Check network access and DNS resolution.\n\n"
|
|
57
|
+
exit 1
|
|
58
|
+
rescue Net::OpenTimeout, Net::ReadTimeout
|
|
59
|
+
puts "\nError: Google AI request timed out while generating output. Try again or use a smaller diff.\n\n"
|
|
60
|
+
exit 1
|
|
61
|
+
rescue StandardError => e
|
|
62
|
+
puts "\nError: Something went wrong (#{e.class}).\n\n"
|
|
63
|
+
exit 1
|
|
64
|
+
end
|
data/lib/commiti.rb
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'services/git/git_reader'
|
|
4
|
+
require_relative 'services/git/git_writer'
|
|
5
|
+
require_relative 'services/google_client'
|
|
6
|
+
require_relative 'services/helpers/config_loader'
|
|
7
|
+
require_relative 'services/git/diff_parser'
|
|
8
|
+
require_relative 'services/diff_summarization/diff_summarizer'
|
|
9
|
+
require_relative 'services/helpers/prompt_builder'
|
|
10
|
+
require_relative 'services/helpers/interactive_prompt'
|
|
11
|
+
require_relative 'services/git/pr/pr_opener'
|
|
12
|
+
require_relative 'services/helpers/clipboard'
|
|
13
|
+
require_relative 'services/helpers/spinner'
|
|
14
|
+
require_relative 'services/flow_context_builder'
|
|
15
|
+
require_relative 'services/message_generator'
|
|
16
|
+
require_relative 'services/message_presenter'
|
|
17
|
+
require_relative 'services/git/commit/commit_staging'
|
|
18
|
+
require_relative 'services/git/commit/commit_execution'
|
|
19
|
+
require_relative 'flows/base_flow'
|
|
20
|
+
require_relative 'flows/commit_flow'
|
|
21
|
+
require_relative 'flows/pr_flow'
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Commiti
|
|
4
|
+
module Flows
|
|
5
|
+
class BaseFlow
|
|
6
|
+
def initialize(options:)
|
|
7
|
+
# Merge defaults/config file with CLI options (CLI options win)
|
|
8
|
+
@options = Commiti::ConfigLoader.load.merge(options || {})
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def run
|
|
12
|
+
prepare!
|
|
13
|
+
diff = collect_diff
|
|
14
|
+
client = Commiti::GoogleClient.new(config: options)
|
|
15
|
+
selected_model = options[:model]
|
|
16
|
+
context = Commiti::FlowContextBuilder.build(
|
|
17
|
+
flow_type: flow_type,
|
|
18
|
+
diff: diff,
|
|
19
|
+
client: client,
|
|
20
|
+
run_stage: method(:run_stage),
|
|
21
|
+
model: selected_model
|
|
22
|
+
)
|
|
23
|
+
Commiti::MessagePresenter.print_summarization_notice(context[:summarized_result])
|
|
24
|
+
|
|
25
|
+
candidates = generate_candidates(
|
|
26
|
+
client: client,
|
|
27
|
+
prompt: context[:prompt],
|
|
28
|
+
diff_metadata: context[:diff_metadata],
|
|
29
|
+
model: selected_model
|
|
30
|
+
)
|
|
31
|
+
message = select_message(candidates)
|
|
32
|
+
|
|
33
|
+
maybe_copy_to_clipboard(message)
|
|
34
|
+
finalize(message)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
attr_reader :options
|
|
40
|
+
|
|
41
|
+
def prepare!; end
|
|
42
|
+
|
|
43
|
+
def collect_diff
|
|
44
|
+
raise NotImplementedError, "#{self.class} must implement #collect_diff"
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def flow_type
|
|
48
|
+
raise NotImplementedError, "#{self.class} must implement #flow_type"
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def finalize(_message); end
|
|
52
|
+
|
|
53
|
+
def run_stage(message, &)
|
|
54
|
+
Commiti::Spinner.run(message, &)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def generate_with_quality_check(client:, prompt:, diff_metadata:, model:)
|
|
58
|
+
message_generator.generate_with_quality_check(
|
|
59
|
+
client: client,
|
|
60
|
+
prompt: prompt,
|
|
61
|
+
diff_metadata: diff_metadata,
|
|
62
|
+
model: model
|
|
63
|
+
)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def generate_candidates(client:, prompt:, diff_metadata:, model:)
|
|
67
|
+
count = options[:candidates].to_i
|
|
68
|
+
message_generator.generate_candidates(
|
|
69
|
+
client: client,
|
|
70
|
+
prompt: prompt,
|
|
71
|
+
diff_metadata: diff_metadata,
|
|
72
|
+
count: count,
|
|
73
|
+
model: model
|
|
74
|
+
)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def select_message(candidates)
|
|
78
|
+
Commiti::MessagePresenter.select_message(candidates)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def print_message(message)
|
|
82
|
+
Commiti::MessagePresenter.print_message(message)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def maybe_copy_to_clipboard(message)
|
|
86
|
+
Commiti::MessagePresenter.maybe_copy_to_clipboard(
|
|
87
|
+
message,
|
|
88
|
+
no_copy: options[:no_copy],
|
|
89
|
+
run_stage: method(:run_stage)
|
|
90
|
+
)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def message_generator
|
|
94
|
+
@message_generator ||= Commiti::MessageGenerator.new(flow_type: flow_type, run_stage: method(:run_stage))
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Commiti
|
|
4
|
+
module Flows
|
|
5
|
+
class CommitFlow < BaseFlow
|
|
6
|
+
private
|
|
7
|
+
|
|
8
|
+
def flow_type
|
|
9
|
+
:commit
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def prepare!
|
|
13
|
+
Commiti::CommitStaging.prepare(run_stage: method(:run_stage))
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def collect_diff
|
|
17
|
+
run_stage('Collecting staged diff') { Commiti::GitReader.staged_diff }
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def finalize(message)
|
|
21
|
+
Commiti::CommitExecution.maybe_commit(
|
|
22
|
+
message,
|
|
23
|
+
run_stage: method(:run_stage),
|
|
24
|
+
print_message: method(:print_message)
|
|
25
|
+
)
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Commiti
|
|
4
|
+
module Flows
|
|
5
|
+
class PrFlow < BaseFlow
|
|
6
|
+
private
|
|
7
|
+
|
|
8
|
+
def flow_type
|
|
9
|
+
:pr
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def collect_diff
|
|
13
|
+
run_stage("Collecting diff against #{options[:base_branch]}...HEAD") do
|
|
14
|
+
Commiti::GitReader.branch_diff(base_branch: options[:base_branch])
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def finalize(message)
|
|
19
|
+
maybe_open_pr_page(message, options[:base_branch])
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def maybe_open_pr_page(description, base_branch)
|
|
23
|
+
pr_url = run_stage('Preparing prefilled PR URL') do
|
|
24
|
+
head_branch = Commiti::GitWriter.current_branch
|
|
25
|
+
origin_url = Commiti::GitWriter.origin_url
|
|
26
|
+
title = Commiti::PrOpener.suggest_title(description, head_branch: head_branch)
|
|
27
|
+
Commiti::PrOpener.compare_url(
|
|
28
|
+
origin_url: origin_url,
|
|
29
|
+
base_branch: base_branch,
|
|
30
|
+
head_branch: head_branch,
|
|
31
|
+
title: title,
|
|
32
|
+
body: description
|
|
33
|
+
)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
if Commiti::InteractivePrompt.ask_yes_no('Open prefilled PR page in browser now?', default: :no)
|
|
37
|
+
run_stage('Opening browser') { Commiti::PrOpener.open_in_browser(pr_url) }
|
|
38
|
+
puts "\nOpened PR page:\n#{pr_url}\n\n"
|
|
39
|
+
else
|
|
40
|
+
puts "\nPR URL:\n#{pr_url}\n\n"
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Commiti
|
|
4
|
+
module DiffSummarizer
|
|
5
|
+
module BatchRunner
|
|
6
|
+
def summarize_chunks(chunks, client:, model:)
|
|
7
|
+
results = Array.new(chunks.length)
|
|
8
|
+
large_jobs = []
|
|
9
|
+
|
|
10
|
+
chunks.each_with_index do |chunk, index|
|
|
11
|
+
if chunk[:diff].bytesize > CHUNK_THRESHOLD
|
|
12
|
+
large_jobs << { index: index, chunk: chunk }
|
|
13
|
+
else
|
|
14
|
+
results[index] = format_chunk_summary(path: chunk[:path], summary: mechanical_summary(chunk[:diff]))
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
batched_jobs = build_batch_jobs(large_jobs)
|
|
19
|
+
run_async_summary_jobs(batched_jobs, results: results, client: client, model: model) unless batched_jobs.empty?
|
|
20
|
+
results
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def run_async_summary_jobs(jobs, results:, client:, model:)
|
|
24
|
+
queue = Queue.new
|
|
25
|
+
jobs.each { |job| queue << job }
|
|
26
|
+
|
|
27
|
+
worker_count = summary_worker_count(jobs.length)
|
|
28
|
+
captured_errors = Queue.new
|
|
29
|
+
|
|
30
|
+
workers = Array.new(worker_count) do
|
|
31
|
+
Thread.new do
|
|
32
|
+
loop do
|
|
33
|
+
job = queue.pop(true)
|
|
34
|
+
process_batch_job(job, results: results, client: client, model: model)
|
|
35
|
+
rescue ThreadError
|
|
36
|
+
break
|
|
37
|
+
rescue StandardError => e
|
|
38
|
+
captured_errors << e
|
|
39
|
+
break
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
workers.each(&:join)
|
|
45
|
+
raise captured_errors.pop unless captured_errors.empty?
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def process_batch_job(job, results:, client:, model:)
|
|
49
|
+
items = job[:items]
|
|
50
|
+
if items.length == 1
|
|
51
|
+
item = items.first
|
|
52
|
+
summary = summarize_single_chunk(item[:chunk], client: client, model: model)
|
|
53
|
+
results[item[:index]] = format_chunk_summary(path: item[:chunk][:path], summary: summary)
|
|
54
|
+
return
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
summaries = summarize_chunk_batch(items, client: client, model: model)
|
|
58
|
+
items.each do |item|
|
|
59
|
+
summary = summaries[item[:chunk][:path].to_s]
|
|
60
|
+
summary ||= summarize_single_chunk(item[:chunk], client: client, model: model)
|
|
61
|
+
results[item[:index]] = format_chunk_summary(path: item[:chunk][:path], summary: summary)
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def build_batch_jobs(jobs)
|
|
66
|
+
batched = []
|
|
67
|
+
current = []
|
|
68
|
+
current_bytes = 0
|
|
69
|
+
|
|
70
|
+
jobs.each do |job|
|
|
71
|
+
chunk_bytes = job[:chunk][:diff].bytesize
|
|
72
|
+
should_split = !current.empty? && (
|
|
73
|
+
current.length >= MAX_BATCH_FILES ||
|
|
74
|
+
current_bytes + chunk_bytes > MAX_BATCH_BYTES
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
if should_split
|
|
78
|
+
batched << { items: current }
|
|
79
|
+
current = []
|
|
80
|
+
current_bytes = 0
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
current << job
|
|
84
|
+
current_bytes += chunk_bytes
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
batched << { items: current } unless current.empty?
|
|
88
|
+
batched
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def summarize_single_chunk(chunk, client:, model:)
|
|
92
|
+
client.generate(
|
|
93
|
+
system: CHUNK_SYSTEM,
|
|
94
|
+
user: "Summarize these changes:\n\n``diff\n#{chunk[:diff]}\n``",
|
|
95
|
+
model: model,
|
|
96
|
+
timeout_seconds: 120,
|
|
97
|
+
open_timeout_seconds: 10
|
|
98
|
+
)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def summarize_chunk_batch(items, client:, model:)
|
|
102
|
+
user = +"Summarize the following file diffs:\n\n"
|
|
103
|
+
items.each do |item|
|
|
104
|
+
path = item[:chunk][:path]
|
|
105
|
+
diff = item[:chunk][:diff]
|
|
106
|
+
user << "### #{path}\n```diff\n#{diff}\n```\n\n"
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
output = client.generate(
|
|
110
|
+
system: BATCH_SYSTEM,
|
|
111
|
+
user: user.rstrip,
|
|
112
|
+
model: model,
|
|
113
|
+
timeout_seconds: 120,
|
|
114
|
+
open_timeout_seconds: 10
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
parse_batched_summary_output(output, expected_paths: items.map { |item| item[:chunk][:path].to_s })
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def parse_batched_summary_output(output, expected_paths:)
|
|
121
|
+
sections = output.to_s.split(/^### /).map(&:strip).reject(&:empty?)
|
|
122
|
+
parsed = {}
|
|
123
|
+
|
|
124
|
+
sections.each do |section|
|
|
125
|
+
lines = section.lines
|
|
126
|
+
path = lines.first.to_s.strip
|
|
127
|
+
next unless expected_paths.include?(path)
|
|
128
|
+
|
|
129
|
+
summary = lines[1..].to_a.join.strip
|
|
130
|
+
parsed[path] = summary unless summary.empty?
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
parsed
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def summary_worker_count(job_count)
|
|
137
|
+
configured = Integer(ENV.fetch('DIFF_SUMMARY_WORKERS', DEFAULT_SUMMARY_WORKERS))
|
|
138
|
+
configured.clamp(1, job_count)
|
|
139
|
+
rescue ArgumentError
|
|
140
|
+
DEFAULT_SUMMARY_WORKERS.clamp(1, job_count)
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def format_chunk_summary(path:, summary:)
|
|
144
|
+
"### #{path}\n#{summary.to_s.strip}"
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
end
|