@link-assistant/hive-mind 2.12.0 → 2.12.2

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.
@@ -20,14 +20,14 @@ export { buildCostInfoString };
20
20
  // #1756: route gh exec calls through transient + rate-limit retry wrapper
21
21
  import { execGhWithRetry } from './github-rate-limit.lib.mjs';
22
22
  import { QUIET_PROBE } from './quiet-probe.lib.mjs'; // issues #2130, #2135: keep read-only probe payloads out of the attached log
23
- // Issue #1625: Named marker constants (single source of truth) + in-memory
24
- // tracking for tool-posted comments. See tool-comments.lib.mjs for design.
23
+ import { isGitHubUrlType, normalizeGitHubUrl, parseGitHubUrl } from './github-url-parser.lib.mjs';
24
+ export { isGitHubUrlType, normalizeGitHubUrl, parseGitHubUrl };
25
+ // Issue #1625: Named marker constants (single source of truth) + in-memory tracking for tool-posted comments. See tool-comments.lib.mjs for design.
25
26
  import { SOLUTION_DRAFT_LOG_MARKER, SOLUTION_DRAFT_FAILED_MARKER, SOLUTION_DRAFT_FINISHED_WITH_ERRORS_MARKER, USAGE_LIMIT_REACHED_MARKER, NOW_WORKING_SESSION_IS_ENDED_MARKER, postTrackedComment, postTrackedCommentFromFile } from './tool-comments.lib.mjs';
26
27
  export const maskGitHubToken = maskToken; // Alias for backward compatibility
