@link-assistant/hive-mind 1.59.2 → 1.59.4
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 +131 -0
- package/package.json +1 -1
- package/src/hive.mjs +5 -2
- package/src/solve.fork-detection.lib.mjs +126 -0
- package/src/solve.mjs +48 -118
- package/src/telegram-terminal-watch-command.lib.mjs +3 -33
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,136 @@
|
|
|
1
1
|
# @link-assistant/hive-mind
|
|
2
2
|
|
|
3
|
+
## 1.59.4
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- b2e0d12: Fix `/terminal_watch` uploading the full session log file when the watch
|
|
8
|
+
completes — addresses issue
|
|
9
|
+
[#1720](https://github.com/link-assistant/hive-mind/issues/1720).
|
|
10
|
+
|
|
11
|
+
Before this fix, `/terminal_watch` finished by calling
|
|
12
|
+
`bot.telegram.sendDocument(chatId, ...)` to attach the `<uuid>.log` file. That
|
|
13
|
+
had two unwanted effects:
|
|
14
|
+
- It duplicated work that the dedicated `/log` command already does.
|
|
15
|
+
- The bare `bot.telegram.sendDocument(chatId, ...)` call did not carry
|
|
16
|
+
`message_thread_id`, so in forum-enabled supergroups the document landed in
|
|
17
|
+
the **General** topic instead of the topic where `/terminal_watch` was
|
|
18
|
+
invoked, and it was not threaded as a reply.
|
|
19
|
+
|
|
20
|
+
`/terminal_watch` now only updates the live "✅ Terminal watch complete"
|
|
21
|
+
message at the end of the session. To download the log, use
|
|
22
|
+
`/log <uuid>` — it correctly replies in the originating topic via
|
|
23
|
+
`ctx.replyWithDocument`, which Telegraf annotates with `message_thread_id`
|
|
24
|
+
automatically.
|
|
25
|
+
|
|
26
|
+
A new regression test (`tests/test-issue-1720-terminal-watch-no-log.mjs`)
|
|
27
|
+
guards both behaviours, and `tests/test-issue-467-terminal-watch.mjs` was
|
|
28
|
+
updated to assert that no document is uploaded by the watcher.
|
|
29
|
+
|
|
30
|
+
- 5c87a38: Fix `hive` to (a) stop forwarding `false` for solve options whose `type` is
|
|
31
|
+
`'string'` but whose `default` is `false`, and (b) exit non-zero when any
|
|
32
|
+
worker fails — issue #1718.
|
|
33
|
+
|
|
34
|
+
Previously, when a user ran `/hive` against several issues, every spawned
|
|
35
|
+
`solve` worker crashed with:
|
|
36
|
+
|
|
37
|
+
```
|
|
38
|
+
Invalid --working-session-live-progress value: "false". Expected "comment" or "pr".
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
…and `hive` itself still exited with code `0`, so the Telegram bot rendered a
|
|
42
|
+
green "Work session finished successfully" envelope even though zero PRs had
|
|
43
|
+
been created.
|
|
44
|
+
|
|
45
|
+
Two independent root causes:
|
|
46
|
+
1. **Auto-forwarder leaked `false` as a string.** In
|
|
47
|
+
[`src/hive.mjs`](./src/hive.mjs), the auto-forward block read:
|
|
48
|
+
|
|
49
|
+
```js
|
|
50
|
+
} else if ((def.type === 'string' || def.type === 'number') && value !== undefined) {
|
|
51
|
+
args.push(`--${optionName}`, String(value));
|
|
52
|
+
}
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
For `working-session-live-progress`, `solve.config.lib.mjs` declares
|
|
56
|
+
`type: 'string', default: false`. yargs preserves the boolean `false`
|
|
57
|
+
verbatim, so hive forwarded `--working-session-live-progress false`,
|
|
58
|
+
which `solve` rejects. The fix adds `&& value !== false` to the
|
|
59
|
+
predicate. Other `type:'string'` options whose `default` is `false`
|
|
60
|
+
are now also protected by a single defense-in-depth check.
|
|
61
|
+
|
|
62
|
+
2. **No non-zero exit on worker failures.** After `monitorWithSentry()`
|
|
63
|
+
resolved, hive returned without consulting `issueQueue.getStats()`. The
|
|
64
|
+
fix queries `finalStats = issueQueue.getStats()` and calls
|
|
65
|
+
`safeExit(1, …)` when `finalStats.failed > 0`, mirroring the exit
|
|
66
|
+
semantics solve already uses. Wrappers like `start-command`, the Telegram
|
|
67
|
+
bot, and CI now correctly observe the failure.
|
|
68
|
+
|
|
69
|
+
`--isolation screen` (R3 of the issue) was already wired through correctly;
|
|
70
|
+
no change required there. The verbose forwarder dump
|
|
71
|
+
(`📋 Command: ${solveCommand} ${args.join(' ')}`) — which is what allowed us
|
|
72
|
+
to diagnose this run in the first place — is preserved.
|
|
73
|
+
|
|
74
|
+
Tests: [`tests/test-issue-1718-hive-passthrough-false.mjs`](./tests/test-issue-1718-hive-passthrough-false.mjs)
|
|
75
|
+
locks the option shape, asserts both fixes are present in `src/hive.mjs`,
|
|
76
|
+
replays the forwarder logic on synthetic argv, and adds a defense-in-depth
|
|
77
|
+
sweep that no `type:'string'` / `default:false` option ever produces
|
|
78
|
+
`--<flag> false`.
|
|
79
|
+
|
|
80
|
+
Documentation: [`docs/case-studies/issue-1718/`](./docs/case-studies/issue-1718/README.md)
|
|
81
|
+
contains the timeline reconstructed from the user's `screen` log, the
|
|
82
|
+
distilled facts, the per-symptom root-cause analysis, the solution plan, and
|
|
83
|
+
notes confirming no upstream report (yargs / start-command) is required.
|
|
84
|
+
|
|
85
|
+
## 1.59.3
|
|
86
|
+
|
|
87
|
+
### Patch Changes
|
|
88
|
+
|
|
89
|
+
- b0bffdc: Fix `solve` to skip fork mode when the upstream repository is private and the
|
|
90
|
+
user has direct write access — even when the existing PR was created from a
|
|
91
|
+
fork (issue #1716).
|
|
92
|
+
|
|
93
|
+
Previously, when a PR was originally created from a fork (e.g. the upstream
|
|
94
|
+
repo was public and the user without write access used `--auto-fork`), but
|
|
95
|
+
the upstream is now private and the user has direct write access, `solve`
|
|
96
|
+
still tried to clone the fork. If the fork had been renamed, deleted, or was
|
|
97
|
+
otherwise inaccessible (which is common after a public→private flip), repo
|
|
98
|
+
setup failed with `Fork not accessible`.
|
|
99
|
+
|
|
100
|
+
The auto-fork path already handled this correctly (logging
|
|
101
|
+
_"Auto-fork: Write access detected to private repository, working directly on
|
|
102
|
+
repository"_ and leaving `forkOwner = null`). The bug was that **continue
|
|
103
|
+
mode** — both the auto-continue path and the direct PR-URL path — re-set
|
|
104
|
+
`forkOwner` from the existing PR's head repository unconditionally,
|
|
105
|
+
overriding the auto-fork bypass.
|
|
106
|
+
|
|
107
|
+
Fix: in [`src/solve.mjs`](./src/solve.mjs):
|
|
108
|
+
- Hoist `detectRepositoryVisibility(owner, repo)` out of the
|
|
109
|
+
`if (argv.autoCleanup === undefined)` block so `isRepoPublic` is
|
|
110
|
+
unconditionally available.
|
|
111
|
+
- Compute one bypass flag,
|
|
112
|
+
`skipForkForPrivateUpstream = !isRepoPublic && !argv.fork && hasWriteAccess`.
|
|
113
|
+
- Gate both fork-from-PR-data branches behind it. When set, log
|
|
114
|
+
_"Issue #1716: Working directly on the private upstream repository"_ and
|
|
115
|
+
leave `forkOwner = null` so the regular non-fork code path runs.
|
|
116
|
+
- Gate the maintainer-modify auto-toggle on `forkOwner` being non-null so it
|
|
117
|
+
doesn't fire when the bypass triggered.
|
|
118
|
+
|
|
119
|
+
Explicit `--fork` still wins (the bypass requires `!argv.fork`), and users
|
|
120
|
+
with no write access on a private repo still hit the existing auto-fork
|
|
121
|
+
private-repo guard (the bypass requires `hasWriteAccess`).
|
|
122
|
+
|
|
123
|
+
Tests: [`tests/test-issue-1716-private-repo-skip-fork.mjs`](./tests/test-issue-1716-private-repo-skip-fork.mjs)
|
|
124
|
+
locks the flag declaration, the exact condition formula, both
|
|
125
|
+
fork-detection paths, and four scenario simulations
|
|
126
|
+
(private+writeAccess → bypass; public → no bypass; explicit `--fork` → no
|
|
127
|
+
bypass; no writeAccess → no bypass).
|
|
128
|
+
|
|
129
|
+
Documentation: [`docs/case-studies/issue-1716/`](./docs/case-studies/issue-1716/README.md)
|
|
130
|
+
contains the timeline reconstructed from the user's failure log, the
|
|
131
|
+
distilled facts, the per-symptom root-cause analysis, and the implementation
|
|
132
|
+
plan.
|
|
133
|
+
|
|
3
134
|
## 1.59.2
|
|
4
135
|
|
|
5
136
|
### Patch Changes
|
package/package.json
CHANGED
package/src/hive.mjs
CHANGED
|
@@ -798,8 +798,8 @@ if (isRunningDirectly) {
|
|
|
798
798
|
for (const entry of value) {
|
|
799
799
|
args.push(`--${optionName}`, String(entry));
|
|
800
800
|
}
|
|
801
|
-
} else if ((def.type === 'string' || def.type === 'number') && value !== undefined) {
|
|
802
|
-
args.push(`--${optionName}`, String(value));
|
|
801
|
+
} else if ((def.type === 'string' || def.type === 'number') && value !== undefined && value !== false) {
|
|
802
|
+
args.push(`--${optionName}`, String(value)); // Issue #1718: skip false (some string options have default:false)
|
|
803
803
|
}
|
|
804
804
|
}
|
|
805
805
|
// Log the actual command being executed so users can investigate/reproduce
|
|
@@ -1483,6 +1483,9 @@ if (isRunningDirectly) {
|
|
|
1483
1483
|
await log(` 📁 Full log file: ${absoluteLogPath}`, { level: 'error' });
|
|
1484
1484
|
await safeExit(1, 'Error occurred');
|
|
1485
1485
|
}
|
|
1486
|
+
|
|
1487
|
+
const finalStats = issueQueue.getStats(); // Issue #1718: surface worker failures via exit code
|
|
1488
|
+
if (finalStats.failed > 0) await safeExit(1, `${finalStats.failed} task(s) failed (completed: ${finalStats.completed})`);
|
|
1486
1489
|
} catch (fatalError) {
|
|
1487
1490
|
// Handle fatal errors during initialization or execution
|
|
1488
1491
|
console.error('\n❌ Fatal error occurred during hive initialization or execution');
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Fork-detection helpers for solve.mjs
|
|
5
|
+
*
|
|
6
|
+
* Extracted from solve.mjs to keep the file under the 1500-line CI limit.
|
|
7
|
+
* - handleAutoForkOption: implements the --auto-fork detection branch.
|
|
8
|
+
* - handleMaintainerForkAccess: handles the
|
|
9
|
+
* --allow-to-push-to-contributors-pull-requests-as-maintainer follow-up
|
|
10
|
+
* that runs after a fork PR has been detected.
|
|
11
|
+
*
|
|
12
|
+
* Tests for Issue #1716 grep solve.mjs textually, so the *call sites* there
|
|
13
|
+
* still hold the canonical condition checks; only the bodies live here.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
if (typeof globalThis.use === 'undefined') {
|
|
17
|
+
globalThis.use = (await eval(await (await fetch('https://unpkg.com/use-m/use.js')).text())).use;
|
|
18
|
+
}
|
|
19
|
+
const use = globalThis.use;
|
|
20
|
+
const { $ } = await use('command-stream');
|
|
21
|
+
|
|
22
|
+
const lib = await import('./lib.mjs');
|
|
23
|
+
const { log, ghCmdRetry } = lib;
|
|
24
|
+
const githubLib = await import('./github.lib.mjs');
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Handle the --auto-fork option: when the user lacks write access to a public
|
|
28
|
+
* repository, automatically enable fork mode; when the repository is private,
|
|
29
|
+
* fail with an actionable error.
|
|
30
|
+
*
|
|
31
|
+
* Mutates argv.fork in place when fork mode is enabled.
|
|
32
|
+
*
|
|
33
|
+
* @param {object} params
|
|
34
|
+
* @param {string} params.owner
|
|
35
|
+
* @param {string} params.repo
|
|
36
|
+
* @param {object} params.argv - CLI arguments (mutated: argv.fork may be set)
|
|
37
|
+
* @param {(code: number, reason?: string) => Promise<void>} params.safeExit
|
|
38
|
+
*/
|
|
39
|
+
export async function handleAutoForkOption({ owner, repo, argv, safeExit }) {
|
|
40
|
+
if (!argv.autoFork || argv.fork) return;
|
|
41
|
+
|
|
42
|
+
const { detectRepositoryVisibility } = githubLib;
|
|
43
|
+
await log('🔍 Checking repository access for auto-fork...');
|
|
44
|
+
const permResult = await ghCmdRetry(() => $`gh api repos/${owner}/${repo} --jq .permissions`, { label: 'auto-fork perms' });
|
|
45
|
+
|
|
46
|
+
if (permResult.code === 0) {
|
|
47
|
+
const permissions = JSON.parse(permResult.stdout.toString().trim());
|
|
48
|
+
const hasWriteAccess = permissions.push === true || permissions.admin === true || permissions.maintain === true;
|
|
49
|
+
|
|
50
|
+
if (!hasWriteAccess) {
|
|
51
|
+
const { isPublic } = await detectRepositoryVisibility(owner, repo);
|
|
52
|
+
|
|
53
|
+
if (!isPublic) {
|
|
54
|
+
await log('');
|
|
55
|
+
await log("❌ --auto-fork failed: Repository is private and you don't have write access", { level: 'error' });
|
|
56
|
+
await log('');
|
|
57
|
+
await log(' 🔍 What happened:', { level: 'error' });
|
|
58
|
+
await log(` Repository ${owner}/${repo} is private`, { level: 'error' });
|
|
59
|
+
await log(" You don't have write access to this repository", { level: 'error' });
|
|
60
|
+
await log(' --auto-fork cannot create a fork of a private repository you cannot access', { level: 'error' });
|
|
61
|
+
await log('');
|
|
62
|
+
await log(' 💡 Solution:', { level: 'error' });
|
|
63
|
+
await log(' • Request collaborator access from the repository owner', { level: 'error' });
|
|
64
|
+
await log(` https://github.com/${owner}/${repo}/settings/access`, { level: 'error' });
|
|
65
|
+
await log('');
|
|
66
|
+
await safeExit(1, 'Auto-fork failed - private repository without access');
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
await log('✅ Auto-fork: No write access detected, enabling fork mode');
|
|
71
|
+
argv.fork = true;
|
|
72
|
+
} else {
|
|
73
|
+
const { isPublic } = await detectRepositoryVisibility(owner, repo);
|
|
74
|
+
await log(`✅ Auto-fork: Write access detected to ${isPublic ? 'public' : 'private'} repository, working directly on repository`);
|
|
75
|
+
}
|
|
76
|
+
} else {
|
|
77
|
+
const { isPublic } = await detectRepositoryVisibility(owner, repo);
|
|
78
|
+
|
|
79
|
+
if (!isPublic) {
|
|
80
|
+
await log('');
|
|
81
|
+
await log('❌ --auto-fork failed: Could not verify permissions for private repository', { level: 'error' });
|
|
82
|
+
await log('');
|
|
83
|
+
await log(' 🔍 What happened:', { level: 'error' });
|
|
84
|
+
await log(` Repository ${owner}/${repo} is private`, { level: 'error' });
|
|
85
|
+
await log(' Could not check your permissions to this repository', { level: 'error' });
|
|
86
|
+
await log('');
|
|
87
|
+
await log(' 💡 Solutions:', { level: 'error' });
|
|
88
|
+
await log(' • Check your GitHub CLI authentication: gh auth status', { level: 'error' });
|
|
89
|
+
await log(" • Request collaborator access if you don't have it yet", { level: 'error' });
|
|
90
|
+
await log(` https://github.com/${owner}/${repo}/settings/access`, { level: 'error' });
|
|
91
|
+
await log('');
|
|
92
|
+
await safeExit(1, 'Auto-fork failed - cannot verify private repository permissions');
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
await log('⚠️ Auto-fork: Could not check permissions, enabling fork mode for public repository');
|
|
97
|
+
argv.fork = true;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* After a fork PR is detected, optionally check whether the maintainer can
|
|
103
|
+
* push directly to the contributor's fork. If not, request access.
|
|
104
|
+
*
|
|
105
|
+
* @param {object} params
|
|
106
|
+
* @param {string} params.owner
|
|
107
|
+
* @param {string} params.repo
|
|
108
|
+
* @param {string|number} params.prNumber
|
|
109
|
+
*/
|
|
110
|
+
export async function handleMaintainerForkAccess({ owner, repo, prNumber }) {
|
|
111
|
+
const { checkMaintainerCanModifyPR, requestMaintainerAccess } = githubLib;
|
|
112
|
+
const { canModify } = await checkMaintainerCanModifyPR(owner, repo, prNumber);
|
|
113
|
+
|
|
114
|
+
if (canModify) {
|
|
115
|
+
await log('✅ Maintainer can push to fork: Enabled by contributor');
|
|
116
|
+
await log(" Will push changes directly to contributor's fork instead of creating own fork");
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
await log('⚠️ Maintainer cannot push to fork: "Allow edits by maintainers" is not enabled', { level: 'warning' });
|
|
121
|
+
await log(' Posting comment to request access...', { level: 'warning' });
|
|
122
|
+
await requestMaintainerAccess(owner, repo, prNumber);
|
|
123
|
+
await log(' Comment posted. Proceeding with own fork instead.', { level: 'warning' });
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export default { handleAutoForkOption, handleMaintainerForkAccess };
|
package/src/solve.mjs
CHANGED
|
@@ -58,6 +58,7 @@ const { postTrackedComment, USAGE_LIMIT_REACHED_MARKER } = await import('./tool-
|
|
|
58
58
|
const { prepareFeedbackAndTimestamps, checkUncommittedChanges, checkForkActions } = await import('./solve.preparation.lib.mjs');
|
|
59
59
|
const { validateAndExitOnInvalidModel } = await import('./models/index.mjs');
|
|
60
60
|
const { autoAcceptInviteForRepo } = await import('./solve.accept-invite.lib.mjs');
|
|
61
|
+
const { handleAutoForkOption, handleMaintainerForkAccess } = await import('./solve.fork-detection.lib.mjs');
|
|
61
62
|
// Initialize log file early (before argument parsing) to capture all output
|
|
62
63
|
const logFile = await initializeLogFile(null);
|
|
63
64
|
// Log version and raw command IMMEDIATELY after log file initialization
|
|
@@ -209,73 +210,7 @@ if (argv.autoAcceptInvite) {
|
|
|
209
210
|
await autoAcceptInviteForRepo(owner, repo, log, argv.verbose);
|
|
210
211
|
}
|
|
211
212
|
// Handle --auto-fork option: automatically fork public repositories without write access
|
|
212
|
-
|
|
213
|
-
const { detectRepositoryVisibility } = githubLib;
|
|
214
|
-
// Check if we have write access first (issue #1536: retry on transient network errors)
|
|
215
|
-
await log('🔍 Checking repository access for auto-fork...');
|
|
216
|
-
const permResult = await lib.ghCmdRetry(() => $`gh api repos/${owner}/${repo} --jq .permissions`, { label: 'auto-fork perms' });
|
|
217
|
-
|
|
218
|
-
if (permResult.code === 0) {
|
|
219
|
-
const permissions = JSON.parse(permResult.stdout.toString().trim());
|
|
220
|
-
const hasWriteAccess = permissions.push === true || permissions.admin === true || permissions.maintain === true;
|
|
221
|
-
|
|
222
|
-
if (!hasWriteAccess) {
|
|
223
|
-
// No write access - check if repository is public before enabling fork mode
|
|
224
|
-
const { isPublic } = await detectRepositoryVisibility(owner, repo);
|
|
225
|
-
|
|
226
|
-
if (!isPublic) {
|
|
227
|
-
// Private repository without write access - cannot fork
|
|
228
|
-
await log('');
|
|
229
|
-
await log("❌ --auto-fork failed: Repository is private and you don't have write access", { level: 'error' });
|
|
230
|
-
await log('');
|
|
231
|
-
await log(' 🔍 What happened:', { level: 'error' });
|
|
232
|
-
await log(` Repository ${owner}/${repo} is private`, { level: 'error' });
|
|
233
|
-
await log(" You don't have write access to this repository", { level: 'error' });
|
|
234
|
-
await log(' --auto-fork cannot create a fork of a private repository you cannot access', {
|
|
235
|
-
level: 'error',
|
|
236
|
-
});
|
|
237
|
-
await log('');
|
|
238
|
-
await log(' 💡 Solution:', { level: 'error' });
|
|
239
|
-
await log(' • Request collaborator access from the repository owner', { level: 'error' });
|
|
240
|
-
await log(` https://github.com/${owner}/${repo}/settings/access`, { level: 'error' });
|
|
241
|
-
await log('');
|
|
242
|
-
await safeExit(1, 'Auto-fork failed - private repository without access');
|
|
243
|
-
}
|
|
244
|
-
|
|
245
|
-
// Public repository without write access - automatically enable fork mode
|
|
246
|
-
await log('✅ Auto-fork: No write access detected, enabling fork mode');
|
|
247
|
-
argv.fork = true;
|
|
248
|
-
} else {
|
|
249
|
-
// Has write access - work directly on the repo (works for both public and private repos)
|
|
250
|
-
const { isPublic } = await detectRepositoryVisibility(owner, repo);
|
|
251
|
-
await log(`✅ Auto-fork: Write access detected to ${isPublic ? 'public' : 'private'} repository, working directly on repository`);
|
|
252
|
-
}
|
|
253
|
-
} else {
|
|
254
|
-
// Could not check permissions - assume no access and try to fork if public
|
|
255
|
-
const { isPublic } = await detectRepositoryVisibility(owner, repo);
|
|
256
|
-
|
|
257
|
-
if (!isPublic) {
|
|
258
|
-
// Cannot determine permissions for private repo - fail safely
|
|
259
|
-
await log('');
|
|
260
|
-
await log('❌ --auto-fork failed: Could not verify permissions for private repository', { level: 'error' });
|
|
261
|
-
await log('');
|
|
262
|
-
await log(' 🔍 What happened:', { level: 'error' });
|
|
263
|
-
await log(` Repository ${owner}/${repo} is private`, { level: 'error' });
|
|
264
|
-
await log(' Could not check your permissions to this repository', { level: 'error' });
|
|
265
|
-
await log('');
|
|
266
|
-
await log(' 💡 Solutions:', { level: 'error' });
|
|
267
|
-
await log(' • Check your GitHub CLI authentication: gh auth status', { level: 'error' });
|
|
268
|
-
await log(" • Request collaborator access if you don't have it yet", { level: 'error' });
|
|
269
|
-
await log(` https://github.com/${owner}/${repo}/settings/access`, { level: 'error' });
|
|
270
|
-
await log('');
|
|
271
|
-
await safeExit(1, 'Auto-fork failed - cannot verify private repository permissions');
|
|
272
|
-
}
|
|
273
|
-
|
|
274
|
-
// Public repository but couldn't check permissions - assume no access and fork
|
|
275
|
-
await log('⚠️ Auto-fork: Could not check permissions, enabling fork mode for public repository');
|
|
276
|
-
argv.fork = true;
|
|
277
|
-
}
|
|
278
|
-
}
|
|
213
|
+
await handleAutoForkOption({ owner, repo, argv, safeExit });
|
|
279
214
|
// Permission check BEFORE entity validation (#1552): avoids false 404 on private repos without access
|
|
280
215
|
const { checkRepositoryWritePermission } = githubLib;
|
|
281
216
|
const hasWriteAccess = await checkRepositoryWritePermission(owner, repo, {
|
|
@@ -296,19 +231,26 @@ if (!entityCheck.valid) {
|
|
|
296
231
|
await safeExit(1, `GitHub entity not found (${entityCheck.level})`);
|
|
297
232
|
}
|
|
298
233
|
|
|
299
|
-
// Detect repository visibility and
|
|
234
|
+
// Detect repository visibility once and reuse for downstream decisions
|
|
235
|
+
// (auto-cleanup default + Issue #1716 private-repo fork bypass)
|
|
236
|
+
const { detectRepositoryVisibility } = githubLib;
|
|
237
|
+
const { isPublic: isRepoPublic } = await detectRepositoryVisibility(owner, repo);
|
|
300
238
|
if (argv.autoCleanup === undefined) {
|
|
301
|
-
const { detectRepositoryVisibility } = githubLib;
|
|
302
|
-
const { isPublic } = await detectRepositoryVisibility(owner, repo);
|
|
303
239
|
// For public repos: keep temp directories (default false)
|
|
304
240
|
// For private repos: clean up temp directories (default true)
|
|
305
|
-
argv.autoCleanup = !
|
|
241
|
+
argv.autoCleanup = !isRepoPublic;
|
|
306
242
|
if (argv.verbose) {
|
|
307
|
-
await log(` Auto-cleanup default: ${argv.autoCleanup} (repository is ${
|
|
243
|
+
await log(` Auto-cleanup default: ${argv.autoCleanup} (repository is ${isRepoPublic ? 'public' : 'private'})`, {
|
|
308
244
|
verbose: true,
|
|
309
245
|
});
|
|
310
246
|
}
|
|
311
247
|
}
|
|
248
|
+
// Issue #1716: When the upstream repository is private and the user has direct
|
|
249
|
+
// write access, fork-based workflows should be skipped — even if the existing
|
|
250
|
+
// PR was originally created from a fork. Forks of private repositories often
|
|
251
|
+
// become inaccessible (renamed, deleted, parent re-private'd) and there's no
|
|
252
|
+
// reason to use them when we can push branches and PRs to the upstream repo.
|
|
253
|
+
const skipForkForPrivateUpstream = !isRepoPublic && !argv.fork && hasWriteAccess;
|
|
312
254
|
// Determine mode and get issue details
|
|
313
255
|
let issueNumber;
|
|
314
256
|
let prNumber;
|
|
@@ -345,32 +287,26 @@ if (autoContinueResult.isContinueMode) {
|
|
|
345
287
|
await log(` Merge status: ${mergeStateStatus || 'UNKNOWN'}`, { verbose: true });
|
|
346
288
|
}
|
|
347
289
|
if (prCheckData.headRepositoryOwner && prCheckData.headRepositoryOwner.login !== owner) {
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
await log(
|
|
354
|
-
|
|
290
|
+
const detectedForkOwner = prCheckData.headRepositoryOwner.login;
|
|
291
|
+
const detectedForkRepoName = prCheckData.headRepository && prCheckData.headRepository.name ? prCheckData.headRepository.name : null;
|
|
292
|
+
// Issue #1716: Skip fork mode for private upstream repos with write access.
|
|
293
|
+
if (skipForkForPrivateUpstream) {
|
|
294
|
+
await log(`🔒 Detected fork PR from ${detectedForkOwner}/${detectedForkRepoName || repo}, but upstream ${owner}/${repo} is private and you have write access.`);
|
|
295
|
+
await log(' Working directly on the private upstream repository (Issue #1716).');
|
|
296
|
+
} else {
|
|
297
|
+
forkOwner = detectedForkOwner;
|
|
298
|
+
// Get actual fork repository name (may be prefixed) and store for use in setupRepository
|
|
299
|
+
forkRepoName = detectedForkRepoName;
|
|
300
|
+
await log(`🍴 Detected fork PR from ${forkOwner}/${forkRepoName || repo}`);
|
|
301
|
+
if (argv.verbose) {
|
|
302
|
+
await log(` Fork owner: ${forkOwner}`, { verbose: true });
|
|
303
|
+
await log(' Will clone fork repository for continue mode', { verbose: true });
|
|
304
|
+
}
|
|
355
305
|
}
|
|
356
306
|
|
|
357
307
|
// Check if maintainer can push to the fork when --allow-to-push-to-contributors-pull-requests-as-maintainer is enabled
|
|
358
|
-
if (argv.allowToPushToContributorsPullRequestsAsMaintainer && argv.autoFork) {
|
|
359
|
-
|
|
360
|
-
const { canModify } = await checkMaintainerCanModifyPR(owner, repo, prNumber);
|
|
361
|
-
|
|
362
|
-
if (canModify) {
|
|
363
|
-
await log('✅ Maintainer can push to fork: Enabled by contributor');
|
|
364
|
-
await log(" Will push changes directly to contributor's fork instead of creating own fork");
|
|
365
|
-
// Don't disable fork mode, but we'll use the contributor's fork
|
|
366
|
-
} else {
|
|
367
|
-
await log('⚠️ Maintainer cannot push to fork: "Allow edits by maintainers" is not enabled', {
|
|
368
|
-
level: 'warning',
|
|
369
|
-
});
|
|
370
|
-
await log(' Posting comment to request access...', { level: 'warning' });
|
|
371
|
-
await requestMaintainerAccess(owner, repo, prNumber);
|
|
372
|
-
await log(' Comment posted. Proceeding with own fork instead.', { level: 'warning' });
|
|
373
|
-
}
|
|
308
|
+
if (forkOwner && argv.allowToPushToContributorsPullRequestsAsMaintainer && argv.autoFork) {
|
|
309
|
+
await handleMaintainerForkAccess({ owner, repo, prNumber });
|
|
374
310
|
}
|
|
375
311
|
}
|
|
376
312
|
}
|
|
@@ -425,32 +361,26 @@ if (isPrUrl) {
|
|
|
425
361
|
prState = prData.state;
|
|
426
362
|
// Check if this is a fork PR
|
|
427
363
|
if (prData.headRepositoryOwner && prData.headRepositoryOwner.login !== owner) {
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
await log(
|
|
434
|
-
|
|
364
|
+
const detectedForkOwner = prData.headRepositoryOwner.login;
|
|
365
|
+
const detectedForkRepoName = prData.headRepository && prData.headRepository.name ? prData.headRepository.name : null;
|
|
366
|
+
// Issue #1716: Skip fork mode for private upstream repos with write access.
|
|
367
|
+
if (skipForkForPrivateUpstream) {
|
|
368
|
+
await log(`🔒 Detected fork PR from ${detectedForkOwner}/${detectedForkRepoName || repo}, but upstream ${owner}/${repo} is private and you have write access.`);
|
|
369
|
+
await log(' Working directly on the private upstream repository (Issue #1716).');
|
|
370
|
+
} else {
|
|
371
|
+
forkOwner = detectedForkOwner;
|
|
372
|
+
// Get actual fork repository name and store for use in setupRepository
|
|
373
|
+
forkRepoName = detectedForkRepoName;
|
|
374
|
+
await log(`🍴 Detected fork PR from ${forkOwner}/${forkRepoName || repo}`);
|
|
375
|
+
if (argv.verbose) {
|
|
376
|
+
await log(` Fork owner: ${forkOwner}`, { verbose: true });
|
|
377
|
+
await log(' Will clone fork repository for continue mode', { verbose: true });
|
|
378
|
+
}
|
|
435
379
|
}
|
|
436
380
|
|
|
437
381
|
// Check if maintainer can push to the fork when --allow-to-push-to-contributors-pull-requests-as-maintainer is enabled
|
|
438
|
-
if (argv.allowToPushToContributorsPullRequestsAsMaintainer && argv.autoFork) {
|
|
439
|
-
|
|
440
|
-
const { canModify } = await checkMaintainerCanModifyPR(owner, repo, prNumber);
|
|
441
|
-
|
|
442
|
-
if (canModify) {
|
|
443
|
-
await log('✅ Maintainer can push to fork: Enabled by contributor');
|
|
444
|
-
await log(" Will push changes directly to contributor's fork instead of creating own fork");
|
|
445
|
-
// Don't disable fork mode, but we'll use the contributor's fork
|
|
446
|
-
} else {
|
|
447
|
-
await log('⚠️ Maintainer cannot push to fork: "Allow edits by maintainers" is not enabled', {
|
|
448
|
-
level: 'warning',
|
|
449
|
-
});
|
|
450
|
-
await log(' Posting comment to request access...', { level: 'warning' });
|
|
451
|
-
await requestMaintainerAccess(owner, repo, prNumber);
|
|
452
|
-
await log(' Comment posted. Proceeding with own fork instead.', { level: 'warning' });
|
|
453
|
-
}
|
|
382
|
+
if (forkOwner && argv.allowToPushToContributorsPullRequestsAsMaintainer && argv.autoFork) {
|
|
383
|
+
await handleMaintainerForkAccess({ owner, repo, prNumber });
|
|
454
384
|
}
|
|
455
385
|
}
|
|
456
386
|
await log(`📝 PR branch: ${prBranch}`);
|
|
@@ -6,15 +6,12 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import fs from 'fs/promises';
|
|
9
|
-
import path from 'path';
|
|
10
|
-
import { constants as fsConstants } from 'fs';
|
|
11
9
|
import { extractSessionIdFromText, decideLogDestination, resolveLogPath } from './telegram-log-command.lib.mjs';
|
|
12
10
|
|
|
13
11
|
const DEFAULT_WIDTH = 120;
|
|
14
12
|
const DEFAULT_HEIGHT = 25;
|
|
15
13
|
const DEFAULT_INTERVAL_MS = 2500;
|
|
16
14
|
const DEFAULT_MAX_CHARS = 3400;
|
|
17
|
-
const TELEGRAM_DOCUMENT_MAX_BYTES = 50 * 1024 * 1024;
|
|
18
15
|
const GITHUB_URL_RE = /https:\/\/github\.com\/[^\s"'`<>]+/i;
|
|
19
16
|
const activeWatches = new Map();
|
|
20
17
|
|
|
@@ -135,23 +132,6 @@ export function formatTerminalWatchMessage({ sessionId, statusResult = null, log
|
|
|
135
132
|
return lines.join('\n');
|
|
136
133
|
}
|
|
137
134
|
|
|
138
|
-
async function fileExists(filePath) {
|
|
139
|
-
try {
|
|
140
|
-
await fs.access(filePath, fsConstants.R_OK);
|
|
141
|
-
return true;
|
|
142
|
-
} catch {
|
|
143
|
-
return false;
|
|
144
|
-
}
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
async function fileSize(filePath) {
|
|
148
|
-
try {
|
|
149
|
-
return (await fs.stat(filePath)).size;
|
|
150
|
-
} catch {
|
|
151
|
-
return null;
|
|
152
|
-
}
|
|
153
|
-
}
|
|
154
|
-
|
|
155
135
|
async function readLogFile(logPath) {
|
|
156
136
|
try {
|
|
157
137
|
return await fs.readFile(logPath, 'utf8');
|
|
@@ -182,16 +162,6 @@ export async function resolveTerminalWatchRepository({ sessionInfo = null, statu
|
|
|
182
162
|
}
|
|
183
163
|
}
|
|
184
164
|
|
|
185
|
-
async function sendLogDocument({ bot, chatId, logPath, sessionId, statusResult }) {
|
|
186
|
-
if (!(await fileExists(logPath))) return;
|
|
187
|
-
const size = await fileSize(logPath);
|
|
188
|
-
if (size !== null && size > TELEGRAM_DOCUMENT_MAX_BYTES) {
|
|
189
|
-
await bot.telegram.sendMessage(chatId, `⚠️ Full log for \`${sessionId}\` is ${(size / (1024 * 1024)).toFixed(1)} MB, above Telegram's 50 MB upload limit.`, { parse_mode: 'Markdown' });
|
|
190
|
-
return;
|
|
191
|
-
}
|
|
192
|
-
await bot.telegram.sendDocument(chatId, { source: logPath, filename: path.basename(logPath) }, { caption: `📄 Full log for session \`${sessionId}\`${statusResult?.status ? `\nStatus: \`${statusResult.status}\`` : ''}`, parse_mode: 'Markdown' });
|
|
193
|
-
}
|
|
194
|
-
|
|
195
165
|
async function querySessionStatusWithRetry(querySessionStatus, sessionId, verbose, attempts = 3) {
|
|
196
166
|
for (let attempt = 1; attempt <= attempts; attempt++) {
|
|
197
167
|
const statusResult = await querySessionStatus(sessionId, verbose);
|
|
@@ -201,7 +171,9 @@ async function querySessionStatusWithRetry(querySessionStatus, sessionId, verbos
|
|
|
201
171
|
return null;
|
|
202
172
|
}
|
|
203
173
|
|
|
204
|
-
|
|
174
|
+
// Note: /terminal_watch never uploads the full session log itself (issue #1720).
|
|
175
|
+
// Use /log <uuid> if you want the log file delivered as a document.
|
|
176
|
+
export function watchTerminalLogSession({ bot, chatId, messageId, sessionId, logPath, querySessionStatus, isTerminalSessionStatus, options = {}, repoDescription = null, verbose = false }) {
|
|
205
177
|
const key = `${chatId}:${messageId}:${sessionId}`;
|
|
206
178
|
activeWatches.get(key)?.stop();
|
|
207
179
|
|
|
@@ -225,7 +197,6 @@ export function watchTerminalLogSession({ bot, chatId, messageId, sessionId, log
|
|
|
225
197
|
if (completed) {
|
|
226
198
|
stopped = true;
|
|
227
199
|
activeWatches.delete(key);
|
|
228
|
-
if (attachLogOnComplete) await sendLogDocument({ bot, chatId, logPath, sessionId, statusResult });
|
|
229
200
|
return;
|
|
230
201
|
}
|
|
231
202
|
} catch (error) {
|
|
@@ -409,6 +380,5 @@ export const __INTERNAL_FOR_TESTS__ = {
|
|
|
409
380
|
DEFAULT_HEIGHT,
|
|
410
381
|
DEFAULT_INTERVAL_MS,
|
|
411
382
|
DEFAULT_MAX_CHARS,
|
|
412
|
-
TELEGRAM_DOCUMENT_MAX_BYTES,
|
|
413
383
|
GITHUB_URL_RE,
|
|
414
384
|
};
|