@loicngr/kobo 1.11.20 → 1.11.23
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 +15 -0
- package/dist/server/db/migrations.js +13 -0
- package/dist/server/db/schema.js +1 -0
- package/dist/server/index.js +2 -0
- package/dist/server/routes/pull-requests.js +112 -0
- package/dist/server/routes/settings.js +11 -0
- package/dist/server/routes/workspaces.js +98 -0
- package/dist/server/services/agent/engines/claude-code/engine.js +22 -5
- package/dist/server/services/forge/bitbucket/provider.js +86 -0
- package/dist/server/services/forge/github/provider.js +76 -0
- package/dist/server/services/forge/gitlab/provider.js +44 -0
- package/dist/server/services/forge/none.js +4 -0
- package/dist/server/services/pr-checkout-service.js +380 -0
- package/dist/server/services/workspace-service.js +4 -3
- package/dist/server/services/worktree-service.js +5 -5
- package/package.json +3 -3
- package/src/client/dist/pwa/assets/ActivityFeed-BNyFzRkx.js +8 -0
- package/src/client/dist/pwa/assets/{ActivityFeed-DccO7DTb.css → ActivityFeed-DUjY6lCm.css} +1 -1
- package/src/client/dist/pwa/assets/{ChangelogPage-DQ8OO52f.js → ChangelogPage-7pgilQJn.js} +1 -1
- package/src/client/dist/pwa/assets/ClosePopup-BK6mNWsy.js +1 -0
- package/src/client/dist/pwa/assets/CreatePage-BCuDTB_0.css +1 -0
- package/src/client/dist/pwa/assets/CreatePage-P4N9bro4.js +2 -0
- package/src/client/dist/pwa/assets/{DiffViewer-BvauBOQx.js → DiffViewer-Cl2w9liE.js} +4 -4
- package/src/client/dist/pwa/assets/{DocumentsPanel-J3O58nlG.js → DocumentsPanel-CARiAfZs.js} +1 -1
- package/src/client/dist/pwa/assets/GitPanel-txKWaxSr.js +3 -0
- package/src/client/dist/pwa/assets/HealthPage-B0_v81_H.js +1 -0
- package/src/client/dist/pwa/assets/MainLayout-Cl26t1Yw.js +2 -0
- package/src/client/dist/pwa/assets/QBtnDropdown-9dpk4IiI.js +1 -0
- package/src/client/dist/pwa/assets/QItemSection-5GBV9MR2.js +1 -0
- package/src/client/dist/pwa/assets/{SchedulePanel-djl5_gLX.js → SchedulePanel-CSJbQpWB.js} +1 -1
- package/src/client/dist/pwa/assets/{SearchPage-BDn_SyYF.js → SearchPage-B5Wndh73.js} +1 -1
- package/src/client/dist/pwa/assets/SettingsPage-BmSaJrE5.js +16 -0
- package/src/client/dist/pwa/assets/{SettingsPage-dMAg4JP8.css → SettingsPage-CDZhPV6A.css} +1 -1
- package/src/client/dist/pwa/assets/{TerminalPanel-CNe14CjM.js → TerminalPanel-CuKWlENm.js} +1 -1
- package/src/client/dist/pwa/assets/TouchPan-CflYDvzb.js +1 -0
- package/src/client/dist/pwa/assets/WorkspacePage-C8SAYVkt.js +4 -0
- package/src/client/dist/pwa/assets/WorkspacePage-xKaXyg_O.css +1 -0
- package/src/client/dist/pwa/assets/{build-path-tree-DFok1z0T.js → build-path-tree-nS5a0hfa.js} +1 -1
- package/src/client/dist/pwa/assets/{cssMode-BvUdbm-I.js → cssMode-BWoHpG_3.js} +1 -1
- package/src/client/dist/pwa/assets/{editor.api-BvICAep3.js → editor.api-CHHKMKIl.js} +1 -1
- package/src/client/dist/pwa/assets/efforts-C0gGtu_O.js +9 -0
- package/src/client/dist/pwa/assets/engineFeatures-Cm3wk7lo.js +1 -0
- package/src/client/dist/pwa/assets/expand-template-CRv7R1En.js +1 -0
- package/src/client/dist/pwa/assets/{freemarker2-vMz0R4BI.js → freemarker2-ChmoNkJH.js} +1 -1
- package/src/client/dist/pwa/assets/{handlebars-zmdpS7uO.js → handlebars-DjdwH2Vr.js} +1 -1
- package/src/client/dist/pwa/assets/{html-D9TWGmZz.js → html-DJszTgCl.js} +1 -1
- package/src/client/dist/pwa/assets/{htmlMode-CWLZjLLJ.js → htmlMode-D0QTtBWO.js} +1 -1
- package/src/client/dist/pwa/assets/i18n-BQFPc-Bc.js +1 -0
- package/src/client/dist/pwa/assets/{index-CprWQVsP.css → index-BpmxQlZi.css} +1 -1
- package/src/client/dist/pwa/assets/{index-Djqr-F_S.js → index-okqx5CVc.js} +7 -7
- package/src/client/dist/pwa/assets/{javascript-D0SGBD_r.js → javascript-ByhJGDgS.js} +1 -1
- package/src/client/dist/pwa/assets/{jsonMode-Dz5fXETW.js → jsonMode-Db1oPA-y.js} +1 -1
- package/src/client/dist/pwa/assets/{liquid-DROderzc.js → liquid-CGMily4q.js} +1 -1
- package/src/client/dist/pwa/assets/{lspLanguageFeatures-C_VifNi2.js → lspLanguageFeatures-B85CeJTe.js} +1 -1
- package/src/client/dist/pwa/assets/{mdx-Dp3MajEK.js → mdx-B5_UPmNo.js} +1 -1
- package/src/client/dist/pwa/assets/{network-auth-DIjlo9g6.js → network-auth-D9vq0xDI.js} +1 -1
- package/src/client/dist/pwa/assets/{notify-theme-_12WX0jW.js → notify-theme-kRr6YTnZ.js} +1 -1
- package/src/client/dist/pwa/assets/{pending-diff-open-CkkxdAgL.js → pending-diff-open-nLdNq-y-.js} +1 -1
- package/src/client/dist/pwa/assets/{permissionModes-B35Bl8_m.js → permissionModes-Du6lXebH.js} +3 -3
- package/src/client/dist/pwa/assets/{project-color-ajxFmK6B.js → project-color-tT7gsmB5.js} +1 -1
- package/src/client/dist/pwa/assets/{python-fI2DruWC.js → python-FzBHx7LG.js} +1 -1
- package/src/client/dist/pwa/assets/{razor-C6Z6sx25.js → razor-BU_JvIXc.js} +1 -1
- package/src/client/dist/pwa/assets/{render-chat-markdown-BxVWz3Uu.js → render-chat-markdown-DrGdVCku.js} +1 -1
- package/src/client/dist/pwa/assets/{tsMode-C1G2aMN3.js → tsMode-BDW6Cb6M.js} +1 -1
- package/src/client/dist/pwa/assets/{typescript-DB5TziUY.js → typescript-21K3hWN-.js} +1 -1
- package/src/client/dist/pwa/assets/{use-onboarding-B3SV8Z98.js → use-onboarding-BCM8AGRg.js} +1 -1
- package/src/client/dist/pwa/assets/{vs-dbLna43M.js → vs-Ch3CDiTS.js} +3 -3
- package/src/client/dist/pwa/assets/{workers-CvmGLkp5.js → workers-CbIvH33O.js} +1 -1
- package/src/client/dist/pwa/assets/{xml-DF73XjiE.js → xml-d-kArTuN.js} +1 -1
- package/src/client/dist/pwa/assets/{yaml-B3gN4ZVV.js → yaml-tzAPZ-1Q.js} +1 -1
- package/src/client/dist/pwa/index.html +2 -2
- package/src/client/dist/pwa/sw.js +1 -1
- package/src/client/dist/pwa/assets/ActivityFeed-BRJyKyYS.js +0 -8
- package/src/client/dist/pwa/assets/ClosePopup-DEy0yFXE.js +0 -1
- package/src/client/dist/pwa/assets/CreatePage-DNBh8EKm.js +0 -2
- package/src/client/dist/pwa/assets/CreatePage-RMBX-Zjn.css +0 -1
- package/src/client/dist/pwa/assets/GitPanel-DxiE3C9g.js +0 -3
- package/src/client/dist/pwa/assets/HealthPage-t95_NSgL.js +0 -1
- package/src/client/dist/pwa/assets/MainLayout-CUT4A1gR.js +0 -2
- package/src/client/dist/pwa/assets/QItemSection-CDBweQnA.js +0 -1
- package/src/client/dist/pwa/assets/QList-Ccqhimcb.js +0 -1
- package/src/client/dist/pwa/assets/SettingsPage-CjhmWrTd.js +0 -16
- package/src/client/dist/pwa/assets/TouchPan-DnEDaTFL.js +0 -1
- package/src/client/dist/pwa/assets/WorkspacePage-BKgqjufK.css +0 -1
- package/src/client/dist/pwa/assets/WorkspacePage-CIdCkKx7.js +0 -4
- package/src/client/dist/pwa/assets/efforts-CQkQbD_d.js +0 -9
- package/src/client/dist/pwa/assets/engineFeatures-B7bviOhV.js +0 -1
- package/src/client/dist/pwa/assets/expand-template-jmdZsKZM.js +0 -1
- package/src/client/dist/pwa/assets/i18n-DVcs7ySS.js +0 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,21 @@ All notable changes to Kōbō are documented here. The format is based on
|
|
|
4
4
|
[Keep a Changelog](https://keepachangelog.com/). Each release is an `## <version>`
|
|
5
5
|
section — the in-app "What's new" dialog reads this file.
|
|
6
6
|
|
|
7
|
+
## 1.11.23
|
|
8
|
+
|
|
9
|
+
- fix(test): configure git identity on the pr-checkout temp repo
|
|
10
|
+
- feat: create workspaces from an existing PR/MR
|
|
11
|
+
|
|
12
|
+
## 1.11.22
|
|
13
|
+
|
|
14
|
+
- feat: create workspaces from an existing PR/MR
|
|
15
|
+
|
|
16
|
+
## 1.11.21
|
|
17
|
+
|
|
18
|
+
- fix: refine session liveness and interface feedback
|
|
19
|
+
- fix(client): restore outline accent
|
|
20
|
+
- feat(settings): compose custom skill prompts
|
|
21
|
+
|
|
7
22
|
## 1.11.20
|
|
8
23
|
|
|
9
24
|
- fix(client): clarify automatic recovery
|
|
@@ -661,6 +661,19 @@ export const migrations = [
|
|
|
661
661
|
}
|
|
662
662
|
},
|
|
663
663
|
},
|
|
664
|
+
{
|
|
665
|
+
version: 37,
|
|
666
|
+
name: 'add-workspace-pr-url',
|
|
667
|
+
migrate: (db) => {
|
|
668
|
+
const table = db.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'workspaces'").get();
|
|
669
|
+
if (!table)
|
|
670
|
+
return;
|
|
671
|
+
const columns = db.prepare('PRAGMA table_info(workspaces)').all().map((column) => column.name);
|
|
672
|
+
if (!columns.includes('pr_url')) {
|
|
673
|
+
db.exec('ALTER TABLE workspaces ADD COLUMN pr_url TEXT');
|
|
674
|
+
}
|
|
675
|
+
},
|
|
676
|
+
},
|
|
664
677
|
];
|
|
665
678
|
/** Current schema version — always equals the highest migration version. */
|
|
666
679
|
export const SCHEMA_VERSION = migrations.length > 0 ? migrations[migrations.length - 1].version : 1;
|
package/dist/server/db/schema.js
CHANGED
package/dist/server/index.js
CHANGED
|
@@ -18,6 +18,7 @@ import healthRouter from './routes/health.js';
|
|
|
18
18
|
import imagesRouter from './routes/images.js';
|
|
19
19
|
import { migrationRouter } from './routes/migration.js';
|
|
20
20
|
import notionRouter from './routes/notion.js';
|
|
21
|
+
import pullRequestsRouter from './routes/pull-requests.js';
|
|
21
22
|
import searchRouter from './routes/search.js';
|
|
22
23
|
import sentryRouter from './routes/sentry.js';
|
|
23
24
|
import settingsRouter from './routes/settings.js';
|
|
@@ -128,6 +129,7 @@ app.use('/api/*', networkAuthMiddleware);
|
|
|
128
129
|
app.get('/api/health', (c) => c.json({ status: 'ok', version: getPackageVersion() }));
|
|
129
130
|
// Mount route sub-routers
|
|
130
131
|
app.route('/api/workspaces', workspacesRouter);
|
|
132
|
+
app.route('/api/pull-requests', pullRequestsRouter);
|
|
131
133
|
app.route('/api/workspaces', imagesRouter);
|
|
132
134
|
app.route('/api/notion', notionRouter);
|
|
133
135
|
app.route('/api/sentry', sentryRouter);
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { Hono } from 'hono';
|
|
2
|
+
import { getForgeProvider } from '../services/forge/registry.js';
|
|
3
|
+
import { resolveForge } from '../services/forge/resolve.js';
|
|
4
|
+
import * as prCheckout from '../services/pr-checkout-service.js';
|
|
5
|
+
import { listWorkspaces } from '../services/workspace-service.js';
|
|
6
|
+
const app = new Hono();
|
|
7
|
+
const FILTERS = ['all', 'mine', 'review-requested'];
|
|
8
|
+
/** Map worktree path -> workspace id, keeping the service free of DB access. */
|
|
9
|
+
function attachedWorktreePaths(workspaces) {
|
|
10
|
+
const map = new Map();
|
|
11
|
+
for (const w of workspaces ?? listWorkspaces(true)) {
|
|
12
|
+
if (w.worktreePath)
|
|
13
|
+
map.set(w.worktreePath, w.id);
|
|
14
|
+
}
|
|
15
|
+
return map;
|
|
16
|
+
}
|
|
17
|
+
app.get('/', async (c) => {
|
|
18
|
+
try {
|
|
19
|
+
const projectPath = c.req.query('projectPath');
|
|
20
|
+
if (!projectPath)
|
|
21
|
+
return c.json({ error: 'Missing required field: projectPath' }, 400);
|
|
22
|
+
const provider = getForgeProvider(resolveForge(projectPath));
|
|
23
|
+
if (!provider.capabilities.canListPullRequests) {
|
|
24
|
+
return c.json({ error: 'This project has no forge that can list pull requests' }, 403);
|
|
25
|
+
}
|
|
26
|
+
const rawFilter = c.req.query('filter');
|
|
27
|
+
const page = await provider.listPullRequests(projectPath, {
|
|
28
|
+
filter: rawFilter && FILTERS.includes(rawFilter) ? rawFilter : 'all',
|
|
29
|
+
search: c.req.query('search') || undefined,
|
|
30
|
+
cursor: c.req.query('cursor') || null,
|
|
31
|
+
perPage: Number(c.req.query('perPage') ?? 25),
|
|
32
|
+
});
|
|
33
|
+
return c.json(page);
|
|
34
|
+
}
|
|
35
|
+
catch (err) {
|
|
36
|
+
return c.json({ error: err instanceof Error ? err.message : String(err) }, 500);
|
|
37
|
+
}
|
|
38
|
+
});
|
|
39
|
+
app.post('/diagnose', async (c) => {
|
|
40
|
+
try {
|
|
41
|
+
const { projectPath, prNumber, worktreesPath } = await c.req.json();
|
|
42
|
+
if (!projectPath || !prNumber) {
|
|
43
|
+
return c.json({ error: 'Missing required fields: projectPath, prNumber' }, 400);
|
|
44
|
+
}
|
|
45
|
+
const provider = getForgeProvider(resolveForge(projectPath));
|
|
46
|
+
const availability = await provider.isAvailable(projectPath);
|
|
47
|
+
if (!availability.available) {
|
|
48
|
+
// The forge can't be reached, so we have no way to resolve the PR's
|
|
49
|
+
// head branch. Return a minimal report early instead of diagnosing a
|
|
50
|
+
// fake empty branch name, which would produce a nonsensical report.
|
|
51
|
+
const report = {
|
|
52
|
+
projectPath,
|
|
53
|
+
headBranch: '',
|
|
54
|
+
targetWorktreePath: '',
|
|
55
|
+
blockers: [{ kind: 'forge-unavailable', reason: availability.reason ?? 'cli_missing' }],
|
|
56
|
+
workspace: { state: 'none' },
|
|
57
|
+
worktree: { state: 'none' },
|
|
58
|
+
localChanges: { present: false, modified: 0, staged: 0, untracked: 0 },
|
|
59
|
+
ongoingOperation: null,
|
|
60
|
+
branch: { state: 'absent' },
|
|
61
|
+
};
|
|
62
|
+
return c.json({ report, pr: null, fingerprint: prCheckout.computeFingerprint(report) });
|
|
63
|
+
}
|
|
64
|
+
// Page through the full list — a PR the user explicitly diagnosed by
|
|
65
|
+
// number must never come back "not found" just for sitting past the
|
|
66
|
+
// first 100 (by recency) results.
|
|
67
|
+
let pr;
|
|
68
|
+
let cursor = null;
|
|
69
|
+
do {
|
|
70
|
+
const page = await provider.listPullRequests(projectPath, { filter: 'all', perPage: 100, cursor });
|
|
71
|
+
pr = page.items.find((item) => item.number === prNumber);
|
|
72
|
+
cursor = page.nextCursor;
|
|
73
|
+
} while (!pr && cursor !== null);
|
|
74
|
+
if (!pr)
|
|
75
|
+
return c.json({ error: `Pull request #${prNumber} not found` }, 404);
|
|
76
|
+
const workspaces = listWorkspaces(true);
|
|
77
|
+
const report = prCheckout.diagnoseLocalState(projectPath, pr.headBranch, worktreesPath ?? null, attachedWorktreePaths(workspaces), workspaces);
|
|
78
|
+
if (pr.isFork)
|
|
79
|
+
report.blockers.push({ kind: 'fork-pr' });
|
|
80
|
+
return c.json({ report, pr, fingerprint: prCheckout.computeFingerprint(report) });
|
|
81
|
+
}
|
|
82
|
+
catch (err) {
|
|
83
|
+
return c.json({ error: err instanceof Error ? err.message : String(err) }, 500);
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
app.post('/resolve', async (c) => {
|
|
87
|
+
try {
|
|
88
|
+
const body = await c.req.json();
|
|
89
|
+
if (!body.projectPath || !body.fingerprint) {
|
|
90
|
+
return c.json({ error: 'Missing required fields: projectPath, fingerprint' }, 400);
|
|
91
|
+
}
|
|
92
|
+
const workspaces = listWorkspaces(true);
|
|
93
|
+
const result = await prCheckout.resolvePrCheckout({
|
|
94
|
+
projectPath: body.projectPath,
|
|
95
|
+
headBranch: body.headBranch,
|
|
96
|
+
baseBranch: body.baseBranch,
|
|
97
|
+
worktreesPath: body.worktreesPath ?? null,
|
|
98
|
+
decisions: body.decisions ?? {},
|
|
99
|
+
fingerprint: body.fingerprint,
|
|
100
|
+
workspaces,
|
|
101
|
+
attachedPaths: attachedWorktreePaths(workspaces),
|
|
102
|
+
});
|
|
103
|
+
return c.json(result);
|
|
104
|
+
}
|
|
105
|
+
catch (err) {
|
|
106
|
+
if (err instanceof prCheckout.StaleDiagnosisError) {
|
|
107
|
+
return c.json({ error: err.message, report: err.report }, 409);
|
|
108
|
+
}
|
|
109
|
+
return c.json({ error: err instanceof Error ? err.message : String(err) }, 422);
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
export default app;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { Hono } from 'hono';
|
|
2
|
+
import { isValidSkillSuite } from '../../shared/skill-suite-prompts.js';
|
|
2
3
|
import { getDb } from '../db/index.js';
|
|
3
4
|
import { getBackendPort } from '../services/agent/orchestrator.js';
|
|
4
5
|
import { DEFAULT_NOTION_INITIAL_PROMPT, DEFAULT_SENTRY_INITIAL_PROMPT, } from '../services/initial-prompt-template-service.js';
|
|
@@ -7,6 +8,7 @@ import { DEFAULT_REVIEW_PROMPT_TEMPLATE } from '../services/review-template-serv
|
|
|
7
8
|
import { DEFAULT_CHANGE_SOURCE_BRANCH_SCRIPT } from '../services/settings-defaults.js';
|
|
8
9
|
import * as settingsService from '../services/settings-service.js';
|
|
9
10
|
import { DEFAULT_CI_FIX_PROMPT_TEMPLATE, DEFAULT_FINALIZATION_PROMPT, DEFAULT_GIT_CONVENTIONS, DEFAULT_PR_PROMPT_TEMPLATE, } from '../services/settings-service.js';
|
|
11
|
+
import { getSuitePrompts } from '../services/skill-suite-prompts.js';
|
|
10
12
|
import { listTemplates, replaceAllTemplates } from '../services/templates-service.js';
|
|
11
13
|
import { countPrunableWsEvents } from '../services/ws-events-retention-service.js';
|
|
12
14
|
/** Hono sub-router for global and per-project settings CRUD. */
|
|
@@ -33,6 +35,15 @@ app.get('/global', (c) => {
|
|
|
33
35
|
return c.json({ error: message }, 500);
|
|
34
36
|
}
|
|
35
37
|
});
|
|
38
|
+
// GET /api/settings/skill-suite-prompts/:suite — source presets that can be
|
|
39
|
+
// copied field-by-field into a custom suite before the user edits them.
|
|
40
|
+
app.get('/skill-suite-prompts/:suite', (c) => {
|
|
41
|
+
const suite = c.req.param('suite');
|
|
42
|
+
if (!isValidSkillSuite(suite)) {
|
|
43
|
+
return c.json({ error: `Unknown skill suite: '${suite}'` }, 400);
|
|
44
|
+
}
|
|
45
|
+
return c.json(getSuitePrompts(suite, {}));
|
|
46
|
+
});
|
|
36
47
|
// GET /api/settings/defaults — expose the in-code DEFAULT_* constants for
|
|
37
48
|
// global text-template settings (PR / review / git conventions / Notion /
|
|
38
49
|
// Sentry initial prompts) so the UI can offer a "reset to default" button
|
|
@@ -61,6 +61,7 @@ export const CREATE_WORKSPACE_STEPS = [
|
|
|
61
61
|
'inspect-worktree',
|
|
62
62
|
'extract-notion',
|
|
63
63
|
'extract-sentry',
|
|
64
|
+
'extract-pr',
|
|
64
65
|
'create-record',
|
|
65
66
|
'create-tasks',
|
|
66
67
|
'create-worktree',
|
|
@@ -210,6 +211,26 @@ function resolveCreateAgentPermissionMode(bodyValue, _projectPath, globalSetting
|
|
|
210
211
|
return global;
|
|
211
212
|
return 'bypass';
|
|
212
213
|
}
|
|
214
|
+
/**
|
|
215
|
+
* Extract the numeric PR/MR number from a forge URL.
|
|
216
|
+
*
|
|
217
|
+
* Strips any query string or fragment first (a comment-anchor link like
|
|
218
|
+
* `.../merge_requests/42#note_123` would otherwise have its trailing
|
|
219
|
+
* fragment digits greedily matched instead of the actual PR number), then
|
|
220
|
+
* takes the last path segment and requires it to be purely numeric. Throws
|
|
221
|
+
* a clear error rather than silently defaulting to 0 when the URL doesn't
|
|
222
|
+
* end on a plain PR/MR number (e.g. a "Files changed" sub-tab URL).
|
|
223
|
+
*/
|
|
224
|
+
function parsePrNumberFromUrl(prUrl) {
|
|
225
|
+
const withoutQueryOrFragment = prUrl.split(/[?#]/)[0];
|
|
226
|
+
const segments = withoutQueryOrFragment.replace(/\/+$/, '').split('/');
|
|
227
|
+
const last = segments[segments.length - 1];
|
|
228
|
+
const number = Number(last);
|
|
229
|
+
if (!last || !Number.isInteger(number) || number <= 0) {
|
|
230
|
+
throw new Error(`Could not parse a pull request number from '${prUrl}'`);
|
|
231
|
+
}
|
|
232
|
+
return number;
|
|
233
|
+
}
|
|
213
234
|
app.get('/', (c) => {
|
|
214
235
|
try {
|
|
215
236
|
const workspaces = workspaceService.listWorkspaces();
|
|
@@ -491,6 +512,44 @@ app.post('/', migrationGuard, async (c) => {
|
|
|
491
512
|
.catch((err) => console.error('[sentry] Auto-assign threw unexpectedly:', err));
|
|
492
513
|
}
|
|
493
514
|
}
|
|
515
|
+
// Pre-flight: extract the PR/MR before any DB write, mirroring the
|
|
516
|
+
// Notion / Sentry extraction above. A throw here must not leave a
|
|
517
|
+
// half-built workspace behind, so we run extraction before
|
|
518
|
+
// createWorkspace and surface failures as 422.
|
|
519
|
+
let prContent = null;
|
|
520
|
+
// Captured here so the write-context-files block below can reuse it
|
|
521
|
+
// instead of calling resolveForge/getForgeProvider (which shells out to
|
|
522
|
+
// git when the project's forge setting is 'auto') a second time.
|
|
523
|
+
let prForgeRequestTermShort;
|
|
524
|
+
if (body.prUrl) {
|
|
525
|
+
currentStep = 'extract-pr';
|
|
526
|
+
emitCreateProgress(creationId, currentStep);
|
|
527
|
+
try {
|
|
528
|
+
const provider = getForgeProvider(resolveForge(body.projectPath));
|
|
529
|
+
prForgeRequestTermShort = provider.capabilities.requestTermShort;
|
|
530
|
+
if (!provider.capabilities.canListPullRequests) {
|
|
531
|
+
throw new Error('This project has no forge that can list pull requests');
|
|
532
|
+
}
|
|
533
|
+
const number = parsePrNumberFromUrl(body.prUrl);
|
|
534
|
+
// Page through the full list rather than only the first 100 (by
|
|
535
|
+
// recency) — a PR the user explicitly picked by URL must never be
|
|
536
|
+
// reported "not found" just because it hasn't been touched recently.
|
|
537
|
+
let cursor = null;
|
|
538
|
+
do {
|
|
539
|
+
const page = await provider.listPullRequests(body.projectPath, { filter: 'all', perPage: 100, cursor });
|
|
540
|
+
prContent = page.items.find((item) => item.number === number) ?? null;
|
|
541
|
+
cursor = page.nextCursor;
|
|
542
|
+
} while (!prContent && cursor !== null);
|
|
543
|
+
if (!prContent)
|
|
544
|
+
throw new Error(`Pull request #${number} not found`);
|
|
545
|
+
}
|
|
546
|
+
catch (err) {
|
|
547
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
548
|
+
const failure = `Failed to read the pull request: ${message}`;
|
|
549
|
+
emitCreateFailed(creationId, 'extract-pr', failure);
|
|
550
|
+
return c.json({ error: failure, step: 'extract-pr' }, 422);
|
|
551
|
+
}
|
|
552
|
+
}
|
|
494
553
|
// Create workspace record
|
|
495
554
|
const globalSettings = settingsService.getGlobalSettings();
|
|
496
555
|
// workingBranch may be updated after Notion extraction to inject the ticket ID,
|
|
@@ -574,6 +633,7 @@ app.post('/', migrationGuard, async (c) => {
|
|
|
574
633
|
notionUrl: body.notionUrl,
|
|
575
634
|
notionPageId: body.notionPageId,
|
|
576
635
|
sentryUrl: body.sentryUrl,
|
|
636
|
+
prUrl: body.prUrl,
|
|
577
637
|
worktreePath: prospectiveWorktreePath,
|
|
578
638
|
worktreeOwned: !useReusedWorktree,
|
|
579
639
|
model: body.model,
|
|
@@ -656,6 +716,13 @@ app.post('/', migrationGuard, async (c) => {
|
|
|
656
716
|
workspace = workspaceService.updateWorkspaceName(workspace.id, notionContent.title);
|
|
657
717
|
}
|
|
658
718
|
}
|
|
719
|
+
// Update workspace name with the PR/MR title if the user did not provide
|
|
720
|
+
// a custom name and neither Notion nor Sentry already claimed it. This
|
|
721
|
+
// makes the PR a THIRD context source, lowest priority of the three
|
|
722
|
+
// (Notion > Sentry > PR), consistent with the checks above.
|
|
723
|
+
if (prContent && !notionContent?.title && !sentryContent?.title && workspace.name === 'workspace') {
|
|
724
|
+
workspace = workspaceService.updateWorkspaceName(workspace.id, prContent.title);
|
|
725
|
+
}
|
|
659
726
|
// Create manual tasks/criteria if no Notion content was extracted
|
|
660
727
|
if (!notionContent && (Array.isArray(body.tasks) || Array.isArray(body.acceptanceCriteria))) {
|
|
661
728
|
let sortOrder = 0;
|
|
@@ -879,6 +946,37 @@ app.post('/', migrationGuard, async (c) => {
|
|
|
879
946
|
}
|
|
880
947
|
}
|
|
881
948
|
// ------------------------------------------------------------------------
|
|
949
|
+
// --- PR/MR file (extraction already done before worktree creation) -----
|
|
950
|
+
if (prContent && body.prUrl) {
|
|
951
|
+
try {
|
|
952
|
+
const thoughtsDir = path.join(worktreePath, '.ai', 'thoughts');
|
|
953
|
+
fs.mkdirSync(thoughtsDir, { recursive: true });
|
|
954
|
+
const today = new Date().toISOString().split('T')[0];
|
|
955
|
+
const term = prForgeRequestTermShort ?? getForgeProvider(resolveForge(body.projectPath)).capabilities.requestTermShort;
|
|
956
|
+
let md = `# ${prContent.title}\n\n`;
|
|
957
|
+
md += `## Source\n\n`;
|
|
958
|
+
md += `- ${term === 'MR' ? 'Merge request' : 'Pull request'}: ${body.prUrl}\n`;
|
|
959
|
+
md += `- Author: ${prContent.author}\n`;
|
|
960
|
+
md += `- Branches: ${prContent.headBranch} → ${prContent.baseBranch}\n`;
|
|
961
|
+
md += `- Retrieved: ${today}\n\n`;
|
|
962
|
+
// Persist the user's initial instructions, exactly like the Notion
|
|
963
|
+
// and Sentry context writes above — the description field is never
|
|
964
|
+
// overwritten, only incorporated under this heading.
|
|
965
|
+
if (body.description?.trim()) {
|
|
966
|
+
md += `## User instructions\n\n${body.description.trim()}\n\n`;
|
|
967
|
+
}
|
|
968
|
+
fs.writeFileSync(path.join(thoughtsDir, `PR-${prContent.number}.md`), md, 'utf-8');
|
|
969
|
+
}
|
|
970
|
+
catch (err) {
|
|
971
|
+
// Best-effort, like the Notion and Sentry context writes: never break
|
|
972
|
+
// creation because a context file could not be written.
|
|
973
|
+
logError('workspaces', 'Failed to save PR content', {
|
|
974
|
+
workspaceId: workspace.id,
|
|
975
|
+
error: err instanceof Error ? err.message : String(err),
|
|
976
|
+
});
|
|
977
|
+
}
|
|
978
|
+
}
|
|
979
|
+
// ------------------------------------------------------------------------
|
|
882
980
|
// Update Notion status if both property name and value are configured
|
|
883
981
|
const notionStatusProp = effectiveSettings.notionStatusProperty;
|
|
884
982
|
const notionTargetStatus = effectiveSettings.notionInProgressStatus;
|
|
@@ -259,6 +259,7 @@ export function createClaudeCodeEngine() {
|
|
|
259
259
|
// Guard so the post-result drain watchdog and the natural loop exit (or
|
|
260
260
|
// catch block) never both emit `session:ended` for the same run.
|
|
261
261
|
let sessionEndedEmitted = false;
|
|
262
|
+
let isCompacting = false;
|
|
262
263
|
const emitSessionEnded = (reason, exitCode) => {
|
|
263
264
|
if (sessionEndedEmitted)
|
|
264
265
|
return;
|
|
@@ -307,6 +308,12 @@ export function createClaudeCodeEngine() {
|
|
|
307
308
|
}, RESULT_DRAIN_TIMEOUT_MS);
|
|
308
309
|
resultDrainTimer.unref?.();
|
|
309
310
|
};
|
|
311
|
+
const clearResultDrainWatchdog = () => {
|
|
312
|
+
if (!resultDrainTimer)
|
|
313
|
+
return;
|
|
314
|
+
clearTimeout(resultDrainTimer);
|
|
315
|
+
resultDrainTimer = undefined;
|
|
316
|
+
};
|
|
310
317
|
// Safety net for the "waiting on a background subagent" branch below:
|
|
311
318
|
// if `activeSubagentToolCallIds` never empties (a missed/unrecognised
|
|
312
319
|
// terminal notification), the workspace would otherwise stay `executing`
|
|
@@ -347,6 +354,11 @@ export function createClaudeCodeEngine() {
|
|
|
347
354
|
turnLiveness.start();
|
|
348
355
|
try {
|
|
349
356
|
for await (const msg of q) {
|
|
357
|
+
// This SDK message proves that any drain condition armed by a
|
|
358
|
+
// *previous* message is no longer current. A drain armed while
|
|
359
|
+
// processing this message remains active if the generator then
|
|
360
|
+
// goes silent.
|
|
361
|
+
clearResultDrainWatchdog();
|
|
350
362
|
const events = mapSdkMessage(msg, mapperState);
|
|
351
363
|
for (const ev of events) {
|
|
352
364
|
if (ev.kind !== 'subagent:progress')
|
|
@@ -356,6 +368,14 @@ export function createClaudeCodeEngine() {
|
|
|
356
368
|
else
|
|
357
369
|
activeSubagentTaskIds.delete(ev.toolCallId);
|
|
358
370
|
}
|
|
371
|
+
for (const ev of events) {
|
|
372
|
+
if (ev.kind === 'session:compacting')
|
|
373
|
+
isCompacting = ev.active;
|
|
374
|
+
// Older SDKs can emit only compact_boundary, without a trailing
|
|
375
|
+
// status update. This boundary marks compaction as complete.
|
|
376
|
+
else if (ev.kind === 'session:compacted')
|
|
377
|
+
isCompacting = false;
|
|
378
|
+
}
|
|
359
379
|
// The stall watchdog is only armed while we're waiting purely on
|
|
360
380
|
// background subagents (no further SDK turn expected unless one
|
|
361
381
|
// reports done). React the moment the last one clears instead of
|
|
@@ -378,7 +398,7 @@ export function createClaudeCodeEngine() {
|
|
|
378
398
|
// A background subagent or a pending permission card can stay
|
|
379
399
|
// legitimately quiet for minutes; their own dedicated watchdogs
|
|
380
400
|
// own those windows, so suspend the turn deadline meanwhile.
|
|
381
|
-
if (activeSubagentTaskIds.size > 0 || pendingResolvers.size > 0)
|
|
401
|
+
if (activeSubagentTaskIds.size > 0 || pendingResolvers.size > 0 || isCompacting)
|
|
382
402
|
turnLiveness.pause();
|
|
383
403
|
else
|
|
384
404
|
turnLiveness.resume();
|
|
@@ -434,10 +454,7 @@ export function createClaudeCodeEngine() {
|
|
|
434
454
|
// The post-result drain watchdog (if armed) is moot once the
|
|
435
455
|
// iterator has exited — clear it so a healthy run never triggers a
|
|
436
456
|
// stray abort after it already ended.
|
|
437
|
-
|
|
438
|
-
clearTimeout(resultDrainTimer);
|
|
439
|
-
resultDrainTimer = undefined;
|
|
440
|
-
}
|
|
457
|
+
clearResultDrainWatchdog();
|
|
441
458
|
turnLiveness.stop();
|
|
442
459
|
clearSubagentStallWatchdog();
|
|
443
460
|
// Drain any callback still pending (SDK terminated while awaiting an
|
|
@@ -102,6 +102,79 @@ function mapBktPr(raw, ci = { rollup: null, checks: [] }) {
|
|
|
102
102
|
readyToMerge: deriveReadyToMerge({ state, ci, reviewDecision, reviewers, mergeable }),
|
|
103
103
|
};
|
|
104
104
|
}
|
|
105
|
+
/** Bitbucket Cloud's `next` URL always carries a `page` query parameter.
|
|
106
|
+
* Extract the bare page number so it matches Data Center's `nextPageStart`
|
|
107
|
+
* shape — both then feed the same `--page <n>` flag on the next call. */
|
|
108
|
+
function extractCloudPageCursor(next) {
|
|
109
|
+
try {
|
|
110
|
+
const page = new URL(next).searchParams.get('page');
|
|
111
|
+
if (page)
|
|
112
|
+
return page;
|
|
113
|
+
}
|
|
114
|
+
catch {
|
|
115
|
+
// fall through to regex fallback below
|
|
116
|
+
}
|
|
117
|
+
const match = next.match(/[?&]page=(\d+)/);
|
|
118
|
+
return match ? match[1] : next;
|
|
119
|
+
}
|
|
120
|
+
/** Map a Bitbucket Cloud or Data Center PR page. Exported for tests. */
|
|
121
|
+
export function mapBitbucketPage(raw) {
|
|
122
|
+
const payload = record(raw);
|
|
123
|
+
const rows = Array.isArray(payload.values) ? payload.values : [];
|
|
124
|
+
const items = rows.map((row) => {
|
|
125
|
+
const pr = record(row);
|
|
126
|
+
const sourceRepo = record(record(pr.source).repository);
|
|
127
|
+
const destRepo = record(record(pr.destination).repository);
|
|
128
|
+
const fromRepo = record(record(pr.fromRef).repository);
|
|
129
|
+
const toRepo = record(record(pr.toRef).repository);
|
|
130
|
+
const sourceId = string(sourceRepo.uuid) || String(fromRepo.id ?? '');
|
|
131
|
+
const destId = string(destRepo.uuid) || String(toRepo.id ?? '');
|
|
132
|
+
const updated = pr.updated_on ?? pr.updatedDate;
|
|
133
|
+
return {
|
|
134
|
+
number: number(pr.id),
|
|
135
|
+
title: string(pr.title),
|
|
136
|
+
url: url(pr),
|
|
137
|
+
author: login(pr.author),
|
|
138
|
+
headBranch: branch(pr, 'source', 'fromRef'),
|
|
139
|
+
baseBranch: branch(pr, 'destination', 'toRef'),
|
|
140
|
+
isFork: Boolean(sourceId) && Boolean(destId) && sourceId !== destId,
|
|
141
|
+
isDraft: pr.draft === true,
|
|
142
|
+
updatedAt: typeof updated === 'string' ? updated : typeof updated === 'number' ? new Date(updated).toISOString() : '',
|
|
143
|
+
// `bkt pr list` doesn't expose CI/review status in list output; avoiding a per-row lookup call here.
|
|
144
|
+
ci: null,
|
|
145
|
+
reviewDecision: null,
|
|
146
|
+
};
|
|
147
|
+
});
|
|
148
|
+
const next = string(payload.next);
|
|
149
|
+
if (next)
|
|
150
|
+
return { items, nextCursor: extractCloudPageCursor(next) };
|
|
151
|
+
if (payload.isLastPage === false && payload.nextPageStart != null) {
|
|
152
|
+
return { items, nextCursor: String(payload.nextPageStart) };
|
|
153
|
+
}
|
|
154
|
+
return { items, nextCursor: null };
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* Narrow a Bitbucket page client-side: `bkt` has no author/reviewer filter.
|
|
158
|
+
*
|
|
159
|
+
* `review-requested` is a KNOWN, DELIBERATE no-op here: `PullRequestSummary`
|
|
160
|
+
* (shared across all four forge providers) carries no reviewer information,
|
|
161
|
+
* and fetching it would mean one extra Bitbucket API call per row in the
|
|
162
|
+
* list — every other filter/search operation here works on data already in
|
|
163
|
+
* hand. Rather than guess at reviewer data this provider doesn't have,
|
|
164
|
+
* `review-requested` falls through to "no filtering" like `'all'`, same as
|
|
165
|
+
* before this comment was added; `mine`, unlike before, is now genuinely
|
|
166
|
+
* narrowed once `currentUser` is supplied by the caller.
|
|
167
|
+
*/
|
|
168
|
+
export function filterBitbucketItems(items, opts, currentUser) {
|
|
169
|
+
const search = opts.search?.trim().toLowerCase();
|
|
170
|
+
return items.filter((item) => {
|
|
171
|
+
if (search && !item.title.toLowerCase().includes(search))
|
|
172
|
+
return false;
|
|
173
|
+
if (opts.filter === 'mine' && currentUser)
|
|
174
|
+
return item.author === currentUser;
|
|
175
|
+
return true;
|
|
176
|
+
});
|
|
177
|
+
}
|
|
105
178
|
function availabilityFromError(err) {
|
|
106
179
|
const code = err.code;
|
|
107
180
|
if (code === 'ENOENT')
|
|
@@ -207,6 +280,7 @@ export const bitbucketProvider = {
|
|
|
207
280
|
canChangePrBase: false,
|
|
208
281
|
canMergeRequest: true,
|
|
209
282
|
canDeleteRemoteBranch: true,
|
|
283
|
+
canListPullRequests: true,
|
|
210
284
|
requestTermShort: 'PR',
|
|
211
285
|
},
|
|
212
286
|
async isAvailable(repoPath) {
|
|
@@ -283,4 +357,16 @@ export const bitbucketProvider = {
|
|
|
283
357
|
async deleteRemoteBranch(repoPath, branchName) {
|
|
284
358
|
await execFileAsync('git', ['push', 'origin', '--delete', branchName], bktOptions(repoPath));
|
|
285
359
|
},
|
|
360
|
+
async listPullRequests(repoPath, opts) {
|
|
361
|
+
const target = await resolveTarget(repoPath);
|
|
362
|
+
const args = ['pr', 'list', '--limit', String(opts.perPage), ...targetArgs(target), '--json'];
|
|
363
|
+
if (opts.cursor)
|
|
364
|
+
args.push('--page', opts.cursor);
|
|
365
|
+
const { stdout } = await execFileAsync('bkt', bktArgs(target, args), bktOptions(repoPath));
|
|
366
|
+
const page = mapBitbucketPage(JSON.parse(stdout || '{}'));
|
|
367
|
+
if (opts.filter === 'all' && !opts.search?.trim())
|
|
368
|
+
return page;
|
|
369
|
+
const currentUser = getGlobalSettings().bitbucketUsername || undefined;
|
|
370
|
+
return { ...page, items: filterBitbucketItems(page.items, opts, currentUser) };
|
|
371
|
+
},
|
|
286
372
|
};
|
|
@@ -83,6 +83,61 @@ function mapGhPrToSnapshot(raw) {
|
|
|
83
83
|
readyToMerge: deriveReadyToMerge({ state, ci: { rollup, checks }, reviewDecision, reviewers, mergeable }),
|
|
84
84
|
};
|
|
85
85
|
}
|
|
86
|
+
function record(value) {
|
|
87
|
+
return value !== null && typeof value === 'object' ? value : {};
|
|
88
|
+
}
|
|
89
|
+
const GH_LIST_QUERY = `
|
|
90
|
+
query($q:String!,$first:Int!,$after:String){
|
|
91
|
+
search(query:$q, type:ISSUE, first:$first, after:$after){
|
|
92
|
+
pageInfo{ hasNextPage endCursor }
|
|
93
|
+
nodes{
|
|
94
|
+
... on PullRequest {
|
|
95
|
+
number title url isDraft updatedAt
|
|
96
|
+
author{ login }
|
|
97
|
+
headRefName baseRefName isCrossRepository reviewDecision
|
|
98
|
+
commits(last:1){ nodes{ commit{ statusCheckRollup{ state } } } }
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}`;
|
|
103
|
+
/** Build the `search` qualifier string. Exported for tests. */
|
|
104
|
+
export function buildGithubSearchQuery(nameWithOwner, opts) {
|
|
105
|
+
const parts = [`repo:${nameWithOwner}`, 'is:pr', 'is:open'];
|
|
106
|
+
if (opts.filter === 'mine')
|
|
107
|
+
parts.push('author:@me');
|
|
108
|
+
if (opts.filter === 'review-requested')
|
|
109
|
+
parts.push('review-requested:@me');
|
|
110
|
+
parts.push('sort:updated-desc');
|
|
111
|
+
const query = parts.join(' ');
|
|
112
|
+
const search = opts.search?.trim();
|
|
113
|
+
return search ? `${query} ${search}` : query;
|
|
114
|
+
}
|
|
115
|
+
/** Map a raw `gh api graphql` response into a page. Exported for tests. */
|
|
116
|
+
export function mapGithubSearchPage(raw) {
|
|
117
|
+
const search = record(record(record(raw).data).search);
|
|
118
|
+
const pageInfo = record(search.pageInfo);
|
|
119
|
+
const nodes = Array.isArray(search.nodes) ? search.nodes : [];
|
|
120
|
+
const items = nodes.map((node) => {
|
|
121
|
+
const pr = record(node);
|
|
122
|
+
const commitNodes = Array.isArray(record(pr.commits).nodes) ? record(pr.commits).nodes : [];
|
|
123
|
+
const rollup = record(record(record(commitNodes[0]).commit).statusCheckRollup).state;
|
|
124
|
+
const decision = pr.reviewDecision;
|
|
125
|
+
return {
|
|
126
|
+
number: typeof pr.number === 'number' ? pr.number : 0,
|
|
127
|
+
title: typeof pr.title === 'string' ? pr.title : '',
|
|
128
|
+
url: typeof pr.url === 'string' ? pr.url : '',
|
|
129
|
+
author: typeof record(pr.author).login === 'string' ? record(pr.author).login : '',
|
|
130
|
+
headBranch: typeof pr.headRefName === 'string' ? pr.headRefName : '',
|
|
131
|
+
baseBranch: typeof pr.baseRefName === 'string' ? pr.baseRefName : '',
|
|
132
|
+
isFork: pr.isCrossRepository === true,
|
|
133
|
+
isDraft: pr.isDraft === true,
|
|
134
|
+
updatedAt: typeof pr.updatedAt === 'string' ? pr.updatedAt : '',
|
|
135
|
+
ci: typeof rollup === 'string' ? rollup : null,
|
|
136
|
+
reviewDecision: typeof decision === 'string' ? decision : null,
|
|
137
|
+
};
|
|
138
|
+
});
|
|
139
|
+
return { items, nextCursor: pageInfo.hasNextPage === true ? String(pageInfo.endCursor ?? '') || null : null };
|
|
140
|
+
}
|
|
86
141
|
/** Map an execFile rejection to a ForgeAvailability reason. */
|
|
87
142
|
function availabilityFromError(err) {
|
|
88
143
|
const code = err.code;
|
|
@@ -101,6 +156,7 @@ export const githubProvider = {
|
|
|
101
156
|
canChangePrBase: true,
|
|
102
157
|
canMergeRequest: true,
|
|
103
158
|
canDeleteRemoteBranch: true,
|
|
159
|
+
canListPullRequests: true,
|
|
104
160
|
requestTermShort: 'PR',
|
|
105
161
|
},
|
|
106
162
|
async isAvailable(repoPath) {
|
|
@@ -140,4 +196,24 @@ export const githubProvider = {
|
|
|
140
196
|
async deleteRemoteBranch(repoPath, branch) {
|
|
141
197
|
await execFileAsync('git', ['push', 'origin', '--delete', branch], cliOptions(repoPath));
|
|
142
198
|
},
|
|
199
|
+
async listPullRequests(repoPath, opts) {
|
|
200
|
+
const { stdout: repoJson } = await execFileAsync('gh', ['repo', 'view', '--json', 'nameWithOwner'], cliOptions(repoPath));
|
|
201
|
+
const nameWithOwner = String(JSON.parse(repoJson).nameWithOwner ?? '');
|
|
202
|
+
if (!nameWithOwner)
|
|
203
|
+
throw new Error('Could not determine the GitHub repository for this path');
|
|
204
|
+
const args = [
|
|
205
|
+
'api',
|
|
206
|
+
'graphql',
|
|
207
|
+
'-f',
|
|
208
|
+
`query=${GH_LIST_QUERY}`,
|
|
209
|
+
'-f',
|
|
210
|
+
`q=${buildGithubSearchQuery(nameWithOwner, opts)}`,
|
|
211
|
+
'-F',
|
|
212
|
+
`first=${opts.perPage}`,
|
|
213
|
+
];
|
|
214
|
+
if (opts.cursor)
|
|
215
|
+
args.push('-f', `after=${opts.cursor}`);
|
|
216
|
+
const { stdout } = await execFileAsync('gh', args, cliOptions(repoPath));
|
|
217
|
+
return mapGithubSearchPage(JSON.parse(stdout));
|
|
218
|
+
},
|
|
143
219
|
};
|