Class: CommitGpt::CommitAi

Inherits:
Object
  • Object
show all
Includes:
DiffHelpers
Defined in:
lib/commitgpt/commit_ai.rb

Overview

Commit AI roboter based on GPT-3

Constant Summary collapse

COMMIT_FORMATS =

Commit format templates

{
  'simple' => '<commit message>',
  'conventional' => '<type>[optional (<scope>)]: <commit message>',
  'gitmoji' => ':emoji: <commit message>'
}.freeze
DETAILED_EXAMPLE =

Worked example of the detailed shape. Models follow a sample far more reliably than a description of one.

<<~EXAMPLE
  Here is an example of the expected output, showing the exact shape only.
  Your subject line must still follow the format above:

  feat(dfb): store and expand DFB event and speed logs

  Persist DFB device logs ingested from the proactive evt (event log) and
  lge (speed stats) topics, one record per message.

  - Add dfb_event_logs and dfb_speed_stats tables and models, each with an
    idempotent unique index so re-delivery and overlapping backfills are safe.
  - Add the canonical event-code table and the loader that expands a device
    event byte into a human label and tags; unknown codes fall back to
    "Unknown Event".
  - Associate both log tables on Product.
  - Update README_DFB.md to document the new cloud tables.
EXAMPLE
CONVENTIONAL_TYPES =

Conventional commit types based on aicommits implementation

{
  'docs' => 'Documentation only changes',
  'style' => 'Changes that do not affect the meaning of the code (white-space, formatting, missing semi-colons, etc)',
  'refactor' => 'A code change that improves code structure without changing functionality (renaming, restructuring classes/methods, extracting functions, etc)',
  'perf' => 'A code change that improves performance',
  'test' => 'Adding missing tests or correcting existing tests',
  'build' => 'Changes that affect the build system or external dependencies',
  'ci' => 'Changes to our CI configuration files and scripts',
  'chore' => "Other changes that don't modify src or test files",
  'revert' => 'Reverts a previous commit',
  'feat' => 'A new feature',
  'fix' => 'A bug fix'
}.freeze
GITMOJI_TYPES =

Gitmoji mappings based on gitmoji.dev

{
  '๐ŸŽจ' => 'Improve structure / format of the code',
  'โšก' => 'Improve performance',
  '๐Ÿ”ฅ' => 'Remove code or files',
  '๐Ÿ›' => 'Fix a bug',
  '๐Ÿš‘' => 'Critical hotfix',
  'โœจ' => 'Introduce new features',
  '๐Ÿ“' => 'Add or update documentation',
  '๐Ÿš€' => 'Deploy stuff',
  '๐Ÿ’„' => 'Add or update the UI and style files',
  '๐ŸŽ‰' => 'Begin a project',
  'โœ…' => 'Add, update, or pass tests',
  '๐Ÿ”’' => 'Fix security or privacy issues',
  '๐Ÿ”' => 'Add or update secrets',
  '๐Ÿ”–' => 'Release / Version tags',
  '๐Ÿšจ' => 'Fix compiler / linter warnings',
  '๐Ÿšง' => 'Work in progress',
  '๐Ÿ’š' => 'Fix CI Build',
  'โฌ‡๏ธ' => 'Downgrade dependencies',
  'โฌ†๏ธ' => 'Upgrade dependencies',
  '๐Ÿ“Œ' => 'Pin dependencies to specific versions',
  '๐Ÿ‘ท' => 'Add or update CI build system',
  '๐Ÿ“ˆ' => 'Add or update analytics or track code',
  'โ™ป๏ธ' => 'Refactor code',
  'โž•' => 'Add a dependency',
  'โž–' => 'Remove a dependency',
  '๐Ÿ”ง' => 'Add or update configuration files',
  '๐Ÿ”จ' => 'Add or update development scripts',
  '๐ŸŒ' => 'Internationalization and localization',
  'โœ๏ธ' => 'Fix typos',
  '๐Ÿ’ฉ' => 'Write bad code that needs to be improved',
  'โช' => 'Revert changes',
  '๐Ÿ”€' => 'Merge branches',
  '๐Ÿ“ฆ' => 'Add or update compiled files or packages',
  '๐Ÿ‘ฝ' => 'Update code due to external API changes',
  '๐Ÿšš' => 'Move or rename resources (e.g.: files, paths, routes)',
  '๐Ÿ“„' => 'Add or update license',
  '๐Ÿ’ฅ' => 'Introduce breaking changes',
  '๐Ÿฑ' => 'Add or update assets',
  'โ™ฟ' => 'Improve accessibility',
  '๐Ÿ’ก' => 'Add or update comments in source code',
  '๐Ÿป' => 'Write code drunkenly',
  '๐Ÿ’ฌ' => 'Add or update text and literals',
  '๐Ÿ—ƒ' => 'Perform database related changes',
  '๐Ÿ”Š' => 'Add or update logs',
  '๐Ÿ”‡' => 'Remove logs',
  '๐Ÿ‘ฅ' => 'Add or update contributor(s)',
  '๐Ÿšธ' => 'Improve user experience / usability',
  '๐Ÿ—' => 'Make architectural changes',
  '๐Ÿ“ฑ' => 'Work on responsive design',
  '๐Ÿคก' => 'Mock things',
  '๐Ÿฅš' => 'Add or update an easter egg',
  '๐Ÿ™ˆ' => 'Add or update a .gitignore file',
  '๐Ÿ“ธ' => 'Add or update snapshots',
  'โš—' => 'Perform experiments',
  '๐Ÿ”' => 'Improve SEO',
  '๐Ÿท' => 'Add or update types',
  '๐ŸŒฑ' => 'Add or update seed files',
  '๐Ÿšฉ' => 'Add, update, or remove feature flags',
  '๐Ÿฅ…' => 'Catch errors',
  '๐Ÿ’ซ' => 'Add or update animations and transitions',
  '๐Ÿ—‘' => 'Deprecate code that needs to be cleaned up',
  '๐Ÿ›‚' => 'Work on code related to authorization, roles and permissions',
  '๐Ÿฉน' => 'Simple fix for a non-critical issue',
  '๐Ÿง' => 'Data exploration/inspection',
  'โšฐ' => 'Remove dead code',
  '๐Ÿงช' => 'Add a failing test',
  '๐Ÿ‘”' => 'Add or update business logic',
  '๐Ÿฉบ' => 'Add or update healthcheck',
  '๐Ÿงฑ' => 'Infrastructure related changes',
  '๐Ÿง‘โ€๐Ÿ’ป' => 'Improve developer experience',
  '๐Ÿ’ธ' => 'Add sponsorships or money related infrastructure',
  '๐Ÿงต' => 'Add or update code related to multithreading or concurrency',
  '๐Ÿฆบ' => 'Add or update code related to validation'
}.freeze

