@link-assistant/hive-mind 1.69.0 → 1.69.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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # @link-assistant/hive-mind
2
2
 
3
+ ## 1.69.2
4
+
5
+ ### Patch Changes
6
+
7
+ - 0ff36b4: Count open draft pull requests when `/hive --skip-issues-with-prs` checks linked solution drafts, preventing duplicate work while an existing PR is still in progress.
8
+
9
+ ## 1.69.1
10
+
11
+ ### Patch Changes
12
+
13
+ - 2911597: Fix feedback comment counting to run local git timestamp checks in the prepared repository directory, avoiding misleading `not a git repository` diagnostics in detached solve sessions.
14
+
3
15
  ## 1.69.0
4
16
 
5
17
  ### Minor Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@link-assistant/hive-mind",
3
- "version": "1.69.0",
3
+ "version": "1.69.2",
4
4
  "description": "AI-powered issue solver and hive mind for collaborative problem solving",
5
5
  "main": "src/hive.mjs",
6
6
  "type": "module",
@@ -26,66 +26,71 @@ export function getGitHubLinkingKeywords() {
26
26
  return ['close', 'closes', 'closed', 'fix', 'fixes', 'fixed', 'resolve', 'resolves', 'resolved'];
27
27
  }
28
28
 
29
- /**
30
- * Check if PR body contains a valid GitHub linking keyword for the given issue
31
- *
32
- * @param {string} prBody - The pull request body text
33
- * @param {string|number} issueNumber - The issue number to check for
34
- * @param {string} [owner] - Repository owner (for cross-repo references)
35
- * @param {string} [repo] - Repository name (for cross-repo references)
36
- * @returns {boolean} True if a valid linking keyword is found
37
- */
38
- export function hasGitHubLinkingKeyword(prBody, issueNumber, owner = null, repo = null) {
39
- if (!prBody || !issueNumber) {
40
- return false;
41
- }
29
+ function escapeRegExp(value) {
30
+ return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
31
+ }
42
32
 
43
- const keywords = getGitHubLinkingKeywords();
44
- const issueNumStr = issueNumber.toString();
33
+ function buildClosingReferencePatterns(keyword, issueNumber, owner = null, repo = null) {
34
+ const issueNumStr = escapeRegExp(issueNumber);
35
+ const separator = String.raw`(?:\s+|\s*:\s*)`;
36
+ const prefix = String.raw`\b${keyword}${separator}`;
37
+ const references = [String.raw`#${issueNumStr}\b`];
45
38
 
46
- // Build regex patterns for each valid format:
47
- // 1. KEYWORD #123
48
- // 2. KEYWORD owner/repo#123
49
- // 3. KEYWORD https://github.com/owner/repo/issues/123
39
+ if (owner && repo) {
40
+ references.push(`${escapeRegExp(owner)}/${escapeRegExp(repo)}#${issueNumStr}\\b`);
41
+ references.push(`https://github\\.com/${escapeRegExp(owner)}/${escapeRegExp(repo)}/issues/${issueNumStr}\\b`);
42
+ }
50
43
 
51
- for (const keyword of keywords) {
52
- // Pattern 1: KEYWORD #123
53
- // Must have word boundary before keyword and # immediately before number
54
- const pattern1 = new RegExp(`\\b${keyword}\\s+#${issueNumStr}\\b`, 'i');
44
+ references.push(String.raw`[\w.-]+/[\w.-]+#${issueNumStr}\b`);
45
+ references.push(`https://github\\.com/[^/\\s]+/[^/\\s]+/issues/${issueNumStr}\\b`);
55
46
 
56
- if (pattern1.test(prBody)) {
57
- return true;
58
- }
59
-
60
- // Pattern 2: KEYWORD owner/repo#123 (for cross-repo or fork references)
61
- if (owner && repo) {
62
- const pattern2 = new RegExp(`\\b${keyword}\\s+${owner}/${repo}#${issueNumStr}\\b`, 'i');
47
+ return references.map(reference => new RegExp(`${prefix}${reference}`, 'i'));
48
+ }
63
49
 
64
- if (pattern2.test(prBody)) {
65
- return true;
66
- }
67
- }
50
+ /**
51
+ * Check whether text contains a GitHub closing keyword for a specific issue.
52
+ *
53
+ * This is the shared parser used by solve and hive code paths so they agree on
54
+ * which PR body/title references are real closing links.
55
+ *
56
+ * @param {string} text - Pull request body or title text
57
+ * @param {string|number} issueNumber - Issue number to check for
58
+ * @param {string} [owner] - Repository owner for exact owner/repo references
59
+ * @param {string} [repo] - Repository name for exact owner/repo references
60
+ * @returns {boolean} True if a valid closing reference is found
61
+ */
62
+ export function prClosesIssue(text, issueNumber, owner = null, repo = null) {
63
+ if (!text || typeof text !== 'string' || issueNumber === null || issueNumber === undefined || String(issueNumber).trim() === '') {
64
+ return false;
65
+ }
68
66
 
69
- // Pattern 3: KEYWORD https://github.com/owner/repo/issues/123
70
- if (owner && repo) {
71
- const pattern3 = new RegExp(`\\b${keyword}\\s+https://github\\.com/${owner}/${repo}/issues/${issueNumStr}\\b`, 'i');
67
+ const issueNumStr = String(issueNumber).trim();
72
68
 
73
- if (pattern3.test(prBody)) {
69
+ for (const keyword of getGitHubLinkingKeywords()) {
70
+ const patterns = buildClosingReferencePatterns(keyword, issueNumStr, owner, repo);
71
+ for (const pattern of patterns) {
72
+ if (pattern.test(text)) {
74
73
  return true;
75
74
  }
76
75
  }
77
-
78
- // Pattern 4: Also check for any URL format (generic)
79
- const pattern4 = new RegExp(`\\b${keyword}\\s+https://github\\.com/[^/]+/[^/]+/issues/${issueNumStr}\\b`, 'i');
80
-
81
- if (pattern4.test(prBody)) {
82
- return true;
83
- }
84
76
  }
85
77
 
86
78
  return false;
87
79
  }
88
80
 
81
+ /**
82
+ * Check if PR body contains a valid GitHub linking keyword for the given issue
83
+ *
84
+ * @param {string} prBody - The pull request body text
85
+ * @param {string|number} issueNumber - The issue number to check for
86
+ * @param {string} [owner] - Repository owner (for cross-repo references)
87
+ * @param {string} [repo] - Repository name (for cross-repo references)
88
+ * @returns {boolean} True if a valid linking keyword is found
89
+ */
90
+ export function hasGitHubLinkingKeyword(prBody, issueNumber, owner = null, repo = null) {
91
+ return prClosesIssue(prBody, issueNumber, owner, repo);
92
+ }
93
+
89
94
  /**
90
95
  * Extract issue number from PR body using GitHub linking keywords
91
96
  * This is used to find which issue a PR is linked to
@@ -10,53 +10,46 @@ if (typeof globalThis.use === 'undefined') {
10
10
  // Import dependencies
11
11
  import { log, cleanErrorMessage } from './lib.mjs';
12
12
  import { githubLimits, timeouts } from './config.lib.mjs';
13
+ import { prClosesIssue } from './github-linking.lib.mjs';
13
14
 
14
15
  import { wrapDollarWithGhRetry as _wrapDollarWithGhRetry, execGhWithRetry } from './github-rate-limit.lib.mjs'; // rate-limit marker (#1726): gh API calls flow through $ wrapped by caller. execGhWithRetry adds transient-network retry (#1756).
16
+ export { prClosesIssue };
17
+
15
18
  /**
16
- * Check if a PR body/title indicates it fixes/closes/resolves a specific issue number
17
- * GitHub auto-closes issues when PR body contains keywords like "fixes #123", "closes #123", "resolves #123"
18
- * See: https://docs.github.com/en/issues/tracking-your-work-with-issues/linking-a-pull-request-to-an-issue
19
- * @param {string} text - PR body or title text
20
- * @param {number} issueNumber - Issue number to check for
21
- * @returns {boolean} True if the text contains a closing keyword for this issue
19
+ * Extract open pull requests that are linked to an issue with closing keywords.
20
+ * Draft pull requests are still open in-progress solution drafts, so they must
21
+ * count for /hive --skip-issues-with-prs.
22
+ * @param {Object} issueData - GraphQL issue node with timelineItems
23
+ * @param {number} issueNum - Issue number to check
24
+ * @param {Function} logger - Async logger, defaults to shared log helper
25
+ * @returns {Promise<Array<Object>>} Linked open PRs that close the issue
22
26
  */
23
- export function prClosesIssue(text, issueNumber) {
24
- if (!text || typeof text !== 'string') {
25
- return false;
26
- }
27
-
28
- // GitHub closing keywords (case-insensitive)
29
- // Supports: fix, fixes, fixed, close, closes, closed, resolve, resolves, resolved
30
- // Also supports variations with repository prefix like "fixes owner/repo#123"
31
- const closingKeywords = ['fix', 'fixes', 'fixed', 'close', 'closes', 'closed', 'resolve', 'resolves', 'resolved'];
32
-
33
- // Build regex pattern that matches any of the keywords followed by #N or repo#N
34
- // Examples matched:
35
- // - "fixes #123"
36
- // - "Closes #123"
37
- // - "RESOLVED #123"
38
- // - "fixes owner/repo#123"
39
- // - "fix: #123" (common commit style)
40
- const issueNum = issueNumber.toString();
41
-
42
- for (const keyword of closingKeywords) {
43
- // Pattern: keyword + optional colon/space + optional repo prefix + # + issue number
44
- // Must be followed by word boundary (not part of larger number)
45
- const patterns = [
46
- // Standard format: "fixes #123"
47
- new RegExp(`\\b${keyword}\\s*:?\\s*#${issueNum}\\b`, 'i'),
48
- // With repo prefix: "fixes owner/repo#123"
49
- new RegExp(`\\b${keyword}\\s*:?\\s*[\\w.-]+/[\\w.-]+#${issueNum}\\b`, 'i'),
50
- ];
51
-
52
- for (const pattern of patterns) {
53
- if (pattern.test(text)) {
54
- return true;
27
+ export async function extractLinkedPullRequestsForIssue(issueData, issueNum, logger = log) {
28
+ const linkedPRs = [];
29
+
30
+ for (const item of issueData.timelineItems?.nodes || []) {
31
+ if (item?.source && item.source.state === 'OPEN') {
32
+ // Check if PR actually closes this issue (has "fixes #N", "closes #N", or "resolves #N")
33
+ const prBody = item.source.body || '';
34
+ const prTitle = item.source.title || '';
35
+ const closesThisIssue = prClosesIssue(prBody, issueNum) || prClosesIssue(prTitle, issueNum);
36
+
37
+ if (closesThisIssue) {
38
+ linkedPRs.push({
39
+ number: item.source.number,
40
+ title: item.source.title,
41
+ state: item.source.state,
42
+ isDraft: Boolean(item.source.isDraft),
43
+ url: item.source.url,
44
+ });
45
+ } else {
46
+ // Log that we're skipping a PR that only mentions the issue
47
+ await logger(` ℹ️ PR #${item.source.number} mentions issue #${issueNum} but doesn't close it (no fixes/closes/resolves keyword)`, { verbose: true });
55
48
  }
56
49
  }
57
50
  }
58
51
 
59
- return false;
52
+ return linkedPRs;
60
53
  }
61
54
 
62
55
  /**
@@ -140,31 +133,11 @@ export async function batchCheckPullRequestsForIssues(owner, repo, issueNumbers)
140
133
  for (const issueNum of batch) {
141
134
  const issueData = data.data?.repository?.[`issue${issueNum}`];
142
135
  if (issueData) {
143
- const linkedPRs = [];
144
-
145
- // Extract linked PRs from timeline items
136
+ // Extract linked PRs from timeline items, including draft PRs.
146
137
  // Issue #1094: Only count PRs that explicitly fix/close/resolve this issue
147
138
  // This prevents false positives from PRs that only mention issues without solving them
148
- for (const item of issueData.timelineItems?.nodes || []) {
149
- if (item?.source && item.source.state === 'OPEN' && !item.source.isDraft) {
150
- // Check if PR actually closes this issue (has "fixes #N", "closes #N", or "resolves #N")
151
- const prBody = item.source.body || '';
152
- const prTitle = item.source.title || '';
153
- const closesThisIssue = prClosesIssue(prBody, issueNum) || prClosesIssue(prTitle, issueNum);
154
-
155
- if (closesThisIssue) {
156
- linkedPRs.push({
157
- number: item.source.number,
158
- title: item.source.title,
159
- state: item.source.state,
160
- url: item.source.url,
161
- });
162
- } else {
163
- // Log that we're skipping a PR that only mentions the issue
164
- await log(` ℹ️ PR #${item.source.number} mentions issue #${issueNum} but doesn't close it (no fixes/closes/resolves keyword)`, { verbose: true });
165
- }
166
- }
167
- }
139
+ // Issue #1760: Draft PRs are still active solution drafts and must block duplicate work
140
+ const linkedPRs = await extractLinkedPullRequestsForIssue(issueData, issueNum);
168
141
 
169
142
  results[issueNum] = {
170
143
  title: issueData.title,
@@ -337,6 +310,7 @@ export async function batchCheckArchivedRepositories(repositories) {
337
310
  // Export all functions as default object too
338
311
  export default {
339
312
  prClosesIssue,
313
+ extractLinkedPullRequestsForIssue,
340
314
  batchCheckPullRequestsForIssues,
341
315
  batchCheckArchivedRepositories,
342
316
  };
@@ -8,7 +8,7 @@ import { reportError } from './sentry.lib.mjs';
8
8
 
9
9
  import { wrapDollarWithGhRetry as _wrapDollarWithGhRetry } from './github-rate-limit.lib.mjs'; // rate-limit marker (#1726): gh API calls flow through $ wrapped by caller
10
10
  export const detectAndCountFeedback = async params => {
11
- const { prNumber, branchName, owner, repo, issueNumber, isContinueMode, argv, mergeStateStatus, prState, workStartTime, log, formatAligned, cleanErrorMessage, $ } = params;
11
+ const { prNumber, branchName, owner, repo, issueNumber, isContinueMode, argv, mergeStateStatus, prState, workStartTime, log, formatAligned, cleanErrorMessage, $, repositoryPath = null } = params;
12
12
 
13
13
  let newPrComments = 0;
14
14
  let newPrReviewComments = 0;
@@ -53,14 +53,18 @@ export const detectAndCountFeedback = async params => {
53
53
  if (argv.verbose) {
54
54
  await log(` PR #${prNumber} on branch: ${branchName}`, { verbose: true });
55
55
  await log(` Owner/Repo: ${owner}/${repo}`, { verbose: true });
56
+ if (repositoryPath) {
57
+ await log(` Repository path: ${repositoryPath}`, { verbose: true });
58
+ }
56
59
  }
57
60
 
58
61
  // Get the last commit timestamp from the PR branch
59
62
  let lastCommitTime = null;
60
- let lastCommitResult = await $`git log -1 --format="%aI" origin/${branchName}`;
63
+ const git$ = repositoryPath ? $({ cwd: repositoryPath }) : $;
64
+ let lastCommitResult = await git$`git log -1 --format="%aI" origin/${branchName}`;
61
65
  if (lastCommitResult.code !== 0) {
62
66
  // Fallback to local branch if remote doesn't exist
63
- lastCommitResult = await $`git log -1 --format="%aI" ${branchName}`;
67
+ lastCommitResult = await git$`git log -1 --format="%aI" ${branchName}`;
64
68
  }
65
69
 
66
70
  if (lastCommitResult.code === 0) {
package/src/solve.mjs CHANGED
@@ -623,6 +623,7 @@ try {
623
623
  log,
624
624
  formatAligned,
625
625
  cleanErrorMessage,
626
+ tempDir,
626
627
  $,
627
628
  });
628
629
 
@@ -8,7 +8,7 @@ import { wrapDollarWithGhRetry as _wrapDollarWithGhRetry } from './github-rate-l
8
8
  const feedback = await import('./solve.feedback.lib.mjs');
9
9
  const { detectAndCountFeedback } = feedback;
10
10
 
11
- export async function prepareFeedbackAndTimestamps({ prNumber, branchName: _branchName, owner, repo, issueNumber, isContinueMode: _isContinueMode, mergeStateStatus: _mergeStateStatus, prState: _prState, argv: _argv, log, formatAligned, cleanErrorMessage: _cleanErrorMessage, $ }) {
11
+ export async function prepareFeedbackAndTimestamps({ tempDir = null, prNumber, branchName: _branchName, owner, repo, issueNumber, isContinueMode: _isContinueMode, mergeStateStatus: _mergeStateStatus, prState: _prState, argv: _argv, log, formatAligned, cleanErrorMessage: _cleanErrorMessage, $ }) {
12
12
  // Count new comments and detect feedback
13
13
  let { feedbackLines } = await detectAndCountFeedback({
14
14
  prNumber,
@@ -25,6 +25,7 @@ export async function prepareFeedbackAndTimestamps({ prNumber, branchName: _bran
25
25
  formatAligned,
26
26
  cleanErrorMessage: _cleanErrorMessage,
27
27
  $,
28
+ repositoryPath: tempDir,
28
29
  });
29
30
 
30
31
  // Get timestamps from GitHub servers before executing the command
@@ -209,6 +209,7 @@ export const watchForFeedback = async params => {
209
209
  formatAligned,
210
210
  cleanErrorMessage,
211
211
  $,
212
+ repositoryPath: tempDir,
212
213
  });
213
214
 
214
215
  // Check if there's any feedback or if it's the first iteration in temporary mode