repr-cli 0.2.7__py3-none-any.whl → 0.2.9__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/__init__.py +1 -1
- repr/__main__.py +37 -0
- repr/cli.py +378 -218
- repr/doctor.py +37 -0
- repr/llm.py +37 -0
- repr/openai_analysis.py +32 -23
- repr/privacy.py +37 -0
- repr/telemetry.py +37 -0
- repr/templates.py +6 -1
- repr/ui.py +37 -0
- repr/updater.py +282 -0
- {repr_cli-0.2.7.dist-info → repr_cli-0.2.9.dist-info}/METADATA +19 -5
- repr_cli-0.2.9.dist-info/RECORD +26 -0
- repr_cli-0.2.7.dist-info/RECORD +0 -25
- {repr_cli-0.2.7.dist-info → repr_cli-0.2.9.dist-info}/WHEEL +0 -0
- {repr_cli-0.2.7.dist-info → repr_cli-0.2.9.dist-info}/entry_points.txt +0 -0
- {repr_cli-0.2.7.dist-info → repr_cli-0.2.9.dist-info}/licenses/LICENSE +0 -0
- {repr_cli-0.2.7.dist-info → repr_cli-0.2.9.dist-info}/top_level.txt +0 -0
repr/cli.py
CHANGED
|
@@ -21,7 +21,8 @@ import os
|
|
|
21
21
|
import sys
|
|
22
22
|
from datetime import datetime, timedelta
|
|
23
23
|
from pathlib import Path
|
|
24
|
-
from typing import Optional, List
|
|
24
|
+
from typing import Optional, List, Dict
|
|
25
|
+
from collections import defaultdict
|
|
25
26
|
|
|
26
27
|
import typer
|
|
27
28
|
from rich.prompt import Confirm, Prompt
|
|
@@ -247,6 +248,81 @@ def init(
|
|
|
247
248
|
# GENERATE
|
|
248
249
|
# =============================================================================
|
|
249
250
|
|
|
251
|
+
def _parse_date_reference(date_str: str) -> str | None:
|
|
252
|
+
"""
|
|
253
|
+
Parse a date reference string into an ISO date string.
|
|
254
|
+
|
|
255
|
+
Supports:
|
|
256
|
+
- ISO dates: "2024-01-01"
|
|
257
|
+
- Day names: "monday", "tuesday", etc.
|
|
258
|
+
- Relative: "3 days ago", "2 weeks ago", "1 month ago"
|
|
259
|
+
|
|
260
|
+
Returns ISO date string or None if parsing fails.
|
|
261
|
+
"""
|
|
262
|
+
import re
|
|
263
|
+
from datetime import datetime, timedelta
|
|
264
|
+
|
|
265
|
+
date_str = date_str.lower().strip()
|
|
266
|
+
|
|
267
|
+
# Try ISO format first
|
|
268
|
+
try:
|
|
269
|
+
parsed = datetime.fromisoformat(date_str)
|
|
270
|
+
return parsed.isoformat()
|
|
271
|
+
except ValueError:
|
|
272
|
+
pass
|
|
273
|
+
|
|
274
|
+
# Day names (find previous occurrence)
|
|
275
|
+
day_names = ["monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday"]
|
|
276
|
+
if date_str in day_names:
|
|
277
|
+
today = datetime.now()
|
|
278
|
+
target_day = day_names.index(date_str)
|
|
279
|
+
current_day = today.weekday()
|
|
280
|
+
|
|
281
|
+
# Calculate days back to that day
|
|
282
|
+
days_back = (current_day - target_day) % 7
|
|
283
|
+
if days_back == 0:
|
|
284
|
+
days_back = 7 # Go to last week's occurrence
|
|
285
|
+
|
|
286
|
+
target_date = today - timedelta(days=days_back)
|
|
287
|
+
return target_date.replace(hour=0, minute=0, second=0, microsecond=0).isoformat()
|
|
288
|
+
|
|
289
|
+
# Relative time: "N days/weeks/months ago"
|
|
290
|
+
match = re.match(r"(\d+)\s+(day|days|week|weeks|month|months)\s+ago", date_str)
|
|
291
|
+
if match:
|
|
292
|
+
amount = int(match.group(1))
|
|
293
|
+
unit = match.group(2).rstrip("s") # Normalize to singular
|
|
294
|
+
|
|
295
|
+
if unit == "day":
|
|
296
|
+
delta = timedelta(days=amount)
|
|
297
|
+
elif unit == "week":
|
|
298
|
+
delta = timedelta(weeks=amount)
|
|
299
|
+
elif unit == "month":
|
|
300
|
+
delta = timedelta(days=amount * 30) # Approximate
|
|
301
|
+
else:
|
|
302
|
+
return None
|
|
303
|
+
|
|
304
|
+
target_date = datetime.now() - delta
|
|
305
|
+
return target_date.replace(hour=0, minute=0, second=0, microsecond=0).isoformat()
|
|
306
|
+
|
|
307
|
+
# "yesterday" / "today"
|
|
308
|
+
if date_str == "yesterday":
|
|
309
|
+
target_date = datetime.now() - timedelta(days=1)
|
|
310
|
+
return target_date.replace(hour=0, minute=0, second=0, microsecond=0).isoformat()
|
|
311
|
+
if date_str == "today":
|
|
312
|
+
target_date = datetime.now()
|
|
313
|
+
return target_date.replace(hour=0, minute=0, second=0, microsecond=0).isoformat()
|
|
314
|
+
|
|
315
|
+
# "last week" / "last month"
|
|
316
|
+
if date_str == "last week":
|
|
317
|
+
target_date = datetime.now() - timedelta(weeks=1)
|
|
318
|
+
return target_date.replace(hour=0, minute=0, second=0, microsecond=0).isoformat()
|
|
319
|
+
if date_str == "last month":
|
|
320
|
+
target_date = datetime.now() - timedelta(days=30)
|
|
321
|
+
return target_date.replace(hour=0, minute=0, second=0, microsecond=0).isoformat()
|
|
322
|
+
|
|
323
|
+
return None
|
|
324
|
+
|
|
325
|
+
|
|
250
326
|
@app.command()
|
|
251
327
|
def generate(
|
|
252
328
|
local: bool = typer.Option(
|
|
@@ -267,6 +343,14 @@ def generate(
|
|
|
267
343
|
None, "--commits",
|
|
268
344
|
help="Generate from specific commits (comma-separated)",
|
|
269
345
|
),
|
|
346
|
+
since_date: Optional[str] = typer.Option(
|
|
347
|
+
None, "--since",
|
|
348
|
+
help="Generate from commits since date (e.g., '2024-01-01', 'monday', '2 weeks ago')",
|
|
349
|
+
),
|
|
350
|
+
days: Optional[int] = typer.Option(
|
|
351
|
+
None, "--days",
|
|
352
|
+
help="Generate from commits in the last N days",
|
|
353
|
+
),
|
|
270
354
|
batch_size: int = typer.Option(
|
|
271
355
|
5, "--batch-size",
|
|
272
356
|
help="Commits per story",
|
|
@@ -283,6 +367,10 @@ def generate(
|
|
|
283
367
|
None, "--prompt", "-p",
|
|
284
368
|
help="Custom prompt to append",
|
|
285
369
|
),
|
|
370
|
+
json_output: bool = typer.Option(
|
|
371
|
+
False, "--json",
|
|
372
|
+
help="Output as JSON",
|
|
373
|
+
),
|
|
286
374
|
):
|
|
287
375
|
"""
|
|
288
376
|
Generate stories from commits.
|
|
@@ -290,6 +378,8 @@ def generate(
|
|
|
290
378
|
Examples:
|
|
291
379
|
repr generate --local
|
|
292
380
|
repr generate --cloud
|
|
381
|
+
repr generate --since "2 weeks ago"
|
|
382
|
+
repr generate --days 30
|
|
293
383
|
repr generate --template changelog
|
|
294
384
|
repr generate --commits abc123,def456
|
|
295
385
|
"""
|
|
@@ -299,6 +389,9 @@ def generate(
|
|
|
299
389
|
if cloud:
|
|
300
390
|
allowed, reason = check_cloud_permission("cloud_generation")
|
|
301
391
|
if not allowed:
|
|
392
|
+
if json_output:
|
|
393
|
+
print(json.dumps({"generated": 0, "stories": [], "error": f"Cloud generation blocked: {reason}"}, indent=2))
|
|
394
|
+
raise typer.Exit(1)
|
|
302
395
|
print_error("Cloud generation blocked")
|
|
303
396
|
print_info(reason)
|
|
304
397
|
console.print()
|
|
@@ -313,11 +406,24 @@ def generate(
|
|
|
313
406
|
else:
|
|
314
407
|
local = True
|
|
315
408
|
|
|
316
|
-
|
|
409
|
+
if not json_output:
|
|
410
|
+
print_header()
|
|
317
411
|
|
|
318
412
|
mode_str = "local LLM" if local else "cloud LLM"
|
|
319
|
-
|
|
320
|
-
|
|
413
|
+
|
|
414
|
+
# Build timeframe string for display
|
|
415
|
+
if commits:
|
|
416
|
+
timeframe_str = f"specific commits"
|
|
417
|
+
elif since_date:
|
|
418
|
+
timeframe_str = f"since {since_date}"
|
|
419
|
+
elif days:
|
|
420
|
+
timeframe_str = f"last {days} days"
|
|
421
|
+
else:
|
|
422
|
+
timeframe_str = "last 90 days"
|
|
423
|
+
|
|
424
|
+
if not json_output:
|
|
425
|
+
console.print(f"Generating stories ({mode_str}, {timeframe_str})...")
|
|
426
|
+
console.print()
|
|
321
427
|
|
|
322
428
|
# Get repos to analyze
|
|
323
429
|
if repo:
|
|
@@ -325,12 +431,18 @@ def generate(
|
|
|
325
431
|
else:
|
|
326
432
|
tracked = get_tracked_repos()
|
|
327
433
|
if not tracked:
|
|
434
|
+
if json_output:
|
|
435
|
+
print(json.dumps({"generated": 0, "stories": [], "error": "No repositories tracked"}, indent=2))
|
|
436
|
+
raise typer.Exit(1)
|
|
328
437
|
print_warning("No repositories tracked.")
|
|
329
438
|
print_info("Run `repr init` or `repr repos add <path>` first.")
|
|
330
439
|
raise typer.Exit(1)
|
|
331
440
|
repo_paths = [Path(r["path"]) for r in tracked if Path(r["path"]).exists()]
|
|
332
441
|
|
|
333
442
|
if not repo_paths:
|
|
443
|
+
if json_output:
|
|
444
|
+
print(json.dumps({"generated": 0, "stories": [], "error": "No valid repositories found"}, indent=2))
|
|
445
|
+
raise typer.Exit(1)
|
|
334
446
|
print_error("No valid repositories found")
|
|
335
447
|
raise typer.Exit(1)
|
|
336
448
|
|
|
@@ -339,21 +451,37 @@ def generate(
|
|
|
339
451
|
from .discovery import analyze_repo
|
|
340
452
|
|
|
341
453
|
total_stories = 0
|
|
454
|
+
all_stories = [] # Collect all generated stories for JSON output
|
|
342
455
|
|
|
343
456
|
for repo_path in repo_paths:
|
|
344
457
|
repo_info = analyze_repo(repo_path)
|
|
345
|
-
|
|
458
|
+
if not json_output:
|
|
459
|
+
console.print(f"[bold]{repo_info.name}[/]")
|
|
346
460
|
|
|
347
461
|
if commits:
|
|
348
462
|
# Specific commits
|
|
349
463
|
commit_shas = [s.strip() for s in commits.split(",")]
|
|
350
464
|
commit_list = get_commits_by_shas(repo_path, commit_shas)
|
|
351
465
|
else:
|
|
352
|
-
#
|
|
353
|
-
|
|
466
|
+
# Determine timeframe
|
|
467
|
+
timeframe_days = days if days is not None else 90 # Default 90 days
|
|
468
|
+
since_str = None
|
|
469
|
+
|
|
470
|
+
# Parse natural language date if provided
|
|
471
|
+
if since_date:
|
|
472
|
+
since_str = _parse_date_reference(since_date)
|
|
473
|
+
|
|
474
|
+
# Recent commits within timeframe
|
|
475
|
+
commit_list = get_commits_with_diffs(
|
|
476
|
+
repo_path,
|
|
477
|
+
count=500, # Higher limit when filtering by time
|
|
478
|
+
days=timeframe_days,
|
|
479
|
+
since=since_str,
|
|
480
|
+
)
|
|
354
481
|
|
|
355
482
|
if not commit_list:
|
|
356
|
-
|
|
483
|
+
if not json_output:
|
|
484
|
+
console.print(f" [{BRAND_MUTED}]No commits found[/]")
|
|
357
485
|
continue
|
|
358
486
|
|
|
359
487
|
# Dry run: show what would be sent
|
|
@@ -434,8 +562,10 @@ def generate(
|
|
|
434
562
|
)
|
|
435
563
|
|
|
436
564
|
for story in stories:
|
|
437
|
-
|
|
565
|
+
if not json_output:
|
|
566
|
+
console.print(f" • {story['summary']}")
|
|
438
567
|
total_stories += 1
|
|
568
|
+
all_stories.append(story)
|
|
439
569
|
|
|
440
570
|
# Log cloud operation if using cloud
|
|
441
571
|
if cloud and stories:
|
|
@@ -450,7 +580,12 @@ def generate(
|
|
|
450
580
|
bytes_sent=len(str(commit_list)) // 2, # Rough estimate
|
|
451
581
|
)
|
|
452
582
|
|
|
453
|
-
|
|
583
|
+
if not json_output:
|
|
584
|
+
console.print()
|
|
585
|
+
|
|
586
|
+
if json_output:
|
|
587
|
+
print(json.dumps({"generated": total_stories, "stories": all_stories}, indent=2, default=str))
|
|
588
|
+
return
|
|
454
589
|
|
|
455
590
|
if dry_run:
|
|
456
591
|
console.print()
|
|
@@ -465,7 +600,7 @@ def generate(
|
|
|
465
600
|
])
|
|
466
601
|
|
|
467
602
|
|
|
468
|
-
def
|
|
603
|
+
async def _generate_stories_async(
|
|
469
604
|
commits: list[dict],
|
|
470
605
|
repo_info,
|
|
471
606
|
batch_size: int,
|
|
@@ -473,7 +608,7 @@ def _generate_stories(
|
|
|
473
608
|
template: str = "resume",
|
|
474
609
|
custom_prompt: str | None = None,
|
|
475
610
|
) -> list[dict]:
|
|
476
|
-
"""Generate stories from commits using LLM."""
|
|
611
|
+
"""Generate stories from commits using LLM (async implementation)."""
|
|
477
612
|
from .openai_analysis import get_openai_client, extract_commit_batch
|
|
478
613
|
from .templates import build_generation_prompt
|
|
479
614
|
|
|
@@ -485,186 +620,103 @@ def _generate_stories(
|
|
|
485
620
|
for i in range(0, len(commits), batch_size)
|
|
486
621
|
]
|
|
487
622
|
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
623
|
+
# Create client once, reuse for all batches
|
|
624
|
+
if local:
|
|
625
|
+
llm_config = get_llm_config()
|
|
626
|
+
client = get_openai_client(
|
|
627
|
+
api_key=llm_config.get("local_api_key") or "ollama",
|
|
628
|
+
base_url=llm_config.get("local_api_url") or "http://localhost:11434/v1",
|
|
629
|
+
)
|
|
630
|
+
model = llm_config.get("local_model") or llm_config.get("extraction_model") or "llama3.2"
|
|
631
|
+
else:
|
|
632
|
+
client = get_openai_client()
|
|
633
|
+
model = None # Use default
|
|
634
|
+
|
|
635
|
+
try:
|
|
636
|
+
for i, batch in enumerate(batches):
|
|
637
|
+
try:
|
|
638
|
+
# Build prompt with template
|
|
639
|
+
system_prompt, user_prompt = build_generation_prompt(
|
|
640
|
+
template_name=template,
|
|
641
|
+
repo_name=repo_info.name,
|
|
642
|
+
commits=batch,
|
|
643
|
+
custom_prompt=custom_prompt,
|
|
504
644
|
)
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
console.print(f" [{BRAND_MUTED}]Batch {i+1} failed: {e}[/]")
|
|
645
|
+
|
|
646
|
+
# Extract story from batch
|
|
647
|
+
content = await extract_commit_batch(
|
|
648
|
+
client=client,
|
|
649
|
+
commits=batch,
|
|
650
|
+
batch_num=i + 1,
|
|
651
|
+
total_batches=len(batches),
|
|
652
|
+
model=model,
|
|
653
|
+
system_prompt=system_prompt,
|
|
654
|
+
user_prompt=user_prompt,
|
|
655
|
+
)
|
|
656
|
+
|
|
657
|
+
if not content or content.startswith("[Batch"):
|
|
658
|
+
continue
|
|
659
|
+
|
|
660
|
+
# Extract summary (first non-empty line)
|
|
661
|
+
lines = [l.strip() for l in content.split("\n") if l.strip()]
|
|
662
|
+
summary = lines[0][:100] if lines else "Story"
|
|
663
|
+
# Clean up summary
|
|
664
|
+
summary = summary.lstrip("#-•* ").strip()
|
|
665
|
+
|
|
666
|
+
# Build metadata
|
|
667
|
+
commit_shas = [c["full_sha"] for c in batch]
|
|
668
|
+
first_date = min(c["date"] for c in batch)
|
|
669
|
+
last_date = max(c["date"] for c in batch)
|
|
670
|
+
total_files = sum(len(c.get("files", [])) for c in batch)
|
|
671
|
+
total_adds = sum(c.get("insertions", 0) for c in batch)
|
|
672
|
+
total_dels = sum(c.get("deletions", 0) for c in batch)
|
|
673
|
+
|
|
674
|
+
metadata = {
|
|
675
|
+
"summary": summary,
|
|
676
|
+
"repo_name": repo_info.name,
|
|
677
|
+
"repo_path": str(repo_info.path),
|
|
678
|
+
"commit_shas": commit_shas,
|
|
679
|
+
"first_commit_at": first_date,
|
|
680
|
+
"last_commit_at": last_date,
|
|
681
|
+
"files_changed": total_files,
|
|
682
|
+
"lines_added": total_adds,
|
|
683
|
+
"lines_removed": total_dels,
|
|
684
|
+
"generated_locally": local,
|
|
685
|
+
"template": template,
|
|
686
|
+
"needs_review": False,
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
# Save story
|
|
690
|
+
story_id = save_story(content, metadata)
|
|
691
|
+
metadata["id"] = story_id
|
|
692
|
+
stories.append(metadata)
|
|
693
|
+
|
|
694
|
+
except Exception as e:
|
|
695
|
+
console.print(f" [{BRAND_MUTED}]Batch {i+1} failed: {e}[/]")
|
|
696
|
+
finally:
|
|
697
|
+
# Properly close the async client
|
|
698
|
+
await client.close()
|
|
560
699
|
|
|
561
700
|
return stories
|
|
562
701
|
|
|
563
702
|
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
"""
|
|
582
|
-
_show_summary_since("this week", days=7, save=save)
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
@app.command()
|
|
586
|
-
def since(
|
|
587
|
-
date: str = typer.Argument(..., help="Date or time reference (e.g., 'monday', '2026-01-01', '3 days ago')"),
|
|
588
|
-
save: bool = typer.Option(
|
|
589
|
-
False, "--save",
|
|
590
|
-
help="Save as a permanent story",
|
|
591
|
-
),
|
|
592
|
-
):
|
|
593
|
-
"""
|
|
594
|
-
Show work since a specific date or time.
|
|
595
|
-
|
|
596
|
-
Examples:
|
|
597
|
-
repr since monday
|
|
598
|
-
repr since "3 days ago"
|
|
599
|
-
repr since 2026-01-01
|
|
600
|
-
"""
|
|
601
|
-
_show_summary_since(date, save=save)
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
@app.command()
|
|
605
|
-
def standup(
|
|
606
|
-
days: int = typer.Option(
|
|
607
|
-
3, "--days",
|
|
608
|
-
help="Number of days to look back",
|
|
609
|
-
),
|
|
610
|
-
):
|
|
611
|
-
"""
|
|
612
|
-
Quick summary for daily standup (last 3 days).
|
|
613
|
-
|
|
614
|
-
Example:
|
|
615
|
-
repr standup
|
|
616
|
-
repr standup --days 5
|
|
617
|
-
"""
|
|
618
|
-
_show_summary_since(f"last {days} days", days=days, save=False)
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
def _show_summary_since(label: str, days: int = None, save: bool = False):
|
|
622
|
-
"""Show summary of work since a date."""
|
|
623
|
-
from .tools import get_commits_with_diffs
|
|
624
|
-
from .discovery import analyze_repo
|
|
625
|
-
|
|
626
|
-
print_header()
|
|
627
|
-
|
|
628
|
-
tracked = get_tracked_repos()
|
|
629
|
-
if not tracked:
|
|
630
|
-
print_warning("No repositories tracked.")
|
|
631
|
-
print_info("Run `repr init` first.")
|
|
632
|
-
raise typer.Exit(1)
|
|
633
|
-
|
|
634
|
-
console.print(f"📊 Work since {label}")
|
|
635
|
-
console.print()
|
|
636
|
-
|
|
637
|
-
total_commits = 0
|
|
638
|
-
all_summaries = []
|
|
639
|
-
|
|
640
|
-
for repo_info in tracked:
|
|
641
|
-
repo_path = Path(repo_info["path"])
|
|
642
|
-
if not repo_path.exists():
|
|
643
|
-
continue
|
|
644
|
-
|
|
645
|
-
commits = get_commits_with_diffs(repo_path, count=100, days=days or 30)
|
|
646
|
-
if not commits:
|
|
647
|
-
continue
|
|
648
|
-
|
|
649
|
-
repo_name = repo_path.name
|
|
650
|
-
console.print(f"[bold]{repo_name}[/] ({len(commits)} commits):")
|
|
651
|
-
|
|
652
|
-
# Group commits by rough topic (simple heuristic)
|
|
653
|
-
for c in commits[:5]:
|
|
654
|
-
msg = c["message"].split("\n")[0][:60]
|
|
655
|
-
console.print(f" • {msg}")
|
|
656
|
-
|
|
657
|
-
if len(commits) > 5:
|
|
658
|
-
console.print(f" [{BRAND_MUTED}]... and {len(commits) - 5} more[/]")
|
|
659
|
-
|
|
660
|
-
total_commits += len(commits)
|
|
661
|
-
console.print()
|
|
662
|
-
|
|
663
|
-
console.print(f"Total: {total_commits} commits")
|
|
664
|
-
|
|
665
|
-
if not save:
|
|
666
|
-
console.print()
|
|
667
|
-
console.print(f"[{BRAND_MUTED}]This summary wasn't saved. Run with --save to create a story.[/]")
|
|
703
|
+
def _generate_stories(
|
|
704
|
+
commits: list[dict],
|
|
705
|
+
repo_info,
|
|
706
|
+
batch_size: int,
|
|
707
|
+
local: bool,
|
|
708
|
+
template: str = "resume",
|
|
709
|
+
custom_prompt: str | None = None,
|
|
710
|
+
) -> list[dict]:
|
|
711
|
+
"""Generate stories from commits using LLM."""
|
|
712
|
+
return asyncio.run(_generate_stories_async(
|
|
713
|
+
commits=commits,
|
|
714
|
+
repo_info=repo_info,
|
|
715
|
+
batch_size=batch_size,
|
|
716
|
+
local=local,
|
|
717
|
+
template=template,
|
|
718
|
+
custom_prompt=custom_prompt,
|
|
719
|
+
))
|
|
668
720
|
|
|
669
721
|
|
|
670
722
|
# =============================================================================
|
|
@@ -698,24 +750,37 @@ def stories(
|
|
|
698
750
|
|
|
699
751
|
console.print(f"[bold]Stories[/] ({len(story_list)} total)")
|
|
700
752
|
console.print()
|
|
701
|
-
|
|
753
|
+
|
|
754
|
+
# Group stories by repository
|
|
755
|
+
by_repo = defaultdict(list)
|
|
702
756
|
for story in story_list[:20]:
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
757
|
+
r_name = story.get("repo_name", "unknown")
|
|
758
|
+
by_repo[r_name].append(story)
|
|
759
|
+
|
|
760
|
+
# Sort repositories by their most recent story
|
|
761
|
+
sorted_repos = sorted(
|
|
762
|
+
by_repo.keys(),
|
|
763
|
+
key=lambda r: max(s.get("created_at", "") for s in by_repo[r]),
|
|
764
|
+
reverse=True
|
|
765
|
+
)
|
|
766
|
+
|
|
767
|
+
for repo_name in sorted_repos:
|
|
768
|
+
console.print(f"[bold]{repo_name}[/]")
|
|
769
|
+
for story in by_repo[repo_name]:
|
|
770
|
+
# Status indicator
|
|
771
|
+
if story.get("needs_review"):
|
|
772
|
+
status = f"[{BRAND_WARNING}]⚠[/]"
|
|
773
|
+
elif story.get("pushed_at"):
|
|
774
|
+
status = f"[{BRAND_SUCCESS}]✓[/]"
|
|
775
|
+
else:
|
|
776
|
+
status = f"[{BRAND_MUTED}]○[/]"
|
|
777
|
+
|
|
778
|
+
summary = story.get("summary", "Untitled")
|
|
779
|
+
created = format_relative_time(story.get("created_at", ""))
|
|
780
|
+
|
|
781
|
+
console.print(f" {status} {summary} [{BRAND_MUTED}]• {created}[/]")
|
|
717
782
|
console.print()
|
|
718
|
-
|
|
783
|
+
|
|
719
784
|
if len(story_list) > 20:
|
|
720
785
|
console.print(f"[{BRAND_MUTED}]... and {len(story_list) - 20} more[/]")
|
|
721
786
|
|
|
@@ -1025,15 +1090,20 @@ def pull():
|
|
|
1025
1090
|
def list_commits(
|
|
1026
1091
|
repo: Optional[str] = typer.Option(None, "--repo", help="Filter by repo name"),
|
|
1027
1092
|
limit: int = typer.Option(50, "--limit", "-n", help="Number of commits"),
|
|
1028
|
-
|
|
1093
|
+
days: Optional[int] = typer.Option(None, "--days", "-d", help="Show commits from last N days (e.g., 7 for week, 3 for standup)"),
|
|
1094
|
+
since: Optional[str] = typer.Option(None, "--since", help="Since date (e.g., '2024-01-01', 'monday', '2 weeks ago')"),
|
|
1029
1095
|
json_output: bool = typer.Option(False, "--json", help="Output as JSON"),
|
|
1030
1096
|
):
|
|
1031
1097
|
"""
|
|
1032
1098
|
List recent commits across all tracked repos.
|
|
1033
1099
|
|
|
1034
|
-
|
|
1100
|
+
Examples:
|
|
1035
1101
|
repr commits --limit 10
|
|
1036
1102
|
repr commits --repo myproject
|
|
1103
|
+
repr commits --days 7 # Last week
|
|
1104
|
+
repr commits --days 3 # For standup
|
|
1105
|
+
repr commits --since monday
|
|
1106
|
+
repr commits --since "2 weeks ago"
|
|
1037
1107
|
"""
|
|
1038
1108
|
from .tools import get_commits_with_diffs
|
|
1039
1109
|
|
|
@@ -1042,6 +1112,18 @@ def list_commits(
|
|
|
1042
1112
|
print_warning("No repositories tracked.")
|
|
1043
1113
|
raise typer.Exit(1)
|
|
1044
1114
|
|
|
1115
|
+
# Determine days filter
|
|
1116
|
+
filter_days = days if days is not None else 90
|
|
1117
|
+
|
|
1118
|
+
# Parse natural language date if --since provided
|
|
1119
|
+
since_str = None
|
|
1120
|
+
if since:
|
|
1121
|
+
since_str = _parse_date_reference(since)
|
|
1122
|
+
if not since_str:
|
|
1123
|
+
print_error(f"Could not parse date: {since}")
|
|
1124
|
+
print_info("Try: '2024-01-01', 'monday', '3 days ago', 'last week'")
|
|
1125
|
+
raise typer.Exit(1)
|
|
1126
|
+
|
|
1045
1127
|
all_commits = []
|
|
1046
1128
|
|
|
1047
1129
|
for repo_info in tracked:
|
|
@@ -1051,7 +1133,7 @@ def list_commits(
|
|
|
1051
1133
|
if not repo_path.exists():
|
|
1052
1134
|
continue
|
|
1053
1135
|
|
|
1054
|
-
commits = get_commits_with_diffs(repo_path, count=limit, days=
|
|
1136
|
+
commits = get_commits_with_diffs(repo_path, count=limit, days=filter_days, since=since_str)
|
|
1055
1137
|
for c in commits:
|
|
1056
1138
|
c["repo_name"] = repo_path.name
|
|
1057
1139
|
all_commits.extend(commits)
|
|
@@ -1068,7 +1150,15 @@ def list_commits(
|
|
|
1068
1150
|
print_info("No commits found")
|
|
1069
1151
|
raise typer.Exit()
|
|
1070
1152
|
|
|
1071
|
-
|
|
1153
|
+
# Build label for display
|
|
1154
|
+
if days:
|
|
1155
|
+
label = f"last {days} days"
|
|
1156
|
+
elif since:
|
|
1157
|
+
label = f"since {since}"
|
|
1158
|
+
else:
|
|
1159
|
+
label = "recent"
|
|
1160
|
+
|
|
1161
|
+
console.print(f"[bold]Commits ({label})[/] — {len(all_commits)} total")
|
|
1072
1162
|
console.print()
|
|
1073
1163
|
|
|
1074
1164
|
current_repo = None
|
|
@@ -2331,22 +2421,64 @@ def status(
|
|
|
2331
2421
|
|
|
2332
2422
|
|
|
2333
2423
|
@app.command()
|
|
2334
|
-
def mode(
|
|
2424
|
+
def mode(
|
|
2425
|
+
json_output: bool = typer.Option(False, "--json", help="Output as JSON"),
|
|
2426
|
+
):
|
|
2335
2427
|
"""
|
|
2336
2428
|
Show current execution mode and settings.
|
|
2337
|
-
|
|
2429
|
+
|
|
2338
2430
|
Example:
|
|
2339
2431
|
repr mode
|
|
2432
|
+
repr mode --json
|
|
2340
2433
|
"""
|
|
2341
2434
|
from .llm import get_llm_status
|
|
2342
|
-
|
|
2435
|
+
|
|
2343
2436
|
authenticated = is_authenticated()
|
|
2344
2437
|
privacy = get_privacy_settings()
|
|
2345
2438
|
llm_status = get_llm_status()
|
|
2346
|
-
|
|
2439
|
+
|
|
2440
|
+
# Determine mode
|
|
2441
|
+
if privacy.get("lock_local_only"):
|
|
2442
|
+
if privacy.get("lock_permanent"):
|
|
2443
|
+
mode = "LOCAL-ONLY"
|
|
2444
|
+
locked = True
|
|
2445
|
+
else:
|
|
2446
|
+
mode = "LOCAL-ONLY"
|
|
2447
|
+
locked = True
|
|
2448
|
+
elif authenticated:
|
|
2449
|
+
mode = "CLOUD"
|
|
2450
|
+
locked = False
|
|
2451
|
+
else:
|
|
2452
|
+
mode = "LOCAL-ONLY"
|
|
2453
|
+
locked = False
|
|
2454
|
+
|
|
2455
|
+
# Build LLM provider string
|
|
2456
|
+
if llm_status["local"]["available"]:
|
|
2457
|
+
llm_provider = f"{llm_status['local']['name']} ({llm_status['local']['model']})"
|
|
2458
|
+
else:
|
|
2459
|
+
llm_provider = "none"
|
|
2460
|
+
|
|
2461
|
+
# Check cloud allowed
|
|
2462
|
+
cloud_allowed = authenticated and is_cloud_allowed()
|
|
2463
|
+
|
|
2464
|
+
if json_output:
|
|
2465
|
+
import json
|
|
2466
|
+
output = {
|
|
2467
|
+
"mode": mode,
|
|
2468
|
+
"locked": locked,
|
|
2469
|
+
"llm_provider": llm_provider,
|
|
2470
|
+
"network_policy": "cloud-enabled" if authenticated else "local-only",
|
|
2471
|
+
"authenticated": authenticated,
|
|
2472
|
+
"cloud_generation_available": cloud_allowed,
|
|
2473
|
+
"sync_available": cloud_allowed,
|
|
2474
|
+
"publishing_available": cloud_allowed,
|
|
2475
|
+
}
|
|
2476
|
+
print(json.dumps(output, indent=2))
|
|
2477
|
+
return
|
|
2478
|
+
|
|
2347
2479
|
console.print("[bold]Mode[/]")
|
|
2348
2480
|
console.print()
|
|
2349
|
-
|
|
2481
|
+
|
|
2350
2482
|
if privacy.get("lock_local_only"):
|
|
2351
2483
|
if privacy.get("lock_permanent"):
|
|
2352
2484
|
console.print("Data boundary: local only (permanently locked)")
|
|
@@ -2356,22 +2488,22 @@ def mode():
|
|
|
2356
2488
|
console.print("Data boundary: cloud-enabled")
|
|
2357
2489
|
else:
|
|
2358
2490
|
console.print("Data boundary: local only")
|
|
2359
|
-
|
|
2491
|
+
|
|
2360
2492
|
console.print(f"Default inference: {llm_status['default_mode']}")
|
|
2361
|
-
|
|
2493
|
+
|
|
2362
2494
|
console.print()
|
|
2363
|
-
|
|
2495
|
+
|
|
2364
2496
|
# LLM info
|
|
2365
2497
|
if llm_status["local"]["available"]:
|
|
2366
2498
|
console.print(f"Local LLM: {llm_status['local']['name']} ({llm_status['local']['model']})")
|
|
2367
2499
|
else:
|
|
2368
2500
|
console.print(f"[{BRAND_MUTED}]Local LLM: not detected[/]")
|
|
2369
|
-
|
|
2501
|
+
|
|
2370
2502
|
console.print()
|
|
2371
|
-
|
|
2503
|
+
|
|
2372
2504
|
console.print("Available:")
|
|
2373
2505
|
console.print(f" [{BRAND_SUCCESS}]✓[/] Local generation")
|
|
2374
|
-
if
|
|
2506
|
+
if cloud_allowed:
|
|
2375
2507
|
console.print(f" [{BRAND_SUCCESS}]✓[/] Cloud generation")
|
|
2376
2508
|
console.print(f" [{BRAND_SUCCESS}]✓[/] Sync")
|
|
2377
2509
|
console.print(f" [{BRAND_SUCCESS}]✓[/] Publishing")
|
|
@@ -2382,6 +2514,34 @@ def mode():
|
|
|
2382
2514
|
console.print(f" [{BRAND_MUTED}]✗[/] Publishing ({reason})")
|
|
2383
2515
|
|
|
2384
2516
|
|
|
2517
|
+
@app.command()
|
|
2518
|
+
def update(
|
|
2519
|
+
check: bool = typer.Option(False, "--check", "-c", help="Only check for updates, don't install"),
|
|
2520
|
+
force: bool = typer.Option(False, "--force", "-f", help="Force update even if already up to date"),
|
|
2521
|
+
):
|
|
2522
|
+
"""
|
|
2523
|
+
Update repr to the latest version.
|
|
2524
|
+
|
|
2525
|
+
Automatically detects installation method (Homebrew, pip, or binary)
|
|
2526
|
+
and updates accordingly.
|
|
2527
|
+
|
|
2528
|
+
Examples:
|
|
2529
|
+
repr update # Update to latest version
|
|
2530
|
+
repr update --check # Just check if update available
|
|
2531
|
+
"""
|
|
2532
|
+
from .updater import check_for_update, perform_update
|
|
2533
|
+
|
|
2534
|
+
if check:
|
|
2535
|
+
new_version = check_for_update()
|
|
2536
|
+
if new_version:
|
|
2537
|
+
print_info(f"New version available: v{new_version}")
|
|
2538
|
+
print_info("Run 'repr update' to install")
|
|
2539
|
+
else:
|
|
2540
|
+
print_success(f"Already up to date (v{__version__})")
|
|
2541
|
+
else:
|
|
2542
|
+
perform_update(force=force)
|
|
2543
|
+
|
|
2544
|
+
|
|
2385
2545
|
@app.command()
|
|
2386
2546
|
def doctor():
|
|
2387
2547
|
"""
|