@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.
- package/CHANGELOG.md +20 -0
- package/LICENSE +24 -0
- package/README.md +769 -0
- package/package.json +58 -0
- package/src/agent.lib.mjs +705 -0
- package/src/agent.prompts.lib.mjs +196 -0
- package/src/buildUserMention.lib.mjs +71 -0
- package/src/claude-limits.lib.mjs +389 -0
- package/src/claude.lib.mjs +1445 -0
- package/src/claude.prompts.lib.mjs +203 -0
- package/src/codex.lib.mjs +552 -0
- package/src/codex.prompts.lib.mjs +194 -0
- package/src/config.lib.mjs +207 -0
- package/src/contributing-guidelines.lib.mjs +268 -0
- package/src/exit-handler.lib.mjs +205 -0
- package/src/git.lib.mjs +145 -0
- package/src/github-issue-creator.lib.mjs +246 -0
- package/src/github-linking.lib.mjs +152 -0
- package/src/github.batch.lib.mjs +272 -0
- package/src/github.graphql.lib.mjs +258 -0
- package/src/github.lib.mjs +1479 -0
- package/src/hive.config.lib.mjs +254 -0
- package/src/hive.mjs +1500 -0
- package/src/instrument.mjs +191 -0
- package/src/interactive-mode.lib.mjs +1000 -0
- package/src/lenv-reader.lib.mjs +206 -0
- package/src/lib.mjs +490 -0
- package/src/lino.lib.mjs +176 -0
- package/src/local-ci-checks.lib.mjs +324 -0
- package/src/memory-check.mjs +419 -0
- package/src/model-mapping.lib.mjs +145 -0
- package/src/model-validation.lib.mjs +278 -0
- package/src/opencode.lib.mjs +479 -0
- package/src/opencode.prompts.lib.mjs +194 -0
- package/src/protect-branch.mjs +159 -0
- package/src/review.mjs +433 -0
- package/src/reviewers-hive.mjs +643 -0
- package/src/sentry.lib.mjs +284 -0
- package/src/solve.auto-continue.lib.mjs +568 -0
- package/src/solve.auto-pr.lib.mjs +1374 -0
- package/src/solve.branch-errors.lib.mjs +341 -0
- package/src/solve.branch.lib.mjs +230 -0
- package/src/solve.config.lib.mjs +342 -0
- package/src/solve.error-handlers.lib.mjs +256 -0
- package/src/solve.execution.lib.mjs +291 -0
- package/src/solve.feedback.lib.mjs +436 -0
- package/src/solve.mjs +1128 -0
- package/src/solve.preparation.lib.mjs +210 -0
- package/src/solve.repo-setup.lib.mjs +114 -0
- package/src/solve.repository.lib.mjs +961 -0
- package/src/solve.results.lib.mjs +558 -0
- package/src/solve.session.lib.mjs +135 -0
- package/src/solve.validation.lib.mjs +325 -0
- package/src/solve.watch.lib.mjs +572 -0
- package/src/start-screen.mjs +324 -0
- package/src/task.mjs +308 -0
- package/src/telegram-bot.mjs +1481 -0
- package/src/telegram-markdown.lib.mjs +64 -0
- package/src/usage-limit.lib.mjs +218 -0
- package/src/version.lib.mjs +41 -0
- package/src/youtrack/solve.youtrack.lib.mjs +116 -0
- package/src/youtrack/youtrack-sync.mjs +219 -0
- 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
|
+
}
|