markdown-table-fixer 0.1.0__tar.gz → 0.1.2__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.
Files changed (23) hide show
  1. {markdown_table_fixer-0.1.0 → markdown_table_fixer-0.1.2}/PKG-INFO +29 -1
  2. {markdown_table_fixer-0.1.0 → markdown_table_fixer-0.1.2}/README.md +28 -0
  3. {markdown_table_fixer-0.1.0 → markdown_table_fixer-0.1.2}/pyproject.toml +12 -0
  4. {markdown_table_fixer-0.1.0 → markdown_table_fixer-0.1.2}/src/markdown_table_fixer/_version.py +2 -2
  5. {markdown_table_fixer-0.1.0 → markdown_table_fixer-0.1.2}/src/markdown_table_fixer/cli.py +119 -50
  6. markdown_table_fixer-0.1.2/src/markdown_table_fixer/git_config.py +303 -0
  7. {markdown_table_fixer-0.1.0 → markdown_table_fixer-0.1.2}/src/markdown_table_fixer/github_client.py +2 -2
  8. {markdown_table_fixer-0.1.0 → markdown_table_fixer-0.1.2}/src/markdown_table_fixer/pr_fixer.py +183 -33
  9. {markdown_table_fixer-0.1.0 → markdown_table_fixer-0.1.2}/.gitignore +0 -0
  10. {markdown_table_fixer-0.1.0 → markdown_table_fixer-0.1.2}/.pre-commit-hooks.yaml +0 -0
  11. {markdown_table_fixer-0.1.0 → markdown_table_fixer-0.1.2}/LICENSE +0 -0
  12. {markdown_table_fixer-0.1.0 → markdown_table_fixer-0.1.2}/LICENSES/Apache-2.0.txt +0 -0
  13. {markdown_table_fixer-0.1.0 → markdown_table_fixer-0.1.2}/scripts/README.md +0 -0
  14. {markdown_table_fixer-0.1.0 → markdown_table_fixer-0.1.2}/src/markdown_table_fixer/__init__.py +0 -0
  15. {markdown_table_fixer-0.1.0 → markdown_table_fixer-0.1.2}/src/markdown_table_fixer/exceptions.py +0 -0
  16. {markdown_table_fixer-0.1.0 → markdown_table_fixer-0.1.2}/src/markdown_table_fixer/graphql_queries.py +0 -0
  17. {markdown_table_fixer-0.1.0 → markdown_table_fixer-0.1.2}/src/markdown_table_fixer/models.py +0 -0
  18. {markdown_table_fixer-0.1.0 → markdown_table_fixer-0.1.2}/src/markdown_table_fixer/pr_scanner.py +0 -0
  19. {markdown_table_fixer-0.1.0 → markdown_table_fixer-0.1.2}/src/markdown_table_fixer/progress_tracker.py +0 -0
  20. {markdown_table_fixer-0.1.0 → markdown_table_fixer-0.1.2}/src/markdown_table_fixer/py.typed +0 -0
  21. {markdown_table_fixer-0.1.0 → markdown_table_fixer-0.1.2}/src/markdown_table_fixer/table_fixer.py +0 -0
  22. {markdown_table_fixer-0.1.0 → markdown_table_fixer-0.1.2}/src/markdown_table_fixer/table_parser.py +0 -0
  23. {markdown_table_fixer-0.1.0 → markdown_table_fixer-0.1.2}/src/markdown_table_fixer/table_validator.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: markdown-table-fixer
3
- Version: 0.1.0
3
+ Version: 0.1.2
4
4
  Summary: Markdown table formatter and linter with GitHub integration
5
5
  Project-URL: Homepage, https://github.com/lfit/markdown-table-fixer
6
6
  Project-URL: Repository, https://github.com/lfit/markdown-table-fixer.git
@@ -123,6 +123,34 @@ Dry run to preview changes without applying them:
123
123
  markdown-table-fixer github https://github.com/owner/repo/pull/123 --dry-run
124
124
  ```
125
125
 
126
+ #### Update Methods
127
+
128
+ The tool supports two methods for applying fixes:
129
+
130
+ **API Method (default)** - Uses GitHub API to create new commits:
131
+
132
+ ```bash
133
+ markdown-table-fixer github https://github.com/owner/repo/pull/123
134
+ ```
135
+
136
+ - Creates new commits via GitHub API
137
+ - Shows as "Verified" by GitHub
138
+ - No Git operations required
139
+ - Faster and simpler
140
+ - Does not support sync strategies (rebase/merge)
141
+
142
+ **Git Method** - Clones repo, amends commit, force-pushes:
143
+
144
+ ```bash
145
+ markdown-table-fixer github https://github.com/owner/repo/pull/123 \
146
+ --update-method git
147
+ ```
148
+
149
+ - Respects your local Git signing configuration
150
+ - Amends the existing commit (preserves commit history)
151
+ - Requires Git operations (clone, amend, push)
152
+ - Use when you need to amend commits or use sync strategies
153
+
126
154
  #### Fix PRs Across an Organization
127
155
 
128
156
  Scan and fix tables in PRs across an entire GitHub organization:
@@ -82,6 +82,34 @@ Dry run to preview changes without applying them:
82
82
  markdown-table-fixer github https://github.com/owner/repo/pull/123 --dry-run
83
83
  ```
84
84
 
