gitflow-analytics 3.9.2__py3-none-any.whl → 3.10.4__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.
@@ -1,4 +1,4 @@
1
1
  """Version information for gitflow-analytics."""
2
2
 
3
- __version__ = "3.9.2"
3
+ __version__ = "3.10.4"
4
4
  __version_info__ = tuple(int(x) for x in __version__.split("."))
@@ -7,6 +7,8 @@ and comprehensive configuration generation.
7
7
  import getpass
8
8
  import logging
9
9
  import os
10
+ import re
11
+ import shutil
10
12
  import stat
11
13
  import subprocess
12
14
  import sys
@@ -18,6 +20,8 @@ from typing import Optional
18
20
  import click
19
21
  import requests
20
22
  import yaml
23
+ from git import GitCommandError, Repo
24
+ from git.exc import InvalidGitRepositoryError
21
25
  from github import Github
22
26
  from github.GithubException import GithubException
23
27
 
@@ -490,6 +494,203 @@ class InstallWizard:
490
494
  logger.debug(f"Path validation error: {type(e).__name__}")
491
495
  return None
492
496
 
497
+ def _detect_git_url(self, input_str: str) -> Optional[str]:
498
+ """Detect if input is a Git URL and normalize it.
499
+
500
+ Args:
501
+ input_str: User input string
502
+
503
+ Returns:
504
+ Normalized Git URL if detected, None if it's a local path
505
+ """
506
+ import re
507
+
508
+ # HTTPS URL patterns
509
+ https_pattern = r"^https?://[^/]+/[^/]+/[^/]+(?:\.git)?$"
510
+ # SSH URL pattern
511
+ ssh_pattern = r"^git@[^:]+:[^/]+/[^/]+(?:\.git)?$"
512
+
513
+ input_str = input_str.strip()
514
+
515
+ if re.match(https_pattern, input_str, re.IGNORECASE) or re.match(ssh_pattern, input_str):
516
+ # Ensure .git extension for consistency
517
+ if not input_str.endswith(".git"):
518
+ input_str = input_str + ".git"
519
+ return input_str
520
+
521
+ return None
522
+
523
+ def _clone_git_repository(self, git_url: str) -> Optional[tuple[Path, str]]:
524
+ """Clone a Git repository to the local repos/ directory.
525
+
526
+ Args:
527
+ git_url: Git URL to clone
528
+
529
+ Returns:
530
+ Tuple of (local_path, original_url) if successful, None if failed
531
+ """
532
+ try:
533
+ # Extract repository name from URL
534
+ # Handle both HTTPS and SSH formats
535
+ match = re.search(r"/([^/]+?)(?:\.git)?$", git_url)
536
+ if not match:
537
+ click.echo("❌ Could not extract repository name from URL")
538
+ return None
539
+
540
+ repo_name = match.group(1)
541
+ click.echo(f"📦 Repository: {repo_name}")
542
+
543
+ # Create repos directory in current working directory
544
+ repos_dir = Path.cwd() / "repos"
545
+ repos_dir.mkdir(parents=True, exist_ok=True)
546
+ click.echo(f"📁 Clone directory: {repos_dir}")
547
+
548
+ # Target path for cloned repository
549
+ target_path = repos_dir / repo_name
550
+
551
+ # Check if repository already exists
552
+ if target_path.exists():
553
+ click.echo(f"⚠️ Directory already exists: {target_path}")
554
+
555
+ # Check if it's a valid git repository
556
+ try:
557
+ existing_repo = Repo(target_path)
558
+ if existing_repo.working_dir:
559
+ click.echo("✅ Found existing git repository")
560
+
561
+ # Check if remote URL matches
562
+ try:
563
+ origin_url = existing_repo.remotes.origin.url
564
+ if origin_url == git_url or self._normalize_git_url(
565
+ origin_url
566
+ ) == self._normalize_git_url(git_url):
567
+ click.echo(f"✅ Remote URL matches: {origin_url}")
568
+
569
+ # Offer to update
570
+ if click.confirm(
571
+ "Update existing repository (git pull)?", default=True
572
+ ):
573
+ click.echo("🔄 Updating repository...")
574
+ origin = existing_repo.remotes.origin
575
+ origin.pull()
576
+ click.echo("✅ Repository updated")
577
+
578
+ return (target_path, git_url)
579
+ else:
580
+ click.echo("⚠️ Remote URL mismatch:")
581
+ click.echo(f" Existing: {origin_url}")
582
+ click.echo(f" Requested: {git_url}")
583
+ if not click.confirm(
584
+ "Use existing repository anyway?", default=False
585
+ ):
586
+ return None
587
+ return (target_path, git_url)
588
+ except Exception as e:
589
+ click.echo(f"⚠️ Could not check remote URL: {type(e).__name__}")
590
+ if click.confirm("Use existing repository anyway?", default=False):
591
+ return (target_path, git_url)
592
+ return None
593
+ except InvalidGitRepositoryError:
594
+ click.echo("❌ Directory exists but is not a git repository")
595
+ if not click.confirm("Remove and re-clone?", default=False):
596
+ return None
597
+
598
+ # Remove existing directory
599
+ shutil.rmtree(target_path)
600
+ click.echo("🗑️ Removed existing directory")
601
+
602
+ # Clone the repository
603
+ click.echo(f"🔄 Cloning {git_url}...")
604
+ click.echo(" This may take a moment depending on repository size...")
605
+
606
+ # Clone with progress
607
+ Repo.clone_from(git_url, target_path, progress=self._get_git_progress())
608
+
609
+ # Verify clone succeeded
610
+ if not (target_path / ".git").exists():
611
+ click.echo("❌ Clone appeared to succeed but .git directory not found")
612
+ return None
613
+
614
+ click.echo(f"✅ Successfully cloned to: {target_path}")
615
+ return (target_path, git_url)
616
+
617
+ except GitCommandError as e:
618
+ click.echo("❌ Git clone failed")
619
+
620
+ # Parse error message for common issues
621
+ error_str = str(e).lower()
622
+ if "authentication failed" in error_str or "permission denied" in error_str:
623
+ click.echo("🔐 Authentication required")
624
+ click.echo(" For HTTPS: Configure Git credentials or use a personal access token")
625
+ click.echo(" For SSH: Ensure your SSH key is added to your Git provider")
626
+ elif "not found" in error_str or "does not exist" in error_str:
627
+ click.echo("🔍 Repository not found")
628
+ click.echo(" Check the URL and ensure you have access")
629
+ elif "network" in error_str or "timeout" in error_str:
630
+ click.echo("🌐 Network error")
631
+ click.echo(" Check your internet connection and try again")
632
+ else:
633
+ logger.debug(f"Git clone error type: {type(e).__name__}")
634
+
635
+ return None
636
+
637
+ except OSError as e:
638
+ error_type = type(e).__name__
639
+ click.echo(f"❌ File system error: {error_type}")
640
+ if "space" in str(e).lower():
641
+ click.echo("💾 Insufficient disk space")
642
+ logger.debug(f"Clone file system error: {error_type}")
643
+ return None
644
+
645
+ except Exception as e:
646
+ error_type = type(e).__name__
647
+ click.echo(f"❌ Unexpected error during clone: {error_type}")
648
+ logger.error(f"Clone error type: {error_type}")
649
+ return None
650
+
651
+ def _normalize_git_url(self, url: str) -> str:
652
+ """Normalize Git URL for comparison.
653
+
654
+ Args:
655
+ url: Git URL to normalize
656
+
657
+ Returns:
658
+ Normalized URL (lowercase, with .git extension)
659
+ """
660
+ url = url.lower().strip()
661
+ if not url.endswith(".git"):
662
+ url = url + ".git"
663
+ return url
664
+
665
+ def _get_git_progress(self):
666
+ """Get a Git progress handler for clone operations.
667
+
668
+ Returns:
669
+ Progress handler for GitPython or None
670
+ """
671
+ try:
672
+ from git import RemoteProgress
673
+
674
+ class CloneProgress(RemoteProgress):
675
+ """Progress handler for git clone operations."""
676
+
677
+ def __init__(self):
678
+ super().__init__()
679
+ self.last_percent = 0
680
+
681
+ def update(self, op_code, cur_count, max_count=None, message=""):
682
+ if max_count:
683
+ percent = int((cur_count / max_count) * 100)
684
+ # Only show updates every 10%
685
+ if percent >= self.last_percent + 10:
686
+ click.echo(f" Progress: {percent}%")
687
+ self.last_percent = percent
688
+
689
+ return CloneProgress()
690
+ except Exception:
691
+ # If progress handler fails, return None (clone will work without it)
692
+ return None
693
+
493
694
  def _setup_manual_repos(self) -> bool:
