juno-code 1.0.44 → 1.0.46

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/README.md +1 -1
  2. package/dist/bin/cli.js +658 -50
  3. package/dist/bin/cli.js.map +1 -1
  4. package/dist/bin/cli.mjs +658 -50
  5. package/dist/bin/cli.mjs.map +1 -1
  6. package/dist/index.js +6 -4
  7. package/dist/index.js.map +1 -1
  8. package/dist/index.mjs +6 -4
  9. package/dist/index.mjs.map +1 -1
  10. package/dist/templates/scripts/__pycache__/attachment_downloader.cpython-38.pyc +0 -0
  11. package/dist/templates/scripts/__pycache__/github.cpython-38.pyc +0 -0
  12. package/dist/templates/scripts/__pycache__/slack_fetch.cpython-38.pyc +0 -0
  13. package/dist/templates/scripts/__pycache__/slack_state.cpython-38.pyc +0 -0
  14. package/dist/templates/scripts/attachment_downloader.py +405 -0
  15. package/dist/templates/scripts/github.py +282 -7
  16. package/dist/templates/scripts/hooks/session_counter.sh +328 -0
  17. package/dist/templates/scripts/kanban.sh +22 -4
  18. package/dist/templates/scripts/log_scanner.sh +790 -0
  19. package/dist/templates/scripts/slack_fetch.py +232 -20
  20. package/dist/templates/services/claude.py +50 -1
  21. package/dist/templates/services/codex.py +5 -4
  22. package/dist/templates/skills/claude/.gitkeep +0 -0
  23. package/dist/templates/skills/claude/plan-kanban-tasks/SKILL.md +25 -0
  24. package/dist/templates/skills/claude/ralph-loop/SKILL.md +43 -0
  25. package/dist/templates/skills/claude/ralph-loop/references/first_check.md +20 -0
  26. package/dist/templates/skills/claude/ralph-loop/references/implement.md +99 -0
  27. package/dist/templates/skills/claude/ralph-loop/scripts/kanban.sh +293 -0
  28. package/dist/templates/skills/claude/understand-project/SKILL.md +39 -0
  29. package/dist/templates/skills/codex/.gitkeep +0 -0
  30. package/dist/templates/skills/codex/ralph-loop/SKILL.md +43 -0
  31. package/dist/templates/skills/codex/ralph-loop/references/first_check.md +20 -0
  32. package/dist/templates/skills/codex/ralph-loop/references/implement.md +99 -0
  33. package/dist/templates/skills/codex/ralph-loop/scripts/kanban.sh +293 -0
  34. package/package.json +3 -2
@@ -12,12 +12,20 @@ Features:
12
12
  - Persistent state tracking (NDJSON-based) to prevent duplicate processing
13
13
  - Tag-based identification using tag_id for O(1) lookups (no fuzzy matching)
14
14
  - Environment-based configuration with secure token management
15
+ - File attachment downloading (saves to .juno_task/attachments/github/)
15
16
 
16
17
  Usage:
17
18
  python github.py fetch --repo owner/repo
19
+ python github.py fetch --repo owner/repo --download-attachments
18
20
  python github.py respond --tag github-input
19
21
  python github.py sync --repo owner/repo --once
20
22
 
23
+ Environment Variables:
24
+ GITHUB_TOKEN GitHub personal access token (required)
25
+ GITHUB_REPO Default repository (format: owner/repo)
26
+ JUNO_DOWNLOAD_ATTACHMENTS Enable/disable file downloads (default: true)
27
+ JUNO_MAX_ATTACHMENT_SIZE Max file size in bytes (default: 50MB)
28
+
21
29
  Version: 1.0.0
22
30
  Package: juno-code@1.x.x
23
31
  Auto-installed by: ScriptInstaller
@@ -53,6 +61,46 @@ shutdown_requested = False
53
61
  # Configure logging
54
62
  logger = logging.getLogger(__name__)
55
63
 