85
+ #### Update Methods
86
+
87
+ The tool supports two methods for applying fixes:
88
+
89
+ **API Method (default)** - Uses GitHub API to create new commits:
90
+
91
+ ```bash
92
+ markdown-table-fixer github https://github.com/owner/repo/pull/123
93
+ ```
94
+
95
+ - Creates new commits via GitHub API
96
+ - Shows as "Verified" by GitHub
97
+ - No Git operations required
98
+ - Faster and simpler
99
+ - Does not support sync strategies (rebase/merge)
100
+
101
+ **Git Method** - Clones repo, amends commit, force-pushes:
102
+
103
+ ```bash
104
+ markdown-table-fixer github https://github.com/owner/repo/pull/123 \
105
+ --update-method git
106
+ ```
107
+
108
+ - Respects your local Git signing configuration
109
+ - Amends the existing commit (preserves commit history)
110
+ - Requires Git operations (clone, amend, push)
111
+ - Use when you need to amend commits or use sync strategies
112
+
85
113
  #### Fix PRs Across an Organization
86
114
 
87
115
  Scan and fix tables in PRs across an entire GitHub organization:
@@ -136,6 +136,18 @@ module = ["tests.*"]
136
136
  disallow_untyped_decorators = false
137
137
  disallow_untyped_defs = false
138
138
 
139
+ [[tool.mypy.overrides]]
140
+ module = ["typer.*"]
141
+ disallow_untyped_decorators = false
142
+
143
+ [[tool.mypy.overrides]]
144
+ module = ["tenacity.*"]
145
+ disallow_untyped_decorators = false
146
+
147
+ [[tool.mypy.overrides]]
148
+ module = ["wcwidth"]
149
+ ignore_missing_imports = true
150
+
139
151
  [tool.pytest.ini_options]
140
152
  testpaths = ["tests"]
141
153
  python_files = ["test_*.py", "*_test.py"]
@@ -28,7 +28,7 @@ version_tuple: VERSION_TUPLE
28
28
  commit_id: COMMIT_ID
29
29
  __commit_id__: COMMIT_ID
30
30
 
31
- __version__ = version = '0.1.0'
32
- __version_tuple__ = version_tuple = (0, 1, 0)
31
+ __version__ = version = '0.1.2'
32
+ __version_tuple__ = version_tuple = (0, 1, 2)
33
33
 
34
34
  __commit_id__ = commit_id = None
@@ -9,20 +9,17 @@ import asyncio
9
9
  import json
10
10
  import logging
11
11
  from pathlib import Path
12
- from typing import TYPE_CHECKING
13
12
 
14
13
  from rich.console import Console
15
14
  from rich.logging import RichHandler
16
15
  import typer
17
16
 
18
- if TYPE_CHECKING:
19
- from collections.abc import Callable
20
-
21
17
  from ._version import __version__
22
18
  from .exceptions import (
23
19
  FileAccessError,
24
20
  TableParseError,
25
21
  )
22
+ from .git_config import GitConfigMode
26
23
  from .github_client import GitHubClient