494
695
  """Setup manual repository configuration.
495
696
 
@@ -497,44 +698,66 @@ class InstallWizard:
497
698
  True if setup successful, False otherwise
498
699
  """
499
700
  click.echo("\n📦 Manual Repository Mode")
500
- click.echo("You can specify one or more local repository paths.")
701
+ click.echo("You can specify one or more local repository paths or Git URLs.")
702
+ click.echo("Supported formats:")
703
+ click.echo(" • Local path: /path/to/repo or ~/repos/myproject")
704
+ click.echo(" • HTTPS URL: https://github.com/owner/repo.git")
705
+ click.echo(" • SSH URL: git@github.com:owner/repo.git")
501
706
  click.echo()
502
707
 
503
708
  repositories = []
504
709
  while True:
505
- repo_path_str = click.prompt(
506
- "Enter repository path (or press Enter to finish)",
710
+ repo_input = click.prompt(
711
+ "Enter repository path or Git URL (or press Enter to finish)",
507
712
  type=str,
508
713
  default="",
509
714
  show_default=False,
510
715
  ).strip()
511
716
 
512
- if not repo_path_str:
717
+ if not repo_input:
513
718
  if not repositories:
514
719
  click.echo("❌ At least one repository is required")
515
720
  continue
516
721
  break
517
722
 
518
- # Validate path is safe
519
- path_obj = self._validate_directory_path(repo_path_str, "Repository path")
520
- if path_obj is None:
521
- continue # Re-prompt
522
-
523
- if not path_obj.exists():
524
- click.echo(f"⚠️ Path does not exist: {path_obj}")
525
- if not click.confirm("Add anyway?", default=False):
723
+ # Check if input is a Git URL
724
+ git_url = self._detect_git_url(repo_input)
725
+ if git_url:
726
+ # Handle Git URL cloning
727
+ result = self._clone_git_repository(git_url)
728
+ if result is None:
729
+ # Clone failed, ask user if they want to retry or skip
730
+ if not click.confirm("Try a different repository?", default=True):
731
+ if repositories:
732
+ break # User has other repos, can finish
733
+ continue # User has no repos yet, must add at least one
526
734
  continue
527
735
 
528
- # Check if it's a git repository
529
- if (path_obj / ".git").exists():
530
- click.echo(f" Valid git repository: {path_obj}")
736
+ # Clone successful
737
+ local_path, original_url = result
738
+ repositories.append({"path": str(local_path), "git_url": original_url})
739
+ click.echo(f"Added repository #{len(repositories)}")
531
740
  else:
532
- click.echo(f"⚠️ Not a git repository: {path_obj}")
533
- if not click.confirm("Add anyway?", default=False):
534
- continue
741
+ # Handle local path
742
+ path_obj = self._validate_directory_path(repo_input, "Repository path")
743
+ if path_obj is None:
744
+ continue # Re-prompt
745
+
746
+ if not path_obj.exists():
747
+ click.echo(f"⚠️ Path does not exist: {path_obj}")
748
+ if not click.confirm("Add anyway?", default=False):
749
+ continue
750
+
751
+ # Check if it's a git repository
752
+ if (path_obj / ".git").exists():
753
+ click.echo(f"✅ Valid git repository: {path_obj}")
754
+ else:
755
+ click.echo(f"⚠️ Not a git repository: {path_obj}")
756
+ if not click.confirm("Add anyway?", default=False):
757
+ continue
535
758
 
536
- repositories.append({"path": str(path_obj)})
537
- click.echo(f"Added repository #{len(repositories)}")
759
+ repositories.append({"path": str(path_obj)})
760
+ click.echo(f"Added repository #{len(repositories)}")
538
761
 
539
762
  if not click.confirm("Add another repository?", default=False):
540
763
  break
@@ -82,11 +82,15 @@ class ActivityScorer:
82
82
  + complexity_score * self.WEIGHTS["complexity"]
83
83
  )
