repr-cli 0.1.0__py3-none-any.whl → 0.2.1__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.
repr/templates.py ADDED
@@ -0,0 +1,229 @@
1
+ """
2
+ Story generation templates.
3
+
4
+ Provides different prompts for generating stories based on use case:
5
+ - resume: Professional accomplishment summaries
6
+ - changelog: Technical change documentation
7
+ - narrative: Storytelling format
8
+ - interview: Behavioral interview preparation
9
+ """
10
+
11
+ from typing import Any
12
+
13
+
14
+ # Template definitions
15
+ TEMPLATES = {
16
+ "resume": {
17
+ "name": "Resume",
18
+ "description": "Professional accomplishment summaries for resumes and portfolios",
19
+ "system_prompt": """You are helping a developer document their work accomplishments for a professional resume or portfolio.
20
+
21
+ Focus on:
22
+ - Quantifiable impact where possible (performance improvements, user metrics, etc.)
23
+ - Technical complexity and problem-solving
24
+ - Leadership and collaboration
25
+ - Technologies and skills demonstrated
26
+
27
+ Write in first person, using action verbs. Keep it concise and impactful.
28
+ Format: 2-3 bullet points per accomplishment, each 1-2 sentences.""",
29
+ "user_prompt_template": """Based on these commits, write professional accomplishment summaries:
30
+
31
+ Repository: {repo_name}
32
+ Commits:
33
+ {commits_summary}
34
+
35
+ Generate 1-3 accomplishment summaries suitable for a resume.""",
36
+ },
37
+
38
+ "changelog": {
39
+ "name": "Changelog",
40
+ "description": "Technical change documentation for release notes",
41
+ "system_prompt": """You are writing technical changelog entries for a software project.
42
+
43
+ Focus on:
44
+ - What changed (features, fixes, improvements)
45
+ - Why it matters (user impact, developer experience)
46
+ - Breaking changes or migration notes
47
+ - Technical details relevant to other developers
48
+
49
+ Use conventional changelog format with categories:
50
+ - Added: New features
51
+ - Changed: Changes to existing functionality
52
+ - Fixed: Bug fixes
53
+ - Removed: Removed features
54
+ - Security: Security improvements""",
55
+ "user_prompt_template": """Generate changelog entries from these commits:
56
+
57
+ Repository: {repo_name}
58
+ Commits:
59
+ {commits_summary}
60
+
61
+ Write changelog entries grouped by category.""",
62
+ },
63
+
64
+ "narrative": {
65
+ "name": "Narrative",
66
+ "description": "Storytelling format for blogs or case studies",
67
+ "system_prompt": """You are helping a developer tell the story of their work in an engaging narrative format.
68
+
69
+ Focus on:
70
+ - The challenge or problem being solved
71
+ - The approach and decision-making process
72
+ - Obstacles encountered and how they were overcome
73
+ - Results and lessons learned
74
+
75
+ Write in a conversational, engaging tone suitable for a blog post or case study.
76
+ Use present tense for engagement. Include technical details but make it accessible.""",
77
+ "user_prompt_template": """Tell the story of this development work:
78
+
79
+ Repository: {repo_name}
80
+ Commits:
81
+ {commits_summary}
82
+
83
+ Write a narrative (2-3 paragraphs) that would work as a blog post section.""",
84
+ },
85
+
86
+ "interview": {
87
+ "name": "Interview Prep",
88
+ "description": "Behavioral interview preparation with STAR format",
89
+ "system_prompt": """You are helping a developer prepare for behavioral interviews using the STAR method.
90
+
91
+ Format each accomplishment as:
92
+ - Situation: Context and background
93
+ - Task: What needed to be done
94
+ - Action: What you did specifically
95
+ - Result: The outcome and impact
96
+
97
+ Focus on:
98
+ - Technical decision-making
99
+ - Problem-solving approach
100
+ - Collaboration and communication
101
+ - Quantifiable results""",
102
+ "user_prompt_template": """Create interview-ready stories from these commits:
103
+
104
+ Repository: {repo_name}
105
+ Commits:
106
+ {commits_summary}
107
+
108
+ Generate 1-2 STAR-format stories for behavioral interviews.""",
109
+ },
110
+ }
111
+
112
+ # Default template
113
+ DEFAULT_TEMPLATE = "resume"
114
+
115
+
116
+ def get_template(name: str) -> dict[str, Any] | None:
117
+ """
118
+ Get a template by name.
119
+
120
+ Args:
121
+ name: Template name
122
+
123
+ Returns:
124
+ Template dict or None if not found
125
+ """
126
+ return TEMPLATES.get(name.lower())
127
+
128
+
129
+ def list_templates() -> list[dict[str, str]]:
130
+ """
131
+ List all available templates.
132
+
133
+ Returns:
134
+ List of template info dicts
135
+ """
136
+ return [
137
+ {
138
+ "name": key,
139
+ "display_name": tmpl["name"],
140
+ "description": tmpl["description"],
141
+ }
142
+ for key, tmpl in TEMPLATES.items()
143
+ ]
144
+
145
+
146
+ def format_commits_for_prompt(commits: list[dict[str, Any]]) -> str:
147
+ """
148
+ Format commit list for inclusion in prompt.
149
+
150
+ Args:
151
+ commits: List of commit dicts
152
+
153
+ Returns:
154
+ Formatted string for prompt
155
+ """
156
+ lines = []
157
+ for c in commits:
158
+ sha = c.get("sha", c.get("full_sha", ""))[:7]
159
+ msg = c.get("message", "").split("\n")[0][:80]
160
+ date = c.get("date", "")[:10]
161
+ files = c.get("files", [])
162
+
163
+ lines.append(f"- [{sha}] {msg}")
164
+ if files:
165
+ lines.append(f" Files: {', '.join(files[:5])}")
166
+ if len(files) > 5:
167
+ lines.append(f" ... and {len(files) - 5} more files")
168
+ if c.get("insertions") or c.get("deletions"):
169
+ lines.append(f" Changes: +{c.get('insertions', 0)}/-{c.get('deletions', 0)}")
170
+
171
+ return "\n".join(lines)
172
+
173
+
174
+ def build_generation_prompt(
175
+ template_name: str,
176
+ repo_name: str,
177
+ commits: list[dict[str, Any]],
178
+ custom_prompt: str | None = None,
179
+ ) -> tuple[str, str]:
180
+ """
181
+ Build the system and user prompts for story generation.
182
+
183
+ Args:
184
+ template_name: Name of template to use
185
+ repo_name: Repository name
186
+ commits: List of commit dicts
187
+ custom_prompt: Optional custom prompt to append
188
+
189
+ Returns:
190
+ Tuple of (system_prompt, user_prompt)
191
+ """
192
+ template = get_template(template_name)
193
+ if not template:
194
+ template = TEMPLATES[DEFAULT_TEMPLATE]
195
+
196
+ system_prompt = template["system_prompt"]
197
+
198
+ # Format commits
199
+ commits_summary = format_commits_for_prompt(commits)
200
+
201
+ # Build user prompt
202
+ user_prompt = template["user_prompt_template"].format(
203
+ repo_name=repo_name,
204
+ commits_summary=commits_summary,
205
+ )
206
+
207
+ # Append custom prompt if provided
208
+ if custom_prompt:
209
+ user_prompt += f"\n\nAdditional instructions: {custom_prompt}"
210
+
211
+ return system_prompt, user_prompt
212
+
213
+
214
+ def get_template_help() -> str:
215
+ """
216
+ Get help text describing all templates.
217
+
218
+ Returns:
219
+ Formatted help string
220
+ """
221
+ lines = ["Available templates:\n"]
222
+
223
+ for key, tmpl in TEMPLATES.items():
224
+ lines.append(f" {key}")
225
+ lines.append(f" {tmpl['description']}")
226
+ lines.append("")
227
+
228
+ return "\n".join(lines)
229
+
repr/tools.py CHANGED
@@ -112,6 +112,7 @@ def get_commits_with_diffs(
112
112
  days: int = 365,
113
113
  max_diff_lines_per_file: int = 50,
114
114
  max_files_per_commit: int = 10,
115
+ since: str | None = None,
115
116
  ) -> list[dict[str, Any]]:
