git-copilot-commit 0.4.3__py3-none-any.whl → 0.4.5__py3-none-any.whl

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.
git_copilot_commit/cli.py CHANGED
@@ -151,6 +151,21 @@ def commit_with_retry_no_verify(
151
151
  raise typer.Exit(1)
152
152
 
153
153
 
154
+ @app.command("authenticate")
155
+ @app.command("login", hidden=True)
156
+ def authenticate(
157
+ force: bool = typer.Option(
158
+ False, "--force", help="Replace cached GitHub Copilot credentials"
159
+ ),
160
+ ):
161
+ """Authenticate with GitHub Copilot and cache credentials locally."""
162
+ try:
163
+ github_copilot.login(force=force)
164
+ except github_copilot.CopilotError as exc:
165
+ console.print(f"[red]Authentication failed: {exc}[/red]")
166
+ raise typer.Exit(1)
167
+
168
+
154
169
  @app.command()
155
170
  def commit(
156
171
  all_files: bool = typer.Option(
@@ -180,11 +195,15 @@ def commit(
180
195
 
181
196
  try:
182
197
  existing_credentials = github_copilot.load_credentials()
183
- except Exception as _:
198
+ except github_copilot.CopilotError:
184
199
  existing_credentials = None
185
200
 
186
201
  if existing_credentials is None:
187
- github_copilot.login()
202
+ try:
203
+ github_copilot.login(force=True)
204
+ except github_copilot.CopilotError as exc:
205
+ console.print(f"[red]Authentication failed: {exc}[/red]")
206
+ raise typer.Exit(1)
188
207
 
189
208
  # Load settings and use default model if none provided
190
209
  settings = Settings()
@@ -228,11 +247,17 @@ def commit(
228
247
  Panel(context.strip(), title="User Context", border_style="magenta")
229
248
  )
230
249
 
231
- # Generate or use provided commit message
232
- with console.status(
233
- "[yellow]Generating commit message based on [bold]`git diff --staged`[/] ...[/yellow]"
234
- ):
235
- commit_message = generate_commit_message(repo, model, context=context)
250
+ try:
251
+ github_copilot.ensure_auth_ready(model=model)
252
+
253
+ # Generate or use provided commit message
254
+ with console.status(
255
+ "[yellow]Generating commit message based on [bold]`git diff --staged`[/] ...[/yellow]"
256
+ ):
257
+ commit_message = generate_commit_message(repo, model, context=context)
258
+ except github_copilot.CopilotError as exc:
259
+ console.print(f"[red]Could not generate a commit message: {exc}[/red]")
260
+ raise typer.Exit(1)
236
261
 
237
262
  console.print("[yellow]Generated commit message.[/yellow]")
238
263
 
@@ -10,7 +10,7 @@ import uuid
10
10
  from dataclasses import dataclass
11
11
  from datetime import datetime
12
12
  from pathlib import Path
13
- from typing import Any
13
+ from typing import Any, Callable, TypeVar
14
14
 
15
15
  import httpx
16
16
  import typer
@@ -19,6 +19,7 @@ from rich.panel import Panel
19
19
  from rich.table import Table
20
20
 
21
21
  APP_NAME = "github-copilot-commit"
22
+ CLI_AUTH_COMMAND = "git-copilot-commit authenticate"
22
23
  DEFAULT_GITHUB_DOMAIN = "github.com"
23
24
  USER_AGENT = "GitHubCopilotChat/0.35.0"
24
25
  EDITOR_VERSION = "vscode/1.107.0"
@@ -49,12 +50,24 @@ app = typer.Typer(
49
50
 
50
51
  console = Console()
51
52
  console_err = Console(stderr=True)
53
+ T = TypeVar("T")
52
54
 
53
55
 
54
56
  class CopilotError(RuntimeError):
55
57
  pass
56
58
 
57
59
 
60
+ class CopilotHttpError(CopilotError):
61
+ def __init__(
62
+ self, status_code: int, reason_phrase: str, detail: str | None = None
63
+ ) -> None:
64
+ self.status_code = status_code
65
+ self.reason_phrase = reason_phrase
66
+ self.detail = detail
67
+ suffix = f": {detail}" if detail else ""
68
+ super().__init__(f"{status_code} {reason_phrase}{suffix}")
69
+
70
+
58
71
  @dataclass(slots=True)
59
72
  class DeviceCodeResponse:
60
73
  device_code: str
@@ -290,8 +303,7 @@ def request_json(
290
303
  detail = response.text.strip()
291
304
  if len(detail) > 400:
292
305
  detail = f"{detail[:397]}..."
293
- suffix = f": {detail}" if detail else ""
294
- raise CopilotError(f"{response.status_code} {response.reason_phrase}{suffix}")
306
+ raise CopilotHttpError(response.status_code, response.reason_phrase, detail)
295
307
 
296
308
  content_type = response.headers.get("content-type", "")
297
309
  if "application/json" not in content_type:
@@ -364,10 +376,9 @@ def iter_sse_events(response: httpx.Response, url: str):
364
376
  yield payload
365
377
 
366
378
 
367
- def load_credentials() -> CopilotCredentials | None:
368
- path = credentials_path()
379
+ def read_json_object(path: Path) -> dict[str, Any] | None:
369
380
  if not path.exists():
370
- return
381
+ return None
371
382
 
372
383
  try:
373
384
  raw = json.loads(path.read_text(encoding="utf-8"))
@@ -379,11 +390,23 @@ def load_credentials() -> CopilotCredentials | None:
379
390
  if not isinstance(raw, dict):
380
391
  raise CopilotError(f"Cached credentials in {path} are not a JSON object.")
381
392
 
393
+ return raw
394
+
395
+
396
+ def load_stored_credentials_from_path(path: Path) -> CopilotCredentials | None:
397
+ raw = read_json_object(path)
398
+ if raw is None:
399
+ return None
400
+
382
401
  return CopilotCredentials.from_dict(raw)
383
402
 
384
403
 
404
+ def load_credentials() -> CopilotCredentials | None:
405
+ return load_stored_credentials_from_path(credentials_path())
406
+
407
+
385
408
  def save_credentials(credentials: CopilotCredentials) -> Path:
386
- path = credentials_path()
409
+ path = credentials_path().expanduser()
387
410
  path.parent.mkdir(parents=True, exist_ok=True)
388
411
  payload = json.dumps(credentials.to_dict(), indent=2, sort_keys=True)
389
412
  path.write_text(f"{payload}\n", encoding="utf-8")
@@ -573,7 +596,7 @@ def ensure_fresh_credentials(client: httpx.Client) -> CopilotCredentials:
573
596
  credentials = load_credentials()
574
597
  if credentials is None:
575
598
  raise CopilotError(
576
- f"No cached Copilot credentials found. Run `{Path(__file__).name} auth login` first."
599
+ f"No cached Copilot credentials found. Run `{CLI_AUTH_COMMAND}` first."
577
600
  )
578
601
 
579
602
  if not credentials.is_expired():
@@ -588,6 +611,23 @@ def ensure_fresh_credentials(client: httpx.Client) -> CopilotCredentials:
588
611
  return refreshed
589
612
 
590
613
 
614
+ def should_reauthenticate(exc: CopilotError) -> bool:
615
+ if isinstance(exc, CopilotHttpError):
616
+ return exc.status_code == 401
617
+
618
+ message = str(exc)
619
+ retryable_prefixes = (
620
+ "No cached Copilot credentials found.",
621
+ "Cached GitHub access token is missing or invalid.",
622
+ "Cached Copilot token is missing or invalid.",
623
+ "Cached Copilot expiration timestamp is missing or invalid.",
624
+ "Cached enterprise domain is invalid.",
625
+ "Unable to read cached credentials from ",
626
+ "Cached credentials in ",
627
+ )
628
+ return any(message.startswith(prefix) for prefix in retryable_prefixes)
629
+
630
+
591
631
  def copilot_request_headers(
592
632
  access_token: str,
593
633
  *,
@@ -886,9 +926,8 @@ def responses_completion(
886
926
  detail = response.read().decode("utf-8", errors="replace").strip()
887
927
  if len(detail) > 400:
888
928
  detail = f"{detail[:397]}..."
889
- suffix = f": {detail}" if detail else ""
890
- raise CopilotError(
891
- f"{response.status_code} {response.reason_phrase}{suffix}"
929
+ raise CopilotHttpError(
930
+ response.status_code, response.reason_phrase, detail
892
931
  )
893
932
 
894
933
  content_type = response.headers.get("content-type", "")
@@ -1101,24 +1140,19 @@ def print_login_summary(
1101
1140
  console.print(Panel.fit(table, title="Login Summary"))
1102
1141
 
1103
1142
 
1104
- def login(
1105
- enterprise_domain: str | None = typer.Option(
1106
- None,
1107
- "--enterprise-domain",
1108
- help="GitHub Enterprise hostname. Omit for github.com.",
1109
- ),
1110
- force: bool = typer.Option(
1111
- False,
1112
- "--force",
1113
- help="Replace any cached Copilot credentials without prompting.",
1114
- ),
1115
- ) -> None:
1143
+ def login(enterprise_domain: str | None = None, force: bool = False) -> None:
1116
1144
  """Authenticate with GitHub and cache Copilot credentials locally."""
1117
1145
  normalized_domain = normalize_domain(enterprise_domain)
1118
1146
  if enterprise_domain and not normalized_domain:
1119
1147
  raise CopilotError("Invalid GitHub Enterprise hostname.")
1120
1148
 
1121
- existing = load_credentials()
1149
+ existing: CopilotCredentials | None = None
1150
+ try:
1151
+ existing = load_credentials()
1152
+ except CopilotError:
1153
+ if not force:
1154
+ raise
1155
+
1122
1156
  if existing and not force:
1123
1157
  raise CopilotError(
1124
1158
  f"Cached credentials already exist at {credentials_path()}. Re-run with --force to replace them."
@@ -1173,16 +1207,45 @@ def login(
1173
1207
  console.print(f"[yellow]Warning:[/yellow] {warning}")
1174
1208
 
1175
1209
 
1176
- def ask(prompt: str, model: str | None = None) -> str:
1177
- """Send a prompt to GitHub Copilot and print the reply."""
1210
+ def _ask_once(client: httpx.Client, prompt: str, model: str | None = None) -> str:
1211
+ credentials = ensure_fresh_credentials(client)
1212
+ all_models = list_models(client, credentials)
1213
+
1214
+ selected_model = pick_model(all_models, model)
1215
+ return complete_text_prompt(
1216
+ client,
1217
+ credentials,
1218
+ model=selected_model,
1219
+ prompt=prompt,
1220
+ )
1221
+
1222
+
1223
+ def _with_reauthentication(action: Callable[[httpx.Client], T]) -> T:
1224
+ try:
1225
+ with make_http_client() as client:
1226
+ return action(client)
1227
+ except CopilotError as exc:
1228
+ if not should_reauthenticate(exc):
1229
+ raise
1230
+
1231
+ console.print(
1232
+ "[yellow]Cached GitHub Copilot credentials are missing or no longer valid. Starting authentication...[/yellow]"
1233
+ )
1234
+ login(force=True)
1235
+
1178
1236
  with make_http_client() as client:
1237
+ return action(client)
1238
+
1239
+
1240
+ def ensure_auth_ready(model: str | None = None) -> None:
1241
+ def validate(client: httpx.Client) -> None:
1179
1242
  credentials = ensure_fresh_credentials(client)
1180
1243
  all_models = list_models(client, credentials)
1244
+ pick_model(all_models, model)
1181
1245
 
1182
- selected_model = pick_model(all_models, model)
1183
- return complete_text_prompt(
1184
- client,
1185
- credentials,
1186
- model=selected_model,
1187
- prompt=prompt,
1188
- )
1246
+ _with_reauthentication(validate)
1247
+
1248
+
1249
+ def ask(prompt: str, model: str | None = None) -> str:
1250
+ """Send a prompt to GitHub Copilot and print the reply."""
1251
+ return _with_reauthentication(lambda client: _ask_once(client, prompt, model))
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: git-copilot-commit
3
- Version: 0.4.3
3
+ Version: 0.4.5
4
4
  Summary: Automatically generate and commit changes using copilot
5
5
  Author-email: Dheepak Krishnamurthy <1813121+kdheepak@users.noreply.github.com>
6
6
  License-File: LICENSE
@@ -77,6 +77,12 @@ pipx install git-copilot-commit
77
77
  uvx git-copilot-commit authenticate
78
78
  ```
79
79
 
80
+ If your cached GitHub token is revoked or expires, refresh it with:
81
+
82
+ ```bash
83
+ uvx git-copilot-commit authenticate --force
84
+ ```
85
+
80
86
  2. Make changes in your repository.
81
87
 
82
88
  3. Generate and commit:
@@ -1,13 +1,13 @@
1
1
  git_copilot_commit/__init__.py,sha256=v3x5oBkxwKJEZLv62QqSmP3iqNKLtZgrWZfH8eFzlQg,60
2
- git_copilot_commit/cli.py,sha256=bLMhakRnKpfJiB_gPCJ67i-oyHarpUqFlOmMXbIx1UY,8535
2
+ git_copilot_commit/cli.py,sha256=gXRoJDhpW0Ck7-sBDpMmulYztUH1IExuF-SczbgQqF0,9434
3
3
  git_copilot_commit/git.py,sha256=f42GawgkyrsFkl127XvDrdg2xVEf87lb-5QO04nuRoU,9459
4
- git_copilot_commit/github_copilot.py,sha256=38oMeOwJ8yh4XKLgK28W39I-OJP7Gq3NWISi4hUROHY,37478
4
+ git_copilot_commit/github_copilot.py,sha256=lP-LLk4mOC4D9-rCtxIBs7XN_oFImZHpoKyHNBQFf-c,39484
5
5
  git_copilot_commit/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
6
6
  git_copilot_commit/settings.py,sha256=asaCxX_TAr5lCRkoSLtHSk1eUrT-y4bJbLUNQZSzAs0,2793
7
7
  git_copilot_commit/version.py,sha256=AieHOUX52g6N67HL0iLWtDKrgOYyulxwHWViu26Jrd4,105
8
8
  git_copilot_commit/prompts/commit-message-generator-prompt.md,sha256=ZvllyqtsLRwj6NmvygNGFajdLKNkO67hUnLsR_P1WOs,2370
9
- git_copilot_commit-0.4.3.dist-info/METADATA,sha256=VJbSZnZzU8tIoOeHEmLiYMeFalHqlD2Eb_Q1OX7jjfI,3921
10
- git_copilot_commit-0.4.3.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
11
- git_copilot_commit-0.4.3.dist-info/entry_points.txt,sha256=Imboc0oJa4Oq1O3C-wWcy7ZxfsVSkkC-OC-iPbTn3Fg,66
12
- git_copilot_commit-0.4.3.dist-info/licenses/LICENSE,sha256=14lNZAoKJPI1U7eGpletjN_PFm1JwP1vT_0jFKY6eWg,1065
13
- git_copilot_commit-0.4.3.dist-info/RECORD,,
9
+ git_copilot_commit-0.4.5.dist-info/METADATA,sha256=BFvZy64MhMB6GGeAiBS6L7N5EcPqNS_9miCE8roZ9E8,4059
10
+ git_copilot_commit-0.4.5.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
11
+ git_copilot_commit-0.4.5.dist-info/entry_points.txt,sha256=Imboc0oJa4Oq1O3C-wWcy7ZxfsVSkkC-OC-iPbTn3Fg,66
12
+ git_copilot_commit-0.4.5.dist-info/licenses/LICENSE,sha256=14lNZAoKJPI1U7eGpletjN_PFm1JwP1vT_0jFKY6eWg,1065
13
+ git_copilot_commit-0.4.5.dist-info/RECORD,,