84
84
 
85
+ # Determine if PR data is available for proper normalization
86
+ has_pr_data = prs > 0
87
+
85
88
  return {
86
89
  "raw_score": raw_score,
87
- "normalized_score": self._normalize_score(raw_score),
90
+ "normalized_score": self._normalize_score(raw_score, has_pr_data),
88
91
  "components": components,
89
- "activity_level": self._get_activity_level(raw_score),
92
+ "activity_level": self._get_activity_level(raw_score, has_pr_data),
93
+ "has_pr_data": has_pr_data,
90
94
  }
91
95
 
92
96
  def _calculate_commit_score(self, commits: int) -> float:
@@ -169,8 +173,13 @@ class ActivityScorer:
169
173
 
170
174
  return max(0, file_score + complexity_bonus)
171
175
 
172
- def _normalize_score(self, raw_score: float) -> float:
173
- """Normalize score to 0-100 range."""
176
+ def _normalize_score(self, raw_score: float, has_pr_data: bool = True) -> float:
177
+ """Normalize score to 0-100 range.
178
+
179
+ Args:
180
+ raw_score: The calculated raw activity score
181
+ has_pr_data: Whether PR data was available (affects normalization divisor)
182
+ """
174
183
  # Based on research, a highly productive week might have:
175
184
  # - 15 commits (150 points after scaling)