64
+ # Import attachment downloader for file handling
65
+ script_dir = Path(__file__).parent
66
+ sys.path.insert(0, str(script_dir))
67
+
68
+ try:
69
+ from attachment_downloader import (
70
+ AttachmentDownloader,
71
+ format_attachments_section,
72
+ is_attachments_enabled
73
+ )
74
+ ATTACHMENTS_AVAILABLE = True
75
+ except ImportError:
76
+ ATTACHMENTS_AVAILABLE = False
77
+ # Define stub functions if attachment_downloader not available
78
+ def is_attachments_enabled():
79
+ return False
80
+ def format_attachments_section(paths):
81
+ return ""
82
+
83
+
84
+ # =============================================================================
85
+ # GitHub Attachment URL Patterns
86
+ # =============================================================================
87
+
88
+ # Regex patterns to extract attachment URLs from issue body and comments
89
+ GITHUB_ATTACHMENT_PATTERNS = [
90
+ # GitHub user-attachments/files (file uploads with numeric ID)
91
+ r'https://github\.com/user-attachments/files/\d+/[^\s\)\"\'\]]+',
92
+ # GitHub user-attachments/assets (new format with UUID)
93
+ r'https://github\.com/user-attachments/assets/[a-f0-9-]+/[^\s\)\"\'\]]+',
94
+ # User images (screenshots, drag-drop uploads)
95
+ r'https://user-images\.githubusercontent\.com/\d+/[^\s\)\"\'\]]+',
96
+ # Private user images
97
+ r'https://private-user-images\.githubusercontent\.com/\d+/[^\s\)\"\'\]]+',
98
+ # Repository assets
99
+ r'https://github\.com/[^/]+/[^/]+/assets/\d+/[^\s\)\"\'\]]+',
100
+ # Objects storage
101
+ r'https://objects\.githubusercontent\.com/[^\s\)\"\'\]]+',
102
+ ]
103
+
56
104
 
57
105
  # =============================================================================
58
106
  # State Management Classes
@@ -911,6 +959,138 @@ def validate_repo_format(repo: str) -> bool:
911
959
  return True
912
960
 
913
961
 
