repr-cli 0.2.8__py3-none-any.whl → 0.2.11__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/__main__.py +37 -0
- repr/cli.py +405 -222
- repr/doctor.py +37 -0
- repr/llm.py +37 -0
- repr/openai_analysis.py +110 -65
- repr/privacy.py +37 -0
- repr/telemetry.py +37 -0
- repr/templates.py +6 -1
- repr/ui.py +37 -0
- repr/updater.py +37 -0
- {repr_cli-0.2.8.dist-info → repr_cli-0.2.11.dist-info}/METADATA +20 -5
- repr_cli-0.2.11.dist-info/RECORD +26 -0
- repr_cli-0.2.8.dist-info/RECORD +0 -26
- {repr_cli-0.2.8.dist-info → repr_cli-0.2.11.dist-info}/WHEEL +0 -0
- {repr_cli-0.2.8.dist-info → repr_cli-0.2.11.dist-info}/entry_points.txt +0 -0
- {repr_cli-0.2.8.dist-info → repr_cli-0.2.11.dist-info}/licenses/LICENSE +0 -0
- {repr_cli-0.2.8.dist-info → repr_cli-0.2.11.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,9 +608,9 @@ 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
|
-
from .templates import build_generation_prompt
|
|
613
|
+
from .templates import build_generation_prompt, StoryOutput
|
|
479
614
|
|
|
480
615
|
stories = []
|
|
481
616
|
|
|
@@ -485,186 +620,119 @@ 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
|
-
|
|
645
|
+
|
|
646
|
+
# Extract story from batch using structured output
|
|
647
|
+
result = 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
|
+
structured=True,
|
|
656
|
+
)
|
|
657
|
+
|
|
658
|
+
# Handle structured output - now returns list[StoryOutput]
|
|
659
|
+
story_outputs: list[StoryOutput] = []
|
|
660
|
+
if isinstance(result, list):
|
|
661
|
+
story_outputs = result
|
|
662
|
+
elif isinstance(result, StoryOutput):
|
|
663
|
+
story_outputs = [result]
|
|
664
|
+
else:
|
|
665
|
+
# Fallback for string response
|
|
666
|
+
content = result
|
|
667
|
+
if not content or content.startswith("[Batch"):
|
|
668
|
+
continue
|
|
669
|
+
lines = [l.strip() for l in content.split("\n") if l.strip()]
|
|
670
|
+
summary = lines[0] if lines else "Story"
|
|
671
|
+
summary = summary.lstrip("#-•* ").strip()
|
|
672
|
+
story_outputs = [StoryOutput(summary=summary, content=content)]
|
|
673
|
+
|
|
674
|
+
# Build shared metadata for all stories from this batch
|
|
675
|
+
commit_shas = [c["full_sha"] for c in batch]
|
|
676
|
+
first_date = min(c["date"] for c in batch)
|
|
677
|
+
last_date = max(c["date"] for c in batch)
|
|
678
|
+
total_files = sum(len(c.get("files", [])) for c in batch)
|
|
679
|
+
total_adds = sum(c.get("insertions", 0) for c in batch)
|
|
680
|
+
total_dels = sum(c.get("deletions", 0) for c in batch)
|
|
681
|
+
|
|
682
|
+
# Save each story from this batch
|
|
683
|
+
for story_output in story_outputs:
|
|
684
|
+
content = story_output.content
|
|
685
|
+
summary = story_output.summary
|
|
686
|
+
|
|
687
|
+
if not content or content.startswith("[Batch"):
|
|
688
|
+
continue
|
|
689
|
+
|
|
690
|
+
metadata = {
|
|
691
|
+
"summary": summary,
|
|
692
|
+
"repo_name": repo_info.name,
|
|
693
|
+
"repo_path": str(repo_info.path),
|
|
694
|
+
"commit_shas": commit_shas,
|
|
695
|
+
"first_commit_at": first_date,
|
|
696
|
+
"last_commit_at": last_date,
|
|
697
|
+
"files_changed": total_files,
|
|
698
|
+
"lines_added": total_adds,
|
|
699
|
+
"lines_removed": total_dels,
|
|
700
|
+
"generated_locally": local,
|
|
701
|
+
"template": template,
|
|
702
|
+
"needs_review": False,
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
# Save story
|
|
706
|
+
story_id = save_story(content, metadata)
|
|
707
|
+
metadata["id"] = story_id
|
|
708
|
+
stories.append(metadata)
|
|
709
|
+
|
|
710
|
+
except Exception as e:
|
|
711
|
+
console.print(f" [{BRAND_MUTED}]Batch {i+1} failed: {e}[/]")
|
|
712
|
+
finally:
|
|
713
|
+
# Properly close the async client
|
|
714
|
+
await client.close()
|
|
560
715
|
|
|
561
716
|
return stories
|
|
562
717
|
|
|
563
718
|
|
|
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.[/]")
|
|
719
|
+
def _generate_stories(
|
|
720
|
+
commits: list[dict],
|
|
721
|
+
repo_info,
|
|
722
|
+
batch_size: int,
|
|
723
|
+
local: bool,
|
|
724
|
+
template: str = "resume",
|
|
725
|
+
custom_prompt: str | None = None,
|
|
726
|
+
) -> list[dict]:
|
|
727
|
+
"""Generate stories from commits using LLM."""
|
|
728
|
+
return asyncio.run(_generate_stories_async(
|
|
729
|
+
commits=commits,
|
|
730
|
+
repo_info=repo_info,
|
|
731
|
+
batch_size=batch_size,
|
|
732
|
+
local=local,
|
|
733
|
+
template=template,
|
|
734
|
+
custom_prompt=custom_prompt,
|
|
735
|
+
))
|
|
668
736
|
|
|
669
737
|
|
|
670
738
|
# =============================================================================
|
|
@@ -698,24 +766,37 @@ def stories(
|
|
|
698
766
|
|
|
699
767
|
console.print(f"[bold]Stories[/] ({len(story_list)} total)")
|
|
700
768
|
console.print()
|
|
701
|
-
|
|
769
|
+
|
|
770
|
+
# Group stories by repository
|
|
771
|
+
by_repo = defaultdict(list)
|
|
702
772
|
for story in story_list[:20]:
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
773
|
+
r_name = story.get("repo_name", "unknown")
|
|
774
|
+
by_repo[r_name].append(story)
|
|
775
|
+
|
|
776
|
+
# Sort repositories by their most recent story
|
|
777
|
+
sorted_repos = sorted(
|
|
778
|
+
by_repo.keys(),
|
|
779
|
+
key=lambda r: max(s.get("created_at", "") for s in by_repo[r]),
|
|
780
|
+
reverse=True
|
|
781
|
+
)
|
|
782
|
+
|
|
783
|
+
for repo_name in sorted_repos:
|
|
784
|
+
console.print(f"[bold]{repo_name}[/]")
|
|
785
|
+
for story in by_repo[repo_name]:
|
|
786
|
+
# Status indicator
|
|
787
|
+
if story.get("needs_review"):
|
|
788
|
+
status = f"[{BRAND_WARNING}]⚠[/]"
|
|
789
|
+
elif story.get("pushed_at"):
|
|
790
|
+
status = f"[{BRAND_SUCCESS}]✓[/]"
|
|
791
|
+
else:
|
|
792
|
+
status = f"[{BRAND_MUTED}]○[/]"
|
|
793
|
+
|
|
794
|
+
summary = story.get("summary", "Untitled")
|
|
795
|
+
created = format_relative_time(story.get("created_at", ""))
|
|
796
|
+
|
|
797
|
+
console.print(f" {status} {summary} [{BRAND_MUTED}]• {created}[/]")
|
|
717
798
|
console.print()
|
|
718
|
-
|
|
799
|
+
|
|
719
800
|
if len(story_list) > 20:
|
|
720
801
|
console.print(f"[{BRAND_MUTED}]... and {len(story_list) - 20} more[/]")
|
|
721
802
|
|
|
@@ -723,7 +804,8 @@ def stories(
|
|
|
723
804
|
@app.command()
|
|
724
805
|
def story(
|
|
725
806
|
action: str = typer.Argument(..., help="Action: view, edit, delete, hide, feature, regenerate"),
|
|
726
|
-
story_id: str = typer.Argument(
|
|
807
|
+
story_id: Optional[str] = typer.Argument(None, help="Story ID (ULID)"),
|
|
808
|
+
all_stories: bool = typer.Option(False, "--all", help="Apply to all stories (for delete)"),
|
|
727
809
|
):
|
|
728
810
|
"""
|
|
729
811
|
Manage a single story.
|
|
@@ -731,7 +813,38 @@ def story(
|
|
|
731
813
|
Examples:
|
|
732
814
|
repr story view 01ARYZ6S41TSV4RRFFQ69G5FAV
|
|
733
815
|
repr story delete 01ARYZ6S41TSV4RRFFQ69G5FAV
|
|
816
|
+
repr story delete --all
|
|
734
817
|
"""
|
|
818
|
+
# Handle --all flag for delete
|
|
819
|
+
if all_stories:
|
|
820
|
+
if action != "delete":
|
|
821
|
+
print_error("--all flag only works with 'delete' action")
|
|
822
|
+
raise typer.Exit(1)
|
|
823
|
+
|
|
824
|
+
story_list = list_stories()
|
|
825
|
+
if not story_list:
|
|
826
|
+
print_info("No stories to delete")
|
|
827
|
+
raise typer.Exit()
|
|
828
|
+
|
|
829
|
+
console.print(f"This will delete [bold]{len(story_list)}[/] stories.")
|
|
830
|
+
if confirm("Delete all stories?"):
|
|
831
|
+
deleted = 0
|
|
832
|
+
for s in story_list:
|
|
833
|
+
try:
|
|
834
|
+
delete_story(s["id"])
|
|
835
|
+
deleted += 1
|
|
836
|
+
except Exception:
|
|
837
|
+
pass
|
|
838
|
+
print_success(f"Deleted {deleted} stories")
|
|
839
|
+
else:
|
|
840
|
+
print_info("Cancelled")
|
|
841
|
+
raise typer.Exit()
|
|
842
|
+
|
|
843
|
+
# Require story_id for single-story operations
|
|
844
|
+
if not story_id:
|
|
845
|
+
print_error("Story ID required (or use --all for delete)")
|
|
846
|
+
raise typer.Exit(1)
|
|
847
|
+
|
|
735
848
|
result = load_story(story_id)
|
|
736
849
|
|
|
737
850
|
if not result:
|
|
@@ -935,7 +1048,9 @@ def push(
|
|
|
935
1048
|
for s in to_push:
|
|
936
1049
|
try:
|
|
937
1050
|
content, meta = load_story(s["id"])
|
|
938
|
-
|
|
1051
|
+
# Use local story ID as client_id for sync
|
|
1052
|
+
payload = {**meta, "content": content, "client_id": s["id"]}
|
|
1053
|
+
asyncio.run(api_push_story(payload))
|
|
939
1054
|
mark_story_pushed(s["id"])
|
|
940
1055
|
console.print(f" [{BRAND_SUCCESS}]✓[/] {s.get('summary', s.get('id'))[:50]}")
|
|
941
1056
|
pushed += 1
|
|
@@ -984,7 +1099,8 @@ def sync():
|
|
|
984
1099
|
for s in unpushed:
|
|
985
1100
|
try:
|
|
986
1101
|
content, meta = load_story(s["id"])
|
|
987
|
-
|
|
1102
|
+
payload = {**meta, "content": content, "client_id": s["id"]}
|
|
1103
|
+
asyncio.run(api_push_story(payload))
|
|
988
1104
|
mark_story_pushed(s["id"])
|
|
989
1105
|
except Exception:
|
|
990
1106
|
pass
|
|
@@ -1025,15 +1141,20 @@ def pull():
|
|
|
1025
1141
|
def list_commits(
|
|
1026
1142
|
repo: Optional[str] = typer.Option(None, "--repo", help="Filter by repo name"),
|
|
1027
1143
|
limit: int = typer.Option(50, "--limit", "-n", help="Number of commits"),
|
|
1028
|
-
|
|
1144
|
+
days: Optional[int] = typer.Option(None, "--days", "-d", help="Show commits from last N days (e.g., 7 for week, 3 for standup)"),
|
|
1145
|
+
since: Optional[str] = typer.Option(None, "--since", help="Since date (e.g., '2024-01-01', 'monday', '2 weeks ago')"),
|
|
1029
1146
|
json_output: bool = typer.Option(False, "--json", help="Output as JSON"),
|
|
1030
1147
|
):
|
|
1031
1148
|
"""
|
|
1032
1149
|
List recent commits across all tracked repos.
|
|
1033
1150
|
|
|
1034
|
-
|
|
1151
|
+
Examples:
|
|
1035
1152
|
repr commits --limit 10
|
|
1036
1153
|
repr commits --repo myproject
|
|
1154
|
+
repr commits --days 7 # Last week
|
|
1155
|
+
repr commits --days 3 # For standup
|
|
1156
|
+
repr commits --since monday
|
|
1157
|
+
repr commits --since "2 weeks ago"
|
|
1037
1158
|
"""
|
|
1038
1159
|
from .tools import get_commits_with_diffs
|
|
1039
1160
|
|
|
@@ -1042,6 +1163,18 @@ def list_commits(
|
|
|
1042
1163
|
print_warning("No repositories tracked.")
|
|
1043
1164
|
raise typer.Exit(1)
|
|
1044
1165
|
|
|
1166
|
+
# Determine days filter
|
|
1167
|
+
filter_days = days if days is not None else 90
|
|
1168
|
+
|
|
1169
|
+
# Parse natural language date if --since provided
|
|
1170
|
+
since_str = None
|
|
1171
|
+
if since:
|
|
1172
|
+
since_str = _parse_date_reference(since)
|
|
1173
|
+
if not since_str:
|
|
1174
|
+
print_error(f"Could not parse date: {since}")
|
|
1175
|
+
print_info("Try: '2024-01-01', 'monday', '3 days ago', 'last week'")
|
|
1176
|
+
raise typer.Exit(1)
|
|
1177
|
+
|
|
1045
1178
|
all_commits = []
|
|
1046
1179
|
|
|
1047
1180
|
for repo_info in tracked:
|
|
@@ -1051,7 +1184,7 @@ def list_commits(
|
|
|
1051
1184
|
if not repo_path.exists():
|
|
1052
1185
|
continue
|
|
1053
1186
|
|
|
1054
|
-
commits = get_commits_with_diffs(repo_path, count=limit, days=
|
|
1187
|
+
commits = get_commits_with_diffs(repo_path, count=limit, days=filter_days, since=since_str)
|
|
1055
1188
|
for c in commits:
|
|
1056
1189
|
c["repo_name"] = repo_path.name
|
|
1057
1190
|
all_commits.extend(commits)
|
|
@@ -1068,7 +1201,15 @@ def list_commits(
|
|
|
1068
1201
|
print_info("No commits found")
|
|
1069
1202
|
raise typer.Exit()
|
|
1070
1203
|
|
|
1071
|
-
|
|
1204
|
+
# Build label for display
|
|
1205
|
+
if days:
|
|
1206
|
+
label = f"last {days} days"
|
|
1207
|
+
elif since:
|
|
1208
|
+
label = f"since {since}"
|
|
1209
|
+
else:
|
|
1210
|
+
label = "recent"
|
|
1211
|
+
|
|
1212
|
+
console.print(f"[bold]Commits ({label})[/] — {len(all_commits)} total")
|
|
1072
1213
|
console.print()
|
|
1073
1214
|
|
|
1074
1215
|
current_repo = None
|
|
@@ -2331,22 +2472,64 @@ def status(
|
|
|
2331
2472
|
|
|
2332
2473
|
|
|
2333
2474
|
@app.command()
|
|
2334
|
-
def mode(
|
|
2475
|
+
def mode(
|
|
2476
|
+
json_output: bool = typer.Option(False, "--json", help="Output as JSON"),
|
|
2477
|
+
):
|
|
2335
2478
|
"""
|
|
2336
2479
|
Show current execution mode and settings.
|
|
2337
|
-
|
|
2480
|
+
|
|
2338
2481
|
Example:
|
|
2339
2482
|
repr mode
|
|
2483
|
+
repr mode --json
|
|
2340
2484
|
"""
|
|
2341
2485
|
from .llm import get_llm_status
|
|
2342
|
-
|
|
2486
|
+
|
|
2343
2487
|
authenticated = is_authenticated()
|
|
2344
2488
|
privacy = get_privacy_settings()
|
|
2345
2489
|
llm_status = get_llm_status()
|
|
2346
|
-
|
|
2490
|
+
|
|
2491
|
+
# Determine mode
|
|
2492
|
+
if privacy.get("lock_local_only"):
|
|
2493
|
+
if privacy.get("lock_permanent"):
|
|
2494
|
+
mode = "LOCAL-ONLY"
|
|
2495
|
+
locked = True
|
|
2496
|
+
else:
|
|
2497
|
+
mode = "LOCAL-ONLY"
|
|
2498
|
+
locked = True
|
|
2499
|
+
elif authenticated:
|
|
2500
|
+
mode = "CLOUD"
|
|
2501
|
+
locked = False
|
|
2502
|
+
else:
|
|
2503
|
+
mode = "LOCAL-ONLY"
|
|
2504
|
+
locked = False
|
|
2505
|
+
|
|
2506
|
+
# Build LLM provider string
|
|
2507
|
+
if llm_status["local"]["available"]:
|
|
2508
|
+
llm_provider = f"{llm_status['local']['name']} ({llm_status['local']['model']})"
|
|
2509
|
+
else:
|
|
2510
|
+
llm_provider = "none"
|
|
2511
|
+
|
|
2512
|
+
# Check cloud allowed
|
|
2513
|
+
cloud_allowed = authenticated and is_cloud_allowed()
|
|
2514
|
+
|
|
2515
|
+
if json_output:
|
|
2516
|
+
import json
|
|
2517
|
+
output = {
|
|
2518
|
+
"mode": mode,
|
|
2519
|
+
"locked": locked,
|
|
2520
|
+
"llm_provider": llm_provider,
|
|
2521
|
+
"network_policy": "cloud-enabled" if authenticated else "local-only",
|
|
2522
|
+
"authenticated": authenticated,
|
|
2523
|
+
"cloud_generation_available": cloud_allowed,
|
|
2524
|
+
"sync_available": cloud_allowed,
|
|
2525
|
+
"publishing_available": cloud_allowed,
|
|
2526
|
+
}
|
|
2527
|
+
print(json.dumps(output, indent=2))
|
|
2528
|
+
return
|
|
2529
|
+
|
|
2347
2530
|
console.print("[bold]Mode[/]")
|
|
2348
2531
|
console.print()
|
|
2349
|
-
|
|
2532
|
+
|
|
2350
2533
|
if privacy.get("lock_local_only"):
|
|
2351
2534
|
if privacy.get("lock_permanent"):
|
|
2352
2535
|
console.print("Data boundary: local only (permanently locked)")
|
|
@@ -2356,22 +2539,22 @@ def mode():
|
|
|
2356
2539
|
console.print("Data boundary: cloud-enabled")
|
|
2357
2540
|
else:
|
|
2358
2541
|
console.print("Data boundary: local only")
|
|
2359
|
-
|
|
2542
|
+
|
|
2360
2543
|
console.print(f"Default inference: {llm_status['default_mode']}")
|
|
2361
|
-
|
|
2544
|
+
|
|
2362
2545
|
console.print()
|
|
2363
|
-
|
|
2546
|
+
|
|
2364
2547
|
# LLM info
|
|
2365
2548
|
if llm_status["local"]["available"]:
|
|
2366
2549
|
console.print(f"Local LLM: {llm_status['local']['name']} ({llm_status['local']['model']})")
|
|
2367
2550
|
else:
|
|
2368
2551
|
console.print(f"[{BRAND_MUTED}]Local LLM: not detected[/]")
|
|
2369
|
-
|
|
2552
|
+
|
|
2370
2553
|
console.print()
|
|
2371
|
-
|
|
2554
|
+
|
|
2372
2555
|
console.print("Available:")
|
|
2373
2556
|
console.print(f" [{BRAND_SUCCESS}]✓[/] Local generation")
|
|
2374
|
-
if
|
|
2557
|
+
if cloud_allowed:
|
|
2375
2558
|
console.print(f" [{BRAND_SUCCESS}]✓[/] Cloud generation")
|
|
2376
2559
|
console.print(f" [{BRAND_SUCCESS}]✓[/] Sync")
|
|
2377
2560
|
console.print(f" [{BRAND_SUCCESS}]✓[/] Publishing")
|