27
28
  export const escapeCodeBlocksInLog = logContent => logContent.replace(/```/g, '\\`\\`\\`'); // Escape ``` in logs
28
29
  const buildIssueFailureActionSection = targetType => {
29
30
  if (targetType !== 'issue') return '';
30
-
31
31
  return `
32
32
 
33
33
  ### What you can do
@@ -42,12 +42,8 @@ const normalizeFailureActionSection = section => {
42
42
  };
43
43
  export const checkFileInBranch = async (owner, repo, fileName, branchName) => {
44
44
  const { $ } = await use('command-stream');
45
-
46
45
  try {
47
- // Issue #2130: this is an existence probe, and "absent" is the answer the
48
- // caller is usually looking for. Mirroring the command would print the whole
49
- // contents payload on a hit and `gh: Not Found (HTTP 404)` on a miss, which
50
- // reads as a failure in the log even though nothing went wrong.
46
+ // Issue #2130: this is an existence probe, and "absent" is the answer the caller is usually looking for. Mirroring the command would print the whole contents payload on a hit and `gh: Not Found (HTTP 404)` on a miss, which reads as a failure in the log even though nothing went wrong.
51
47
  const result = await $(QUIET_PROBE)`gh api repos/${owner}/${repo}/contents/${fileName}?ref=${branchName}`;
52
48
  return result.code === 0;
53
49
  } catch (error) {
@@ -70,9 +66,7 @@ export const checkGitHubPermissions = async () => {
70
66
  const { $ } = await use('command-stream');
71
67
  try {
72
68
  await log('\nšŸ” Checking GitHub authentication and permissions...');
73
- // Get auth status including token scopes.
74
- // Issue #2130: capture without mirroring - the parsed summary below is what
75
- // belongs in the log, not gh's raw account/token/scope block.
69
+ // Get auth status including token scopes. Issue #2130: capture without mirroring - the parsed summary below is what belongs in the log, not gh's raw account/token/scope block.
76
70
  const authStatusResult = await $(QUIET_PROBE)`gh auth status 2>&1`;
77
71
  const authOutput = authStatusResult.stdout.toString() + authStatusResult.stderr.toString();
78
72
  if (authStatusResult.code !== 0 || authOutput.includes('not logged into any GitHub hosts')) {
@@ -287,10 +281,7 @@ Could you please enable the **"Allow edits by maintainers"** checkbox? This will
287
281
  3. Check the box āœ…
288
282
  Alternatively, you can enable it when creating/editing the PR. See: https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/working-with-forks/allowing-changes-to-a-pull-request-branch-created-from-a-fork
289
283
  Thank you! šŸ™`;
290
- // Issue #1625: track this comment so it's not counted as AI-authored by
291
- // --auto-attach-solution-summary. The "Allow edits by maintainers"
292
- // phrase embedded above matches MAINTAINER_ACCESS_REQUEST_MARKER as a
293
- // fallback if the ID capture fails.
284
+ // Issue #1625: track this comment so it's not counted as AI-authored by --auto-attach-solution-summary. The "Allow edits by maintainers" phrase embedded above matches MAINTAINER_ACCESS_REQUEST_MARKER as a fallback if the ID capture fails.
294
285
  const posted = await postTrackedComment({ $, owner, repo, targetNumber: prNumber, body: commentBody });
295
286
  if (posted.ok) {
296
287
  await log(`āœ… Comment posted successfully${posted.commentId ? ` (id=${posted.commentId})` : ''}`, { verbose: true });
@@ -313,24 +304,19 @@ Thank you! šŸ™`;
313
304
  };
314
305
  export const selectLogUploadUrl = ({ uploadResult, isPublicRepo }) => {
315
306
  if (!uploadResult?.success) return null;
316
-
317
307
  const chunks = Number.isFinite(uploadResult.chunks) ? uploadResult.chunks : 1;
318
308
  const rawUrl = uploadResult.rawUrl || null;
319
309
  const pageUrl = uploadResult.url || null;
320
310
  const canUseRawUrl = chunks === 1 && rawUrl && (isPublicRepo || uploadResult.type !== 'repository');
321
-
322
311
  return canUseRawUrl ? rawUrl : pageUrl;
323
312
  };
324
-
325
313
  const isUsableLogUrl = value => typeof value === 'string' && /^https:\/\/[^\s)]+$/u.test(value);
326
-
327
314
  const getLogUploadTerminalStatus = ({ errorMessage, errorDuringExecution, isUsageLimit }) => {
328
315
  if (errorMessage) return { emoji: 'šŸ“Ž', label: 'Failure log' };
329
316
  if (errorDuringExecution) return { emoji: 'šŸ“Ž', label: 'Finished-with-errors log' };
330
317
  if (isUsageLimit) return { emoji: 'šŸ“Ž', label: 'Usage-limit execution log' };
331
318
  return { emoji: 'āœ…', label: 'Solution draft log' };
332
319
  };
333
-
334
320
  /** Attaches a log file to a GitHub PR or issue as a comment. Returns true if upload succeeded. */
335
321
  export async function attachLogToGitHub(options) {
336
322
  const fs = (await use('fs')).promises;
@@ -393,8 +379,7 @@ export async function attachLogToGitHub(options) {
393
379
  }
394
380
  let totalCostUSD = publicPricingEstimate; // Issue #1225: token usage + actual model IDs
395
381
  let actualModelIds = null;
396
- // Issue #2037 (review): per-model output-token map, used to report the share of
397
- // output tokens produced by the fallback model in the "Models used:" section.
382
+ // Issue #2037 (review): per-model output-token map, used to report the share of output tokens produced by the fallback model in the "Models used:" section.
398
383
  let modelUsageForComment = null;
399
384
  if (totalCostUSD === null && sessionId && tempDir && !errorMessage) {
400
385
  try {
@@ -438,8 +423,7 @@ export async function attachLogToGitHub(options) {
438
423
  let modelInfoString = '';
439
424
  if (requestedModel || tool || actualModelIds) {
440
425
  try {
441
- // Issue #1949: prefer an explicit thinkingInfo, otherwise derive it from argv
442
- // (e.g. "high (~24000 tokens)"). null when the run used the tool's default.
426
+ // Issue #1949: prefer an explicit thinkingInfo, otherwise derive it from argv (e.g. "high (~24000 tokens)"). null when the run used the tool's default.
443
427
  const resolvedThinkingInfo = thinkingInfo ?? describeRequestedThinking(argv);
444
428
  modelInfoString = await getModelInfoForComment({ requestedModel, tool, pricingInfo, actualModelIds, thinkingInfo: resolvedThinkingInfo, fallbackModel: argv?.fallbackModel ?? null, modelUsage: modelUsageForComment });
445
429
  if (verbose && modelInfoString) {
@@ -458,7 +442,6 @@ export async function attachLogToGitHub(options) {
458
442
  await log(' šŸ” Sanitizing log content to mask GitHub tokens...', { verbose: true });
459
443
  }
460
444
  let logContent = await sanitizeForPublication(rawLogContent);
461
-
462
445
  // Escape code blocks in the log content to prevent them from breaking markdown formatting
463
446
  if (verbose) {
464
447
  await log(' šŸ”§ Escaping code blocks in log content for safe embedding...', { verbose: true });
@@ -467,8 +450,7 @@ export async function attachLogToGitHub(options) {
467
450
  const failureAction = normalizeFailureActionSection(failureActionSection ?? buildIssueFailureActionSection(targetType));
468
451
  // Create formatted comment
469
452
  let logComment;
470
- // Usage limit comments should be shown whenever isUsageLimit is true,
471
- // regardless of whether a generic errorMessage is provided.
453
+ // Usage limit comments should be shown whenever isUsageLimit is true, regardless of whether a generic errorMessage is provided.
472
454
  if (isUsageLimit) {
473
455
  // Usage limit error format - separate from general failures
474
456
  logComment = `## ā³ ${USAGE_LIMIT_REACHED_MARKER}
@@ -478,27 +460,19 @@ The automated solution draft was interrupted because the ${toolName} usage limit
478
460
  ### šŸ“Š Limit Information
479
461
  - **Tool**: ${toolName}
480
462
  - **Limit Type**: Usage limit exceeded`;
481
-
482
463
  if (limitResetTime) {
483
- // Format reset time with relative time and UTC for better user understanding
484
- // Shows "in 14m (Feb 6, 3:00 PM UTC)" instead of just "4:00 PM"
485
- // See: https://github.com/link-assistant/hive-mind/issues/1236
464
+ // Format reset time with relative time and UTC for better user understanding Shows "in 14m (Feb 6, 3:00 PM UTC)" instead of just "4:00 PM" See: https://github.com/link-assistant/hive-mind/issues/1236
486
465
  const formattedResetTime = formatResetTimeWithRelative(limitResetTime, global.limitTimezone || null) || limitResetTime;
487
466
  logComment += `\n- **Reset Time**: ${formattedResetTime}`;
488
467
  }
489
-
490
468
  if (sessionId) {
491
469
  logComment += `\n- **Session ID**: ${sessionId}`;
492
470
  }
493
-
494
471
  logComment += '\n\n### šŸ”„ How to Continue\n';
495
-
496
- // If auto-resume/auto-restart is enabled, show automatic continuation message instead of CLI commands
497
- // See: https://github.com/link-assistant/hive-mind/issues/1152
472
+ // If auto-resume/auto-restart is enabled, show automatic continuation message instead of CLI commands See: https://github.com/link-assistant/hive-mind/issues/1152
498
473
  if (isAutoResumeEnabled) {
499
474
  const modeName = autoResumeMode === 'restart' ? 'restart' : 'resume';
500
475
  const modeDescription = autoResumeMode === 'restart' ? 'The session will automatically restart (fresh start) when the limit resets.' : 'The session will automatically resume (with context preserved) when the limit resets.';
501
-
502
476
  logComment += `**Auto-${modeName} is enabled.** ${modeDescription}`;
503
477
  } else {
504
478
  // Manual resume mode - show CLI commands
@@ -507,7 +481,6 @@ The automated solution draft was interrupted because the ${toolName} usage limit
507
481
  } else {
508
482
  logComment += 'Once the limit resets, ';
509
483
  }
510
-
511
484
  if (resumeCommand) {
512
485
  logComment += `you can resume this session by running:
513
486
  \`\`\`bash
@@ -519,9 +492,7 @@ ${resumeCommand}
519
492
  logComment += 'you can retry the operation.';
520
493
  }
521
494
  }
522
-
523
495
  const footerNote = isAutoResumeEnabled ? (autoResumeMode === 'restart' ? '*This session was interrupted due to usage limits. The session will automatically restart when the limit resets.*' : '*This session was interrupted due to usage limits. The session will automatically resume when the limit resets.*') : '*This session was interrupted due to usage limits. You can resume once the limit resets.*';
524
-
525
496
  logComment += `${modelInfoString}
526
497
 
527
498
  <details>
@@ -575,10 +546,7 @@ ${logContent}
575
546
  *${NOW_WORKING_SESSION_IS_ENDED_MARKER}, feel free to review and add any feedback on the solution draft.*`;
576
547
  } else {
577
548
  const costInfo = buildCostInfoString(totalCostUSD, anthropicTotalCostUSD, pricingInfo, { includeTokenUsage: !budgetStats });
578
- // Determine title based on session type (Issue #1152)
579
- // Issue #1625: Every title variant embeds SOLUTION_DRAFT_LOG_MARKER so
580
- // the filter in checkForAiCreatedComments matches every variant with a
581
- // single substring check against the centralized marker constant.
549
+ // Determine title based on session type (Issue #1152) Issue #1625: Every title variant embeds SOLUTION_DRAFT_LOG_MARKER so the filter in checkForAiCreatedComments matches every variant with a single substring check against the centralized marker constant.
582
550
  let title = customTitle;
583
551
  let sessionNote = '';
584
552
  if (sessionType === 'auto-resume') {
@@ -606,8 +574,7 @@ ${logContent}
606
574
  ---
607
575
  *${NOW_WORKING_SESSION_IS_ENDED_MARKER}, feel free to review and add any feedback on the solution draft.*`;
608
576
  }
609
- // Check GitHub comment size limit or large file mode
610
- // Issue #1173: Also use gh-upload-log for large files, not just long comments
577
+ // Check GitHub comment size limit or large file mode Issue #1173: Also use gh-upload-log for large files, not just long comments
611
578
  if (useLargeFileMode || logComment.length > githubLimits.commentMaxSize) {
612
579
  if (useLargeFileMode) {
613
580
  await log(` šŸ“ Log file too large for inline comment (${Math.round(logStats.size / 1024 / 1024)}MB), using gh-upload-log`);
@@ -616,8 +583,7 @@ ${logContent}
616
583
  }
617
584
  await log(' šŸ“Ž Uploading log using gh-upload-log...');
618
585
  try {
619
- // Check if repository is public or private
620
- // Issue #1173: Use public upload for public repos, private for private repos
586
+ // Check if repository is public or private Issue #1173: Use public upload for public repos, private for private repos
621
587
  let isPublicRepo = true;
622
588
  try {
623
589
  const repoVisibilityResult = await $(QUIET_PROBE)`gh api repos/${owner}/${repo} --jq .visibility`;
@@ -643,7 +609,6 @@ ${logContent}
643
609
  const tempLogFile = `/tmp/solution-draft-log-${targetType}-${Date.now()}.txt`;
644
610
  // Use the original sanitized content for upload since it's a plain text file
645
611
  await writeSanitizedPublicationFile(tempLogFile, rawLogContent);
646
-
647
612
  // Use gh-upload-log default auto mode and shared repository fallback.
648
613
  const uploadDescription = `Solution draft log for https://github.com/${owner}/${repo}/${targetType === 'pr' ? 'pull' : 'issues'}/${targetNumber}`;
649
614
  let uploadResult;
@@ -657,12 +622,8 @@ ${logContent}
657
622
  } finally {
658
623
  await fs.unlink(tempLogFile).catch(() => {});
659
624
  }
660
-
661
625
  if (uploadResult.success) {
662
- // Use rawUrl for direct file access (single chunk) or url for repository (multiple chunks)
663
- // Requirements: 1 chunk = direct raw link, >1 chunks = repo link
664
- // Private repository raw URLs can contain short-lived tokens, so keep
665
- // private uploads on the stable repository/tree page URL.
626
+ // Use rawUrl for direct file access (single chunk) or url for repository (multiple chunks) Requirements: 1 chunk = direct raw link, >1 chunks = repo link Private repository raw URLs can contain short-lived tokens, so keep private uploads on the stable repository/tree page URL.
666
627
  const logUrl = selectLogUploadUrl({ uploadResult, isPublicRepo });
667
628
  if (!isUsableLogUrl(logUrl)) {
668
629
  await log(' āŒ gh-upload-log completed but no usable log URL was resolved');
@@ -670,10 +631,8 @@ ${logContent}
670
631
  await log(` šŸ“ Full log remains available locally at: ${logFile}`);
671
632
  return false;
672
633
  }
673
-
674
634
  const uploadTypeLabel = uploadResult.type === 'gist' ? 'Gist' : 'Repository';
675
635
  const chunkInfo = uploadResult.chunks > 1 ? ` (${uploadResult.chunks} chunks)` : '';
676
-
677
636
  // Create comment with log link
678
637
  let logUploadComment;
679
638
  // For usage limit cases, always use the dedicated format regardless of errorMessage
@@ -686,27 +645,19 @@ The automated solution draft was interrupted because the ${toolName} usage limit
686
645
  ### šŸ“Š Limit Information
687
646
  - **Tool**: ${toolName}
688
647
  - **Limit Type**: Usage limit exceeded`;
689
-
690
648
  if (limitResetTime) {
691
- // Format reset time with relative time and UTC for better user understanding
692
- // Shows "in 14m (Feb 6, 3:00 PM UTC)" instead of just "4:00 PM"
693
- // See: https://github.com/link-assistant/hive-mind/issues/1236
649
+ // Format reset time with relative time and UTC for better user understanding Shows "in 14m (Feb 6, 3:00 PM UTC)" instead of just "4:00 PM" See: https://github.com/link-assistant/hive-mind/issues/1236
694
650
  const formattedUploadResetTime = formatResetTimeWithRelative(limitResetTime, global.limitTimezone || null) || limitResetTime;
695
651
  logUploadComment += `\n- **Reset Time**: ${formattedUploadResetTime}`;
696
652
  }
697
-
698
653
  if (sessionId) {
699
654
  logUploadComment += `\n- **Session ID**: ${sessionId}`;
700
655
  }
701
-
702
656
  logUploadComment += '\n\n### šŸ”„ How to Continue\n';
703
-
704
- // If auto-resume/auto-restart is enabled, show automatic continuation message instead of CLI commands
705
- // See: https://github.com/link-assistant/hive-mind/issues/1152
657
+ // If auto-resume/auto-restart is enabled, show automatic continuation message instead of CLI commands See: https://github.com/link-assistant/hive-mind/issues/1152
706
658
  if (isAutoResumeEnabled) {
707
659
  const modeName = autoResumeMode === 'restart' ? 'restart' : 'resume';
708
660
  const modeDescription = autoResumeMode === 'restart' ? 'The session will automatically restart (fresh start) when the limit resets.' : 'The session will automatically resume (with context preserved) when the limit resets.';
709
-
710
661
  logUploadComment += `**Auto-${modeName} is enabled.** ${modeDescription}`;
711
662
  } else {
712
663
  // Manual resume mode - show CLI commands
@@ -715,7 +666,6 @@ The automated solution draft was interrupted because the ${toolName} usage limit
715
666
  } else {
716
667
  logUploadComment += 'Once the limit resets, ';
717
668
  }
718
-
719
669
  if (resumeCommand) {
720
670
  logUploadComment += `you can resume this session by running:
721
671
  \`\`\`bash
@@ -727,9 +677,7 @@ ${resumeCommand}
727
677
  logUploadComment += 'you can retry the operation.';
728
678
  }
729
679
  }
730
-
731
680
  const uploadFooterNote = isAutoResumeEnabled ? (autoResumeMode === 'restart' ? '*This session was interrupted due to usage limits. The session will automatically restart when the limit resets.*' : '*This session was interrupted due to usage limits. The session will automatically resume when the limit resets.*') : '*This session was interrupted due to usage limits. You can resume once the limit resets.*';
732
-
733
681
  logUploadComment += `${modelInfoString}
734
682
 
735
683
  ### šŸ“Ž **Execution log uploaded as ${uploadTypeLabel}${chunkInfo}** (${Math.round(logStats.size / 1024)}KB)
@@ -766,9 +714,7 @@ This log file contains the complete execution trace of the AI ${targetType === '
766
714
  } else {
767
715
  // Success log format - use helper function for cost info
768
716
  const costInfo = buildCostInfoString(totalCostUSD, anthropicTotalCostUSD, pricingInfo, { includeTokenUsage: !budgetStats });
769
- // Determine title based on session type
770
- // See: https://github.com/link-assistant/hive-mind/issues/1152
771
- // Issue #1625: titles embed SOLUTION_DRAFT_LOG_MARKER (single source).
717
+ // Determine title based on session type See: https://github.com/link-assistant/hive-mind/issues/1152 Issue #1625: titles embed SOLUTION_DRAFT_LOG_MARKER (single source).
772
718
  let title = customTitle;
773
719
  let sessionNote = '';
774
720
  if (sessionType === 'auto-resume') {
@@ -792,9 +738,7 @@ ${sessionNote}
792
738
  }
793
739
  const tempCommentFile = `/tmp/log-upload-comment-${targetType}-${Date.now()}.md`;
794
740
  await writeSanitizedPublicationFile(tempCommentFile, logUploadComment);
795
- // Issue #1625: post via postTrackedCommentFromFile so the returned
796
- // comment ID is registered in-memory and excluded from the
797
- // "did the AI post anything?" check.
741
+ // Issue #1625: post via postTrackedCommentFromFile so the returned comment ID is registered in-memory and excluded from the "did the AI post anything?" check.
798
742
  let posted;
799
743
  try {
800
744
  posted = await postTrackedCommentFromFile({ $, owner, repo, targetNumber, bodyFile: tempCommentFile });
@@ -806,8 +750,7 @@ ${sessionNote}
806
750
  await log(` ${status.emoji} ${status.label} uploaded to ${targetName} as ${isPublicRepo ? 'public' : 'private'} ${uploadTypeLabel}${chunkInfo}${posted.commentId ? ` (comment id=${posted.commentId})` : ''}`);
807
751
  await log(` šŸ”— Log URL: ${logUrl}`);
808
752
  await log(` šŸ“Š Log size: ${Math.round(logStats.size / 1024)}KB`);
809
- // Issue #1952: Record that a session log was attached anywhere in this process so the
810
- // top-level --attach-logs safety net can guarantee no session finishes with no logs.
753
+ // Issue #1952: Record that a session log was attached anywhere in this process so the top-level --attach-logs safety net can guarantee no session finishes with no logs.
811
754
  global.logAttachedToGitHub = true;
812
755
  return true;
813
756
  } else {
@@ -852,24 +795,19 @@ ${sessionNote}
852
795
  async function attachRegularComment(options, logComment) {
853
796
  const fs = (await use('fs')).promises;
854
797
  const { targetType, targetNumber, owner, repo, $, log, logFile, errorMessage, errorDuringExecution, isUsageLimit } = options;
855
-
856
798
  const targetName = targetType === 'pr' ? 'Pull Request' : 'Issue';
857
799
  const ghCommand = targetType === 'pr' ? 'pr' : 'issue';
858
800
  void ghCommand;
859
801
  const logStats = await fs.stat(logFile);
860
-
861
802
  const tempFile = `/tmp/log-comment-${targetType}-${Date.now()}.md`;
862
803
  await writeSanitizedPublicationFile(tempFile, logComment);
863
-
864
- // Issue #1625: track the posted comment ID so it's excluded from the
865
- // AI-authored-comment check in --auto-attach-solution-summary.
804
+ // Issue #1625: track the posted comment ID so it's excluded from the AI-authored-comment check in --auto-attach-solution-summary.
866
805
  let posted;
867
806
  try {
868
807
  posted = await postTrackedCommentFromFile({ $, owner, repo, targetNumber, bodyFile: tempFile });
869
808
  } finally {
870
809
  await fs.unlink(tempFile).catch(() => {});
871
810
  }
872
-
873
811
  if (posted.ok) {
874
812
  const status = getLogUploadTerminalStatus({ errorMessage, errorDuringExecution, isUsageLimit });
875
813
  await log(` ${status.emoji} ${status.label} uploaded to ${targetName} as comment${posted.commentId ? ` (id=${posted.commentId})` : ''}`);
@@ -978,8 +916,7 @@ export async function fetchProjectIssues(projectNumber, owner, statusFilter) {
978
916
  await log(`šŸ” Fetching issues from GitHub Project #${projectNumber} (owner: ${owner}, status: ${statusFilter})`);
979
917
  // Check for project scope in GitHub CLI authentication
980
918
  try {
981
- // Issue #2130: --show-token prints the token in clear text; mirroring it
982
- // would put a live credential on stdout and in the log file.
919
+ // Issue #2130: --show-token prints the token in clear text; mirroring it would put a live credential on stdout and in the log file.
983
920
  const authStatus = await $(QUIET_PROBE)`gh auth status --show-token`;
984
921
  if (!authStatus.stdout.includes('project')) {
985
922
  throw new Error('Missing project scope. Run: gh auth refresh -s project');
@@ -1055,264 +992,6 @@ export async function fetchProjectIssues(projectNumber, owner, statusFilter) {
1055
992
  }
1056
993
  // Re-export batch operations from separate module
1057
994
  export const batchCheckPullRequestsForIssues = batchCheckPRs;
1058
- /**
1059
- * Universal GitHub URL parser that handles various formats
1060
- * @param {string} url - The GitHub URL to parse
1061
- * @returns {Object} Parsed URL information including:
1062
- * - valid: boolean indicating if the URL is valid
1063
- * - normalized: the normalized URL (https://github.com/...)
1064
- * - type: 'user', 'repo', 'issue', 'pull', 'gist', 'actions', etc.
1065
- * - owner: repository owner/organization
1066
- * - repo: repository name (if applicable)
1067
- * - number: issue/PR number (if applicable)
1068
- * - path: additional path components
1069
- * - error: error message if invalid
1070
- */
1071
- export function parseGitHubUrl(url) {
1072
- if (!url || typeof url !== 'string') {
1073
- return {
1074
- valid: false,
1075
- error: 'Invalid input: URL must be a non-empty string',
1076
- };
1077
- }
1078
- // Trim whitespace and remove trailing slashes
1079
- let normalizedUrl = url.trim().replace(/\/+$/, '');
1080
- // Check if this looks like a valid GitHub-related input
1081
- // Reject clearly invalid inputs (spaces in the URL, special chars at the start, etc.)
1082
- if (/\s/.test(normalizedUrl) || /^[!@#$%^&*()[\]{}|\\:;"'<>,?`~]/.test(normalizedUrl)) {
1083
- return {
1084
- valid: false,
1085
- error: 'Invalid GitHub URL format',
1086
- };
1087
- }
1088
- // Handle protocol normalization
1089
- if (!normalizedUrl.startsWith('http://') && !normalizedUrl.startsWith('https://')) {
1090
- // Check if it starts with github.com
1091
- if (normalizedUrl.startsWith('github.com/')) {
1092
- normalizedUrl = 'https://' + normalizedUrl;
1093
- } else if (!normalizedUrl.includes('github.com')) {
1094
- // Assume it's a shorthand format (owner, owner/repo, owner/repo/issues/123, etc.)
1095
- normalizedUrl = 'https://github.com/' + normalizedUrl;
1096
- } else {
1097
- // Has github.com somewhere but not at the start - likely malformed
1098
- return {
1099
- valid: false,
1100
- error: 'Invalid GitHub URL format',
1101
- };
1102
- }
1103
- }
1104
- // Convert http to https
1105
- if (normalizedUrl.startsWith('http://')) {
1106
- normalizedUrl = normalizedUrl.replace(/^http:\/\//, 'https://');
1107
- }
1108
-
1109
- // Check for backslashes in the URL path (excluding query params and hash)
1110
- // According to RFC 3986, backslash is not a valid character in URL paths
1111
- const urlBeforeQueryAndHash = normalizedUrl.split('?')[0].split('#')[0];
1112
- if (urlBeforeQueryAndHash.includes('\\')) {
1113
- // Generate suggested URL by replacing backslashes with forward slashes
1114
- const suggestedUrl = urlBeforeQueryAndHash.replace(/\\/g, '/');
1115
- const urlAfterPath = normalizedUrl.substring(urlBeforeQueryAndHash.length);
1116
-
1117
- return {
1118
- valid: false,
1119
- error: 'Invalid character in URL: backslash (\\) is not allowed in URL paths',
1120
- suggestion: suggestedUrl + urlAfterPath,
1121
- };
1122
- }
1123
-
1124
- // Parse the URL
1125
- let urlObj;
1126
- try {
1127
- urlObj = new globalThis.URL(normalizedUrl);
1128
- } catch (e) {
1129
- if (global.verboseMode) {
1130
- reportError(e, {
1131
- context: 'github.lib.mjs - URL parsing',
1132
- level: 'debug',
1133
- url: normalizedUrl,
1134
- });
1135
- }
1136
- return {
1137
- valid: false,
1138
- error: 'Invalid URL format',
1139
- };
1140
- }
1141
- // Ensure it's a GitHub URL
1142
- if (urlObj.hostname !== 'github.com' && urlObj.hostname !== 'www.github.com') {
1143
- return {
1144
- valid: false,
1145
- error: 'Not a GitHub URL',
1146
- };
1147
- }
1148
- // Normalize hostname
1149
- if (urlObj.hostname === 'www.github.com') {
1150
- normalizedUrl = normalizedUrl.replace('www.github.com', 'github.com');
1151
- urlObj = new globalThis.URL(normalizedUrl);
1152
- }
1153
- // Parse the pathname
1154
- const pathParts = urlObj.pathname.split('/').filter(p => p);
1155
- // Handle different GitHub URL patterns
1156
- const result = {
1157
- valid: true,
1158
- normalized: normalizedUrl,
1159
- hostname: 'github.com',
1160
- protocol: 'https',
1161
- path: urlObj.pathname,
1162
- };
1163
- // No path - just github.com
1164
- if (pathParts.length === 0) {
1165
- result.type = 'home';
1166
- return result;
1167
- }
1168
- // User/Organization page: /owner
1169
- if (pathParts.length === 1) {
1170
- result.type = 'user';
1171
- result.owner = pathParts[0];
1172
- return result;
1173
- }
1174
- // Set owner for all other cases
1175
- result.owner = pathParts[0];
1176
- // Repository page: /owner/repo
1177
- if (pathParts.length === 2) {
1178
- result.type = 'repo';
1179
- result.repo = pathParts[1];
1180
- return result;
1181
- }
1182
- // Set repo for paths with 3+ parts
1183
- result.repo = pathParts[1];
1184
- // Handle specific GitHub paths
1185
- const thirdPart = pathParts[2];
1186
- switch (thirdPart) {
1187
- case 'issues':
1188
- if (pathParts.length === 3) {
1189
- // /owner/repo/issues - issues list
1190
- result.type = 'issues_list';
1191
- } else if (pathParts.length === 4 && /^\d+$/.test(pathParts[3])) {
1192
- // /owner/repo/issues/123 - specific issue
1193
- result.type = 'issue';
1194
- result.number = parseInt(pathParts[3]);
1195
- } else {
1196
- result.type = 'issues_page';
1197
- result.subpath = pathParts.slice(3).join('/');
1198
- }
1199
- break;
1200
- case 'pull':
1201
- if (pathParts.length === 4 && /^\d+$/.test(pathParts[3])) {
1202
- // /owner/repo/pull/456 - specific PR
1203
- result.type = 'pull';
1204
- result.number = parseInt(pathParts[3]);
1205
- } else {
1206
- result.type = 'pull_page';
1207
- result.subpath = pathParts.slice(3).join('/');
1208
- }
1209
- break;
1210
- case 'pulls':
1211
- // /owner/repo/pulls - PR list
1212
- result.type = 'pulls_list';
1213
- if (pathParts.length > 3) {
1214
- result.subpath = pathParts.slice(3).join('/');
1215
- }
1216
- break;
1217
- case 'actions':
1218
- // /owner/repo/actions - GitHub Actions
1219
- result.type = 'actions';
1220
- if (pathParts.length > 3) {
1221
- result.subpath = pathParts.slice(3).join('/');
1222
- if (pathParts[3] === 'runs' && pathParts[4] && /^\d+$/.test(pathParts[4])) {
1223
- result.type = 'action_run';
1224
- result.runId = parseInt(pathParts[4]);
1225
- }
1226
- }
1227
- break;
1228
- case 'releases':
1229
- // /owner/repo/releases
1230
- result.type = 'releases';
1231
- if (pathParts.length > 3) {
1232
- result.subpath = pathParts.slice(3).join('/');
1233
- if (pathParts[3] === 'tag' && pathParts[4]) {
1234
- result.type = 'release';
1235
- result.tag = pathParts[4];
1236
- }
1237
- }
1238
- break;
1239
- case 'tree':
1240
- case 'blob':
1241
- // /owner/repo/tree/branch or /owner/repo/blob/branch/file
1242
- result.type = thirdPart === 'tree' ? 'tree' : 'file';
1243
- if (pathParts.length > 3) {
1244
- result.branch = pathParts[3];
1245
- if (pathParts.length > 4) {
1246
- result.filepath = pathParts.slice(4).join('/');
1247
- }
1248
- }
1249
- break;
1250
- case 'commit':
1251
- case 'commits':
1252
- // /owner/repo/commit/sha or /owner/repo/commits/branch
1253
- result.type = thirdPart === 'commit' ? 'commit' : 'commits';
1254
- if (pathParts.length > 3) {
1255
- result.ref = pathParts[3]; // Could be SHA or branch
1256
- }
1257
- break;
1258
- case 'compare':
1259
- // /owner/repo/compare/base...head
1260
- result.type = 'compare';
1261
- if (pathParts.length > 3) {
1262
- result.comparison = pathParts[3];
1263
- }
1264
- break;
1265
- case 'wiki':
1266
- // /owner/repo/wiki
1267
- result.type = 'wiki';
1268
- if (pathParts.length > 3) {
1269
- result.subpath = pathParts.slice(3).join('/');
1270
- }
1271
- break;
1272
- case 'settings':
1273
- // /owner/repo/settings
1274
- result.type = 'settings';
1275
- if (pathParts.length > 3) {
1276
- result.subpath = pathParts.slice(3).join('/');
1277
- }
1278
- break;
1279
- case 'projects':
1280
- // /owner/repo/projects or /owner/repo/projects/1
1281
- result.type = 'projects';
1282
- if (pathParts.length > 3 && /^\d+$/.test(pathParts[3])) {
1283
- result.type = 'project';
1284
- result.projectNumber = parseInt(pathParts[3]);
1285
- }
1286
- break;
1287
- default:
1288
- // Unknown path structure but still valid GitHub URL
1289
- result.type = 'other';
1290
- result.subpath = pathParts.slice(2).join('/');
1291
- }
1292
- return result;
1293
- }
1294
- /**
1295
- * Normalize a GitHub URL to standard https://github.com format
1296
- * This is a convenience function that uses parseGitHubUrl
1297
- * @param {string} url - The URL to normalize
1298
- * @returns {string|null} The normalized URL or null if invalid
1299
- */
1300
- export function normalizeGitHubUrl(url) {
1301
- const parsed = parseGitHubUrl(url);
1302
- return parsed.valid ? parsed.normalized : null;
1303
- }
1304
- /**
1305
- * Check if a URL is a valid GitHub URL of a specific type
1306
- * @param {string} url - The URL to check
1307
- * @param {string|Array} types - The type(s) to check for ('issue', 'pull', 'repo', etc.)
1308
- * @returns {boolean} True if the URL matches the specified type(s)
1309
- */
1310
- export function isGitHubUrlType(url, types) {
1311
- const parsed = parseGitHubUrl(url);
1312
- if (!parsed.valid) return false;
1313
- const typeArray = Array.isArray(types) ? types : [types];
1314
- return typeArray.includes(parsed.type);
1315
- }
1316
995
  /**
1317
996
  * Universal function to view a pull request using gh pr view
1318
997
  * @param {Object} options - Configuration options