116
117
  """
117
118
  Get commits with actual diff content for LLM analysis.
@@ -122,6 +123,7 @@ def get_commits_with_diffs(
122
123
  days: Number of days to look back (default 365 for 1 year)
123
124
  max_diff_lines_per_file: Maximum diff lines to include per file
124
125
  max_files_per_commit: Maximum files to include per commit
126
+ since: Optional SHA or date string to start from (only analyze commits after this)
125
127
 
126
128
  Returns:
127
129
  List of commit objects with diffs
@@ -133,11 +135,31 @@ def get_commits_with_diffs(
133
135
  cutoff_date = datetime.now() - timedelta(days=days)
134
136
  cutoff_timestamp = cutoff_date.timestamp()
135
137
 
138
+ # Parse since parameter if provided
139
+ since_timestamp: float | None = None
140
+ since_sha: str | None = None
141
+ if since:
142
+ # Try to parse as date first
143
+ try:
144
+ since_date = datetime.fromisoformat(since.replace("Z", "+00:00"))
145
+ since_timestamp = since_date.timestamp()
146
+ except ValueError:
147
+ # Treat as SHA
148
+ since_sha = since
149
+
136
150
  for commit in repo.iter_commits(max_count=count):
137
151
  # Stop if we've gone past the time window
138
152
  if commit.committed_date < cutoff_timestamp:
139
153
  break
140
154
 
155
+ # Stop if we've reached the since SHA
156
+ if since_sha and commit.hexsha.startswith(since_sha):
157
+ break
158
+
159
+ # Skip commits before the since timestamp
160
+ if since_timestamp and commit.committed_date <= since_timestamp:
161
+ break
162
+
141
163
  # Get files changed with diffs
142
164
  files = []
143
165
  parent = commit.parents[0] if commit.parents else None
@@ -444,3 +466,183 @@ def get_dependencies(
444
466
  """
