@link-assistant/hive-mind 0.39.0

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 (63) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/LICENSE +24 -0
  3. package/README.md +769 -0
  4. package/package.json +58 -0
  5. package/src/agent.lib.mjs +705 -0
  6. package/src/agent.prompts.lib.mjs +196 -0
  7. package/src/buildUserMention.lib.mjs +71 -0
  8. package/src/claude-limits.lib.mjs +389 -0
  9. package/src/claude.lib.mjs +1445 -0
  10. package/src/claude.prompts.lib.mjs +203 -0
  11. package/src/codex.lib.mjs +552 -0
  12. package/src/codex.prompts.lib.mjs +194 -0
  13. package/src/config.lib.mjs +207 -0
  14. package/src/contributing-guidelines.lib.mjs +268 -0
  15. package/src/exit-handler.lib.mjs +205 -0
  16. package/src/git.lib.mjs +145 -0
  17. package/src/github-issue-creator.lib.mjs +246 -0
  18. package/src/github-linking.lib.mjs +152 -0
  19. package/src/github.batch.lib.mjs +272 -0
  20. package/src/github.graphql.lib.mjs +258 -0
  21. package/src/github.lib.mjs +1479 -0
  22. package/src/hive.config.lib.mjs +254 -0
  23. package/src/hive.mjs +1500 -0
  24. package/src/instrument.mjs +191 -0
  25. package/src/interactive-mode.lib.mjs +1000 -0
  26. package/src/lenv-reader.lib.mjs +206 -0
  27. package/src/lib.mjs +490 -0
  28. package/src/lino.lib.mjs +176 -0
  29. package/src/local-ci-checks.lib.mjs +324 -0
  30. package/src/memory-check.mjs +419 -0
  31. package/src/model-mapping.lib.mjs +145 -0
  32. package/src/model-validation.lib.mjs +278 -0
  33. package/src/opencode.lib.mjs +479 -0
  34. package/src/opencode.prompts.lib.mjs +194 -0
  35. package/src/protect-branch.mjs +159 -0
  36. package/src/review.mjs +433 -0
  37. package/src/reviewers-hive.mjs +643 -0
  38. package/src/sentry.lib.mjs +284 -0
  39. package/src/solve.auto-continue.lib.mjs +568 -0
  40. package/src/solve.auto-pr.lib.mjs +1374 -0
  41. package/src/solve.branch-errors.lib.mjs +341 -0
  42. package/src/solve.branch.lib.mjs +230 -0
  43. package/src/solve.config.lib.mjs +342 -0
  44. package/src/solve.error-handlers.lib.mjs +256 -0
  45. package/src/solve.execution.lib.mjs +291 -0
  46. package/src/solve.feedback.lib.mjs +436 -0
  47. package/src/solve.mjs +1128 -0
  48. package/src/solve.preparation.lib.mjs +210 -0
  49. package/src/solve.repo-setup.lib.mjs +114 -0
  50. package/src/solve.repository.lib.mjs +961 -0
  51. package/src/solve.results.lib.mjs +558 -0
  52. package/src/solve.session.lib.mjs +135 -0
  53. package/src/solve.validation.lib.mjs +325 -0
  54. package/src/solve.watch.lib.mjs +572 -0
  55. package/src/start-screen.mjs +324 -0
  56. package/src/task.mjs +308 -0
  57. package/src/telegram-bot.mjs +1481 -0
  58. package/src/telegram-markdown.lib.mjs +64 -0
  59. package/src/usage-limit.lib.mjs +218 -0
  60. package/src/version.lib.mjs +41 -0
  61. package/src/youtrack/solve.youtrack.lib.mjs +116 -0
  62. package/src/youtrack/youtrack-sync.mjs +219 -0
  63. package/src/youtrack/youtrack.lib.mjs +425 -0
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Telegram markdown escaping utilities
3
+ * @module telegram-markdown.lib
4
+ */
5
+
6
+ /**
7
+ * Escape special characters for Telegram's basic Markdown parser.
8
+ * Only escapes underscore and asterisk to prevent parsing errors.
9
+ * @param {string} text - Text to escape
10
+ * @returns {string} Escaped text safe for Markdown parse_mode
11
+ */
12
+ export function escapeMarkdown(text) {
13
+ if (!text || typeof text !== 'string') {
14
+ return text;
15
+ }
16
+ // Escape underscore and asterisk which are the most common issues in URLs
17
+ // These can cause "Can't find end of entity" errors when Telegram tries to parse them
18
+ return text.replace(/_/g, '\\_').replace(/\*/g, '\\*');
19
+ }
20
+
21
+ /**
22
+ * Escape special characters for Telegram's MarkdownV2 parser.
23
+ * According to Telegram Bot API, these characters must be escaped in MarkdownV2:
24
+ * _ * [ ] ( ) ~ ` > # + - = | { } . ! \
25
+ * @param {string} text - Text to escape
26
+ * @param {Object} options - Configuration options
27
+ * @param {boolean} options.preserveCodeBlocks - If true, preserves inline code blocks (text between backticks) without escaping. Default: false
28
+ * @returns {string} Escaped text safe for MarkdownV2 parse_mode
29
+ */
30
+ export function escapeMarkdownV2(text, options = {}) {
31
+ if (!text || typeof text !== 'string') return text;
32
+
33
+ const { preserveCodeBlocks = false } = options;
34
+
35
+ // If not preserving code blocks, escape everything including backticks
36
+ if (!preserveCodeBlocks) {
37
+ return text.replace(/([_*[\]()~`>#+\-=|{}.!\\])/g, '\\$1');
38
+ }
39
+
40
+ // Split text into parts: inline code blocks and regular text
41
+ const parts = [];
42
+ let lastIndex = 0;
43
+ const codeBlockRegex = /`[^`]+`/g;
44
+ let match;
45
+
46
+ while ((match = codeBlockRegex.exec(text)) !== null) {
47
+ // Add escaped regular text before code block
48
+ if (match.index > lastIndex) {
49
+ const regularText = text.substring(lastIndex, match.index);
50
+ parts.push(regularText.replace(/([_*[\]()~`>#+\-=|{}.!\\])/g, '\\$1'));
51
+ }
52
+ // Add unescaped code block
53
+ parts.push(match[0]);
54
+ lastIndex = match.index + match[0].length;
55
+ }
56
+
57
+ // Add remaining text after last code block
58
+ if (lastIndex < text.length) {
59
+ const regularText = text.substring(lastIndex);
60
+ parts.push(regularText.replace(/([_*[\]()~`>#+\-=|{}.!\\])/g, '\\$1'));
61
+ }
62
+
63
+ return parts.join('');
64
+ }
@@ -0,0 +1,218 @@
1
+ /**
2
+ * Usage Limit Detection Utilities
3
+ *
4
+ * This module provides utilities for detecting and handling usage limit errors
5
+ * from AI tools (Claude, Codex, OpenCode).
6
+ *
7
+ * Related issue: https://github.com/link-assistant/hive-mind/issues/719
8
+ */
9
+
10
+ /**
11
+ * Detect if an error message indicates a usage limit has been reached
12
+ *
13
+ * @param {string} message - Error message to analyze
14
+ * @returns {boolean} - True if message indicates usage limit
15
+ */
16
+ export function isUsageLimitError(message) {
17
+ if (!message || typeof message !== 'string') {
18
+ return false;
19
+ }
20
+
21
+ const lowerMessage = message.toLowerCase();
22
+
23
+ // Check for specific usage limit patterns
24
+ const patterns = [
25
+ // Generic
26
+ "you've hit your usage limit",
27
+ 'hit your usage limit',
28
+ 'you have exceeded your rate limit',
29
+ 'usage limit reached',
30
+ 'usage limit exceeded',
31
+ 'rate_limit_exceeded',
32
+ 'rate limit exceeded',
33
+ 'limit reached',
34
+ 'limit has been reached',
35
+ // Provider-specific phrasings we’ve seen in the wild
36
+ 'session limit reached', // Claude
37
+ 'weekly limit reached', // Claude
38
+ 'daily limit reached',
39
+ 'monthly limit reached',
40
+ 'billing hard limit',
41
+ 'please try again at', // Codex/OpenCode style
42
+ 'available again at',
43
+ 'resets' // Claude shows: ā€œāˆ™ resets 5amā€
44
+ ];
45
+
46
+ return patterns.some(pattern => lowerMessage.includes(pattern));
47
+ }
48
+
49
+ /**
50
+ * Extract reset time from usage limit error message
51
+ *
52
+ * @param {string} message - Error message to analyze
53
+ * @returns {string|null} - Reset time string (e.g., "12:16 PM") or null if not found
54
+ */
55
+ export function extractResetTime(message) {
56
+ if (!message || typeof message !== 'string') {
57
+ return null;
58
+ }
59
+
60
+ // Normalize whitespace for easier matching
61
+ const normalized = message.replace(/\s+/g, ' ');
62
+
63
+ // Pattern 1: "try again at 12:16 PM"
64
+ const tryAgainMatch = normalized.match(/try again at ([0-9]{1,2}:[0-9]{2}\s*[AP]M)/i);
65
+ if (tryAgainMatch) {
66
+ return tryAgainMatch[1];
67
+ }
68
+
69
+ // Pattern 2: "available at 12:16 PM"
70
+ const availableMatch = normalized.match(/available at ([0-9]{1,2}:[0-9]{2}\s*[AP]M)/i);
71
+ if (availableMatch) {
72
+ return availableMatch[1];
73
+ }
74
+
75
+ // Pattern 3: "reset at 12:16 PM"
76
+ const resetMatch = normalized.match(/reset at ([0-9]{1,2}:[0-9]{2}\s*[AP]M)/i);
77
+ if (resetMatch) {
78
+ return resetMatch[1];
79
+ }
80
+
81
+ // Pattern 4: Claude-style: "resets 5am" or "resets at 5am" (no minutes)
82
+ const resetsAmPmNoMinutes = normalized.match(/resets(?:\s+at)?\s+([0-9]{1,2})\s*([AP]M)/i);
83
+ if (resetsAmPmNoMinutes) {
84
+ const hour = resetsAmPmNoMinutes[1];
85
+ const ampm = resetsAmPmNoMinutes[2].toUpperCase();
86
+ return `${hour}:00 ${ampm}`;
87
+ }
88
+
89
+ // Pattern 5: Claude-style with minutes: "resets 5:00am" or "resets at 5:00 am"
90
+ const resetsAmPmWithMinutes = normalized.match(/resets(?:\s+at)?\s+([0-9]{1,2}:[0-9]{2})\s*([AP]M)/i);
91
+ if (resetsAmPmWithMinutes) {
92
+ const time = resetsAmPmWithMinutes[1];
93
+ const ampm = resetsAmPmWithMinutes[2].toUpperCase();
94
+ return `${time} ${ampm}`;
95
+ }
96
+
97
+ // Pattern 6: 24-hour time: "resets 17:00" or "resets at 05:00"
98
+ const resets24h = normalized.match(/resets(?:\s+at)?\s+([0-2]?[0-9]):([0-5][0-9])\b/i);
99
+ if (resets24h) {
100
+ let hour = parseInt(resets24h[1], 10);
101
+ const minute = resets24h[2];
102
+ const ampm = hour >= 12 ? 'PM' : 'AM';
103
+ if (hour === 0) hour = 12; // 0 -> 12 AM
104
+ else if (hour > 12) hour -= 12; // 13-23 -> 1-11 PM
105
+ return `${hour}:${minute} ${ampm}`;
106
+ }
107
+
108
+ // Pattern 7: "resets 5am" written without space (already partially covered) – ensure we catch compact forms
109
+ const resetsCompact = normalized.match(/resets(?:\s+at)?\s*([0-9]{1,2})(?::([0-9]{2}))?\s*([ap]m)/i);
110
+ if (resetsCompact) {
111
+ const hour = resetsCompact[1];
112
+ const minute = resetsCompact[2] || '00';
113
+ const ampm = resetsCompact[3].toUpperCase();
114
+ return `${hour}:${minute} ${ampm}`;
115
+ }
116
+
117
+ // Pattern 8: standalone time like "12:16 PM" (less reliable, so last)
118
+ const timeMatch = normalized.match(/\b([0-9]{1,2}:[0-9]{2}\s*[AP]M)\b/i);
119
+ if (timeMatch) {
120
+ // Normalize spacing in AM/PM
121
+ const t = timeMatch[1].replace(/\s*([AP]M)/i, ' $1');
122
+ return t;
123
+ }
124
+
125
+ return null;
126
+ }
127
+
128
+ /**
129
+ * Detect usage limit error and extract all relevant information
130
+ *
131
+ * @param {string} message - Error message to analyze
132
+ * @returns {Object} - { isUsageLimit: boolean, resetTime: string|null }
133
+ */
134
+ export function detectUsageLimit(message) {
135
+ const isUsageLimit = isUsageLimitError(message);
136
+ const resetTime = isUsageLimit ? extractResetTime(message) : null;
137
+
138
+ return {
139
+ isUsageLimit,
140
+ resetTime
141
+ };
142
+ }
143
+
144
+ /**
145
+ * Format usage limit error message for console output
146
+ *
147
+ * @param {Object} options - Formatting options
148
+ * @param {string} options.tool - Tool name (claude, codex, opencode)
149
+ * @param {string|null} options.resetTime - Time when limit resets
150
+ * @param {string|null} options.sessionId - Session ID for resuming
151
+ * @param {string|null} options.resumeCommand - Command to resume session
152
+ * @returns {string[]} - Array of formatted message lines
153
+ */
154
+ export function formatUsageLimitMessage({ tool, resetTime, sessionId, resumeCommand }) {
155
+ const lines = [
156
+ '',
157
+ 'ā³ Usage Limit Reached!',
158
+ '',
159
+ `Your ${tool || 'AI tool'} usage limit has been reached.`
160
+ ];
161
+
162
+ if (resetTime) {
163
+ lines.push(`The limit will reset at: ${resetTime}`);
164
+ } else {
165
+ lines.push('Please wait for the limit to reset.');
166
+ }
167
+
168
+ if (sessionId && resumeCommand) {
169
+ lines.push('');
170
+ lines.push(`šŸ“Œ Session ID: ${sessionId}`);
171
+ lines.push('');
172
+ lines.push('To resume this session after the limit resets, run:');
173
+ lines.push(` ${resumeCommand}`);
174
+ }
175
+
176
+ lines.push('');
177
+
178
+ return lines;
179
+ }
180
+
181
+ /**
182
+ * Check if a message contains both usage limit error and is in JSON format
183
+ * Useful for parsing structured error responses
184
+ *
185
+ * @param {string} line - Line to check
186
+ * @returns {Object|null} - Parsed JSON object if valid, null otherwise
187
+ */
188
+ export function parseUsageLimitJson(line) {
189
+ try {
190
+ const data = JSON.parse(line);
191
+
192
+ // Check for error in JSON
193
+ if (data.type === 'error' && data.message) {
194
+ if (isUsageLimitError(data.message)) {
195
+ return {
196
+ type: 'error',
197
+ message: data.message,
198
+ limitInfo: detectUsageLimit(data.message)
199
+ };
200
+ }
201
+ }
202
+
203
+ // Check for turn.failed with error
204
+ if (data.type === 'turn.failed' && data.error && data.error.message) {
205
+ if (isUsageLimitError(data.error.message)) {
206
+ return {
207
+ type: 'turn.failed',
208
+ message: data.error.message,
209
+ limitInfo: detectUsageLimit(data.error.message)
210
+ };
211
+ }
212
+ }
213
+
214
+ return null;
215
+ } catch {
216
+ return null;
217
+ }
218
+ }
@@ -0,0 +1,41 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { readFile, access } from 'fs/promises';
4
+ import { dirname, join } from 'path';
5
+ import { fileURLToPath } from 'url';
6
+ import { getGitVersion } from './git.lib.mjs';
7
+
8
+ async function isRunningAsScript() {
9
+ const __filename = fileURLToPath(import.meta.url);
10
+ const __dirname = dirname(__filename);
11
+ const gitDir = join(__dirname, '..', '.git');
12
+ try {
13
+ await access(gitDir);
14
+ return true;
15
+ } catch {
16
+ return false;
17
+ }
18
+ }
19
+
20
+ export async function getVersion() {
21
+ const __filename = fileURLToPath(import.meta.url);
22
+ const __dirname = dirname(__filename);
23
+ const packagePath = join(__dirname, '..', 'package.json');
24
+
25
+ try {
26
+ const packageJsonContent = await readFile(packagePath, 'utf8');
27
+ const packageJson = JSON.parse(packageJsonContent);
28
+ const currentVersion = packageJson.version;
29
+
30
+ if (await isRunningAsScript()) {
31
+ const version = await getGitVersion(undefined, currentVersion);
32
+ return version;
33
+ }
34
+
35
+ return currentVersion;
36
+ } catch {
37
+ return 'unknown';
38
+ }
39
+ }
40
+
41
+ export default { getVersion };
@@ -0,0 +1,116 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * YouTrack integration module for solve.mjs
5
+ * Handles YouTrack URL validation, configuration, and issue updates
6
+ */
7
+
8
+ // Import YouTrack-related functions
9
+ const youTrackLib = await import('./youtrack.lib.mjs');
10
+ const {
11
+ parseYouTrackIssueId,
12
+ updateYouTrackIssueStage,
13
+ addYouTrackComment,
14
+ createYouTrackConfigFromEnv
15
+ } = youTrackLib;
16
+
17
+ /**
18
+ * Validates YouTrack URLs and extracts issue information
19
+ * @param {string} issueUrl - The URL or issue ID to validate
20
+ * @returns {Object} Validation result with YouTrack info
21
+ */
22
+ export async function validateYouTrackUrl(issueUrl) {
23
+ let isYouTrackUrl = null;
24
+ let youTrackIssueId = null;
25
+ let youTrackConfig = null;
26
+
27
+ if (!issueUrl) {
28
+ return { isYouTrackUrl: false, youTrackIssueId: null, youTrackConfig: null };
29
+ }
30
+
31
+ // Check for YouTrack issue format (youtrack://PROJECT-123 or youtrack://2-123)
32
+ isYouTrackUrl = issueUrl.match(/^youtrack:\/\/([A-Z0-9]+-\d+)$/i);
33
+
34
+ // Also check if it's a direct YouTrack issue ID
35
+ if (!isYouTrackUrl) {
36
+ youTrackIssueId = parseYouTrackIssueId(issueUrl);
37
+ if (youTrackIssueId) {
38
+ isYouTrackUrl = [issueUrl, youTrackIssueId];
39
+ }
40
+ } else {
41
+ youTrackIssueId = isYouTrackUrl[1];
42
+ }
43
+
44
+ // If YouTrack URL detected, set up YouTrack configuration
45
+ if (isYouTrackUrl) {
46
+ youTrackConfig = createYouTrackConfigFromEnv();
47
+ if (!youTrackConfig) {
48
+ console.error('Error: YouTrack URL detected but YouTrack configuration not found');
49
+ console.error(' Required environment variables:');
50
+ console.error(' YOUTRACK_URL - Your YouTrack instance URL');
51
+ console.error(' YOUTRACK_API_KEY - Your YouTrack API token');
52
+ console.error(' YOUTRACK_PROJECT_CODE - Project code (e.g., PAG)');
53
+ console.error(' YOUTRACK_STAGE - Current stage field value');
54
+ console.error(' Optional:');
55
+ console.error(' YOUTRACK_NEXT_STAGE - Stage to move issue to after PR creation');
56
+ process.exit(1);
57
+ }
58
+ }
59
+
60
+ return {
61
+ isYouTrackUrl: !!isYouTrackUrl,
62
+ youTrackIssueId,
63
+ youTrackConfig
64
+ };
65
+ }
66
+
67
+ /**
68
+ * Updates YouTrack issue with PR information and stage changes
69
+ * @param {string} youTrackIssueId - The YouTrack issue ID
70
+ * @param {Object} youTrackConfig - YouTrack configuration
71
+ * @param {string} prUrl - Pull request URL
72
+ * @param {Function} log - Logging function
73
+ */
74
+ export async function updateYouTrackIssue(youTrackIssueId, youTrackConfig, prUrl, log) {
75
+ if (!youTrackIssueId || !youTrackConfig || !prUrl) {
76
+ return;
77
+ }
78
+
79
+ await log(`\nšŸ”— Updating YouTrack issue ${youTrackIssueId}...`);
80
+
81
+ // Add comment about PR
82
+ const prComment = `Pull Request created: ${prUrl}\n\nPlease review the proposed solution.`;
83
+ const commentAdded = await addYouTrackComment(youTrackIssueId, prComment, youTrackConfig);
84
+ if (commentAdded) {
85
+ await log('āœ… Added comment to YouTrack issue');
86
+ } else {
87
+ await log('āš ļø Failed to add comment to YouTrack issue', { level: 'warning' });
88
+ }
89
+
90
+ // Update issue stage if nextStage is configured
91
+ if (youTrackConfig.nextStage) {
92
+ const stageUpdated = await updateYouTrackIssueStage(youTrackIssueId, youTrackConfig.nextStage, youTrackConfig);
93
+ if (stageUpdated) {
94
+ await log(`āœ… Updated YouTrack issue stage to "${youTrackConfig.nextStage}"`);
95
+ } else {
96
+ await log('āš ļø Failed to update YouTrack issue stage', { level: 'warning' });
97
+ }
98
+ }
99
+ }
100
+
101
+ /**
102
+ * Checks if a URL format could be a YouTrack URL
103
+ * @param {string} url - URL to check
104
+ * @returns {boolean} True if it matches YouTrack patterns
105
+ */
106
+ export function isYouTrackFormat(url) {
107
+ if (!url) return false;
108
+
109
+ // Check for youtrack:// format
110
+ if (url.match(/^youtrack:\/\//)) return true;
111
+
112
+ // Check for direct issue ID format (PROJECT-123 or 2-123)
113
+ if (url.match(/^[A-Z0-9]+-\d+$/i)) return true;
114
+
115
+ return false;
116
+ }
@@ -0,0 +1,219 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * YouTrack to GitHub Issue Synchronization Module
5
+ *
6
+ * This module syncs YouTrack issues to GitHub, creating GitHub issues that:
7
+ * - Include the YouTrack ID in the title for automatic linking
8
+ * - Can be processed by solve.mjs normally
9
+ * - Result in PRs that YouTrack will automatically link back
10
+ */
11
+
12
+ // Import YouTrack functions
13
+ const youTrackLib = await import('./youtrack.lib.mjs');
14
+ const {
15
+ fetchYouTrackIssues
16
+ } = youTrackLib;
17
+
18
+ /**
19
+ * Find existing GitHub issue for a YouTrack issue
20
+ * @param {string} youTrackId - The YouTrack issue ID (e.g., "2-3606")
21
+ * @param {string} owner - GitHub repository owner
22
+ * @param {string} repo - GitHub repository name
23
+ * @param {Object} $ - Command execution function
24
+ * @returns {Object|null} GitHub issue if found
25
+ */
26
+ export async function findGitHubIssueForYouTrack(youTrackId, owner, repo, $) {
27
+ try {
28
+ // Search for both open and closed issues with the YouTrack ID in the title
29
+ // This prevents creating duplicates even if an issue was closed
30
+ const searchResult = await $`gh api search/issues --jq '.items' -X GET -f q="repo:${owner}/${repo} \"${youTrackId}\" in:title is:issue"`;
31
+
32
+ if (searchResult.code !== 0) {
33
+ return null;
34
+ }
35
+
36
+ const issues = JSON.parse(searchResult.stdout.toString().trim() || '[]');
37
+
38
+ // Find exact match (YouTrack ID should be in brackets or at start)
39
+ // Return the first matching issue (prefer open issues)
40
+ const openIssue = issues.find(issue =>
41
+ issue.state === 'open' && (issue.title.includes(`[${youTrackId}]`) || issue.title.startsWith(`${youTrackId}:`))
42
+ );
43
+
44
+ if (openIssue) return openIssue;
45
+
46
+ // If no open issue, check for closed issues to prevent duplicates
47
+ const closedIssue = issues.find(issue =>
48
+ issue.state === 'closed' && (issue.title.includes(`[${youTrackId}]`) || issue.title.startsWith(`${youTrackId}:`))
49
+ );
50
+
51
+ return closedIssue || null;
52
+ } catch {
53
+ return null;
54
+ }
55
+ }
56
+
57
+ /**
58
+ * Create or update GitHub issue from YouTrack issue
59
+ * @param {Object} youTrackIssue - YouTrack issue object
60
+ * @param {string} owner - GitHub repository owner
61
+ * @param {string} repo - GitHub repository name
62
+ * @param {Object} youTrackConfig - YouTrack configuration
63
+ * @param {Object} $ - Command execution function
64
+ * @param {Function} log - Logging function
65
+ * @returns {Object} Created or updated GitHub issue
66
+ */
67
+ export async function syncYouTrackIssueToGitHub(youTrackIssue, owner, repo, youTrackConfig, $, log) {
68
+ const youTrackId = youTrackIssue.id;
69
+ const youTrackUrl = youTrackIssue.url || `${youTrackConfig.url}/issue/${youTrackId}`;
70
+
71
+ // Format title with YouTrack ID for automatic linking
72
+ // Format: "[PROJECT-123] Original Title" or "PROJECT-123: Original Title"
73
+ const ghTitle = `[${youTrackId}] ${youTrackIssue.summary}`;
74
+
75
+ // Build issue body with YouTrack details
76
+ const ghBody = `## YouTrack Issue
77
+
78
+ **ID:** ${youTrackId}
79
+ **Link:** ${youTrackUrl}
80
+ **Stage:** ${youTrackIssue.customFields?.find(f => f.name === 'Stage')?.value?.name || 'Unknown'}
81
+
82
+ ## Description
83
+
84
+ ${youTrackIssue.description || 'No description provided.'}
85
+
86
+ ---
87
+ *This issue is automatically synchronized from YouTrack. Any commits or PRs that reference \`${youTrackId}\` will be automatically linked in YouTrack.*
88
+
89
+ **Note:** To process this issue, ensure the 'help wanted' label exists in your repository.`;
90
+
91
+ // Check if issue already exists
92
+ const existingIssue = await findGitHubIssueForYouTrack(youTrackId, owner, repo, $);
93
+
94
+ if (existingIssue) {
95
+ // If issue is closed, skip it (don't recreate or update)
96
+ if (existingIssue.state === 'closed') {
97
+ await log(` ā­ļø Skipping ${youTrackId} - GitHub issue #${existingIssue.number} is closed`);
98
+ return existingIssue;
99
+ }
100
+
101
+ // Update existing open issue if title or body changed
102
+ const needsUpdate = existingIssue.title !== ghTitle || existingIssue.body !== ghBody;
103
+
104
+ if (needsUpdate) {
105
+ await log(` šŸ“ Updating issue #${existingIssue.number} for ${youTrackId}...`);
106
+
107
+ const updateResult = await $`gh issue edit ${existingIssue.number} --repo ${owner}/${repo} --title "${ghTitle}" --body "${ghBody}"`;
108
+
109
+ if (updateResult.code === 0) {
110
+ await log(` āœ… Updated issue #${existingIssue.number}`);
111
+ } else {
112
+ await log(` āš ļø Failed to update issue #${existingIssue.number}`, { level: 'warning' });
113
+ }
114
+ } else {
115
+ await log(` āœ“ Issue #${existingIssue.number} already up to date for ${youTrackId}`);
116
+ }
117
+
118
+ // Ensure help wanted label is applied
119
+ const hasLabel = existingIssue.labels?.some(l => l.name === 'help wanted');
120
+ if (!hasLabel) {
121
+ try {
122
+ await $`gh issue edit ${existingIssue.number} --repo ${owner}/${repo} --add-label "help wanted"`;
123
+ await log(` šŸ·ļø Added 'help wanted' label to #${existingIssue.number}`);
124
+ } catch {
125
+ // Silently skip if label doesn't exist
126
+ await log(' āš ļø Could not add \'help wanted\' label (may not exist in repo)', { verbose: true });
127
+ }
128
+ }
129
+
130
+ return existingIssue;
131
+ } else {
132
+ // Create new issue
133
+ await log(` āž• Creating GitHub issue for ${youTrackId}...`);
134
+
135
+ try {
136
+ const createResult = await $`gh issue create --repo ${owner}/${repo} --title "${ghTitle}" --body "${ghBody}" --label "help wanted"`;
137
+
138
+ if (createResult.code === 0) {
139
+ const issueUrl = createResult.stdout.toString().trim();
140
+ const issueNumber = issueUrl.match(/\/issues\/(\d+)/)?.[1];
141
+ await log(` āœ… Created issue #${issueNumber} for ${youTrackId}`);
142
+ await log(` šŸ“ URL: ${issueUrl}`);
143
+
144
+ return {
145
+ number: issueNumber,
146
+ title: ghTitle,
147
+ body: ghBody,
148
+ html_url: issueUrl
149
+ };
150
+ } else {
151
+ await log(` āŒ Failed to create issue for ${youTrackId}`, { level: 'error' });
152
+ return null;
153
+ }
154
+ } catch (error) {
155
+ await log(` āŒ Error creating issue: ${error.message}`, { level: 'error' });
156
+ return null;
157
+ }
158
+ }
159
+ }
160
+
161
+ /**
162
+ * Sync all YouTrack issues to GitHub
163
+ * @param {Object} youTrackConfig - YouTrack configuration
164
+ * @param {string} owner - GitHub repository owner
165
+ * @param {string} repo - GitHub repository name
166
+ * @param {Object} $ - Command execution function
167
+ * @param {Function} log - Logging function
168
+ * @returns {Array} Array of GitHub issues (created or updated)
169
+ */
170
+ export async function syncYouTrackToGitHub(youTrackConfig, owner, repo, $, log) {
171
+ await log('\nšŸ”„ Syncing YouTrack issues to GitHub...');
172
+ await log(` šŸ“ YouTrack: ${youTrackConfig.url}`);
173
+ await log(` šŸ“‹ Project: ${youTrackConfig.projectCode}`);
174
+ await log(` šŸ“Œ Stage: "${youTrackConfig.stage}"`);
175
+ await log(` šŸŽÆ Target: ${owner}/${repo}`);
176
+
177
+ // Fetch YouTrack issues
178
+ const youTrackIssues = await fetchYouTrackIssues(youTrackConfig);
179
+
180
+ if (!youTrackIssues || youTrackIssues.length === 0) {
181
+ await log(` ā„¹ļø No issues found in YouTrack with stage "${youTrackConfig.stage}"`);
182
+ return [];
183
+ }
184
+
185
+ await log(` šŸ“Š Found ${youTrackIssues.length} issue(s) to sync`);
186
+
187
+ // Sync each issue to GitHub
188
+ const githubIssues = [];
189
+ for (const ytIssue of youTrackIssues) {
190
+ const ghIssue = await syncYouTrackIssueToGitHub(ytIssue, owner, repo, youTrackConfig, $, log);
191
+ if (ghIssue) {
192
+ githubIssues.push({
193
+ ...ghIssue,
194
+ youtrackId: ytIssue.id,
195
+ youtrackUrl: `${youTrackConfig.url}/issue/${ytIssue.idReadable}`
196
+ });
197
+ }
198
+ }
199
+
200
+ await log(` āœ… Sync complete: ${githubIssues.length} issues ready in GitHub`);
201
+
202
+ return githubIssues;
203
+ }
204
+
205
+ /**
206
+ * Convert synced GitHub issues to format expected by hive.mjs
207
+ * @param {Array} githubIssues - Array of GitHub issues with YouTrack metadata
208
+ * @returns {Array} Issues in hive.mjs format
209
+ */
210
+ export function formatIssuesForHive(githubIssues) {
211
+ return githubIssues.map(issue => ({
212
+ number: issue.number,
213
+ title: issue.title,
214
+ html_url: issue.html_url,
215
+ labels: issue.labels || [{ name: 'help-wanted' }],
216
+ youtrackId: issue.youtrackId,
217
+ youtrackUrl: issue.youtrackUrl
218
+ }));
219
+ }