176
185
  # - 3 PRs of optimal size (180 points)
@@ -178,12 +187,17 @@ class ActivityScorer:
178
187
  # - 20 files changed (50 points)
179
188
  # Total: ~500 points = 100 normalized
180
189
 
181
- normalized = (raw_score / 500) * 100
190
+ # When PR data is unavailable (e.g., weekly reports), adjust divisor
191
+ # since PR component (30% weight) contributes 0
192
+ # Effective max becomes 70% of 50 = 35
193
+ divisor = 35 if not has_pr_data else 50
194
+
195
+ normalized = (raw_score / divisor) * 100
182
196
  return min(100, normalized) # Cap at 100
183
197
 
184
- def _get_activity_level(self, raw_score: float) -> str:
198
+ def _get_activity_level(self, raw_score: float, has_pr_data: bool = True) -> str:
185
199
  """Categorize activity level based on score."""
186
- normalized = self._normalize_score(raw_score)
200
+ normalized = self._normalize_score(raw_score, has_pr_data)
187
201
 
188
202
  if normalized >= 80:
189
203
  return "exceptional"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: gitflow-analytics
3
- Version: 3.9.2
3
+ Version: 3.10.4
4
4
  Summary: Analyze Git repositories for developer productivity insights
5
5
  Author-email: Bob Matyas <bobmatnyc@gmail.com>
6
6
  License: MIT
@@ -1,5 +1,5 @@
1
1
  gitflow_analytics/__init__.py,sha256=W3Jaey5wuT1nBPehVLTIRkVIyBa5jgYOlBKc_UFfh-4,773
2
- gitflow_analytics/_version.py,sha256=1vJ2cOJm4JBq3ASHO5cRbx3_lFVH3QIh1Caj_cSjWN4,137
2
+ gitflow_analytics/_version.py,sha256=Ern03L7_bWd8JxtBPPk3W30NHPMvxVnsv_nDZU6D_sM,138
3
3
  gitflow_analytics/cli.py,sha256=pYW6V0b6SRa3-NyOmXGQhf5emcKHUHgOVL2PFOAS8LQ,273331
4
4
  gitflow_analytics/config.py,sha256=XRuxvzLWyn_ML7mDCcuZ9-YFNAEsnt33vIuWxQQ_jxg,1033
5
5
  gitflow_analytics/constants.py,sha256=GXEncUJS9ijOI5KWtQCTANwdqxPfXpw-4lNjhaWTKC4,2488
@@ -11,7 +11,7 @@ gitflow_analytics/classification/feature_extractor.py,sha256=W82vztPQO8-MFw9Yt17
11
11
  gitflow_analytics/classification/linguist_analyzer.py,sha256=HjLx9mM7hGXtrvMba6osovHJLAacTx9oDmN6CS5w0bE,17687
12
12
  gitflow_analytics/classification/model.py,sha256=2KbmFh9MpyvHMcNHbqwUTAAVLHHu3MiTfFIPyZSGa-8,16356
13
13
  gitflow_analytics/cli_wizards/__init__.py,sha256=D73D97cS1hZsB_fCQQaAiWtd_w2Lb8TtcGc9Pn2DIyE,343
14
- gitflow_analytics/cli_wizards/install_wizard.py,sha256=KcmDD2RiqFrPuXdBhmXCYvftlV_V-_7p82Z6Gu8NgO0,60817
14
+ gitflow_analytics/cli_wizards/install_wizard.py,sha256=gz5c1NYeGLCzs-plL6ju7GXn7VldF7VyMw8MO4CzUGk,70345
15
15
  gitflow_analytics/cli_wizards/run_launcher.py,sha256=J6G_C7IqxPg7_GhAfbV99D1dIIWwb1s_qmHC7Iv2iGI,15038