Constants included from DiffHelpers

DiffHelpers::LOCK_FILES

Instance Attribute Summary collapse

Instance Method Summary collapse

Methods included from DiffHelpers

#detect_lock_file_changes, #git_diff, #prompt_diff_handling, #prompt_no_staged_changes, #split_diff_by_length

Constructor Details

#initializeCommitAi

Returns a new instance of CommitAi.



140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
# File 'lib/commitgpt/commit_ai.rb', line 140

def initialize
  provider_config = ConfigManager.get_active_provider_config

  if provider_config
    @api_key = provider_config['api_key']
    @base_url = provider_config['base_url']
    @model = provider_config['model']
    @diff_len = provider_config['diff_len'] || 32_768
  else
    @api_key = nil
    @base_url = nil
    @model = nil
    @diff_len = 32_768
  end

  @commit_format = ConfigManager.get_commit_format
  @commit_detail = ConfigManager.get_commit_detail
end

Instance Attribute Details

#api_keyObject (readonly)

Returns the value of attribute api_key.



19
20
21
# File 'lib/commitgpt/commit_ai.rb', line 19

def api_key
  @api_key
end

#base_urlObject (readonly)

Returns the value of attribute base_url.



19
20
21
# File 'lib/commitgpt/commit_ai.rb', line 19

def base_url
  @base_url
end

#commit_formatObject (readonly)

Returns the value of attribute commit_format.



19
20
21
# File 'lib/commitgpt/commit_ai.rb', line 19

def commit_format
  @commit_format
end

#diff_lenObject (readonly)

Returns the value of attribute diff_len.



19
20
21
# File 'lib/commitgpt/commit_ai.rb', line 19

def diff_len
  @diff_len
end

#modelObject (readonly)

Returns the value of attribute model.



19
20
21
# File 'lib/commitgpt/commit_ai.rb', line 19

def model
  @model
end

Instance Method Details

#aicm(verbose: false) ⇒ Object



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
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
# File 'lib/commitgpt/commit_ai.rb', line 224