27
24
  from .models import (
28
25
  BlockedPR,
@@ -42,10 +39,15 @@ from .table_validator import TableValidator
42
39
  console = Console()
43
40
 
44
41
 
42
+ def get_version_string() -> str:
43
+ """Get the formatted version string."""
44
+ return f"🏷️ markdown-table-fixer version {__version__}"
45
+
46
+
45
47
  def version_callback(value: bool) -> None:
46
48
  """Show version and exit."""
47
49
  if value:
48
- console.print(f"🏷️ markdown-table-fixer version {__version__}")
50
+ console.print(get_version_string())
49
51
  console.print()
50
52
  raise typer.Exit()
51
53
 
@@ -71,28 +73,30 @@ def setup_logging(
71
73
  logging.getLogger("httpx").setLevel(logging.WARNING)
72
74
 
73
75
 
74
- # Create Typer app with version in help
75
- app = typer.Typer(
76
+ # Create Typer app with custom help formatter
77
+ class CustomTyper(typer.Typer):
78
+ """Custom Typer class to add version to help output."""
79
+
80
+ def __call__(self, *args: object, **kwargs: object) -> object:
81
+ """Override to inject version string in help output."""
82
+ # Check if help is being requested (for any command or subcommand)
83
+ import sys
84
+
85
+ if "--help" in sys.argv or "-h" in sys.argv:
86
+ console.print(get_version_string())
87
+ return super().__call__(*args, **kwargs)
88
+
89
+
90
+ # Create Typer app
91
+ app = CustomTyper(
76
92
  name="markdown-table-fixer",
77
- help=f"markdown-table-fixer version {__version__}\n\nFix markdown table formatting issues",
93
+ help="Fix markdown table formatting issues",
78
94
  add_completion=False,
79
95
  rich_markup_mode="rich",
80
96
  )
81
97
 
82
98
 
83
- # Update command docstrings with version at module load time
84
- def _add_version_to_docstring(
85
- cmd_func: Callable[..., None],
86
- ) -> Callable[..., None]:
87
- """Add version info to command docstring."""
88
- if cmd_func.__doc__:
89
- cmd_func.__doc__ = (
90
- f"markdown-table-fixer version {__version__}\n\n" + cmd_func.__doc__
91
- )
92
- return cmd_func
93
-
94
-
95
- @app.callback() # type: ignore[misc]
99
+ @app.callback()
96
100
  def main(
97
101
  version: bool = typer.Option(
98
102
  False,
@@ -106,8 +110,7 @@ def main(
106
110
  pass
107
111
 
108
112
 
109
- @app.command() # type: ignore[misc]
110
- @_add_version_to_docstring # type: ignore[misc]
113
+ @app.command(help="Scan and optionally fix markdown table formatting issues")
111
114
  def lint(
112
115
  path: Path = typer.Argument(
113
116
  Path("."),
@@ -131,13 +134,6 @@ def lint(
131
134
  "--check",
132
135
  help="Exit with error if issues found (CI mode)",
133
136
  ),
134
- _version: bool = typer.Option(
135
- False,
136
- "--version",
137
- help="Show version and exit",
138
- callback=version_callback,
139
- is_eager=True,
140
- ),
141
137
  max_line_length: int = typer.Option(
142
138
  80,
143
139
  "--max-line-length",
@@ -215,8 +211,7 @@ def lint(
215
211
  raise typer.Exit(1) from e
216
212
 
217
213
 
218
- @app.command() # type: ignore[misc]
219
- @_add_version_to_docstring # type: ignore[misc]
214
+ @app.command(help="Fix markdown tables in GitHub pull requests")
220
215
  def github(
221
216
  target: str = typer.Argument(
222
217
  ...,
@@ -241,6 +236,22 @@ def github(
241
236
  help="How to resolve conflicts: 'fail', 'ours' (keep PR changes), or 'theirs' (keep base changes)",
242
237
  case_sensitive=False,
243
238
  ),
239
+ update_method: str = typer.Option(
240
+ "api",
241
+ "--update-method",
242
+ help="Method to apply fixes: 'git' (clone, amend, push) or 'api' (GitHub API commits, default)",
243
+ case_sensitive=False,
244
+ ),
245
+ no_user_signing: bool = typer.Option(
246
+ False,
247
+ "--no-user-signing",
248
+ help="Use user identity but disable commit signing (only applies to 'git' method)",
249
+ ),
250
+ bot_identity: bool = typer.Option(
251
+ False,
252
+ "--bot-identity",
253
+ help="Use bot identity without signing (only applies to 'git' method)",
254
+ ),
244
255
  dry_run: bool = typer.Option(
245
256
  False,
246
257
  "--dry-run",
@@ -281,13 +292,6 @@ def github(
281
292
  "--log-level",
282
293
  help="Set logging level",
283
294
  ),
284
- _version: bool = typer.Option(
285
- False,
286
- "--version",
287
- help="Show version and exit",
288
- callback=version_callback,
289
- is_eager=True,
290
- ),
291
295
  ) -> None:
292
296
  """Fix markdown tables in GitHub PRs.
293
297
 
@@ -295,7 +299,16 @@ def github(
295
299
  - An entire organization (scans all PRs for table issues)
296
300
  - A specific PR by URL
297
301
 
298
- Sync strategies:
302
+ Update Methods:
303
+ - 'api' (default): Use GitHub API to create new commits (shows as verified by GitHub)
304
+ - 'git': Clone repo, amend commit, force-push (respects signing)
305
+
306
+ Git Identity & Signing (only applies to 'git' update method):
307
+ - By default, uses your git user.name, user.email, and commit signing settings
308
+ - --no-user-signing: Use your identity but disable commit signing
309
+ - --bot-identity: Use bot identity without signing
310
+
311
+ Sync strategies (only applies to 'git' update method):
299
312
  - 'none': Fix tables on PR branch as-is (may have conflicts)
300
313
  - 'rebase': Rebase PR onto base branch before fixing (cleaner history)
301
314
  - 'merge': Merge base branch into PR before fixing (safer)
@@ -313,16 +326,41 @@ def github(
313
326
  """
314
327
  setup_logging(log_level=log_level, quiet=quiet, verbose=verbose)
315
328
 
316
- # Validate sync strategy
317
- if sync_strategy.lower() not in ["none", "rebase", "merge"]:
329
+ # Normalize and validate update method
330
+ update_method = update_method.lower()
331
+ if update_method not in ["git", "api"]:
332
+ console.print(
333
+ f"[red]Error:[/red] Invalid update method '{update_method}'. "
334
+ "Use 'git' or 'api'"
335
+ )
336
+ raise typer.Exit(1)
337
+
338
+ # Determine git config mode from CLI flags (only relevant for git method)
339
+ if bot_identity and no_user_signing:
340
+ console.print(
341
+ "[red]Error:[/red] Cannot use both --bot-identity and --no-user-signing"
342
+ )
343
+ raise typer.Exit(1)
344
+
345
+ if bot_identity:
346
+ git_config_mode = GitConfigMode.BOT_IDENTITY
347
+ elif no_user_signing:
348
+ git_config_mode = GitConfigMode.USER_NO_SIGN
349
+ else:
350
+ git_config_mode = GitConfigMode.USER_INHERIT
351
+
352
+ # Normalize and validate sync strategy
353
+ sync_strategy = sync_strategy.lower()
354
+ if sync_strategy not in ["none", "rebase", "merge"]:
318
355
  console.print(
319
356
  f"[red]Error:[/red] Invalid sync strategy '{sync_strategy}'. "
320
357
  "Use 'none', 'rebase', or 'merge'"
321
358
  )
322
359
  raise typer.Exit(1)
323
360
 
324
- # Validate conflict strategy
325
- if conflict_strategy.lower() not in ["fail", "ours", "theirs"]:
361
+ # Normalize and validate conflict strategy
362
+ conflict_strategy = conflict_strategy.lower()
363
+ if conflict_strategy not in ["fail", "ours", "theirs"]:
326
364
  console.print(
327
365
  f"[red]Error:[/red] Invalid conflict strategy '{conflict_strategy}'. "
328
366
  "Use 'fail', 'ours', or 'theirs'"
@@ -343,10 +381,12 @@ def github(
343
381
  _fix_single_pr(
344
382
  target,
345
383
  token,
346
- sync_strategy=sync_strategy.lower(),
347
- conflict_strategy=conflict_strategy.lower(),
384
+ sync_strategy=sync_strategy,
385
+ conflict_strategy=conflict_strategy,
348
386
  dry_run=dry_run,
349
387
  quiet=quiet,
388
+ git_config_mode=git_config_mode,
389
+ update_method=update_method,
350
390
  )
351
391
  )
352
392
  else:
@@ -355,12 +395,14 @@ def github(
355
395
  _scan_organization(
356
396
  target,
357
397
  token,
358
- sync_strategy=sync_strategy.lower(),
359
- conflict_strategy=conflict_strategy.lower(),
398
+ sync_strategy=sync_strategy,
399
+ conflict_strategy=conflict_strategy,
360
400
  dry_run=dry_run,
361
401
  include_drafts=include_drafts,
362
402
  debug_org=debug_org,
363
403
  quiet=quiet,
404
+ git_config_mode=git_config_mode,
405
+ update_method=update_method,
364
406
  )
365
407
  )
366
408
 
@@ -372,6 +414,8 @@ async def _fix_single_pr(
372
414
  conflict_strategy: str = "fail",
373
415
  dry_run: bool = False,
374
416
  quiet: bool = False,
417
+ git_config_mode: str = GitConfigMode.USER_INHERIT,
418
+ update_method: str = "api",
375
419
  ) -> None:
376
420
  """Fix markdown tables in a single PR."""
377
421
  if not quiet:
@@ -379,12 +423,13 @@ async def _fix_single_pr(
379
423
 
380
424
  try:
381
425
  async with GitHubClient(token) as client: # type: ignore[attr-defined]
382
- fixer = PRFixer(client)
426
+ fixer = PRFixer(client, git_config_mode=git_config_mode)
383
427
  result = await fixer.fix_pr_by_url( # type: ignore[attr-defined]
384
428
  pr_url,
385
429
  sync_strategy=sync_strategy,
386
430
  conflict_strategy=conflict_strategy,
387
431
  dry_run=dry_run,
432
+ update_method=update_method,
388
433
  )
389
434
 
390
435
  if result.success:
@@ -412,6 +457,8 @@ async def _scan_organization(
412
457
  include_drafts: bool = False,
413
458
  debug_org: bool = False,
414
459
  quiet: bool = False,
460
+ git_config_mode: str = GitConfigMode.USER_INHERIT,
461
+ update_method: str = "api",
415
462
  ) -> None:
416
463
  """Scan organization for PRs with markdown table issues."""
417
464
  # Remove github.com prefix if present
@@ -429,7 +476,28 @@ async def _scan_organization(
429
476
  )
430
477
 
431
478
  scanner = PRScanner(client, progress_tracker=progress_tracker)
432
- fixer = PRFixer(client)
479
+ fixer = PRFixer(client, git_config_mode=git_config_mode)
480
+
481
+ # Inform about update method
482
+ if not quiet:
483
+ method_desc = (
484
+ "Git clone/amend/push"
485
+ if update_method.lower() == "git"
486
+ else "GitHub API commits"
487
+ )
488
+ console.print(f"Update method: {method_desc}")
489
+ if update_method.lower() == "git":
490
+ if git_config_mode == GitConfigMode.BOT_IDENTITY:
491
+ console.print(
492
+ "Git identity: Bot (markdown-table-fixer)"
493
+ )
494
+ elif git_config_mode == GitConfigMode.USER_NO_SIGN:
495
+ console.print("Git identity: User (signing disabled)")
496
+ else:
497
+ console.print(
498
+ "Git identity: User (inheriting signing config)"
499
+ )
500
+ console.print()
433
501
 
434
502
  # Collect PRs from async generator
435
503
  prs_to_process = []
@@ -537,6 +605,7 @@ async def _scan_organization(
537
605
  sync_strategy=sync_strategy,
538
606
  conflict_strategy=conflict_strategy,
539
607
  dry_run=dry_run,
608
+ update_method=update_method,
540
609
  )
541
610
 
542
611
  if result.success:
@@ -0,0 +1,303 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ # SPDX-FileCopyrightText: 2025 The Linux Foundation
3
+
4
+ """Git configuration management for commit signing and user identity."""
5
+
6
+ from __future__ import annotations
7
+
8
+ from enum import Enum
9
+ import logging
10
+ from pathlib import Path # noqa: TC003
11
+ import subprocess
12
+
13
+ logger = logging.getLogger(__name__)
14
+
15
+
16
+ class GitConfigMode(str, Enum):
17
+ """Git configuration modes."""
18
+
19
+ USER_INHERIT = (
20
+ "user_inherit" # Inherit user's full config including signing
21
+ )
22
+ USER_NO_SIGN = "user_no_sign" # Use user identity but disable signing
23
+ BOT_IDENTITY = "bot_identity" # Use bot identity without signing
24
+
25
+
26
+ def _get_global_git_config(key: str) -> str | None:
27
+ """Get a value from global git config.
28
+
29
+ Args:
30
+ key: Git config key (e.g., "user.name")
31
+
32
+ Returns:
33
+ Config value or None if not set
34
+ """
35
+ try:
36
+ result = subprocess.run(
37
+ ["git", "config", "--global", key],
38
+ capture_output=True,
39
+ text=True,
40
+ check=False,
41
+ )
42
+ if result.returncode == 0:
43
+ value = result.stdout.strip()
44
+ return value if value else None
45
+ return None
46
+ except Exception as e:
47
+ logger.debug(f"Could not get git config {key}: {e}")
48
+ return None
49
+
50
+
51
+ def _set_repo_git_config(repo_dir: Path, key: str, value: str) -> bool:
52
+ """Set a git config value in the repository.
53
+
54
+ Args:
55
+ repo_dir: Repository directory path
56
+ key: Git config key
57
+ value: Value to set
58
+
59
+ Returns:
60
+ True if successful, False otherwise
61
+ """
62
+ try:
63
+ subprocess.run(
64
+ ["git", "config", key, value],
65
+ cwd=repo_dir,
66
+ check=True,
67
+ capture_output=True,
68
+ )
69
+ return True
70
+ except Exception as e:
71
+ logger.debug(f"Could not set git config {key}={value}: {e}")
72
+ return False
73
+
74
+
75
+ def configure_git_identity(
76
+ repo_dir: Path,
77
+ mode: str = GitConfigMode.USER_INHERIT,
78
+ *,
79
+ bot_name: str = "markdown-table-fixer",
80
+ bot_email: str = "noreply@linuxfoundation.org",
81
+ ) -> dict[str, str]:
82
+ """Configure git identity and signing settings in a repository.
83
+
84
+ Args:
85
+ repo_dir: Repository directory path
86
+ mode: Configuration mode (USER_INHERIT, USER_NO_SIGN, or BOT_IDENTITY)
87
+ bot_name: Bot name for BOT_IDENTITY mode
88
+ bot_email: Bot email for BOT_IDENTITY mode
89
+
90
+ Returns:
91
+ Dict with applied configuration details
92
+
93
+ Raises:
94
+ ValueError: If mode is invalid
95
+ """
96
+ valid_modes = [m.value for m in GitConfigMode]
97
+ if mode not in valid_modes:
98
+ msg = f"Invalid mode: {mode}. Must be one of {valid_modes}"
99
+ raise ValueError(msg)
100
+
101
+ applied_config: dict[str, str] = {}
102
+
103
+ if mode == GitConfigMode.BOT_IDENTITY:
104
+ # Use bot identity without signing
105
+ logger.debug("Configuring git with bot identity (no signing)")
106
+ if _set_repo_git_config(repo_dir, "user.name", bot_name):
107
+ applied_config["user.name"] = bot_name
108
+ else:
109
+ logger.warning(f"Failed to set user.name to {bot_name}")
110
+
111
+ if _set_repo_git_config(repo_dir, "user.email", bot_email):
112
+ applied_config["user.email"] = bot_email
113
+ else:
114
+ logger.warning(f"Failed to set user.email to {bot_email}")
115
+
116
+ applied_config["mode"] = "bot_identity"
117
+ return applied_config
118
+
119
+ # For USER_INHERIT and USER_NO_SIGN modes, try to get user's config
120
+ user_name = _get_global_git_config("user.name")
121
+ user_email = _get_global_git_config("user.email")
122
+
123
+ if not user_name or not user_email:
124
+ logger.warning(
125
+ "User git config not found, falling back to bot identity"
126
+ )
127
+ if _set_repo_git_config(repo_dir, "user.name", bot_name):
128
+ applied_config["user.name"] = bot_name
129
+ else:
130
+ logger.warning(f"Failed to set user.name to {bot_name}")
131
+
132
+ if _set_repo_git_config(repo_dir, "user.email", bot_email):
133
+ applied_config["user.email"] = bot_email
134
+ else:
135
+ logger.warning(f"Failed to set user.email to {bot_email}")
136
+
137
+ applied_config["mode"] = "bot_identity_fallback"
138
+ return applied_config
139
+
140
+ # Set user identity
141
+ if _set_repo_git_config(repo_dir, "user.name", user_name):
142
+ applied_config["user.name"] = user_name
143
+ else:
144
+ logger.warning(f"Failed to set user.name to {user_name}")
145
+
146
+ if _set_repo_git_config(repo_dir, "user.email", user_email):
147
+ applied_config["user.email"] = user_email
148
+ else:
149
+ logger.warning(f"Failed to set user.email to {user_email}")
150
+
151
+ if mode == GitConfigMode.USER_NO_SIGN:
152
+ # Explicitly disable signing
153
+ logger.debug("Configuring git with user identity (signing disabled)")
154
+ if _set_repo_git_config(repo_dir, "commit.gpgsign", "false"):
155
+ applied_config["commit.gpgsign"] = "false"
156
+ else:
157
+ logger.warning("Failed to set commit.gpgsign to false")
158
+
159
+ applied_config["mode"] = "user_no_sign"
160
+ return applied_config
161
+
162
+ # USER_INHERIT mode: copy signing configuration
163
+ logger.debug("Configuring git with user identity (inheriting signing)")
164
+
165
+ # Check if user has signing enabled
166
+ gpgsign = _get_global_git_config("commit.gpgsign")
167
+ if gpgsign == "true":
168
+ if _set_repo_git_config(repo_dir, "commit.gpgsign", "true"):
169
+ applied_config["commit.gpgsign"] = "true"
170
+ else:
171
+ logger.warning("Failed to set commit.gpgsign to true")
172
+
173
+ # Get GPG format (ssh, openpgp, x509) - default to openpgp if not set
174
+ gpg_format = _get_global_git_config("gpg.format") or "openpgp"
175
+ if _set_repo_git_config(repo_dir, "gpg.format", gpg_format):
176
+ applied_config["gpg.format"] = gpg_format
177
+ else:
178
+ logger.warning(f"Failed to set gpg.format to {gpg_format}")
179
+
180
+ # Handle SSH-specific configuration
181
+ if gpg_format == "ssh":
182
+ # Copy SSH signing key
183
+ ssh_key = _get_global_git_config("user.signingkey")
184
+ if ssh_key:
185
+ if _set_repo_git_config(repo_dir, "user.signingkey", ssh_key):
186
+ applied_config["user.signingkey"] = ssh_key
187
+ else:
188
+ logger.warning(
189
+ f"Failed to set user.signingkey to {ssh_key}"
190
+ )
191
+
192
+ # Copy allowed signers file
193
+ allowed_signers = _get_global_git_config(
194
+ "gpg.ssh.allowedSignersFile"
195
+ )
196
+ if allowed_signers:
197
+ if _set_repo_git_config(
198
+ repo_dir, "gpg.ssh.allowedSignersFile", allowed_signers
199
+ ):
200
+ applied_config["gpg.ssh.allowedSignersFile"] = (
201
+ allowed_signers
202
+ )
203
+ else:
204
+ logger.warning(
205
+ f"Failed to set gpg.ssh.allowedSignersFile to {allowed_signers}"
206
+ )
207
+
208
+ # Copy default key ID if set
209
+ default_key = _get_global_git_config("gpg.ssh.defaultKeyCommand")
210
+ if default_key:
211
+ if _set_repo_git_config(
212
+ repo_dir, "gpg.ssh.defaultKeyCommand", default_key
213
+ ):
214
+ applied_config["gpg.ssh.defaultKeyCommand"] = default_key
215
+ else:
216
+ logger.warning(
217
+ f"Failed to set gpg.ssh.defaultKeyCommand to {default_key}"
218
+ )
219
+
220
+ # Handle GPG-specific configuration (openpgp, x509)
221
+ elif gpg_format in ["openpgp", "x509"]:
222
+ # Copy GPG signing key
223
+ signing_key = _get_global_git_config("user.signingkey")
224
+ if signing_key:
225
+ if _set_repo_git_config(
226
+ repo_dir, "user.signingkey", signing_key
227
+ ):
228
+ applied_config["user.signingkey"] = signing_key
229
+ else:
230
+ logger.warning(
231
+ f"Failed to set user.signingkey to {signing_key}"
232
+ )
233
+
234
+ # Copy GPG program if specified
235
+ gpg_program = _get_global_git_config("gpg.program")
236
+ if gpg_program:
237
+ if _set_repo_git_config(repo_dir, "gpg.program", gpg_program):
238
+ applied_config["gpg.program"] = gpg_program
239
+ else:
240
+ logger.warning(
241
+ f"Failed to set gpg.program to {gpg_program}"
242
+ )
243
+
244
+ logger.info(
245
+ f"Git signing enabled: format={gpg_format}, "
246
+ f"key={'configured' if applied_config.get('user.signingkey') else 'default'}"
247
+ )
248
+ else:
249
+ logger.debug("User does not have commit signing enabled")
250
+
251
+ applied_config["mode"] = "user_inherit"
252
+ return applied_config
253
+
254
+
255
+ def get_signing_info(repo_dir: Path) -> dict[str, str | bool]:
256
+ """Get current signing configuration from a repository.
257
+
258
+ Args:
259
+ repo_dir: Repository directory path
260
+
261
+ Returns:
262
+ Dict with signing configuration details
263
+ """
264
+ info: dict[str, str | bool] = {}
265
+
266
+ try:
267
+ result = subprocess.run(
268
+ ["git", "config", "commit.gpgsign"],
269
+ cwd=repo_dir,
270
+ capture_output=True,
271
+ text=True,
272
+ check=False,
273
+ )
274
+ info["signing_enabled"] = result.stdout.strip() == "true"
275
+
276
+ if info["signing_enabled"]:
277
+ # Get format
278
+ result = subprocess.run(
279
+ ["git", "config", "gpg.format"],
280
+ cwd=repo_dir,
281
+ capture_output=True,
282
+ text=True,
283
+ check=False,
284
+ )
285
+ gpg_format = result.stdout.strip()
286
+ info["format"] = gpg_format if gpg_format else "openpgp"
287
+
288
+ # Get signing key
289
+ result = subprocess.run(
290
+ ["git", "config", "user.signingkey"],
291
+ cwd=repo_dir,
292
+ capture_output=True,
293
+ text=True,
294
+ check=False,
295
+ )
296
+ signing_key = result.stdout.strip()
297
+ if signing_key:
298
+ info["signing_key"] = signing_key
299
+
300
+ except Exception as e:
301
+ logger.debug(f"Could not get signing info: {e}")
302
+
303
+ return info
@@ -40,7 +40,7 @@ class GitHubClient:
40
40
  """Async context manager exit."""
41
41
  pass
42
42
 
43
- @retry( # type: ignore[misc]
43
+ @retry(
44
44
  stop=stop_after_attempt(3),
45
45
  wait=wait_exponential(multiplier=1, min=2, max=10),
46
46
  )
@@ -83,7 +83,7 @@ class GitHubClient:
83
83
  msg = f"Request failed: {e}"
84
84
  raise FileAccessError(msg) from e
85
85
 
86
- @retry( # type: ignore[misc]
86
+ @retry(
87
87
  stop=stop_after_attempt(3),
88
88
  wait=wait_exponential(multiplier=1, min=2, max=10),
89
89
  )
@@ -16,6 +16,7 @@ from typing import TYPE_CHECKING, Any
16
16
  if TYPE_CHECKING:
17
17
  from .github_client import GitHubClient
18
18
 
19
+ from .git_config import GitConfigMode, configure_git_identity
19
20
  from .models import GitHubFixResult, PRInfo
20
21
  from .table_fixer import FileFixer, TableFixer
21
22
  from .table_parser import MarkdownFileScanner, TableParser
@@ -25,13 +26,19 @@ from .table_validator import TableValidator
25
26
  class PRFixer:
26
27
  """Fix markdown tables in pull requests."""
27
28
 
28
- def __init__(self, client: GitHubClient):
29
+ def __init__(
30
+ self,
31
+ client: GitHubClient,
32
+ git_config_mode: str = GitConfigMode.USER_INHERIT,
33
+ ):
29
34
  """Initialize PR fixer.
30
35
 
31
36
  Args:
32
37
  client: GitHub API client
38
+ git_config_mode: Git configuration mode (USER_INHERIT, USER_NO_SIGN, or BOT_IDENTITY)
33
39
  """
34
40
  self.client = client
41
+ self.git_config_mode = git_config_mode
35
42
  self.logger = logging.getLogger("markdown_table_fixer.pr_fixer")
36
43
 
37
44
  def _sanitize_message(self, message: str) -> str:
@@ -58,25 +65,114 @@ class PRFixer:
58
65
 
59
66
  return sanitized
60
67
 
61
- async def fix_pr_by_url(
68
+ def _create_error_pr_info(self, pr_url: str) -> PRInfo:
69
+ """Create a dummy PRInfo for error responses.
70
+
71
+ Args:
72
+ pr_url: PR URL for error messages
73
+
74
+ Returns:
75
+ PRInfo with dummy values for error cases
76
+ """
77
+ return PRInfo(
78
+ number=0,
79
+ title="",
80
+ repository="",
81
+ url=pr_url,
82
+ author="",
83
+ is_draft=False,
84
+ head_ref="",
85
+ head_sha="",
86
+ base_ref="",
87
+ mergeable="",
88
+ merge_state_status="",
89
+ )
90
+
91
+ def _validate_parameters(
92
+ self,
93
+ pr_url: str,
94
+ update_method: str,
95
+ sync_strategy: str,
96
+ conflict_strategy: str,
97
+ ) -> GitHubFixResult | None:
98
+ """Validate input parameters.
99
+
100
+ Args:
101
+ pr_url: PR URL for error messages
102
+ update_method: Update method (already normalized)
103
+ sync_strategy: Sync strategy (already normalized)
104
+ conflict_strategy: Conflict strategy (already normalized)
105
+
106
+ Returns:
107
+ GitHubFixResult with error if validation fails, None if valid
108
+ """
109
+ # Validate update_method
110
+ if update_method not in ["git", "api"]:
111
+ return GitHubFixResult(
112
+ pr_info=self._create_error_pr_info(pr_url),
113
+ success=False,
114
+ message=f"Invalid update_method '{update_method}'. Use 'git' or 'api'",
115
+ )
116
+
117
+ # Validate sync_strategy (only relevant for git method)
118
+ if update_method == "git" and sync_strategy not in [
119
+ "none",
120
+ "rebase",
121
+ "merge",
122
+ ]:
123
+ return GitHubFixResult(
124
+ pr_info=self._create_error_pr_info(pr_url),
125
+ success=False,
126
+ message=f"Invalid sync_strategy '{sync_strategy}'. Use 'none', 'rebase', or 'merge'",
127
+ )
128
+
129
+ # Validate conflict_strategy (only relevant for git method)
130
+ if update_method == "git" and conflict_strategy not in [
131
+ "fail",
132
+ "ours",
133
+ "theirs",
134
+ ]:
135
+ return GitHubFixResult(
136
+ pr_info=self._create_error_pr_info(pr_url),
137
+ success=False,
138
+ message=f"Invalid conflict_strategy '{conflict_strategy}'. Use 'fail', 'ours', or 'theirs'",
139
+ )
140
+
141
+ return None
142
+
143
+ async def fix_pr_by_url( # noqa: PLR0911
62
144
  self,
63
145
  pr_url: str,
64
146
  *,
65
147
  sync_strategy: str = "none",
66
148
  conflict_strategy: str = "fail",
67
149
  dry_run: bool = False,
150
+ update_method: str = "api",
68
151
  ) -> GitHubFixResult:
69
- """Fix markdown tables in a PR by URL, amending the existing commit.
152
+ """Fix markdown tables in a PR by URL.
70
153
 
71
154
  Args:
72
155
  pr_url: GitHub PR URL (e.g., https://github.com/owner/repo/pull/123)
73
- sync_strategy: How to sync with base branch: 'none', 'rebase', or 'merge'
74
- conflict_strategy: How to resolve conflicts: 'fail', 'ours', or 'theirs'
156
+ sync_strategy: How to sync with base branch: 'none', 'rebase', or 'merge' (git method only)
157
+ conflict_strategy: How to resolve conflicts: 'fail', 'ours', or 'theirs' (git method only)
75
158
  dry_run: If True, don't actually push changes
159
+ update_method: Method to apply fixes: 'git' (clone, amend, push) or 'api' (GitHub API commits)
76
160
 
77
161
  Returns:
78
162
  GitHubFixResult with operation details
79
163
  """
164
+ # Normalize parameters to lowercase for case-insensitive comparison
165
+ update_method = update_method.lower()
166
+ sync_strategy = sync_strategy.lower()
167
+ conflict_strategy = conflict_strategy.lower()
168
+
169
+ # Validate parameters
170
+ validation_error = self._validate_parameters(
171
+ pr_url, update_method, sync_strategy, conflict_strategy
172
+ )
173
+ if validation_error:
174
+ return validation_error
175
+
80
176
  # Parse PR URL
81
177
  match = re.match(
82
178
  r"https?://github\.com/([^/]+)/([^/]+)/pull/(\d+)", pr_url
@@ -150,23 +246,34 @@ class PRFixer:
150
246
  merge_state_status=pr_data.get("mergeable_state", "unknown"),
151
247
  )
152
248
 
153
- if not clone_url:
249
+ if not clone_url and update_method == "git":
154
250
  return GitHubFixResult(
155
251
  pr_info=pr_info,
156
252
  success=False,
157
253
  message="PR head repository not accessible",
158
254
  )
159
255
 
160
- # Clone and fix using Git operations
161
- return await self._fix_pr_with_git(
162
- pr_info,
163
- clone_url,
164
- owner,
165
- repo,
166
- sync_strategy=sync_strategy,
167
- conflict_strategy=conflict_strategy,
168
- dry_run=dry_run,
169
- )
256
+ # Route to appropriate update method
257
+ if update_method == "api":
258
+ # Use GitHub API to update files
259
+ return await self._fix_pr_with_api(
260
+ pr_info,
261
+ owner,
262
+ repo,
263
+ pr_data,
264
+ dry_run=dry_run,
265
+ )
266
+ else:
267
+ # Clone and fix using Git operations
268
+ return await self._fix_pr_with_git(
269
+ pr_info,
270
+ clone_url,
271
+ owner,
272
+ repo,
273
+ sync_strategy=sync_strategy,
274
+ conflict_strategy=conflict_strategy,
275
+ dry_run=dry_run,
276
+ )
170
277
 
171
278
  except Exception as e:
172
279
  self.logger.error(f"Error fixing PR: {e}", exc_info=True)
@@ -199,6 +306,7 @@ class PRFixer:
199
306
  sync_strategy: str = "none",
200
307
  conflict_strategy: str = "fail",
201
308
  dry_run: bool = False,
309
+ git_config_mode: str | None = None,
202
310
  ) -> GitHubFixResult:
203
311
  """Fix PR using Git operations (clone, fix, amend, push).
204
312
 
@@ -210,10 +318,13 @@ class PRFixer:
210
318
  sync_strategy: How to sync with base branch: 'none', 'rebase', or 'merge'
211
319
  conflict_strategy: How to resolve conflicts: 'fail', 'ours', or 'theirs'
212
320
  dry_run: If True, don't push changes
321
+ git_config_mode: Override git config mode for this operation
213
322
 
214
323
  Returns:
215
324
  GitHubFixResult with operation details
216
325
  """
326
+ # Use provided mode or fall back to instance default
327
+ config_mode = git_config_mode or self.git_config_mode
217
328
  with tempfile.TemporaryDirectory() as tmpdir:
218
329
  repo_dir = Path(tmpdir) / "repo"
219
330
  self.logger.debug(f"Cloning {clone_url} to {repo_dir}")
@@ -318,24 +429,14 @@ class PRFixer:
318
429
  files_modified=result_files,
319
430
  )
320
431
 
321
- # Configure git
322
- subprocess.run(
323
- ["git", "config", "user.name", "markdown-table-fixer"],
324
- cwd=repo_dir,
325
- check=True,
326
- capture_output=True,
327
- )
328
- subprocess.run(
329
- [
330
- "git",
331
- "config",
332
- "user.email",
333
- "noreply@linuxfoundation.org",
334
- ],
335
- cwd=repo_dir,
336
- check=True,
337
- capture_output=True,
432
+ # Configure git identity and signing
433
+ git_config = configure_git_identity(
434
+ repo_dir,
435
+ mode=config_mode,
436
+ bot_name="markdown-table-fixer",
437
+ bot_email="noreply@linuxfoundation.org",
338
438
  )
439
+ self.logger.debug(f"Git config applied: {git_config}")
339
440
 
340
441
  # Stage the changes
341
442
  for file_path in files_modified:
@@ -575,6 +676,55 @@ class PRFixer:
575
676
  f"Merge had conflicts, attempting to resolve with strategy '{conflict_strategy}'"
576
677
  )
577
678
 
679
+ async def _fix_pr_with_api(
680
+ self,
681
+ pr_info: PRInfo,
682
+ owner: str,
683
+ repo: str,
684
+ pr_data: dict[str, Any],
685
+ *,
686
+ dry_run: bool = False,
687
+ ) -> GitHubFixResult:
688
+ """Fix PR using GitHub API (creates new commits, shows as verified by GitHub).
689
+
690
+ Args:
691
+ pr_info: PR information
692
+ owner: Repository owner
693
+ repo: Repository name
694
+ pr_data: Full PR data from API
695
+ dry_run: If True, don't actually push changes
696
+
697
+ Returns:
698
+ GitHubFixResult with operation details
699
+ """
700
+ result = await self.fix_pr_tables(
701
+ owner,
702
+ repo,
703
+ pr_data,
704
+ dry_run=dry_run,
705
+ create_comment=True,
706
+ )
707
+
708
+ if result.get("success"):
709
+ files_modified = [
710
+ Path(f["filename"]) for f in result.get("fixed_files", [])
711
+ ]
712
+ message = f"Fixed {result.get('tables_fixed', 0)} table(s) in {result.get('files_fixed', 0)} file(s)"
713
+
714
+ return GitHubFixResult(
715
+ pr_info=pr_info,
716
+ success=True,
717
+ message=message,
718
+ files_modified=files_modified,
719
+ )
720
+ else:
721
+ return GitHubFixResult(
722
+ pr_info=pr_info,
723
+ success=False,
724
+ message=result.get("error", "Failed to fix tables via API"),
725
+ error=result.get("error"),
726
+ )
727
+
578
728
  async def fix_pr_tables(
579
729
  self,
580
730
  owner: str,