diffsage 1.3.0__tar.gz → 1.4.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.
- {diffsage-1.3.0/src/diffsage.egg-info → diffsage-1.4.0}/PKG-INFO +47 -6
- {diffsage-1.3.0 → diffsage-1.4.0}/README.md +45 -4
- {diffsage-1.3.0 → diffsage-1.4.0}/pyproject.toml +23 -3
- diffsage-1.4.0/src/diffsage/cli.py +79 -0
- diffsage-1.4.0/src/diffsage/commands/ask.py +32 -0
- diffsage-1.4.0/src/diffsage/commands/auth.py +147 -0
- diffsage-1.4.0/src/diffsage/commands/commit.py +142 -0
- diffsage-1.4.0/src/diffsage/commands/config.py +159 -0
- {diffsage-1.3.0 → diffsage-1.4.0}/src/diffsage/commands/doctor.py +2 -0
- diffsage-1.4.0/src/diffsage/commands/error_handler.py +52 -0
- diffsage-1.4.0/src/diffsage/commands/pr.py +150 -0
- {diffsage-1.3.0 → diffsage-1.4.0}/src/diffsage/config/defaults.py +1 -0
- diffsage-1.4.0/src/diffsage/config/file_loader.py +52 -0
- diffsage-1.4.0/src/diffsage/config/loader.py +24 -0
- {diffsage-1.3.0 → diffsage-1.4.0}/src/diffsage/config/paths.py +22 -9
- diffsage-1.4.0/src/diffsage/config/resolver.py +156 -0
- diffsage-1.4.0/src/diffsage/config/schema.py +92 -0
- {diffsage-1.3.0 → diffsage-1.4.0}/src/diffsage/config/settings.py +1 -0
- {diffsage-1.3.0 → diffsage-1.4.0}/src/diffsage/exceptions/__init__.py +15 -1
- diffsage-1.4.0/src/diffsage/exceptions/base.py +83 -0
- diffsage-1.4.0/src/diffsage/exceptions/credentials.py +24 -0
- {diffsage-1.3.0 → diffsage-1.4.0}/src/diffsage/exceptions/git.py +14 -0
- {diffsage-1.3.0 → diffsage-1.4.0}/src/diffsage/git/client.py +61 -81
- diffsage-1.4.0/src/diffsage/models/commit_message.py +26 -0
- diffsage-1.4.0/src/diffsage/models/config.py +42 -0
- {diffsage-1.3.0 → diffsage-1.4.0}/src/diffsage/models/doctor.py +1 -0
- {diffsage-1.3.0 → diffsage-1.4.0}/src/diffsage/models/git.py +4 -12
- diffsage-1.4.0/src/diffsage/parsers/commit_message_parser.py +93 -0
- {diffsage-1.3.0 → diffsage-1.4.0}/src/diffsage/services/ai_service.py +3 -10
- {diffsage-1.3.0 → diffsage-1.4.0}/src/diffsage/services/commit_service.py +15 -1
- diffsage-1.4.0/src/diffsage/services/config_service.py +134 -0
- diffsage-1.4.0/src/diffsage/services/credentials_service.py +120 -0
- diffsage-1.4.0/src/diffsage/services/diff_sanitizer.py +256 -0
- {diffsage-1.3.0 → diffsage-1.4.0}/src/diffsage/services/doctor_service.py +12 -3
- diffsage-1.4.0/src/diffsage/services/editor_service.py +87 -0
- {diffsage-1.3.0 → diffsage-1.4.0}/src/diffsage/services/git_service.py +0 -1
- {diffsage-1.3.0 → diffsage-1.4.0}/src/diffsage/services/github_service.py +17 -6
- {diffsage-1.3.0 → diffsage-1.4.0}/src/diffsage/services/prompt_service.py +24 -5
- {diffsage-1.3.0 → diffsage-1.4.0}/src/diffsage/services/pull_request_service.py +17 -0
- {diffsage-1.3.0 → diffsage-1.4.0}/src/diffsage/storage/config_repository.py +13 -9
- diffsage-1.4.0/src/diffsage/storage/credentials_repository.py +176 -0
- diffsage-1.4.0/src/diffsage/storage/files.py +52 -0
- {diffsage-1.3.0 → diffsage-1.4.0}/src/diffsage/ui/commit_view.py +3 -0
- {diffsage-1.3.0 → diffsage-1.4.0}/src/diffsage/ui/config_view.py +25 -2
- {diffsage-1.3.0 → diffsage-1.4.0}/src/diffsage/ui/doctor_view.py +7 -1
- {diffsage-1.3.0 → diffsage-1.4.0/src/diffsage.egg-info}/PKG-INFO +47 -6
- {diffsage-1.3.0 → diffsage-1.4.0}/src/diffsage.egg-info/SOURCES.txt +6 -1
- {diffsage-1.3.0 → diffsage-1.4.0}/src/diffsage.egg-info/requires.txt +1 -1
- diffsage-1.4.0/tests/test_cli.py +21 -0
- diffsage-1.4.0/tests/test_harness.py +128 -0
- diffsage-1.3.0/src/diffsage/cli.py +0 -44
- diffsage-1.3.0/src/diffsage/commands/ask.py +0 -68
- diffsage-1.3.0/src/diffsage/commands/auth.py +0 -153
- diffsage-1.3.0/src/diffsage/commands/commit.py +0 -142
- diffsage-1.3.0/src/diffsage/commands/config.py +0 -216
- diffsage-1.3.0/src/diffsage/commands/pr.py +0 -220
- diffsage-1.3.0/src/diffsage/config/file_loader.py +0 -21
- diffsage-1.3.0/src/diffsage/config/loader.py +0 -8
- diffsage-1.3.0/src/diffsage/config/resolver.py +0 -118
- diffsage-1.3.0/src/diffsage/config/schema.py +0 -53
- diffsage-1.3.0/src/diffsage/exceptions/base.py +0 -44
- diffsage-1.3.0/src/diffsage/exceptions/credentials.py +0 -8
- diffsage-1.3.0/src/diffsage/models/commit_message.py +0 -11
- diffsage-1.3.0/src/diffsage/models/config.py +0 -16
- diffsage-1.3.0/src/diffsage/parsers/commit_message_parser.py +0 -43
- diffsage-1.3.0/src/diffsage/services/config_service.py +0 -109
- diffsage-1.3.0/src/diffsage/services/credentials_service.py +0 -61
- diffsage-1.3.0/src/diffsage/services/editor_service.py +0 -75
- diffsage-1.3.0/src/diffsage/storage/credentials_repository.py +0 -121
- {diffsage-1.3.0 → diffsage-1.4.0}/LICENSE +0 -0
- {diffsage-1.3.0 → diffsage-1.4.0}/setup.cfg +0 -0
- {diffsage-1.3.0 → diffsage-1.4.0}/src/diffsage/__init__.py +0 -0
- {diffsage-1.3.0 → diffsage-1.4.0}/src/diffsage/__main__.py +0 -0
- {diffsage-1.3.0 → diffsage-1.4.0}/src/diffsage/commands/__init__.py +0 -0
- {diffsage-1.3.0 → diffsage-1.4.0}/src/diffsage/config/__init__.py +0 -0
- {diffsage-1.3.0 → diffsage-1.4.0}/src/diffsage/config/scope.py +0 -0
- {diffsage-1.3.0 → diffsage-1.4.0}/src/diffsage/exceptions/github.py +0 -0
- {diffsage-1.3.0 → diffsage-1.4.0}/src/diffsage/exceptions/provider.py +0 -0
- {diffsage-1.3.0 → diffsage-1.4.0}/src/diffsage/git/__init__.py +0 -0
- {diffsage-1.3.0 → diffsage-1.4.0}/src/diffsage/github/__init__.py +0 -0
- {diffsage-1.3.0 → diffsage-1.4.0}/src/diffsage/github/client.py +0 -0
- {diffsage-1.3.0 → diffsage-1.4.0}/src/diffsage/logging/__init__.py +0 -0
- {diffsage-1.3.0 → diffsage-1.4.0}/src/diffsage/logging/formatter.py +0 -0
- {diffsage-1.3.0 → diffsage-1.4.0}/src/diffsage/logging/logger.py +0 -0
- {diffsage-1.3.0 → diffsage-1.4.0}/src/diffsage/models/__init__.py +0 -0
- {diffsage-1.3.0 → diffsage-1.4.0}/src/diffsage/models/credentials.py +0 -0
- {diffsage-1.3.0 → diffsage-1.4.0}/src/diffsage/models/provider.py +0 -0
- {diffsage-1.3.0 → diffsage-1.4.0}/src/diffsage/models/pull_request.py +0 -0
- {diffsage-1.3.0 → diffsage-1.4.0}/src/diffsage/models/pull_request_analysis.py +0 -0
- {diffsage-1.3.0 → diffsage-1.4.0}/src/diffsage/parsers/pull_request_parser.py +0 -0
- {diffsage-1.3.0 → diffsage-1.4.0}/src/diffsage/prompts/__init__.py +0 -0
- {diffsage-1.3.0 → diffsage-1.4.0}/src/diffsage/providers/__init__.py +0 -0
- {diffsage-1.3.0 → diffsage-1.4.0}/src/diffsage/providers/base.py +0 -0
- {diffsage-1.3.0 → diffsage-1.4.0}/src/diffsage/providers/factory.py +0 -0
- {diffsage-1.3.0 → diffsage-1.4.0}/src/diffsage/providers/gemini_provider.py +0 -0
- {diffsage-1.3.0 → diffsage-1.4.0}/src/diffsage/services/__init__.py +0 -0
- {diffsage-1.3.0 → diffsage-1.4.0}/src/diffsage/services/pull_request_analysis_service.py +0 -0
- {diffsage-1.3.0 → diffsage-1.4.0}/src/diffsage/storage/__init__.py +0 -0
- {diffsage-1.3.0 → diffsage-1.4.0}/src/diffsage/storage/base.py +0 -0
- {diffsage-1.3.0 → diffsage-1.4.0}/src/diffsage/ui/__init__.py +0 -0
- {diffsage-1.3.0 → diffsage-1.4.0}/src/diffsage/ui/ask_view.py +0 -0
- {diffsage-1.3.0 → diffsage-1.4.0}/src/diffsage/ui/auth_view.py +0 -0
- {diffsage-1.3.0 → diffsage-1.4.0}/src/diffsage/ui/base.py +0 -0
- {diffsage-1.3.0 → diffsage-1.4.0}/src/diffsage/ui/pull_request_view.py +0 -0
- {diffsage-1.3.0 → diffsage-1.4.0}/src/diffsage.egg-info/dependency_links.txt +0 -0
- {diffsage-1.3.0 → diffsage-1.4.0}/src/diffsage.egg-info/entry_points.txt +0 -0
- {diffsage-1.3.0 → diffsage-1.4.0}/src/diffsage.egg-info/top_level.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: diffsage
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.4.0
|
|
4
4
|
Summary: AI-powered Git workflow assistant
|
|
5
5
|
Author: Gunitha Rathnayake
|
|
6
6
|
License: MIT
|
|
@@ -26,7 +26,6 @@ Requires-Dist: platformdirs>=4.0.0
|
|
|
26
26
|
Requires-Dist: tomlkit<1,>=0.13
|
|
27
27
|
Requires-Dist: httpx<0.29,>=0.28
|
|
28
28
|
Requires-Dist: PyYAML<7,>=6.0
|
|
29
|
-
Requires-Dist: python-dotenv<2,>=1.1
|
|
30
29
|
Requires-Dist: google-genai<2,>=1
|
|
31
30
|
Provides-Extra: dev
|
|
32
31
|
Requires-Dist: pytest<10,>=9; extra == "dev"
|
|
@@ -35,6 +34,7 @@ Requires-Dist: ruff<0.13,>=0.12; extra == "dev"
|
|
|
35
34
|
Requires-Dist: pre-commit<5,>=4; extra == "dev"
|
|
36
35
|
Requires-Dist: build<2,>=1; extra == "dev"
|
|
37
36
|
Requires-Dist: pip-audit<3,>=2; extra == "dev"
|
|
37
|
+
Requires-Dist: mypy<2,>=1.18; extra == "dev"
|
|
38
38
|
Dynamic: license-file
|
|
39
39
|
|
|
40
40
|
# DiffSage
|
|
@@ -234,7 +234,7 @@ pipx install diffsage
|
|
|
234
234
|
A specific version can be installed with:
|
|
235
235
|
|
|
236
236
|
```bash
|
|
237
|
-
pipx install diffsage==1.
|
|
237
|
+
pipx install diffsage==1.4.0
|
|
238
238
|
```
|
|
239
239
|
|
|
240
240
|
Release builds and PyPI publishing are automated through GitHub Actions.
|
|
@@ -270,12 +270,45 @@ diffsage config unset provider
|
|
|
270
270
|
|
|
271
271
|
---
|
|
272
272
|
|
|
273
|
+
# What Is Sent to the AI Provider
|
|
274
|
+
|
|
275
|
+
`diffsage commit` and `diffsage pr` send your Git diff to the configured AI provider. Before
|
|
276
|
+
anything is sent, DiffSage:
|
|
277
|
+
|
|
278
|
+
- omits the contents of files that usually hold secrets (`.env`, `.env.*`, `*.pem`, `*.key`,
|
|
279
|
+
SSH keys, `.npmrc`, ...); `.env.example`-style templates are sent with their values redacted
|
|
280
|
+
- omits lockfile contents (`package-lock.json`, `poetry.lock`, `uv.lock`, ...)
|
|
281
|
+
- replaces values that look like credentials with `[REDACTED]`: Google, GitHub, AWS, Slack,
|
|
282
|
+
OpenAI and Anthropic keys, private key blocks, and `key = "value"` style secret assignments
|
|
283
|
+
- limits the diff to 100,000 characters, keeping whole files and listing any that are left out
|
|
284
|
+
|
|
285
|
+
Whenever something is removed, DiffSage prints a `!` notice, so nothing is hidden silently.
|
|
286
|
+
This is a safety net, not a guarantee: review what you stage, and keep secrets out of
|
|
287
|
+
your repository.
|
|
288
|
+
|
|
289
|
+
---
|
|
290
|
+
|
|
273
291
|
# Quick Start
|
|
274
292
|
|
|
275
|
-
Set up your provider credential
|
|
293
|
+
Set up your provider credential. DiffSage asks for the API key at a hidden prompt, so it
|
|
294
|
+
never appears on screen, in your shell history or in the process list:
|
|
295
|
+
|
|
296
|
+
```bash
|
|
297
|
+
diffsage auth set gemini
|
|
298
|
+
```
|
|
299
|
+
|
|
300
|
+
In scripts, pipe the key in instead of typing it:
|
|
301
|
+
|
|
302
|
+
```bash
|
|
303
|
+
printf '%s\n' "$GEMINI_API_KEY" | diffsage auth set gemini
|
|
304
|
+
```
|
|
305
|
+
|
|
306
|
+
In CI or containers you can skip the credentials file entirely and provide the key through
|
|
307
|
+
the `DIFFSAGE_API_KEY` environment variable. When it is set, it is used instead of any
|
|
308
|
+
stored credential, and `diffsage auth list` says so:
|
|
276
309
|
|
|
277
310
|
```bash
|
|
278
|
-
diffsage
|
|
311
|
+
DIFFSAGE_API_KEY="$GEMINI_API_KEY" diffsage pr
|
|
279
312
|
```
|
|
280
313
|
|
|
281
314
|
Verify your environment:
|
|
@@ -349,7 +382,15 @@ Display PR URL
|
|
|
349
382
|
Named credential profiles are also supported:
|
|
350
383
|
|
|
351
384
|
```bash
|
|
352
|
-
diffsage auth set gemini
|
|
385
|
+
diffsage auth set gemini --name paid
|
|
386
|
+
```
|
|
387
|
+
|
|
388
|
+
Choose which profile AI commands use with the `credential_profile` setting, per repository
|
|
389
|
+
or globally, or for a single run with `DIFFSAGE_CREDENTIAL_PROFILE`:
|
|
390
|
+
|
|
391
|
+
```bash
|
|
392
|
+
diffsage config set credential_profile paid --local
|
|
393
|
+
DIFFSAGE_CREDENTIAL_PROFILE=paid diffsage pr
|
|
353
394
|
```
|
|
354
395
|
|
|
355
396
|
View configured credentials:
|
|
@@ -195,7 +195,7 @@ pipx install diffsage
|
|
|
195
195
|
A specific version can be installed with:
|
|
196
196
|
|
|
197
197
|
```bash
|
|
198
|
-
pipx install diffsage==1.
|
|
198
|
+
pipx install diffsage==1.4.0
|
|
199
199
|
```
|
|
200
200
|
|
|
201
201
|
Release builds and PyPI publishing are automated through GitHub Actions.
|
|
@@ -231,12 +231,45 @@ diffsage config unset provider
|
|
|
231
231
|
|
|
232
232
|
---
|
|
233
233
|
|
|
234
|
+
# What Is Sent to the AI Provider
|
|
235
|
+
|
|
236
|
+
`diffsage commit` and `diffsage pr` send your Git diff to the configured AI provider. Before
|
|
237
|
+
anything is sent, DiffSage:
|
|
238
|
+
|
|
239
|
+
- omits the contents of files that usually hold secrets (`.env`, `.env.*`, `*.pem`, `*.key`,
|
|
240
|
+
SSH keys, `.npmrc`, ...); `.env.example`-style templates are sent with their values redacted
|
|
241
|
+
- omits lockfile contents (`package-lock.json`, `poetry.lock`, `uv.lock`, ...)
|
|
242
|
+
- replaces values that look like credentials with `[REDACTED]`: Google, GitHub, AWS, Slack,
|
|
243
|
+
OpenAI and Anthropic keys, private key blocks, and `key = "value"` style secret assignments
|
|
244
|
+
- limits the diff to 100,000 characters, keeping whole files and listing any that are left out
|
|
245
|
+
|
|
246
|
+
Whenever something is removed, DiffSage prints a `!` notice, so nothing is hidden silently.
|
|
247
|
+
This is a safety net, not a guarantee: review what you stage, and keep secrets out of
|
|
248
|
+
your repository.
|
|
249
|
+
|
|
250
|
+
---
|
|
251
|
+
|
|
234
252
|
# Quick Start
|
|
235
253
|
|
|
236
|
-
Set up your provider credential
|
|
254
|
+
Set up your provider credential. DiffSage asks for the API key at a hidden prompt, so it
|
|
255
|
+
never appears on screen, in your shell history or in the process list:
|
|
256
|
+
|
|
257
|
+
```bash
|
|
258
|
+
diffsage auth set gemini
|
|
259
|
+
```
|
|
260
|
+
|
|
261
|
+
In scripts, pipe the key in instead of typing it:
|
|
262
|
+
|
|
263
|
+
```bash
|
|
264
|
+
printf '%s\n' "$GEMINI_API_KEY" | diffsage auth set gemini
|
|
265
|
+
```
|
|
266
|
+
|
|
267
|
+
In CI or containers you can skip the credentials file entirely and provide the key through
|
|
268
|
+
the `DIFFSAGE_API_KEY` environment variable. When it is set, it is used instead of any
|
|
269
|
+
stored credential, and `diffsage auth list` says so:
|
|
237
270
|
|
|
238
271
|
```bash
|
|
239
|
-
diffsage
|
|
272
|
+
DIFFSAGE_API_KEY="$GEMINI_API_KEY" diffsage pr
|
|
240
273
|
```
|
|
241
274
|
|
|
242
275
|
Verify your environment:
|
|
@@ -310,7 +343,15 @@ Display PR URL
|
|
|
310
343
|
Named credential profiles are also supported:
|
|
311
344
|
|
|
312
345
|
```bash
|
|
313
|
-
diffsage auth set gemini
|
|
346
|
+
diffsage auth set gemini --name paid
|
|
347
|
+
```
|
|
348
|
+
|
|
349
|
+
Choose which profile AI commands use with the `credential_profile` setting, per repository
|
|
350
|
+
or globally, or for a single run with `DIFFSAGE_CREDENTIAL_PROFILE`:
|
|
351
|
+
|
|
352
|
+
```bash
|
|
353
|
+
diffsage config set credential_profile paid --local
|
|
354
|
+
DIFFSAGE_CREDENTIAL_PROFILE=paid diffsage pr
|
|
314
355
|
```
|
|
315
356
|
|
|
316
357
|
View configured credentials:
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "diffsage"
|
|
7
|
-
version = "1.
|
|
7
|
+
version = "1.4.0"
|
|
8
8
|
description = "AI-powered Git workflow assistant"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.12,<3.13"
|
|
@@ -41,7 +41,6 @@ dependencies = [
|
|
|
41
41
|
"tomlkit>=0.13,<1",
|
|
42
42
|
"httpx>=0.28,<0.29",
|
|
43
43
|
"PyYAML>=6.0,<7",
|
|
44
|
-
"python-dotenv>=1.1,<2",
|
|
45
44
|
"google-genai>=1,<2",
|
|
46
45
|
]
|
|
47
46
|
|
|
@@ -53,6 +52,7 @@ dev = [
|
|
|
53
52
|
"pre-commit>=4,<5",
|
|
54
53
|
"build>=1,<2",
|
|
55
54
|
"pip-audit>=2,<3",
|
|
55
|
+
"mypy>=1.18,<2",
|
|
56
56
|
]
|
|
57
57
|
|
|
58
58
|
[project.scripts]
|
|
@@ -80,4 +80,24 @@ line-length = 100
|
|
|
80
80
|
target-version = "py312"
|
|
81
81
|
|
|
82
82
|
[tool.ruff.lint]
|
|
83
|
-
select = ["E", "F", "I", "UP", "B"]
|
|
83
|
+
select = ["E", "F", "I", "UP", "B", "ARG"]
|
|
84
|
+
|
|
85
|
+
[tool.mypy]
|
|
86
|
+
python_version = "3.12"
|
|
87
|
+
files = ["src"]
|
|
88
|
+
warn_unused_configs = true
|
|
89
|
+
warn_redundant_casts = true
|
|
90
|
+
warn_unused_ignores = true
|
|
91
|
+
|
|
92
|
+
# Temporary baseline: these modules have known type errors that are fixed by
|
|
93
|
+
# later remediation branches. Each branch removes its modules from this list.
|
|
94
|
+
[[tool.mypy.overrides]]
|
|
95
|
+
module = [
|
|
96
|
+
# feature/ai-provider-hardening
|
|
97
|
+
"diffsage.providers.gemini_provider",
|
|
98
|
+
"diffsage.services.ai_service",
|
|
99
|
+
# chore/cleanup
|
|
100
|
+
"diffsage.ui.auth_view",
|
|
101
|
+
"diffsage.ui.config_view",
|
|
102
|
+
]
|
|
103
|
+
ignore_errors = true
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
from importlib.metadata import version
|
|
3
|
+
|
|
4
|
+
import typer
|
|
5
|
+
|
|
6
|
+
from diffsage.commands.ask import ask
|
|
7
|
+
from diffsage.commands.auth import app as auth_app
|
|
8
|
+
from diffsage.commands.commit import commit
|
|
9
|
+
from diffsage.commands.config import app as config_app
|
|
10
|
+
from diffsage.commands.doctor import doctor
|
|
11
|
+
from diffsage.commands.error_handler import handle_command_errors
|
|
12
|
+
from diffsage.commands.pr import pr
|
|
13
|
+
from diffsage.config.loader import load_settings
|
|
14
|
+
from diffsage.exceptions import ConfigError
|
|
15
|
+
from diffsage.logging.logger import configure_logging
|
|
16
|
+
|
|
17
|
+
app = typer.Typer(help="DiffSage: AI-aware Git workflow toolkit")
|
|
18
|
+
|
|
19
|
+
# Commands that must run even when the configuration is invalid, so it can be
|
|
20
|
+
# repaired (`config`) or diagnosed (`doctor`).
|
|
21
|
+
CONFIG_TOLERANT_COMMANDS = {"config", "doctor"}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def version_callback(value: bool) -> None:
|
|
25
|
+
if value:
|
|
26
|
+
typer.echo(version("diffsage"))
|
|
27
|
+
raise typer.Exit()
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@app.callback()
|
|
31
|
+
@handle_command_errors("diffsage")
|
|
32
|
+
def main(
|
|
33
|
+
ctx: typer.Context,
|
|
34
|
+
_version: bool = typer.Option(
|
|
35
|
+
False,
|
|
36
|
+
"--version",
|
|
37
|
+
"-v",
|
|
38
|
+
callback=version_callback,
|
|
39
|
+
is_eager=True,
|
|
40
|
+
help="Show the Diffsage version.",
|
|
41
|
+
),
|
|
42
|
+
) -> None:
|
|
43
|
+
"""
|
|
44
|
+
Initialize DiffSage.
|
|
45
|
+
"""
|
|
46
|
+
try:
|
|
47
|
+
settings = load_settings()
|
|
48
|
+
except ConfigError:
|
|
49
|
+
# Start the log file at the default level so the error handler's warning is
|
|
50
|
+
# written there, instead of to stderr through Python's last-resort handler.
|
|
51
|
+
configure_logging()
|
|
52
|
+
|
|
53
|
+
if ctx.invoked_subcommand in CONFIG_TOLERANT_COMMANDS:
|
|
54
|
+
return
|
|
55
|
+
|
|
56
|
+
raise
|
|
57
|
+
|
|
58
|
+
configure_logging(
|
|
59
|
+
getattr(
|
|
60
|
+
logging,
|
|
61
|
+
settings.log_level.upper(),
|
|
62
|
+
logging.INFO,
|
|
63
|
+
)
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
app.command()(doctor)
|
|
68
|
+
app.command()(ask)
|
|
69
|
+
app.command()(commit)
|
|
70
|
+
app.command()(pr)
|
|
71
|
+
|
|
72
|
+
app.add_typer(
|
|
73
|
+
config_app,
|
|
74
|
+
name="config",
|
|
75
|
+
)
|
|
76
|
+
app.add_typer(
|
|
77
|
+
auth_app,
|
|
78
|
+
name="auth",
|
|
79
|
+
)
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
from diffsage.commands.error_handler import handle_command_errors
|
|
2
|
+
from diffsage.config.loader import load_settings
|
|
3
|
+
from diffsage.config.paths import get_credentials_path
|
|
4
|
+
from diffsage.logging.logger import get_logger
|
|
5
|
+
from diffsage.services.ai_service import AIService
|
|
6
|
+
from diffsage.services.credentials_service import CredentialService
|
|
7
|
+
from diffsage.storage.credentials_repository import CredentialsRepository
|
|
8
|
+
from diffsage.ui.ask_view import AskView
|
|
9
|
+
|
|
10
|
+
logger = get_logger(__name__)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@handle_command_errors("ask")
|
|
14
|
+
def ask(prompt: str) -> None:
|
|
15
|
+
"""Ask the configured AI provider a question"""
|
|
16
|
+
|
|
17
|
+
logger.info("Running ask command")
|
|
18
|
+
view = AskView()
|
|
19
|
+
|
|
20
|
+
settings = load_settings()
|
|
21
|
+
|
|
22
|
+
credential_path = get_credentials_path()
|
|
23
|
+
repository = CredentialsRepository(credential_path)
|
|
24
|
+
credential_service = CredentialService(repository)
|
|
25
|
+
|
|
26
|
+
service = AIService(
|
|
27
|
+
settings,
|
|
28
|
+
credential_service,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
response = service.ask(prompt)
|
|
32
|
+
view.show_response(response)
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import sys
|
|
2
|
+
|
|
3
|
+
import typer
|
|
4
|
+
|
|
5
|
+
from diffsage.commands.error_handler import handle_command_errors
|
|
6
|
+
from diffsage.config.paths import get_credentials_path
|
|
7
|
+
from diffsage.exceptions import CredentialNotFoundError, InvalidCredentialError
|
|
8
|
+
from diffsage.logging.logger import get_logger
|
|
9
|
+
from diffsage.services.credentials_service import API_KEY_ENVIRONMENT_VARIABLE, CredentialService
|
|
10
|
+
from diffsage.storage.credentials_repository import CredentialsRepository
|
|
11
|
+
from diffsage.ui.auth_view import AuthView
|
|
12
|
+
|
|
13
|
+
logger = get_logger(__name__)
|
|
14
|
+
|
|
15
|
+
app = typer.Typer(
|
|
16
|
+
help="DiffSage credential management",
|
|
17
|
+
invoke_without_command=False,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _stdin_is_terminal() -> bool:
|
|
22
|
+
return sys.stdin.isatty()
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _read_api_key(provider: str) -> str:
|
|
26
|
+
"""Ask for the key at a hidden prompt, or read it from piped input."""
|
|
27
|
+
|
|
28
|
+
if _stdin_is_terminal():
|
|
29
|
+
return typer.prompt(f"API key for {provider}", hide_input=True)
|
|
30
|
+
|
|
31
|
+
# Piped input (scripts, CI): nothing to hide on screen, so no prompt. Using the
|
|
32
|
+
# hidden prompt here would print getpass's "input may be echoed" warning.
|
|
33
|
+
api_key = sys.stdin.readline().strip()
|
|
34
|
+
|
|
35
|
+
if not api_key:
|
|
36
|
+
raise InvalidCredentialError("No API key was received on standard input.")
|
|
37
|
+
|
|
38
|
+
return api_key
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _warn_if_environment_key_is_set(view: AuthView) -> None:
|
|
42
|
+
if CredentialService.environment_api_key() is not None:
|
|
43
|
+
view.show_warning(
|
|
44
|
+
f"{API_KEY_ENVIRONMENT_VARIABLE} is set, so AI commands use it instead of "
|
|
45
|
+
"stored credentials."
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _credential_service() -> CredentialService:
|
|
50
|
+
path = get_credentials_path()
|
|
51
|
+
repository = CredentialsRepository(path)
|
|
52
|
+
return CredentialService(repository)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@app.command("set")
|
|
56
|
+
@handle_command_errors("auth set")
|
|
57
|
+
def set_credential(
|
|
58
|
+
provider: str,
|
|
59
|
+
name: str = typer.Option(
|
|
60
|
+
"default",
|
|
61
|
+
"--name",
|
|
62
|
+
help="Credential profile name.",
|
|
63
|
+
),
|
|
64
|
+
) -> None:
|
|
65
|
+
"""Store a DiffSage provider credential.
|
|
66
|
+
|
|
67
|
+
The API key is entered at a hidden prompt, never as an argument.
|
|
68
|
+
|
|
69
|
+
For scripts, pipe it in: printf '%s\\n' "$KEY" | diffsage auth set gemini
|
|
70
|
+
"""
|
|
71
|
+
|
|
72
|
+
view = AuthView()
|
|
73
|
+
|
|
74
|
+
api_key = _read_api_key(provider)
|
|
75
|
+
|
|
76
|
+
_credential_service().set_credential(
|
|
77
|
+
provider,
|
|
78
|
+
api_key,
|
|
79
|
+
name,
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
view.show_success("Credential saved.")
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@app.command("get")
|
|
86
|
+
@handle_command_errors("auth get")
|
|
87
|
+
def get_credential(
|
|
88
|
+
provider: str,
|
|
89
|
+
name: str = typer.Option(
|
|
90
|
+
"default",
|
|
91
|
+
"--name",
|
|
92
|
+
help="Credential profile name.",
|
|
93
|
+
),
|
|
94
|
+
) -> None:
|
|
95
|
+
"""Get a DiffSage provider credential."""
|
|
96
|
+
|
|
97
|
+
view = AuthView()
|
|
98
|
+
|
|
99
|
+
credential = _credential_service().get_credential(
|
|
100
|
+
provider,
|
|
101
|
+
name,
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
_warn_if_environment_key_is_set(view)
|
|
105
|
+
|
|
106
|
+
if credential is None:
|
|
107
|
+
raise CredentialNotFoundError(provider, name)
|
|
108
|
+
|
|
109
|
+
view.show_credential(credential)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
@app.command("list")
|
|
113
|
+
@handle_command_errors("auth list")
|
|
114
|
+
def list_credentials() -> None:
|
|
115
|
+
"""List configured DiffSage credentials."""
|
|
116
|
+
|
|
117
|
+
view = AuthView()
|
|
118
|
+
|
|
119
|
+
credentials = _credential_service().list_credentials()
|
|
120
|
+
|
|
121
|
+
_warn_if_environment_key_is_set(view)
|
|
122
|
+
view.show_credentials(credentials)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
@app.command("unset")
|
|
126
|
+
@handle_command_errors("auth unset")
|
|
127
|
+
def unset_credential(
|
|
128
|
+
provider: str,
|
|
129
|
+
name: str = typer.Option(
|
|
130
|
+
"default",
|
|
131
|
+
"--name",
|
|
132
|
+
help="Credential profile name.",
|
|
133
|
+
),
|
|
134
|
+
) -> None:
|
|
135
|
+
"""Unset DiffSage provider credential."""
|
|
136
|
+
|
|
137
|
+
view = AuthView()
|
|
138
|
+
|
|
139
|
+
deleted = _credential_service().delete_credential(
|
|
140
|
+
provider,
|
|
141
|
+
name,
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
if not deleted:
|
|
145
|
+
raise CredentialNotFoundError(provider, name)
|
|
146
|
+
|
|
147
|
+
view.show_success("Credential removed.")
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
from collections.abc import Callable
|
|
2
|
+
|
|
3
|
+
from diffsage.commands.error_handler import handle_command_errors
|
|
4
|
+
from diffsage.config.loader import load_settings
|
|
5
|
+
from diffsage.config.paths import get_credentials_path
|
|
6
|
+
from diffsage.exceptions import InvalidCommitMessageError
|
|
7
|
+
from diffsage.git.client import GitClient
|
|
8
|
+
from diffsage.logging.logger import get_logger
|
|
9
|
+
from diffsage.parsers.commit_message_parser import CommitMessageParser
|
|
10
|
+
from diffsage.services.ai_service import AIService
|
|
11
|
+
from diffsage.services.commit_service import CommitService
|
|
12
|
+
from diffsage.services.credentials_service import CredentialService
|
|
13
|
+
from diffsage.services.editor_service import EditorService
|
|
14
|
+
from diffsage.services.git_service import GitService
|
|
15
|
+
from diffsage.services.prompt_service import PromptService
|
|
16
|
+
from diffsage.storage.credentials_repository import CredentialsRepository
|
|
17
|
+
from diffsage.ui.commit_view import CommitView
|
|
18
|
+
|
|
19
|
+
logger = get_logger(__name__)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def generate_message(
|
|
23
|
+
commit_service: CommitService,
|
|
24
|
+
on_attempt: Callable[[int, int], None] | None = None,
|
|
25
|
+
on_notice: Callable[[str], None] | None = None,
|
|
26
|
+
) -> str:
|
|
27
|
+
return commit_service.generate_commit_message(
|
|
28
|
+
on_attempt=on_attempt,
|
|
29
|
+
on_notice=on_notice,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@handle_command_errors("commit")
|
|
34
|
+
def commit() -> None:
|
|
35
|
+
"""Generate a conventional commit message"""
|
|
36
|
+
|
|
37
|
+
git_client = GitClient()
|
|
38
|
+
view = CommitView()
|
|
39
|
+
editor = EditorService()
|
|
40
|
+
|
|
41
|
+
settings = load_settings()
|
|
42
|
+
|
|
43
|
+
credential_path = get_credentials_path()
|
|
44
|
+
credential_repository = CredentialsRepository(credential_path)
|
|
45
|
+
credential_service = CredentialService(credential_repository)
|
|
46
|
+
|
|
47
|
+
git_service = GitService(git_client)
|
|
48
|
+
ai_service = AIService(
|
|
49
|
+
settings,
|
|
50
|
+
credential_service,
|
|
51
|
+
)
|
|
52
|
+
prompt_service = PromptService()
|
|
53
|
+
|
|
54
|
+
commit_service = CommitService(
|
|
55
|
+
git_client,
|
|
56
|
+
git_service,
|
|
57
|
+
prompt_service,
|
|
58
|
+
ai_service,
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
with view.generating() as status:
|
|
62
|
+
raw_message = generate_message(
|
|
63
|
+
commit_service,
|
|
64
|
+
on_attempt=lambda attempt, total: status.update(
|
|
65
|
+
f"Generating commit message... Attempt {attempt}/{total}"
|
|
66
|
+
),
|
|
67
|
+
on_notice=view.show_warning,
|
|
68
|
+
)
|
|
69
|
+
commit_message = CommitMessageParser.parse(raw_message)
|
|
70
|
+
|
|
71
|
+
view.show_generated()
|
|
72
|
+
view.show_commit(commit_message)
|
|
73
|
+
|
|
74
|
+
# Text of an edit that did not parse; the next E reopens it so nothing typed is lost.
|
|
75
|
+
pending_edit: str | None = None
|
|
76
|
+
|
|
77
|
+
while True:
|
|
78
|
+
choice = view.prompt_action()
|
|
79
|
+
|
|
80
|
+
if choice in ("", "y"):
|
|
81
|
+
logger.info("User selected commit.")
|
|
82
|
+
# Commit the cleaned message: no code fences or preamble from the AI reply.
|
|
83
|
+
message_text = commit_message.to_text()
|
|
84
|
+
git_client.commit(message_text)
|
|
85
|
+
view.show_success(message_text)
|
|
86
|
+
break
|
|
87
|
+
|
|
88
|
+
if choice == "e":
|
|
89
|
+
logger.info("User selected edit.")
|
|
90
|
+
edited = editor.edit(pending_edit or commit_message.to_text())
|
|
91
|
+
|
|
92
|
+
if edited is None:
|
|
93
|
+
view.show_warning("Edit cancelled. The message below is unchanged.")
|
|
94
|
+
view.show_commit(commit_message)
|
|
95
|
+
continue
|
|
96
|
+
|
|
97
|
+
try:
|
|
98
|
+
commit_message = CommitMessageParser.parse(edited)
|
|
99
|
+
except InvalidCommitMessageError as error:
|
|
100
|
+
pending_edit = edited
|
|
101
|
+
view.show_error(error.message)
|
|
102
|
+
view.show_warning(
|
|
103
|
+
"Your edit was kept. Press E to continue editing it. The message below "
|
|
104
|
+
"is unchanged, and Y commits it."
|
|
105
|
+
)
|
|
106
|
+
else:
|
|
107
|
+
pending_edit = None
|
|
108
|
+
|
|
109
|
+
view.show_commit(commit_message)
|
|
110
|
+
continue
|
|
111
|
+
|
|
112
|
+
if choice == "r":
|
|
113
|
+
logger.info("User selected regenerate.")
|
|
114
|
+
with view.generating() as status:
|
|
115
|
+
raw_message = generate_message(
|
|
116
|
+
commit_service,
|
|
117
|
+
on_attempt=lambda attempt, total: status.update(
|
|
118
|
+
f"Generating commit message... Attempt {attempt}/{total}"
|
|
119
|
+
),
|
|
120
|
+
on_notice=view.show_warning,
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
try:
|
|
124
|
+
commit_message = CommitMessageParser.parse(raw_message)
|
|
125
|
+
except InvalidCommitMessageError as error:
|
|
126
|
+
view.show_error(error.message)
|
|
127
|
+
view.show_warning(
|
|
128
|
+
"The new suggestion could not be used. The message below is unchanged."
|
|
129
|
+
)
|
|
130
|
+
else:
|
|
131
|
+
pending_edit = None
|
|
132
|
+
view.show_generated()
|
|
133
|
+
|
|
134
|
+
view.show_commit(commit_message)
|
|
135
|
+
continue
|
|
136
|
+
|
|
137
|
+
if choice == "n":
|
|
138
|
+
logger.info("User cancelled commit.")
|
|
139
|
+
view.show_cancelled()
|
|
140
|
+
break
|
|
141
|
+
|
|
142
|
+
view.show_invalid_option()
|