@link-assistant/hive-mind 1.60.0 → 1.61.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 CHANGED
@@ -1,5 +1,11 @@
1
1
  # @link-assistant/hive-mind
2
2
 
3
+ ## 1.61.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 728b0ed: Add Telegram `/task` issue creation from repository links and issue text while preserving `/split` behavior.
8
+
3
9
  ## 1.60.0
4
10
 
5
11
  ### Minor Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@link-assistant/hive-mind",
3
- "version": "1.60.0",
3
+ "version": "1.61.0",
4
4
  "description": "AI-powered issue solver and hive mind for collaborative problem solving",
5
5
  "main": "src/hive.mjs",
6
6
  "type": "module",
@@ -0,0 +1,203 @@
1
+ import os from 'os';
2
+ import path from 'path';
3
+ import { spawn } from 'child_process';
4
+ import { promises as fs } from 'fs';
5
+ import { parseGitHubUrl } from './github.lib.mjs';
6
+
7
+ export const TASK_ISSUE_TITLE_MAX_LENGTH = 256;
8
+
9
+ function normalizeNewlines(value) {
10
+ return String(value || '').replace(/\r\n?/g, '\n');
11
+ }
12
+
13
+ function cleanRepositoryCandidate(value) {
14
+ return String(value || '')
15
+ .trim()
16
+ .replace(/^[<([{]+/, '')
17
+ .replace(/[>\])}.,;:]+$/, '');
18
+ }
19
+
20
+ export function stripTaskCommandPrefix(text) {
21
+ const value = normalizeNewlines(text).trimStart();
22
+ return value.replace(/^\/(?:task|split)(?:@\S+)?(?:[ \t]+|\n|$)/i, '').trim();
23
+ }
24
+
25
+ export function resolveTaskIssueCreationInput({ commandText = '', replyText = '' } = {}) {
26
+ const inlineText = stripTaskCommandPrefix(commandText);
27
+ if (inlineText) return inlineText;
28
+ return normalizeNewlines(replyText).trim();
29
+ }
30
+
31
+ export function parseTaskRepository(value) {
32
+ const candidate = cleanRepositoryCandidate(value);
33
+ const parsed = parseGitHubUrl(candidate);
34
+ if (!parsed.valid || parsed.type !== 'repo') return null;
35
+ return {
36
+ owner: parsed.owner,
37
+ repo: parsed.repo,
38
+ fullName: `${parsed.owner}/${parsed.repo}`,
39
+ url: `https://github.com/${parsed.owner}/${parsed.repo}`,
40
+ };
41
+ }
42
+
43
+ function parseRepositoryDirective(line) {
44
+ const trimmed = line.trim();
45
+ if (!trimmed.startsWith('--repository')) return { matched: false };
46
+
47
+ const match = trimmed.match(/^--repository(?:=(\S+)|\s+(\S+))$/);
48
+ if (!match) {
49
+ return {
50
+ matched: true,
51
+ error: 'Invalid --repository syntax. Use --repository <github-repository-url>.',
52
+ };
53
+ }
54
+
55
+ const repository = parseTaskRepository(match[1] || match[2]);
56
+ if (!repository) {
57
+ return {
58
+ matched: true,
59
+ error: '--repository must point to a GitHub repository URL.',
60
+ };
61
+ }
62
+
63
+ return { matched: true, repository };
64
+ }
65
+
66
+ function parseRepositoryLine(line) {
67
+ const trimmed = line.trim();
68
+ if (!trimmed || /\s/.test(trimmed)) return null;
69
+ return parseTaskRepository(trimmed);
70
+ }
71
+
72
+ function setRepository(currentRepository, nextRepository) {
73
+ if (!nextRepository) return { repository: currentRepository };
74
+ if (currentRepository) {
75
+ return {
76
+ repository: currentRepository,
77
+ error: 'Only one GitHub repository may be provided.',
78
+ };
79
+ }
80
+ return { repository: nextRepository };
81
+ }
82
+
83
+ export function buildTaskIssueTitle(issueText, maxLength = TASK_ISSUE_TITLE_MAX_LENGTH) {
84
+ const firstLine = normalizeNewlines(issueText).trim().split('\n')[0]?.trim() || 'New task';
85
+ if (firstLine.length <= maxLength) return firstLine;
86
+ return `${firstLine.slice(0, Math.max(0, maxLength - 3)).trimEnd()}...`;
87
+ }
88
+
89
+ export function parseTaskIssueCreationInput(input) {
90
+ const normalized = normalizeNewlines(input).trim();
91
+ if (!normalized) {
92
+ return { valid: false, error: 'Missing repository and issue text.' };
93
+ }
94
+
95
+ const lines = normalized.split('\n');
96
+ let repository = null;
97
+ let bodyLines = [];
98
+
99
+ for (const line of lines) {
100
+ const directive = parseRepositoryDirective(line);
101
+ if (!directive.matched) {
102
+ bodyLines.push(line);
103
+ continue;
104
+ }
105
+ if (directive.error) return { valid: false, error: directive.error };
106
+ const next = setRepository(repository, directive.repository);
107
+ if (next.error) return { valid: false, error: next.error };
108
+ repository = next.repository;
109
+ }
110
+
111
+ if (!repository) {
112
+ bodyLines = [];
113
+ for (const line of lines) {
114
+ const lineRepository = parseRepositoryLine(line);
115
+ if (!lineRepository) {
116
+ bodyLines.push(line);
117
+ continue;
118
+ }
119
+ const next = setRepository(repository, lineRepository);
120
+ if (next.error) return { valid: false, error: next.error };
121
+ repository = next.repository;
122
+ }
123
+ }
124
+
125
+ if (!repository) {
126
+ return {
127
+ valid: false,
128
+ error: 'Missing GitHub repository URL. Provide it on its own line or with --repository <github-repository-url>.',
129
+ };
130
+ }
131
+
132
+ const issueText = bodyLines.join('\n').trim();
133
+ if (!issueText) {
134
+ return { valid: false, error: 'Missing issue text.' };
135
+ }
136
+
137
+ return {
138
+ valid: true,
139
+ repository,
140
+ issueText,
141
+ title: buildTaskIssueTitle(issueText),
142
+ };
143
+ }
144
+
145
+ function runCommand(command, args, options = {}) {
146
+ return new Promise(resolve => {
147
+ const child = spawn(command, args, {
148
+ stdio: ['ignore', 'pipe', 'pipe'],
149
+ env: process.env,
150
+ ...options,
151
+ });
152
+
153
+ let stdout = '';
154
+ let stderr = '';
155
+ child.stdout.on('data', data => {
156
+ stdout += data.toString();
157
+ });
158
+ child.stderr.on('data', data => {
159
+ stderr += data.toString();
160
+ });
161
+ child.on('error', error => {
162
+ resolve({ code: 1, stdout, stderr: stderr || error.message });
163
+ });
164
+ child.on('close', code => {
165
+ resolve({ code, stdout, stderr });
166
+ });
167
+ });
168
+ }
169
+
170
+ export function parseCreatedTaskIssueOutput(output) {
171
+ const tokens = String(output || '')
172
+ .split(/\s+/)
173
+ .filter(Boolean);
174
+ for (const token of tokens) {
175
+ const parsed = parseGitHubUrl(cleanRepositoryCandidate(token));
176
+ if (parsed.valid && parsed.type === 'issue') {
177
+ return {
178
+ owner: parsed.owner,
179
+ repo: parsed.repo,
180
+ number: parsed.number,
181
+ url: parsed.normalized,
182
+ };
183
+ }
184
+ }
185
+ throw new Error(`Could not parse created issue URL from gh output: ${String(output || '').trim()}`);
186
+ }
187
+
188
+ export async function createTaskIssue({ repository, title, body, run = runCommand }) {
189
+ const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'hive-mind-task-issue-'));
190
+ const bodyFile = path.join(tempDir, 'body.md');
191
+
192
+ try {
193
+ await fs.writeFile(bodyFile, body);
194
+ const result = await run('gh', ['issue', 'create', '--repo', repository.fullName, '--title', title, '--body-file', bodyFile]);
195
+ if (result.code !== 0) {
196
+ const output = `${result.stderr || ''}${result.stdout || ''}`.trim();
197
+ throw new Error(output || `gh issue create exited with code ${result.code}`);
198
+ }
199
+ return parseCreatedTaskIssueOutput(result.stdout);
200
+ } finally {
201
+ await fs.rm(tempDir, { recursive: true, force: true }).catch(() => {});
202
+ }
203
+ }
@@ -539,8 +539,11 @@ bot.command('help', async ctx => {
539
539
  }
540
540
 
541
541
  if (taskEnabled) {
542
- message += '*/task* / */split* - Split a GitHub issue into smaller issues\n';
543
- message += 'Usage: `/split <github-issue-url> [options]`\n';
542
+ message += '*/task* - Create a GitHub issue from a repository link and issue text\n';
543
+ message += 'Usage: `/task <github-repository-url>` followed by issue text, or reply with `/task`\n';
544
+ message += 'Example: `/task https://github.com/owner/repo` then the issue text on following lines\n';
545
+ message += '*/split* - Split a GitHub issue into smaller issues\n';
546
+ message += 'Usage: `/split <github-issue-url> [options]` or `/task --split <github-issue-url>`\n';
544
547
  message += 'Example: `/split https://github.com/owner/repo/issues/123 --split-count 2`\n\n';
545
548
  } else {
546
549
  message += '*/task* / */split* - ❌ Disabled\n\n';
@@ -1,5 +1,6 @@
1
1
  import { buildUserMention } from './buildUserMention.lib.mjs';
2
2
  import { validateModelName } from './models/index.mjs';
3
+ import { createTaskIssue, parseTaskIssueCreationInput, resolveTaskIssueCreationInput } from './task.issue-creation.lib.mjs';
3
4
  import { parseTaskIssueUrl } from './task.split.lib.mjs';
4
5
  import { escapeMarkdown } from './telegram-markdown.lib.mjs';
5
6
  import { extractIsolationFromArgs, isValidPerCommandIsolation } from './telegram-isolation.lib.mjs';
@@ -16,7 +17,12 @@ export function getTaskCommandNameFromText(text) {
16
17
  return TASK_COMMAND_NAMES.includes(command) ? command : null;
17
18
  }
18
19
 
19
- export function applyTaskCommandDefaults(args) {
20
+ export function hasTaskSplitFlag(args) {
21
+ return args.includes('--split') || args.some(arg => arg.startsWith('--split='));
22
+ }
23
+
24
+ export function applyTaskCommandDefaults(args, commandName = 'task') {
25
+ if (commandName !== 'split') return args;
20
26
  const hasSplit = args.includes('--split') || args.some(arg => arg.startsWith('--split='));
21
27
  return hasSplit ? args : [...args, '--split'];
22
28
  }
@@ -49,7 +55,8 @@ function validateTaskModel(args) {
49
55
  }
50
56
 
51
57
  export function buildTaskCommandArgs(text) {
52
- const args = applyTaskCommandDefaults(parseCommandArgs(text));
58
+ const commandName = getTaskCommandNameFromText(text) || 'task';
59
+ const args = applyTaskCommandDefaults(parseCommandArgs(text), commandName);
53
60
  const issueUrl = findTaskIssueUrl(args);
54
61
  return {
55
62
  args: issueUrl ? moveArgumentToFront(args, issueUrl) : args,
@@ -57,8 +64,26 @@ export function buildTaskCommandArgs(text) {
57
64
  };
58
65
  }
59
66
 
67
+ function getReplyText(message) {
68
+ const reply = message?.reply_to_message;
69
+ if (!reply || reply.forum_topic_created) return '';
70
+ return reply.text || reply.caption || '';
71
+ }
72
+
73
+ function buildTaskIssueCreationUsage(commandDisplay) {
74
+ return [`Usage: ${commandDisplay} <github-repository-url> followed by issue text.`, '', `Or reply to a message containing a repository URL and issue text with \`${commandDisplay}\`.`, '', 'To split an existing issue, use `/split <github-issue-url>` or `/task --split <github-issue-url>`.'].join('\n');
75
+ }
76
+
77
+ async function editTelegramMessage(ctx, message, text) {
78
+ try {
79
+ await ctx.telegram.editMessageText(message.chat.id, message.message_id, undefined, text, { disable_web_page_preview: true });
80
+ } catch (error) {
81
+ console.error(`[telegram-task-command] Failed to edit status message: ${error.message}`);
82
+ }
83
+ }
84
+
60
85
  export function registerTaskCommands(bot, options) {
61
- const { VERBOSE, taskEnabled, addBreadcrumb, isOldMessage, isGroupChat, isTopicAuthorized, buildAuthErrorMessage, isChatStopped, getStoppedChatRejectMessage, safeReply, executeAndUpdateMessage } = options;
86
+ const { VERBOSE, taskEnabled, addBreadcrumb, isOldMessage, isGroupChat, isTopicAuthorized, buildAuthErrorMessage, isChatStopped, getStoppedChatRejectMessage, safeReply, executeAndUpdateMessage, createTaskIssue: createTaskIssueFn = createTaskIssue } = options;
62
87
 
63
88
  async function handleTaskCommand(ctx) {
64
89
  const commandName = getTaskCommandNameFromText(ctx.message?.text) || 'task';
@@ -90,6 +115,39 @@ export function registerTaskCommands(bot, options) {
90
115
  return;
91
116
  }
92
117
 
118
+ const parsedArgs = parseCommandArgs(ctx.message.text);
119
+ const splitMode = commandName === 'split' || hasTaskSplitFlag(parsedArgs);
120
+
121
+ if (!splitMode) {
122
+ const creationInput = resolveTaskIssueCreationInput({
123
+ commandText: ctx.message.text,
124
+ replyText: getReplyText(ctx.message),
125
+ });
126
+ const creation = parseTaskIssueCreationInput(creationInput);
127
+
128
+ if (!creation.valid) {
129
+ await safeReply(ctx, `❌ ${escapeMarkdown(creation.error)}\n\n${buildTaskIssueCreationUsage(commandDisplay)}`, { reply_to_message_id: ctx.message.message_id });
130
+ return;
131
+ }
132
+
133
+ const statusMessage = await ctx.reply(`Creating GitHub issue in ${creation.repository.fullName}...`, {
134
+ reply_to_message_id: ctx.message.message_id,
135
+ disable_web_page_preview: true,
136
+ });
137
+
138
+ try {
139
+ const createdIssue = await createTaskIssueFn({
140
+ repository: creation.repository,
141
+ title: creation.title,
142
+ body: creation.issueText,
143
+ });
144
+ await editTelegramMessage(ctx, statusMessage, `Created GitHub issue:\n${createdIssue.url}\n\nReply to this message with /solve to start a solution.`);
145
+ } catch (error) {
146
+ await editTelegramMessage(ctx, statusMessage, `Error creating GitHub issue:\n${error.message || String(error)}`);
147
+ }
148
+ return;
149
+ }
150
+
93
151
  const built = buildTaskCommandArgs(ctx.message.text);
94
152
  if (!built.issueUrl) {
95
153
  await safeReply(ctx, `❌ Missing GitHub issue URL. Usage: \`${commandDisplay} <github-issue-url> [options]\`\n\nExample: \`${commandDisplay} https://github.com/owner/repo/issues/123\``, { reply_to_message_id: ctx.message.message_id });