def aicm(verbose: false)
  exit(1) unless welcome
  diff = git_diff || exit(1)
  if verbose
    puts "โ†’ Git diff (#{diff.length} chars):".cyan
    puts diff
    puts "\n"
  end

  loop do
    ai_commit_message = message(diff) || exit(1)
    action = confirm_commit(ai_commit_message)

    case action
    when :commit
      # Pass argv directly: a body with quotes, backticks or newlines must
      # not be re-interpreted by the shell
      puts "\nโ†’ Executing: git commit -m <message>".yellow
      system('git', 'commit', '-m', ai_commit_message)
      puts "\n\n"
      puts `git log -1`
      break
    when :regenerate
      puts "\n"
      next
    when :edit
      # Let git open the message in the user's editor: it honours the
      # GIT_EDITOR/core.editor/VISUAL/EDITOR chain, handles multi-line
      # bodies, and aborts by itself if the message is left empty
      puts "\nโ†’ Opening #{git_editor} with the generated message...".yellow
      if system('git', 'commit', '-e', '-m', ai_commit_message)
        puts "\n"
        puts `git log -1`
      else
        puts 'โœ– Commit aborted.'.red
      end
      break
    when :exit
      puts 'โš  Exit without commit.'.yellow
      break
    end
  end
end

#content_prefixObject

Header printed before the streamed message starts arriving



186
187
188
# File 'lib/commitgpt/commit_ai.rb', line 186

def content_prefix
  detailed? ? "โœฆ Commit message:\n" : 'โœฆ Commit message: git commit -am "'
end

#content_suffixObject

Closing quote that matches content_prefix; a body has no quote to close



191
192
193
# File 'lib/commitgpt/commit_ai.rb', line 191

def content_suffix
  detailed? ? '' : '"'
end

#detailed?Boolean

True when the message should carry a body, not just a subject line

Returns:

  • (Boolean)


160
161
162
# File 'lib/commitgpt/commit_ai.rb', line 160

def detailed?
  @commit_detail == 'detailed'
end

#finalize_message(full_content) ⇒ Object

Trim the model output down to what git should receive



209
210
211
212
213
214
215
216
217
218
219
220
221
222
# File 'lib/commitgpt/commit_ai.rb', line 209

def finalize_message(full_content)
  content = strip_reasoning(full_content)

  unless detailed?
    # Take only the first non-empty line to avoid repetition or multi-line garbage
    first_line = content.split("\n").map(&:strip).reject(&:empty?).first
    return first_line&.gsub(/\A["']|["']\z/, '') || ''
  end

  body = strip_code_fence(content.strip).gsub(/\A["']|["']\z/, '').strip
  # Keep the subject/body/bullets structure but drop trailing spaces and
  # collapse the extra blank lines models like to emit between sections
  body.split("\n").map(&:rstrip).join("\n").gsub(/\n{3,}/, "\n\n")
end

#git_editorObject

Editor git will actually use, resolved through its own precedence chain



196
197
198
199
# File 'lib/commitgpt/commit_ai.rb', line 196

def git_editor
  editor = `git var GIT_EDITOR 2>/dev/null`.strip
  editor.empty? ? 'your editor' : editor
end

#list_modelsObject



268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
# File 'lib/commitgpt/commit_ai.rb', line 268

def list_models
  headers = {
    'Content-Type' => 'application/json',
    'User-Agent' => "Ruby/#{RUBY_VERSION}"
  }
  headers['Authorization'] = "Bearer #{@api_key}" if @api_key

  begin
    response = HTTParty.get("#{@base_url}/models", headers: headers)
    models = response['data'] || []
    models.each { |m| puts m['id'] }
  rescue StandardError => e
    puts "โœ– Failed to list models: #{e.message}".red
  end
end

#max_content_charsObject

Streaming cutoff. Generous because models that ignore disable_reasoning spend it on inline text before the message itself arrives.



166
167
168
# File 'lib/commitgpt/commit_ai.rb', line 166

def max_content_chars
  detailed? ? 6000 : 1500
end

#reasoning_disabled_max_tokensObject



170
171
172
# File 'lib/commitgpt/commit_ai.rb', line 170

def reasoning_disabled_max_tokens
  detailed? ? 1200 : 300
end

#strip_code_fence(text) ⇒ Object

Models like to wrap the whole message in a fence despite being told not to



202
203
204
205
206
# File 'lib/commitgpt/commit_ai.rb', line 202

def strip_code_fence(text)
  return text unless text.start_with?('```')

  text.sub(/\A```[a-zA-Z]*\n?/, '').sub(/\n?```\s*\z/, '')
end

#strip_reasoning(content) ⇒ Object

Some models stream their chain of thought inside content instead of the reasoning_content field, either wrapped in ... or with only the closing tag when the chat template pre-fills the opener. Keep what comes after the thinking, never the thinking itself.



178
179
180
181
182
183
# File 'lib/commitgpt/commit_ai.rb', line 178

def strip_reasoning(content)
  text = content.gsub(%r{<think(?:ing)?>.*?</think(?:ing)?>}m, '')
  text = text.rpartition(%r{</think(?:ing)?>}).last if text.match?(%r{</think(?:ing)?>})
  # Opener with no closer: the model never came back from thinking
  text.sub(/<think(?:ing)?>.*\z/m, '')
end