445
467
  return detect_dependencies(repo_path)
446
468
 
469
+
470
+ def get_commits_by_shas(
471
+ repo_path: Path,
472
+ shas: list[str],
473
+ max_diff_lines_per_file: int = 50,
474
+ max_files_per_commit: int = 10,
475
+ ) -> list[dict[str, Any]]:
476
+ """
477
+ Get specific commits by their SHAs with diffs.
478
+
479
+ Args:
480
+ repo_path: Path to the repository
481
+ shas: List of commit SHAs (full or short)
482
+ max_diff_lines_per_file: Maximum diff lines to include per file
483
+ max_files_per_commit: Maximum files to include per commit
484
+
485
+ Returns:
486
+ List of commit objects with diffs in the order of SHAs provided
487
+ """
488
+ repo = Repo(repo_path)
489
+ commits = []
490
+
491
+ for sha in shas:
492
+ try:
493
+ commit = repo.commit(sha)
494
+ except Exception:
495
+ # Skip invalid SHAs
496
+ continue
497
+
498
+ # Get files changed with diffs
499
+ files = []
500
+ parent = commit.parents[0] if commit.parents else None
501
+
502
+ try:
503
+ # Get diff for this commit
504
+ if parent:
505
+ diffs = parent.diff(commit, create_patch=True)
506
+ else:
507
+ # Initial commit - show all files as additions
508
+ diffs = commit.diff(None, create_patch=True)
509
+
510
+ for diff_item in list(diffs)[:max_files_per_commit]:
511
+ file_path = diff_item.b_path or diff_item.a_path
512
+ if not file_path:
513
+ continue
514
+
515
+ # Get the diff text
516
+ diff_text = ""
517
+ if diff_item.diff:
518
+ try:
519
+ diff_text = diff_item.diff.decode('utf-8', errors='ignore')
520
+ except:
521
+ diff_text = str(diff_item.diff)
522
+
523
+ # Truncate diff if too long
524
+ diff_lines = diff_text.split('\n')
525
+ if len(diff_lines) > max_diff_lines_per_file:
526
+ diff_text = '\n'.join(diff_lines[:max_diff_lines_per_file])
527
+ diff_text += f"\n... ({len(diff_lines) - max_diff_lines_per_file} more lines)"
528
+
529
+ files.append({
530
+ "path": file_path,
531
+ "change_type": diff_item.change_type, # A=added, D=deleted, M=modified, R=renamed
532
+ "diff": diff_text,
533
+ })
534
+ except (GitCommandError, Exception):
535
+ # If we can't get diff, just include file list
536
+ for filename in commit.stats.files.keys():
537
+ if len(files) >= max_files_per_commit:
538
+ break
539
+ files.append({
540
+ "path": filename,
541
+ "change_type": "M",
542
+ "diff": "",
543
+ })
544
+
545
+ commits.append({
546
+ "sha": commit.hexsha[:8],
547
+ "full_sha": commit.hexsha,
548
+ "message": commit.message.strip(),
549
+ "author": commit.author.name,
550
+ "author_email": commit.author.email,
551
+ "date": datetime.fromtimestamp(commit.committed_date).isoformat(),
552
+ "files": files,
553
+ "insertions": commit.stats.total["insertions"],
554
+ "deletions": commit.stats.total["deletions"],
555
+ })
556
+
557
+ return commits
558
+
559
+
560
+ def suggest_related_commits(
561
+ repo_path: Path,
562
+ limit: int = 50,
563
+ min_overlap: int = 2,
564
+ ) -> list[dict[str, Any]]:
565
+ """
566
+ Suggest groups of related commits based on file overlap.
567
+
568
+ Args:
569
+ repo_path: Path to the repository
570
+ limit: Maximum commits to analyze
571
+ min_overlap: Minimum number of overlapping files to consider commits related
572
+
573
+ Returns:
574
+ List of suggested commit groups with overlap info
575
+ """
576
+ from collections import defaultdict
577
+
578
+ # Get recent commits with file info
579
+ commits = get_commits_with_diffs(repo_path, count=limit, days=90)
580
+
581
+ if len(commits) < 2:
582
+ return []
583
+
584
+ # Build file -> commits mapping
585
+ file_commits: dict[str, list[int]] = defaultdict(list)
586
+ for idx, commit in enumerate(commits):
587
+ files = commit.get('files', [])
588
+ for file_info in files:
589
+ file_path = file_info.get('path', '')
590
+ if file_path:
591
+ file_commits[file_path].append(idx)
592
+
593
+ # Find commit pairs with overlapping files
594
+ overlap_scores: dict[tuple[int, int], int] = defaultdict(int)
595
+ overlapping_files: dict[tuple[int, int], set[str]] = defaultdict(set)
596
+
597
+ for file_path, commit_indices in file_commits.items():
598
+ if len(commit_indices) > 1:
599
+ # These commits touch the same file
600
+ for i in range(len(commit_indices)):
601
+ for j in range(i + 1, len(commit_indices)):
602
+ idx1, idx2 = commit_indices[i], commit_indices[j]
603
+ pair = (min(idx1, idx2), max(idx1, idx2))
604
+ overlap_scores[pair] += 1
605
+ overlapping_files[pair].add(file_path)
606
+
607
+ # Filter by minimum overlap and create suggestions
608
+ suggestions = []
609
+ seen_commits = set()
610
+
611
+ for (idx1, idx2), score in sorted(overlap_scores.items(), key=lambda x: -x[1]):
612
+ if score < min_overlap:
613
+ continue
614
+
615
+ # Skip if commits already in a group
616
+ if idx1 in seen_commits or idx2 in seen_commits:
617
+ continue
618
+
619
+ # Find other related commits
620
+ related = [idx1, idx2]
621
+ for idx3 in range(len(commits)):
622
+ if idx3 in seen_commits or idx3 == idx1 or idx3 == idx2:
623
+ continue
624
+
625
+ # Check overlap with any commit in the group
626
+ overlap_with_group = False
627
+ for group_idx in related:
628
+ pair = (min(idx3, group_idx), max(idx3, group_idx))
629
+ if pair in overlap_scores and overlap_scores[pair] >= min_overlap:
630
+ overlap_with_group = True
631
+ break
632
+
633
+ if overlap_with_group:
634
+ related.append(idx3)
635
+
636
+ if len(related) >= 2:
637
+ suggestion = {
638
+ 'commit_shas': [commits[i]['full_sha'] for i in related],
639
+ 'commit_count': len(related),
640
+ 'overlap_score': score,
641
+ 'overlapping_files': list(overlapping_files[(idx1, idx2)]),
642
+ 'summary': f"{len(related)} related commits on {', '.join(list(overlapping_files[(idx1, idx2)])[:3])}",
643
+ 'first_commit_message': commits[related[0]]['message'].split('\n')[0],
644
+ }
645
+ suggestions.append(suggestion)
646
+ seen_commits.update(related)
647
+
648
+ return suggestions[:10] # Return top 10 suggestions