962
+ # =============================================================================
963
+ # Attachment Extraction and Download Functions
964
+ # =============================================================================
965
+
966
+ def extract_attachment_urls(body: str, comments: Optional[List[Dict[str, Any]]] = None) -> List[str]:
967
+ """
968
+ Extract attachment URLs from issue body and comments.
969
+
970
+ GitHub doesn't have a dedicated attachment API - files are embedded as URLs
971
+ in the issue body or comments when users drag-drop or paste images.
972
+
973
+ Args:
974
+ body: Issue body text
975
+ comments: Optional list of comment dicts
976
+
977
+ Returns:
978
+ Deduplicated list of attachment URLs
979
+ """
980
+ urls = set()
981
+
982
+ def extract_from_text(text: str, source: str = "text") -> None:
983
+ if not text:
984
+ logger.debug(f"extract_attachment_urls: Empty {source}, skipping")
985
+ return
986
+ for pattern in GITHUB_ATTACHMENT_PATTERNS:
987
+ matches = re.findall(pattern, text, re.IGNORECASE)
988
+ if matches:
989
+ logger.debug(f"extract_attachment_urls: Pattern '{pattern[:50]}...' matched {len(matches)} URL(s) in {source}")
990
+ for url in matches:
991
+ logger.info(f" Detected attachment URL: {url}")
992
+ urls.update(matches)
993
+
994
+ # Extract from body
995
+ logger.debug(f"extract_attachment_urls: Scanning body ({len(body) if body else 0} chars)")
996
+ extract_from_text(body, "body")
997
+
998
+ # Extract from comments
999
+ if comments:
1000
+ logger.debug(f"extract_attachment_urls: Scanning {len(comments)} comments")
1001
+ for i, comment in enumerate(comments):
1002
+ extract_from_text(comment.get('body', ''), f"comment[{i}]")
1003
+
1004
+ if urls:
1005
+ logger.info(f"extract_attachment_urls: Found {len(urls)} unique attachment URL(s)")
1006
+ else:
1007
+ logger.debug("extract_attachment_urls: No attachment URLs found")
1008
+
1009
+ return list(urls)
1010
+
1011
+
1012
+ def download_github_attachments(
1013
+ urls: List[str],
1014
+ token: str,
1015
+ repo: str,
1016
+ issue_number: int,
1017
+ downloader: 'AttachmentDownloader'
1018
+ ) -> List[str]:
1019
+ """
1020
+ Download GitHub attachment files.
1021
+
1022
+ Args:
1023
+ urls: List of attachment URLs
1024
+ token: GitHub token for authentication
1025
+ repo: Repository in owner/repo format
1026
+ issue_number: Issue number
1027
+ downloader: AttachmentDownloader instance
1028
+
1029
+ Returns:
1030
+ List of local file paths
1031
+ """
1032
+ if not urls:
1033
+ return []
1034
+
1035
+ downloaded_paths = []
1036
+ headers = {
1037
+ 'Authorization': f'token {token}',
1038
+ 'Accept': 'application/octet-stream'
1039
+ }
1040
+
1041
+ # Sanitize repo for directory name
1042
+ repo_dir = repo.replace('/', '_')
1043
+ target_dir = downloader.base_dir / 'github' / repo_dir
1044
+
1045
+ for url in urls:
1046
+ # Extract filename from URL
1047
+ try:
1048
+ from urllib.parse import urlparse, unquote
1049
+ parsed = urlparse(url)
1050
+ path_parts = parsed.path.split('/')
1051
+ filename = unquote(path_parts[-1]) if path_parts else None
1052
+
1053
+ # Handle URLs without clear filename
1054
+ if not filename or filename in ['/', '']:
1055
+ # Use hash of URL as filename
1056
+ import hashlib
1057
+ url_hash = hashlib.md5(url.encode()).hexdigest()[:8]
1058
+ # Try to extract extension from URL path
1059
+ ext = ''
1060
+ for part in reversed(path_parts):
1061
+ if '.' in part:
1062
+ ext = '.' + part.split('.')[-1]
1063
+ break
1064
+ filename = f"attachment_{url_hash}{ext}"
1065
+
1066
+ except Exception as e:
1067
+ logger.warning(f"Error parsing URL {url}: {e}")
1068
+ continue
1069
+
1070
+ metadata = {
1071
+ 'source': 'github',
1072
+ 'repo': repo,
1073
+ 'issue_number': issue_number,
1074
+ }
1075
+
1076
+ path, error = downloader.download_file(
1077
+ url=url,
1078
+ target_dir=target_dir,
1079
+ filename_prefix=f"issue_{issue_number}",
1080
+ original_filename=filename,
1081
+ headers=headers,
1082
+ metadata=metadata
1083
+ )
1084
+
1085
+ if path:
1086
+ downloaded_paths.append(path)
1087
+ logger.info(f"Downloaded GitHub attachment: {filename}")
1088
+ else:
1089
+ logger.warning(f"Failed to download {url}: {error}")
1090
+
1091
+ return downloaded_paths
1092
+
1093
+
914
1094
  def extract_task_ids_from_text(text: str) -> List[str]:
915
1095
  """
916
1096
  Extract task IDs from text using [task_id]...[/task_id] format.
@@ -1097,7 +1277,8 @@ def create_kanban_task_from_issue(
1097
1277
  issue: Dict[str, Any],
1098
1278
  repo: str,
1099
1279
  kanban_script: str,
1100
- dry_run: bool = False
1280
+ dry_run: bool = False,
1281
+ attachment_paths: Optional[List[str]] = None
1101
1282
  ) -> Optional[str]:
1102
1283
  """
1103
1284
  Create kanban task from GitHub issue.