16
16
  gitflow_analytics/config/__init__.py,sha256=KziRIbBJctB5LOLcKLzELWA1rXwjS6-C2_DeM_hT9rM,1133
17
17
  gitflow_analytics/config/aliases.py,sha256=z9F0X6qbbF544Tw7sHlOoBj5mpRSddMkCpoKLzvVzDU,10960
@@ -47,7 +47,7 @@ gitflow_analytics/integrations/github_integration.py,sha256=52lyq5GNJIlTXIv7iwrk
47
47
  gitflow_analytics/integrations/jira_integration.py,sha256=3DV1hGNs1HxAOSGt2BfqBrWSigRN5H8BT1-G7E_8hGg,28761
48
48
  gitflow_analytics/integrations/orchestrator.py,sha256=u3FKZF2yD5g5HhNFm6nIJe69ZKfU1QLni4S14GDRIrY,13205
49
49
  gitflow_analytics/metrics/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
50
- gitflow_analytics/metrics/activity_scoring.py,sha256=h1uj_6dTKpCwNJfsimfaY0TB3Qaexw7I7IEFutoHwUY,12539
50
+ gitflow_analytics/metrics/activity_scoring.py,sha256=lSrdeH9SVNzeSR80vvzdWBuFcD3BkHsOdSZu97q6Bdg,13171
51
51
  gitflow_analytics/metrics/branch_health.py,sha256=MkfyiUc1nHEakKBJ_uTlvxmofX1QX_s4hm4XBTYKVLM,17522
52
52
  gitflow_analytics/metrics/dora.py,sha256=U4Xk0tr7kPcpR7r-PevYBUDtZPkDIG-w_yS2DJOlTrk,27549
53
53
  gitflow_analytics/models/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
@@ -120,9 +120,9 @@ gitflow_analytics/training/model_loader.py,sha256=xGZLSopGxDhC--2XN6ytRgi2CyjOKY
120
120
  gitflow_analytics/training/pipeline.py,sha256=PQegTk_-OsPexVyRDfiy-3Df-7pcs25C4vPASr-HT9E,19951
121
121
  gitflow_analytics/ui/__init__.py,sha256=UBhYhZMvwlSrCuGWjkIdoP2zNbiQxOHOli-I8mqIZUE,441
122
122
  gitflow_analytics/ui/progress_display.py,sha256=3xJnCOSs1DRVAfS-rTu37EsLfWDFW5-mbv-bPS9NMm4,59182
123
- gitflow_analytics-3.9.2.dist-info/licenses/LICENSE,sha256=xwvSwY1GYXpRpmbnFvvnbmMwpobnrdN9T821sGvjOY0,1066
124
- gitflow_analytics-3.9.2.dist-info/METADATA,sha256=zFtrIA8qcqvHtfmDMA9wqTLf2RQbhweU02RzzqqeerY,36830
125
- gitflow_analytics-3.9.2.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
126
- gitflow_analytics-3.9.2.dist-info/entry_points.txt,sha256=ZOsX0GLsnMysp5FWPOfP_qyoS7WJ8IgcaDFDxWBYl1g,98
127
- gitflow_analytics-3.9.2.dist-info/top_level.txt,sha256=CQyxZXjKvpSB1kgqqtuE0PCRqfRsXZJL8JrYpJKtkrk,18
128
- gitflow_analytics-3.9.2.dist-info/RECORD,,
123
+ gitflow_analytics-3.10.4.dist-info/licenses/LICENSE,sha256=xwvSwY1GYXpRpmbnFvvnbmMwpobnrdN9T821sGvjOY0,1066
124
+ gitflow_analytics-3.10.4.dist-info/METADATA,sha256=OASbKCl7rhlw1eGy1I2zkKOUNHkMHgs52przeGpZ4Fc,36831
125
+ gitflow_analytics-3.10.4.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
126
+ gitflow_analytics-3.10.4.dist-info/entry_points.txt,sha256=ZOsX0GLsnMysp5FWPOfP_qyoS7WJ8IgcaDFDxWBYl1g,98
127
+ gitflow_analytics-3.10.4.dist-info/top_level.txt,sha256=CQyxZXjKvpSB1kgqqtuE0PCRqfRsXZJL8JrYpJKtkrk,18
128
+ gitflow_analytics-3.10.4.dist-info/RECORD,,