@@ -1107,6 +1288,7 @@ def create_kanban_task_from_issue(
1107
1288
  repo: Repository in format "owner/repo"
1108
1289
  kanban_script: Path to kanban.sh script
1109
1290
  dry_run: If True, don't actually create the task
1291
+ attachment_paths: Optional list of downloaded attachment file paths
1110
1292
 
1111
1293
  Returns:
1112
1294
  Task ID if created, None if failed
@@ -1122,6 +1304,10 @@ def create_kanban_task_from_issue(
1122
1304
  task_body = f"# {issue['title']}\n\n"
1123
1305
  task_body += issue['body'] or "(No description)"
1124
1306
 
1307
+ # Append attachment paths if any
1308
+ if attachment_paths:
1309
+ task_body += format_attachments_section(attachment_paths)
1310
+
1125
1311
  # Build tags - all metadata goes here for token efficiency
1126
1312
  tags = [
1127
1313
  'github-input',
@@ -1140,10 +1326,16 @@ def create_kanban_task_from_issue(
1140
1326
  # Add tag_id as a tag
1141
1327
  tags.append(tag_id)
1142
1328
 
1329
+ # Add has-attachments tag if files were downloaded
1330
+ if attachment_paths:
1331
+ tags.append('has-attachments')
1332
+
1143
1333
  if dry_run:
1144
1334
  logger.info(f"[DRY RUN] Would create task with tag_id: {tag_id}")
1145
1335
  logger.debug(f"[DRY RUN] Body: {task_body[:200]}...")
1146
1336
  logger.debug(f"[DRY RUN] Tags: {', '.join(tags)}")
1337
+ if attachment_paths:
1338
+ logger.debug(f"[DRY RUN] Attachments: {len(attachment_paths)} files")
1147
1339
  return "dry-run-task-id"
1148
1340
 
1149
1341
  try:
@@ -1337,7 +1529,8 @@ def create_kanban_task_from_comment(
1337
1529
  repo: str,
1338
1530
  kanban_script: str,
1339
1531
  related_task_ids: List[str],
1340
- dry_run: bool = False
1532
+ dry_run: bool = False,
1533
+ attachment_paths: Optional[List[str]] = None
1341
1534
  ) -> Optional[str]:
1342
1535
  """
1343
1536
  Create kanban task from a GitHub issue comment (user reply).
@@ -1349,6 +1542,7 @@ def create_kanban_task_from_comment(
1349
1542
  kanban_script: Path to kanban.sh script
1350
1543
  related_task_ids: List of previous task IDs from the thread
1351
1544
  dry_run: If True, don't actually create the task
1545
+ attachment_paths: Optional list of downloaded attachment file paths
1352
1546
 
1353
1547
  Returns:
1354
1548
  Task ID if created, None if failed
@@ -1367,6 +1561,10 @@ def create_kanban_task_from_comment(
1367
1561
  task_body = f"# Reply to Issue #{issue_number}: {issue['title']}\n\n"
1368
1562
  task_body += comment['body'] or "(No content)"
1369
1563
 
1564
+ # Append attachment paths if any
1565
+ if attachment_paths:
1566
+ task_body += format_attachments_section(attachment_paths)
1567
+
1370
1568
  # Add previous task_id references if any
1371
1569
  if related_task_ids:
1372
1570
  task_body += f"\n\n[task_id]{','.join(related_task_ids)}[/task_id]"
@@ -1388,11 +1586,17 @@ def create_kanban_task_from_comment(
1388
1586
  # Add comment tag_id
1389
1587
  tags.append(comment_tag_id)
1390
1588
 
1589
+ # Add has-attachments tag if files were downloaded
1590
+ if attachment_paths:
1591
+ tags.append('has-attachments')
1592
+
1391
1593
  if dry_run:
1392
1594
  logger.info(f"[DRY RUN] Would create task for comment #{comment_id} on issue #{issue_number}")
1393
1595
  logger.debug(f"[DRY RUN] Body: {task_body[:200]}...")
1394
1596
  logger.debug(f"[DRY RUN] Tags: {', '.join(tags)}")
1395
1597
  logger.debug(f"[DRY RUN] Related task IDs: {related_task_ids}")
1598
+ if attachment_paths:
1599
+ logger.debug(f"[DRY RUN] Attachments: {len(attachment_paths)} files")
1396
1600
  return "dry-run-task-id"
1397
1601
 
1398
1602
  try:
@@ -1436,7 +1640,10 @@ def process_issue_comments(
1436
1640
  repo: str,
1437
1641
  kanban_script: str,
1438
1642
  comment_state_mgr: 'CommentStateManager',
1439
- dry_run: bool = False
1643
+ dry_run: bool = False,
1644
+ download_attachments: bool = False,
1645
+ downloader: Optional['AttachmentDownloader'] = None,
1646
+ token: Optional[str] = None
1440
1647
  ) -> Tuple[int, int]:
1441
1648
  """
1442
1649
  Process comments on an issue, creating kanban tasks for user replies.
@@ -1454,6 +1661,9 @@ def process_issue_comments(
1454
1661
  kanban_script: Path to kanban.sh script
1455
1662
  comment_state_mgr: CommentStateManager for tracking processed comments
1456
1663
  dry_run: If True, don't actually create tasks
1664
+ download_attachments: If True, download file attachments from comments
1665
+ downloader: AttachmentDownloader instance for handling downloads
1666
+ token: GitHub token for authenticated downloads
1457
1667
 
1458
1668
  Returns:
1459
1669
  Tuple of (processed_count, created_count)
@@ -1500,8 +1710,30 @@ def process_issue_comments(
1500
1710
  logger.info(f" Comment #{comment_id} (@{comment_author}): User reply detected")
1501
1711
  processed += 1
1502
1712
 
1713
+ # Handle attachments if enabled
1714
+ attachment_paths = []
1715
+ if download_attachments and downloader and token:
1716
+ attachment_urls = extract_attachment_urls(comment_body)
1717
+ if attachment_urls:
1718
+ logger.info(f" Found {len(attachment_urls)} attachment(s) in comment")
1719
+ if not dry_run:
1720
+ attachment_paths = download_github_attachments(
1721
+ urls=attachment_urls,
1722
+ token=token,
1723
+ repo=repo,
1724
+ issue_number=issue_number,
1725
+ downloader=downloader
1726
+ )
1727
+ if attachment_paths:
1728
+ logger.info(f" Downloaded {len(attachment_paths)} attachment(s)")
1729
+ else:
1730
+ logger.warning(f" Failed to download some attachments")
1731
+ else:
1732
+ logger.info(f" [DRY RUN] Would download {len(attachment_urls)} attachment(s)")
1733
+
1503
1734
  task_id = create_kanban_task_from_comment(
1504
- comment, issue, repo, kanban_script, all_task_ids, dry_run
1735
+ comment, issue, repo, kanban_script, all_task_ids, dry_run,
1736
+ attachment_paths=attachment_paths
1505
1737
  )
1506
1738
 
1507
1739
  if task_id:
@@ -1593,12 +1825,24 @@ def handle_fetch(args: argparse.Namespace) -> int:
1593
1825
  # Check if we should include comments
1594
1826
  include_comments = getattr(args, 'include_comments', True) # Default to True
1595
1827
 
1828
+ # Initialize attachment downloader if enabled
1829
+ downloader = None
1830
+ download_attachments = getattr(args, 'download_attachments', True) and is_attachments_enabled()
1831
+ if download_attachments and ATTACHMENTS_AVAILABLE:
1832
+ attachments_dir = project_dir / '.juno_task' / 'attachments'
1833
+ downloader = AttachmentDownloader(base_dir=str(attachments_dir))
1834
+ logger.info(f"Attachment downloads enabled: {attachments_dir}")
1835
+ elif download_attachments and not ATTACHMENTS_AVAILABLE:
1836
+ logger.warning("Attachment downloads requested but attachment_downloader module not available")
1837
+ download_attachments = False
1838
+
1596
1839
  if args.dry_run:
1597
1840
  logger.info("Running in DRY RUN mode - no tasks will be created")
1598
1841
 
1599
1842
  logger.info(f"Monitoring repository: {repo}")
1600
1843
  logger.info(f"Filters: labels={args.labels or 'None'} assignee={args.assignee or 'None'} state={args.state}")
1601
1844
  logger.info(f"Include comments/replies: {include_comments}")
1845
+ logger.info(f"Download attachments: {download_attachments}")
1602
1846
  logger.info(f"Mode: {'once' if args.once else 'continuous'}")
1603
1847
  if since:
1604
1848
  logger.info(f"Incremental sync since: {since}")
@@ -1642,7 +1886,29 @@ def handle_fetch(args: argparse.Namespace) -> int:
1642
1886
  for issue in new_issues:
1643
1887
  logger.info(f" Issue #{issue['number']} (@{issue['user']['login']}): {issue['title']}")
1644
1888
 
1645
- task_id = create_kanban_task_from_issue(issue, repo, kanban_script, args.dry_run)
1889
+ # Handle attachments if enabled
1890
+ attachment_paths = []
1891
+ if download_attachments and downloader:
1892
+ attachment_urls = extract_attachment_urls(issue.get('body', ''))
1893
+ if attachment_urls:
1894
+ logger.info(f" Found {len(attachment_urls)} attachment(s) in issue body")
1895
+ if not args.dry_run:
1896
+ attachment_paths = download_github_attachments(
1897
+ urls=attachment_urls,
1898
+ token=token,
1899
+ repo=repo,
1900
+ issue_number=issue['number'],
1901
+ downloader=downloader
1902
+ )
1903
+ if attachment_paths:
1904
+ logger.info(f" Downloaded {len(attachment_paths)} attachment(s)")
1905
+ else:
1906
+ logger.info(f" [DRY RUN] Would download {len(attachment_urls)} attachment(s)")
1907
+
1908
+ task_id = create_kanban_task_from_issue(
1909
+ issue, repo, kanban_script, args.dry_run,
1910
+ attachment_paths=attachment_paths
1911
+ )
1646
1912
 
1647
1913
  if task_id:
1648
1914
  if not args.dry_run:
@@ -1659,7 +1925,9 @@ def handle_fetch(args: argparse.Namespace) -> int:
1659
1925
  'created_at': issue['created_at'],
1660
1926
  'updated_at': issue['updated_at'],
1661
1927
  'issue_url': issue['url'],
1662
- 'issue_html_url': issue['html_url']
1928
+ 'issue_html_url': issue['html_url'],
1929
+ 'attachment_count': len(attachment_paths),
1930
+ 'attachment_paths': attachment_paths
1663
1931
  }, task_id)
1664
1932
 
1665
1933
  logger.info(f" ✓ Created kanban task: {task_id}")
@@ -1692,7 +1960,10 @@ def handle_fetch(args: argparse.Namespace) -> int:
1692
1960
  repo,
1693
1961
  kanban_script,
1694
1962
  comment_state_mgr,
1695
- args.dry_run
1963
+ args.dry_run,
1964
+ download_attachments=download_attachments,
1965
+ downloader=downloader,
1966
+ token=token
1696
1967
  )
1697
1968
 
1698
1969
  if replies_created > 0:
@@ -2294,6 +2565,8 @@ Notes:
2294
2565
  fetch_parser.add_argument('--verbose', '-v', action='store_true', help='Enable DEBUG level logging')
2295
2566
  fetch_parser.add_argument('--include-comments', dest='include_comments', action='store_true', default=True, help='Include user replies/comments (default: True)')
2296
2567
  fetch_parser.add_argument('--no-comments', dest='include_comments', action='store_false', help='Skip processing user replies/comments')
2568
+ fetch_parser.add_argument('--download-attachments', dest='download_attachments', action='store_true', default=True, help='Download file attachments (default: True)')
2569
+ fetch_parser.add_argument('--no-attachments', dest='download_attachments', action='store_false', help='Skip downloading file attachments')
2297
2570
 
2298
2571
  # Respond subcommand
2299
2572
  respond_parser = subparsers.add_parser('respond', help='Post comments on GitHub issues for completed tasks')
@@ -2322,6 +2595,8 @@ Notes:
2322
2595
  sync_parser.add_argument('--reset-tracker', action='store_true', help='Reset response tracker (WARNING: will re-send all responses)')
2323
2596
  sync_parser.add_argument('--include-comments', dest='include_comments', action='store_true', default=True, help='Include user replies/comments (default: True)')
2324
2597
  sync_parser.add_argument('--no-comments', dest='include_comments', action='store_false', help='Skip processing user replies/comments')
2598
+ sync_parser.add_argument('--download-attachments', dest='download_attachments', action='store_true', default=True, help='Download file attachments (default: True)')
2599
+ sync_parser.add_argument('--no-attachments', dest='download_attachments', action='store_false', help='Skip downloading file attachments')
2325
2600
 
2326
2601
  # Push subcommand
2327
2602
  push_parser = subparsers.add_parser('push', help='Create GitHub issues from kanban tasks without issues')