@loicngr/kobo 1.7.17 → 1.7.19
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 +14 -0
- package/dist/server/db/migrations.js +19 -0
- package/dist/server/db/schema.js +1 -0
- package/dist/server/routes/settings.js +2 -1
- package/dist/server/routes/workspaces.js +227 -55
- package/dist/server/services/agent/orchestrator.js +1 -0
- package/dist/server/services/ci-fix-template-service.js +34 -0
- package/dist/server/services/pr-watcher-service.js +25 -4
- package/dist/server/services/settings-service.js +37 -0
- package/dist/server/services/workspace-service.js +18 -0
- package/dist/server/utils/branch-resolver.js +48 -0
- package/package.json +1 -1
- package/src/client/dist/spa/assets/{ActivityFeed-CDhLuhI3.js → ActivityFeed-4FmMcUzT.js} +1 -1
- package/src/client/dist/spa/assets/ChangelogPage-BTIcoTHM.js +1 -0
- package/src/client/dist/spa/assets/ChangelogPage-CQ33An2f.css +1 -0
- package/src/client/dist/spa/assets/{ClosePopup-A-tSm4aa.js → ClosePopup-cTUqStmn.js} +1 -1
- package/src/client/dist/spa/assets/CreatePage-DNaNhVRI.js +2 -0
- package/src/client/dist/spa/assets/CreatePage-DcqSlkD5.css +1 -0
- package/src/client/dist/spa/assets/{DiffViewer-CcgF65Mo.js → DiffViewer-CyQ3xZ9y.js} +3 -3
- package/src/client/dist/spa/assets/{HealthPage-Bw-9__wY.js → HealthPage-DeVdQ57j.js} +1 -1
- package/src/client/dist/spa/assets/{MainLayout-KEr19FOv.css → MainLayout-Cq4nlTx3.css} +1 -1
- package/src/client/dist/spa/assets/{MainLayout-C2ULeep-.js → MainLayout-Dbh8sZj9.js} +4 -4
- package/src/client/dist/spa/assets/QChip-D2TVel5I.js +1 -0
- package/src/client/dist/spa/assets/{QExpansionItem-CgJQdznK.js → QExpansionItem-DEwaFJ-l.js} +1 -1
- package/src/client/dist/spa/assets/{QMenu-NVDU7D3u.js → QMenu-DqPMRjtc.js} +1 -1
- package/src/client/dist/spa/assets/QScrollArea-BRZX618r.js +1 -0
- package/src/client/dist/spa/assets/QScrollObserver-B-U-JUNE.js +1 -0
- package/src/client/dist/spa/assets/{QTooltip-BC7PnZJ1.js → QTooltip-CayfN3AV.js} +1 -1
- package/src/client/dist/spa/assets/{SearchPage-D2x2X7K7.js → SearchPage-CoStu9JM.js} +1 -1
- package/src/client/dist/spa/assets/SettingsPage-BGHYXEmO.js +9 -0
- package/src/client/dist/spa/assets/{SettingsPage-BTGPZaqC.css → SettingsPage-DV10Se1K.css} +1 -1
- package/src/client/dist/spa/assets/{TouchPan-D0fJnlOC.js → TouchPan-CPNDMpOw.js} +1 -1
- package/src/client/dist/spa/assets/WorkspacePage-BWMcLN6l.js +4 -0
- package/src/client/dist/spa/assets/WorkspacePage-iHGFGSo5.css +1 -0
- package/src/client/dist/spa/assets/{build-path-tree-CyqReJkk.js → build-path-tree-C-c8LVcf.js} +1 -1
- package/src/client/dist/spa/assets/{cssMode-BsT_HBz-.js → cssMode-iA5eJq1h.js} +1 -1
- package/src/client/dist/spa/assets/{editor.api-CIxiApSC.js → editor.api-B85Aec2m.js} +1 -1
- package/src/client/dist/spa/assets/{editor.main-D-1-e3_n.js → editor.main-D55xuhHL.js} +3 -3
- package/src/client/dist/spa/assets/{engineFeatures-baMvMT98.js → engineFeatures-BIgXMO3j.js} +1 -1
- package/src/client/dist/spa/assets/{expand-template-CF0lBr4L.js → expand-template-B3Mua3eb.js} +1 -1
- package/src/client/dist/spa/assets/{freemarker2-q2PyKiM2.js → freemarker2-D_SY8XAy.js} +1 -1
- package/src/client/dist/spa/assets/{handlebars-DoaZIK6r.js → handlebars-DYHJKLiX.js} +1 -1
- package/src/client/dist/spa/assets/{html-DHcse-fd.js → html-CBV84hsj.js} +1 -1
- package/src/client/dist/spa/assets/{htmlMode-DPZCU7DB.js → htmlMode-oAFa-pC8.js} +1 -1
- package/src/client/dist/spa/assets/i18n-BSaDh2Jk.js +1 -0
- package/src/client/dist/spa/assets/index-Dxua-s9Z.js +52 -0
- package/src/client/dist/spa/assets/{javascript-Ddw7c3eO.js → javascript-Dtki-9M1.js} +1 -1
- package/src/client/dist/spa/assets/{jsonMode-vmAmvg_N.js → jsonMode-Dxgt3z_U.js} +1 -1
- package/src/client/dist/spa/assets/kobo-commands-ItyV88dT.js +9 -0
- package/src/client/dist/spa/assets/{liquid-Bwz3vr4k.js → liquid-AqH0N0cI.js} +1 -1
- package/src/client/dist/spa/assets/{mdx-B2uBtVef.js → mdx-B2rkMIhO.js} +1 -1
- package/src/client/dist/spa/assets/{monaco.contribution-B3cRHiXp.js → monaco.contribution-fnrc8bv7.js} +2 -2
- package/src/client/dist/spa/assets/{notifications-Hq-6rEYv.js → notifications-BmNlCl7A.js} +1 -1
- package/src/client/dist/spa/assets/permissionModes-B2nlp6IT.js +1 -0
- package/src/client/dist/spa/assets/{python-D5ykDuc8.js → python-DoQszzJj.js} +1 -1
- package/src/client/dist/spa/assets/{razor-CStvtec5.js → razor-CLSwglaW.js} +1 -1
- package/src/client/dist/spa/assets/render-chat-markdown-B3NEX5ju.js +60 -0
- package/src/client/dist/spa/assets/skill-suite-prompts-8f_JW79j.js +36 -0
- package/src/client/dist/spa/assets/{tsMode-WEgYYKFt.js → tsMode-CshiJr76.js} +1 -1
- package/src/client/dist/spa/assets/{typescript-BzFHuirT.js → typescript-CCaWpP5V.js} +1 -1
- package/src/client/dist/spa/assets/{use-onboarding-C98jCHZu.js → use-onboarding-DxlBVNno.js} +1 -1
- package/src/client/dist/spa/assets/{xml-NuCdCQMI.js → xml-WKm1dO8L.js} +1 -1
- package/src/client/dist/spa/assets/{yaml-CkTTgcUh.js → yaml-CSs38mRE.js} +1 -1
- package/src/client/dist/spa/index.html +2 -2
- package/src/client/dist/spa/assets/CreatePage-DgHjL4cZ.css +0 -1
- package/src/client/dist/spa/assets/CreatePage-xIjxPliD.js +0 -2
- package/src/client/dist/spa/assets/QChip-DnJyQVs2.js +0 -36
- package/src/client/dist/spa/assets/QScrollArea-_Ji1cgqL.js +0 -1
- package/src/client/dist/spa/assets/SettingsPage-BLb9B9iY.js +0 -9
- package/src/client/dist/spa/assets/WorkspacePage-BlAVs03z.js +0 -4
- package/src/client/dist/spa/assets/WorkspacePage-DTV0oWHS.css +0 -1
- package/src/client/dist/spa/assets/i18n-DwzfgKc3.js +0 -1
- package/src/client/dist/spa/assets/index-DMUFfCIq.js +0 -52
- package/src/client/dist/spa/assets/kobo-commands-B2AhWe1S.js +0 -9
- package/src/client/dist/spa/assets/permissionModes-DuwIe4ty.js +0 -1
- package/src/client/dist/spa/assets/render-chat-markdown-BywKNkXe.js +0 -60
- /package/src/client/dist/spa/assets/{QBadge-NEwszYs7.js → QBadge-DNQSqPg-.js} +0 -0
- /package/src/client/dist/spa/assets/{QBanner-Jsq4uJZs.js → QBanner-C_TvxtXi.js} +0 -0
- /package/src/client/dist/spa/assets/{QList-B3TuWSqL.js → QList-CRYZxnPD.js} +0 -0
- /package/src/client/dist/spa/assets/{QPage-DO_bQyV_.js → QPage-3-ah4oor.js} +0 -0
- /package/src/client/dist/spa/assets/{QSpace-DONPiIes.js → QSpace-Crcx82On.js} +0 -0
- /package/src/client/dist/spa/assets/{_plugin-vue_export-helper-Cj6tcsj6.js → _plugin-vue_export-helper-r4mAJOHR.js} +0 -0
- /package/src/client/dist/spa/assets/{documents-B3nitIYF.js → documents-CX2-4fhr.js} +0 -0
- /package/src/client/dist/spa/assets/{formatters-9dcj2tyJ.js → formatters-BKR66G3t.js} +0 -0
- /package/src/client/dist/spa/assets/{use-quasar-k24tGxE-.js → use-quasar-q6dh7QVJ.js} +0 -0
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,20 @@ 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.7.19
|
|
8
|
+
|
|
9
|
+
- feat(client): archived banner, Fix-CI button, changelog page, prompt-retry banner (Open archived workspace)
|
|
10
|
+
- feat(server): workspace lifecycle, CI failure UX, collision-safe creation (error in setup script)
|
|
11
|
+
- feat(client): disable mutating actions on archived workspaces
|
|
12
|
+
- feat(pr-watcher): mark workspace unread on attention transitions (ci request changes)
|
|
13
|
+
- chore(deps): npm audit fix
|
|
14
|
+
- chore(CHANGELOG): update
|
|
15
|
+
|
|
16
|
+
## 1.7.18
|
|
17
|
+
|
|
18
|
+
- chore(audit): fix npm audit
|
|
19
|
+
- feat(client): collapsible ask-user-question panel
|
|
20
|
+
|
|
7
21
|
## 1.7.17
|
|
8
22
|
|
|
9
23
|
- feat: per-workspace chat history + inline file editing in the diff viewer
|
|
@@ -300,6 +300,25 @@ export const migrations = [
|
|
|
300
300
|
`);
|
|
301
301
|
},
|
|
302
302
|
},
|
|
303
|
+
{
|
|
304
|
+
version: 25,
|
|
305
|
+
name: 'add-workspace-initial-prompt',
|
|
306
|
+
migrate: (db) => {
|
|
307
|
+
// Stores the initial agent prompt assembled at workspace-creation time so
|
|
308
|
+
// it survives a setup-script crash. Cleared after the agent successfully
|
|
309
|
+
// ingests it; null otherwise (= nothing pending or already consumed).
|
|
310
|
+
// Defensive: skip if the workspaces table doesn't exist yet — covers
|
|
311
|
+
// synthetic test DBs that seed only a subset of tables before running
|
|
312
|
+
// migrations.
|
|
313
|
+
const table = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='workspaces'").get();
|
|
314
|
+
if (!table)
|
|
315
|
+
return;
|
|
316
|
+
const cols = db.prepare('PRAGMA table_info(workspaces)').all();
|
|
317
|
+
if (!cols.some((c) => c.name === 'initial_prompt')) {
|
|
318
|
+
db.prepare('ALTER TABLE workspaces ADD COLUMN initial_prompt TEXT').run();
|
|
319
|
+
}
|
|
320
|
+
},
|
|
321
|
+
},
|
|
303
322
|
];
|
|
304
323
|
/** Current schema version — always equals the highest migration version. */
|
|
305
324
|
export const SCHEMA_VERSION = migrations.length > 0 ? migrations[migrations.length - 1].version : 1;
|
package/dist/server/db/schema.js
CHANGED
|
@@ -3,7 +3,7 @@ import { DEFAULT_NOTION_INITIAL_PROMPT, DEFAULT_SENTRY_INITIAL_PROMPT, } from '.
|
|
|
3
3
|
import { DEFAULT_REVIEW_PROMPT_TEMPLATE } from '../services/review-template-service.js';
|
|
4
4
|
import { DEFAULT_CHANGE_SOURCE_BRANCH_SCRIPT } from '../services/settings-defaults.js';
|
|
5
5
|
import * as settingsService from '../services/settings-service.js';
|
|
6
|
-
import { DEFAULT_GIT_CONVENTIONS, DEFAULT_PR_PROMPT_TEMPLATE, } from '../services/settings-service.js';
|
|
6
|
+
import { DEFAULT_CI_FIX_PROMPT_TEMPLATE, DEFAULT_GIT_CONVENTIONS, DEFAULT_PR_PROMPT_TEMPLATE, } from '../services/settings-service.js';
|
|
7
7
|
import { listTemplates, replaceAllTemplates } from '../services/templates-service.js';
|
|
8
8
|
/** Hono sub-router for global and per-project settings CRUD. */
|
|
9
9
|
const app = new Hono();
|
|
@@ -37,6 +37,7 @@ app.get('/defaults', (c) => {
|
|
|
37
37
|
return c.json({
|
|
38
38
|
prPromptTemplate: DEFAULT_PR_PROMPT_TEMPLATE,
|
|
39
39
|
reviewPromptTemplate: DEFAULT_REVIEW_PROMPT_TEMPLATE,
|
|
40
|
+
ciFixPromptTemplate: DEFAULT_CI_FIX_PROMPT_TEMPLATE,
|
|
40
41
|
gitConventions: DEFAULT_GIT_CONVENTIONS,
|
|
41
42
|
notionInitialPromptTemplate: DEFAULT_NOTION_INITIAL_PROMPT,
|
|
42
43
|
sentryInitialPromptTemplate: DEFAULT_SENTRY_INITIAL_PROMPT,
|
|
@@ -13,6 +13,7 @@ import * as archiveScriptService from '../services/archive-script-service.js';
|
|
|
13
13
|
import * as autoLoopService from '../services/auto-loop-service.js';
|
|
14
14
|
import { changeSourceBranch } from '../services/change-source-branch-service.js';
|
|
15
15
|
import { listChatHistory, pushChatHistory } from '../services/chat-history-service.js';
|
|
16
|
+
import { renderCiFixTemplate } from '../services/ci-fix-template-service.js';
|
|
16
17
|
import * as cronService from '../services/cron-service.js';
|
|
17
18
|
import * as devServerService from '../services/dev-server-service.js';
|
|
18
19
|
import { saveWorkspaceFile, shaOf } from '../services/file-editor-service.js';
|
|
@@ -35,9 +36,10 @@ import * as wakeupService from '../services/wakeup-service.js';
|
|
|
35
36
|
import * as wsService from '../services/websocket-service.js';
|
|
36
37
|
import * as workspaceService from '../services/workspace-service.js';
|
|
37
38
|
import * as worktreeService from '../services/worktree-service.js';
|
|
39
|
+
import { resolveUniqueBranchAndPath } from '../utils/branch-resolver.js';
|
|
38
40
|
import * as gitOps from '../utils/git-ops.js';
|
|
39
41
|
import { slugifyProjectName } from '../utils/project-slug.js';
|
|
40
|
-
import { resolveSiblingWorkspaceWorktreePath
|
|
42
|
+
import { resolveSiblingWorkspaceWorktreePath } from '../utils/worktree-paths.js';
|
|
41
43
|
/** Hono sub-router for workspace CRUD, tasks, agent lifecycle, git operations, and PR creation. */
|
|
42
44
|
const app = new Hono();
|
|
43
45
|
/** Tracks workspaces currently running a setup script to prevent concurrent executions. */
|
|
@@ -242,17 +244,33 @@ app.post('/', migrationGuard, async (c) => {
|
|
|
242
244
|
? slugifyProjectName(projectSettings?.displayName ?? '', body.projectPath)
|
|
243
245
|
: undefined;
|
|
244
246
|
// Resolve the prospective worktree path unconditionally so that:
|
|
245
|
-
// 1.
|
|
246
|
-
// 2. createWorkspace always receives the correct slug-prefixed path and
|
|
247
|
+
// 1. createWorkspace always receives the correct slug-prefixed path and
|
|
247
248
|
// never falls back to the no-slug resolver inside workspace-service.
|
|
249
|
+
// 2. When the requested branch / on-disk path is already taken we append
|
|
250
|
+
// a short hash (e.g. `feature/foo-A45C`) instead of rejecting the
|
|
251
|
+
// request — keeps the user's flow smooth at the cost of a longer
|
|
252
|
+
// branch name. The same hash is applied to BOTH the branch and the
|
|
253
|
+
// worktree path so they stay aligned.
|
|
248
254
|
let prospectiveWorktreePath;
|
|
255
|
+
let workingBranchAdjusted = false;
|
|
249
256
|
if (useReusedWorktree) {
|
|
250
257
|
prospectiveWorktreePath = body.worktreePath;
|
|
251
258
|
}
|
|
252
259
|
else {
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
260
|
+
try {
|
|
261
|
+
const resolved = resolveUniqueBranchAndPath({
|
|
262
|
+
projectPath: body.projectPath,
|
|
263
|
+
baseBranch: workingBranch,
|
|
264
|
+
worktreesPath: globalSettings.worktreesPath,
|
|
265
|
+
projectSlug,
|
|
266
|
+
});
|
|
267
|
+
workingBranch = resolved.workingBranch;
|
|
268
|
+
prospectiveWorktreePath = resolved.worktreePath;
|
|
269
|
+
workingBranchAdjusted = resolved.adjusted;
|
|
270
|
+
}
|
|
271
|
+
catch (err) {
|
|
272
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
273
|
+
return c.json({ error: message }, 409);
|
|
256
274
|
}
|
|
257
275
|
}
|
|
258
276
|
let workspace = workspaceService.createWorkspace({
|
|
@@ -413,33 +431,10 @@ app.post('/', migrationGuard, async (c) => {
|
|
|
413
431
|
console.error('[workspaces] Failed to write .git-conventions.md:', err);
|
|
414
432
|
}
|
|
415
433
|
}
|
|
416
|
-
//
|
|
434
|
+
// The setup script runs LATER (after the brainstorm prompt is built and
|
|
435
|
+
// persisted via setInitialPrompt). This guarantees the prompt survives a
|
|
436
|
+
// setup-script crash and can be replayed by /:id/start.
|
|
417
437
|
let setupScriptFailed = false;
|
|
418
|
-
// Skip the setup script when reusing an existing worktree — the user
|
|
419
|
-
// already has the environment set up there and rerunning it could be
|
|
420
|
-
// destructive (drop a node_modules they curated, etc.).
|
|
421
|
-
if (effectiveSettings.setupScript && !body.skipSetupScript && !useReusedWorktree) {
|
|
422
|
-
workspaceService.updateWorkspaceStatus(workspace.id, 'extracting');
|
|
423
|
-
wsService.emit(workspace.id, 'setup:output', { text: '[kobo] Running setup script...' });
|
|
424
|
-
try {
|
|
425
|
-
const result = await runSetupScript(workspace.id, worktreePath, effectiveSettings.setupScript, {
|
|
426
|
-
workspaceName: workspace.name,
|
|
427
|
-
branchName: workingBranch,
|
|
428
|
-
sourceBranch: body.sourceBranch,
|
|
429
|
-
projectPath: body.projectPath,
|
|
430
|
-
});
|
|
431
|
-
if (result.exitCode !== 0) {
|
|
432
|
-
workspaceService.updateWorkspaceStatus(workspace.id, 'error');
|
|
433
|
-
setupScriptFailed = true;
|
|
434
|
-
}
|
|
435
|
-
}
|
|
436
|
-
catch (err) {
|
|
437
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
438
|
-
console.error(`[workspaces] Setup script error: ${message}`);
|
|
439
|
-
workspaceService.updateWorkspaceStatus(workspace.id, 'error');
|
|
440
|
-
setupScriptFailed = true;
|
|
441
|
-
}
|
|
442
|
-
}
|
|
443
438
|
// Save Notion content as markdown in worktree
|
|
444
439
|
let notionFilePath = null;
|
|
445
440
|
if (notionContent && body.notionUrl) {
|
|
@@ -551,10 +546,15 @@ app.post('/', migrationGuard, async (c) => {
|
|
|
551
546
|
console.error('[workspaces] Failed to update Notion status:', err);
|
|
552
547
|
});
|
|
553
548
|
}
|
|
554
|
-
//
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
549
|
+
// Build the initial agent prompt BEFORE the setup script runs so a crash
|
|
550
|
+
// there cannot lose user input (description, Notion/Sentry context, tasks).
|
|
551
|
+
// The prompt is persisted to workspace.initial_prompt; the agent-start path
|
|
552
|
+
// clears it once successfully consumed. The workspace status is moved to
|
|
553
|
+
// `brainstorming` LATER — either after a successful setup script, or
|
|
554
|
+
// directly if no setup script is configured. This keeps the
|
|
555
|
+
// VALID_TRANSITIONS contract intact (`created → extracting → brainstorming`
|
|
556
|
+
// vs `created → brainstorming` when setup is skipped).
|
|
557
|
+
{
|
|
558
558
|
// Resolve the per-feature initial-prompt templates with single-fallback
|
|
559
559
|
// semantics: project || global is already handled inside getEffectiveSettings,
|
|
560
560
|
// and a whitespace-only string acts as a user escape hatch (skip injection).
|
|
@@ -684,20 +684,79 @@ ${AUTO_LOOP_HARD_RULES}`;
|
|
|
684
684
|
|
|
685
685
|
Once the brainstorming + planning steps above are complete and you have a saved plan file, output [BRAINSTORM_COMPLETE] on its own line BEFORE starting implementation. Kōbō uses that marker to transition the workspace from \`brainstorming\` to \`executing\`. Then proceed with implementation.`;
|
|
686
686
|
}
|
|
687
|
+
// Persist the assembled prompt so a setup-script crash (or any later
|
|
688
|
+
// failure) doesn't lose the user's input. Cleared once the agent
|
|
689
|
+
// successfully ingests it below or by POST /:id/start on retry.
|
|
687
690
|
try {
|
|
688
|
-
|
|
689
|
-
// Persist the initial prompt in the feed so it's visible in the chat,
|
|
690
|
-
// tagged with the freshly created session id so the strict session filter shows it.
|
|
691
|
-
wsService.emit(workspace.id, 'user:message', { content: brainstormPrompt, sender: 'system-prompt' }, agent.agentSessionId);
|
|
691
|
+
workspaceService.setInitialPrompt(workspace.id, brainstormPrompt);
|
|
692
692
|
}
|
|
693
693
|
catch (err) {
|
|
694
|
-
|
|
695
|
-
|
|
694
|
+
console.error('[workspaces] setInitialPrompt failed:', err);
|
|
695
|
+
}
|
|
696
|
+
// Setup script — runs AFTER the prompt is persisted so a crash here
|
|
697
|
+
// leaves the workspace in `error` state with `initial_prompt` ready for
|
|
698
|
+
// a retry via POST /:id/start. Skipped when reusing an existing worktree
|
|
699
|
+
// (rerunning could be destructive — drop a curated node_modules, etc.).
|
|
700
|
+
const setupScriptConfigured = effectiveSettings.setupScript && !body.skipSetupScript && !useReusedWorktree;
|
|
701
|
+
if (setupScriptConfigured) {
|
|
702
|
+
workspaceService.updateWorkspaceStatus(workspace.id, 'extracting');
|
|
703
|
+
wsService.emit(workspace.id, 'setup:output', { text: '[kobo] Running setup script...' });
|
|
696
704
|
try {
|
|
705
|
+
const result = await runSetupScript(workspace.id, worktreePath, effectiveSettings.setupScript, {
|
|
706
|
+
workspaceName: workspace.name,
|
|
707
|
+
branchName: workingBranch,
|
|
708
|
+
sourceBranch: body.sourceBranch,
|
|
709
|
+
projectPath: body.projectPath,
|
|
710
|
+
});
|
|
711
|
+
if (result.exitCode !== 0) {
|
|
712
|
+
workspaceService.updateWorkspaceStatus(workspace.id, 'error');
|
|
713
|
+
setupScriptFailed = true;
|
|
714
|
+
}
|
|
715
|
+
else {
|
|
716
|
+
workspaceService.updateWorkspaceStatus(workspace.id, 'brainstorming');
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
catch (err) {
|
|
720
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
721
|
+
console.error(`[workspaces] Setup script error: ${message}`);
|
|
697
722
|
workspaceService.updateWorkspaceStatus(workspace.id, 'error');
|
|
723
|
+
setupScriptFailed = true;
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
else {
|
|
727
|
+
// No setup step → go straight from `created` to `brainstorming`.
|
|
728
|
+
workspaceService.updateWorkspaceStatus(workspace.id, 'brainstorming');
|
|
729
|
+
}
|
|
730
|
+
if (setupScriptFailed) {
|
|
731
|
+
wsService.emit(workspace.id, 'setup:output', {
|
|
732
|
+
text: '[kobo] Setup script failed — the agent was NOT started. Your initial prompt has been saved. ' +
|
|
733
|
+
'Fix the setup script (Settings → Scripts) and click Start to retry with the original prompt.',
|
|
734
|
+
});
|
|
735
|
+
}
|
|
736
|
+
else {
|
|
737
|
+
try {
|
|
738
|
+
const agent = agentManager.startAgent(workspace.id, worktreePath, brainstormPrompt, workspace.model, false, workspace.agentPermissionMode, undefined, workspace.reasoningEffort);
|
|
739
|
+
// Persist the initial prompt in the feed so it's visible in the chat,
|
|
740
|
+
// tagged with the freshly created session id so the strict session filter shows it.
|
|
741
|
+
wsService.emit(workspace.id, 'user:message', { content: brainstormPrompt, sender: 'system-prompt' }, agent.agentSessionId);
|
|
742
|
+
// Agent successfully ingested the prompt — clear it so /:id/start
|
|
743
|
+
// doesn't replay it on a future restart.
|
|
744
|
+
try {
|
|
745
|
+
workspaceService.clearInitialPrompt(workspace.id);
|
|
746
|
+
}
|
|
747
|
+
catch (err) {
|
|
748
|
+
console.error('[workspaces] clearInitialPrompt failed:', err);
|
|
749
|
+
}
|
|
698
750
|
}
|
|
699
|
-
catch {
|
|
700
|
-
|
|
751
|
+
catch (err) {
|
|
752
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
753
|
+
console.error(`[workspaces] Failed to start agent: ${message}`);
|
|
754
|
+
try {
|
|
755
|
+
workspaceService.updateWorkspaceStatus(workspace.id, 'error');
|
|
756
|
+
}
|
|
757
|
+
catch {
|
|
758
|
+
/* already logged */
|
|
759
|
+
}
|
|
701
760
|
}
|
|
702
761
|
}
|
|
703
762
|
}
|
|
@@ -719,6 +778,12 @@ Once the brainstorming + planning steps above are complete and you have a saved
|
|
|
719
778
|
}
|
|
720
779
|
// Return created workspace with tasks
|
|
721
780
|
const workspaceWithTasks = workspaceService.getWorkspaceWithTasks(workspace.id);
|
|
781
|
+
if (workingBranchAdjusted) {
|
|
782
|
+
// Surface the auto-suffix via a custom header so the client can toast
|
|
783
|
+
// "Branch already existed — created <new-branch> instead". The actual
|
|
784
|
+
// resolved branch is on the returned workspace already.
|
|
785
|
+
c.header('X-Kobo-Branch-Adjusted', '1');
|
|
786
|
+
}
|
|
722
787
|
return c.json(workspaceWithTasks, 201);
|
|
723
788
|
}
|
|
724
789
|
catch (err) {
|
|
@@ -1979,7 +2044,13 @@ app.post('/:id/start', migrationGuard, async (c) => {
|
|
|
1979
2044
|
const body = await c.req
|
|
1980
2045
|
.json()
|
|
1981
2046
|
.catch(() => ({ prompt: undefined, agentSessionId: undefined, resume: undefined }));
|
|
1982
|
-
|
|
2047
|
+
// Prompt resolution order:
|
|
2048
|
+
// 1. Explicit body.prompt (user-typed in the chat input)
|
|
2049
|
+
// 2. Pending workspace.initial_prompt — set by workspace creation when
|
|
2050
|
+
// a setup-script crash prevented the original agent launch
|
|
2051
|
+
// 3. Generic resume fallback
|
|
2052
|
+
const pendingInitialPrompt = workspace.initialPrompt && workspace.initialPrompt.length > 0 ? workspace.initialPrompt : null;
|
|
2053
|
+
const prompt = body.prompt ?? pendingInitialPrompt ?? 'Continue the previous task where you left off.';
|
|
1983
2054
|
const agentSessionId = body.agentSessionId;
|
|
1984
2055
|
const resume = body.resume === true;
|
|
1985
2056
|
// Stop existing agent if running
|
|
@@ -1998,6 +2069,21 @@ app.post('/:id/start', migrationGuard, async (c) => {
|
|
|
1998
2069
|
if (body.prompt) {
|
|
1999
2070
|
wsService.emit(id, 'user:message', { content: body.prompt, sender: 'user' }, agent.agentSessionId);
|
|
2000
2071
|
}
|
|
2072
|
+
else if (pendingInitialPrompt) {
|
|
2073
|
+
// Pending brainstorm prompt — surface it in the feed so the user sees
|
|
2074
|
+
// what the agent just received, mirroring the workspace-creation flow.
|
|
2075
|
+
wsService.emit(id, 'user:message', { content: pendingInitialPrompt, sender: 'system-prompt' }, agent.agentSessionId);
|
|
2076
|
+
}
|
|
2077
|
+
// Clear the pending prompt once the agent has been handed it — subsequent
|
|
2078
|
+
// /:id/start calls fall back to the generic "Continue…" string.
|
|
2079
|
+
if (pendingInitialPrompt) {
|
|
2080
|
+
try {
|
|
2081
|
+
workspaceService.clearInitialPrompt(id);
|
|
2082
|
+
}
|
|
2083
|
+
catch (err) {
|
|
2084
|
+
console.error('[workspaces] clearInitialPrompt after start failed:', err);
|
|
2085
|
+
}
|
|
2086
|
+
}
|
|
2001
2087
|
return c.json({ status: 'started' });
|
|
2002
2088
|
}
|
|
2003
2089
|
catch (err) {
|
|
@@ -2032,11 +2118,13 @@ app.get('/:id/git-stats', async (c) => {
|
|
|
2032
2118
|
}
|
|
2033
2119
|
});
|
|
2034
2120
|
// GET /api/workspaces/:id/diff?mode=branch|unpushed — list changed files
|
|
2035
|
-
// - `branch` (default): committed + working tree changes vs
|
|
2036
|
-
//
|
|
2121
|
+
// - `branch` (default): committed + working tree changes vs
|
|
2122
|
+
// `origin/<sourceBranch>` (which can be ahead of the local ref when
|
|
2123
|
+
// upstream landed merges Kōbō hasn't pulled). The handler refreshes
|
|
2124
|
+
// `origin/<sourceBranch>` synchronously so the diff is never stale.
|
|
2037
2125
|
// - `unpushed`: committed-only changes vs `origin/<workingBranch>`,
|
|
2038
2126
|
// i.e. what the next `git push` will send.
|
|
2039
|
-
app.get('/:id/diff', (c) => {
|
|
2127
|
+
app.get('/:id/diff', async (c) => {
|
|
2040
2128
|
try {
|
|
2041
2129
|
const id = c.req.param('id');
|
|
2042
2130
|
const mode = c.req.query('mode') === 'unpushed' ? 'unpushed' : 'branch';
|
|
@@ -2048,6 +2136,13 @@ app.get('/:id/diff', (c) => {
|
|
|
2048
2136
|
return c.json({ error: `Workspace '${id}' not found` }, 404);
|
|
2049
2137
|
}
|
|
2050
2138
|
const worktreePath = workspace.worktreePath;
|
|
2139
|
+
// Sync fetch in `branch` mode so the diff reflects upstream's HEAD, not a
|
|
2140
|
+
// stale local copy of the source branch. Best-effort: a failed fetch
|
|
2141
|
+
// (offline, no remote configured) still returns the diff against whatever
|
|
2142
|
+
// `origin/<source>` we have in cache.
|
|
2143
|
+
if (mode === 'branch') {
|
|
2144
|
+
await gitOps.fetchSourceBranchAsync(worktreePath, workspace.sourceBranch);
|
|
2145
|
+
}
|
|
2051
2146
|
const files = mode === 'unpushed'
|
|
2052
2147
|
? gitOps.getUnpushedChangedFiles(worktreePath, workspace.workingBranch)
|
|
2053
2148
|
: gitOps.getChangedFiles(worktreePath, workspace.sourceBranch, includeUntracked);
|
|
@@ -2132,10 +2227,11 @@ app.post('/:id/rollback-file', async (c) => {
|
|
|
2132
2227
|
}
|
|
2133
2228
|
});
|
|
2134
2229
|
// GET /api/workspaces/:id/branch-divergence?limit=50
|
|
2135
|
-
// Returns commits on the working branch ahead of
|
|
2136
|
-
// commits on
|
|
2137
|
-
//
|
|
2138
|
-
|
|
2230
|
+
// Returns commits on the working branch ahead of `origin/<sourceBranch>`
|
|
2231
|
+
// (`ahead`) and commits on `origin/<sourceBranch>` not yet on the working
|
|
2232
|
+
// branch (`behind`). Refreshes `origin/<sourceBranch>` synchronously so the
|
|
2233
|
+
// counts and lists are never computed against a stale local source ref.
|
|
2234
|
+
app.get('/:id/branch-divergence', async (c) => {
|
|
2139
2235
|
try {
|
|
2140
2236
|
const id = c.req.param('id');
|
|
2141
2237
|
const workspace = workspaceService.getWorkspace(id);
|
|
@@ -2146,6 +2242,7 @@ app.get('/:id/branch-divergence', (c) => {
|
|
|
2146
2242
|
const parsed = parseInt(limitRaw ?? '50', 10);
|
|
2147
2243
|
const limit = Math.min(Math.max(1, Number.isNaN(parsed) ? 50 : parsed), 200);
|
|
2148
2244
|
const worktreePath = workspace.worktreePath;
|
|
2245
|
+
await gitOps.fetchSourceBranchAsync(worktreePath, workspace.sourceBranch);
|
|
2149
2246
|
const ahead = gitOps.listBranchCommits(worktreePath, workspace.sourceBranch, workspace.workingBranch, limit);
|
|
2150
2247
|
const behind = gitOps.listCommitsBehind(worktreePath, workspace.sourceBranch, workspace.workingBranch, limit);
|
|
2151
2248
|
c.header('Cache-Control', 'no-store');
|
|
@@ -2161,9 +2258,11 @@ app.get('/:id/branch-divergence', (c) => {
|
|
|
2161
2258
|
return c.json({ error: message }, 500);
|
|
2162
2259
|
}
|
|
2163
2260
|
});
|
|
2164
|
-
// GET /api/workspaces/:id/commits?limit=50 — list commits between
|
|
2165
|
-
// and HEAD, each tagged with whether it's already
|
|
2166
|
-
|
|
2261
|
+
// GET /api/workspaces/:id/commits?limit=50 — list commits between
|
|
2262
|
+
// `origin/<sourceBranch>` and HEAD, each tagged with whether it's already
|
|
2263
|
+
// pushed to origin/<branch>. Refreshes `origin/<sourceBranch>` synchronously
|
|
2264
|
+
// so the list is not computed against a stale local source ref.
|
|
2265
|
+
app.get('/:id/commits', async (c) => {
|
|
2167
2266
|
try {
|
|
2168
2267
|
const id = c.req.param('id');
|
|
2169
2268
|
const workspace = workspaceService.getWorkspace(id);
|
|
@@ -2173,6 +2272,7 @@ app.get('/:id/commits', (c) => {
|
|
|
2173
2272
|
const limitRaw = c.req.query('limit');
|
|
2174
2273
|
const limit = Math.min(Math.max(1, parseInt(limitRaw ?? '50', 10) || 50), 200);
|
|
2175
2274
|
const worktreePath = workspace.worktreePath;
|
|
2275
|
+
await gitOps.fetchSourceBranchAsync(worktreePath, workspace.sourceBranch);
|
|
2176
2276
|
const commits = gitOps.listBranchCommits(worktreePath, workspace.sourceBranch, workspace.workingBranch, limit);
|
|
2177
2277
|
c.header('Cache-Control', 'no-store');
|
|
2178
2278
|
return c.json({ commits, sourceBranch: workspace.sourceBranch, workingBranch: workspace.workingBranch });
|
|
@@ -2835,6 +2935,78 @@ app.post('/:id/start-review', async (c) => {
|
|
|
2835
2935
|
return c.json({ error: message }, 500);
|
|
2836
2936
|
}
|
|
2837
2937
|
});
|
|
2938
|
+
// POST /api/workspaces/:id/start-ci-fix — dispatch the configured CI-fix
|
|
2939
|
+
// prompt to the agent when the workspace's PR has failing CI.
|
|
2940
|
+
// Resumes the current session (or starts a fresh one if none is alive).
|
|
2941
|
+
app.post('/:id/start-ci-fix', migrationGuard, async (c) => {
|
|
2942
|
+
try {
|
|
2943
|
+
const id = c.req.param('id');
|
|
2944
|
+
const workspace = workspaceService.getWorkspace(id);
|
|
2945
|
+
if (!workspace) {
|
|
2946
|
+
return c.json({ error: `Workspace '${id}' not found` }, 404);
|
|
2947
|
+
}
|
|
2948
|
+
if (workspace.archivedAt) {
|
|
2949
|
+
return c.json({ error: `Workspace '${id}' is archived` }, 400);
|
|
2950
|
+
}
|
|
2951
|
+
// Refresh the PR snapshot best-effort so the action operates on the
|
|
2952
|
+
// freshest CI rollup; fall back to the cache if the refresh fails.
|
|
2953
|
+
let snapshot = await refreshPrSnapshot(id).catch(() => null);
|
|
2954
|
+
if (!snapshot) {
|
|
2955
|
+
snapshot = getAllPrSnapshots()[id] ?? null;
|
|
2956
|
+
}
|
|
2957
|
+
if (!snapshot || snapshot.state !== 'OPEN' || snapshot.ci.rollup !== 'FAILURE') {
|
|
2958
|
+
return c.json({ error: 'No failing CI detected on this workspace' }, 400);
|
|
2959
|
+
}
|
|
2960
|
+
const effective = settingsService.getEffectiveSettings(workspace.projectPath);
|
|
2961
|
+
const template = effective.ciFixPromptTemplate;
|
|
2962
|
+
if (!template || template.trim().length === 0) {
|
|
2963
|
+
return c.json({ error: 'No CI-fix prompt template configured. Set one in Settings → Prompts.' }, 400);
|
|
2964
|
+
}
|
|
2965
|
+
const failedChecks = snapshot.ci.checks
|
|
2966
|
+
.filter((check) => check.conclusion === 'FAILURE')
|
|
2967
|
+
.map((check) => ({ name: check.name, detailsUrl: check.detailsUrl }));
|
|
2968
|
+
// Best-effort first details URL as a stand-in for `ci_run_url` — the
|
|
2969
|
+
// forge providers don't expose a top-level run URL, but the first failed
|
|
2970
|
+
// check's `detailsUrl` reliably points back at the failing run.
|
|
2971
|
+
const ciRunUrl = failedChecks.find((c) => c.detailsUrl)?.detailsUrl ?? null;
|
|
2972
|
+
const rendered = renderCiFixTemplate(template, {
|
|
2973
|
+
workspace,
|
|
2974
|
+
prNumber: snapshot.number,
|
|
2975
|
+
prUrl: snapshot.url,
|
|
2976
|
+
prTitle: snapshot.title,
|
|
2977
|
+
failedChecks,
|
|
2978
|
+
ciRunUrl,
|
|
2979
|
+
});
|
|
2980
|
+
try {
|
|
2981
|
+
wakeupService.cancel(workspace.id, 'user-message');
|
|
2982
|
+
}
|
|
2983
|
+
catch {
|
|
2984
|
+
/* swallow */
|
|
2985
|
+
}
|
|
2986
|
+
const session = workspaceService.getActiveSession(workspace.id);
|
|
2987
|
+
let emitSessionId = session?.id;
|
|
2988
|
+
try {
|
|
2989
|
+
agentManager.sendMessage(workspace.id, rendered);
|
|
2990
|
+
}
|
|
2991
|
+
catch {
|
|
2992
|
+
try {
|
|
2993
|
+
const agent = agentManager.startAgent(workspace.id, workspace.worktreePath, rendered, workspace.model, true /* resume */, workspace.agentPermissionMode, undefined, workspace.reasoningEffort);
|
|
2994
|
+
workspaceService.updateWorkspaceStatus(workspace.id, 'executing');
|
|
2995
|
+
emitSessionId = agent?.agentSessionId ?? emitSessionId;
|
|
2996
|
+
}
|
|
2997
|
+
catch (resumeErr) {
|
|
2998
|
+
const msg = resumeErr instanceof Error ? resumeErr.message : String(resumeErr);
|
|
2999
|
+
return c.json({ error: `Failed to dispatch CI-fix prompt: ${msg}` }, 500);
|
|
3000
|
+
}
|
|
3001
|
+
}
|
|
3002
|
+
wsService.emit(workspace.id, 'user:message', { content: rendered, sender: 'user' }, emitSessionId);
|
|
3003
|
+
return c.json({ ok: true, failedChecksCount: failedChecks.length });
|
|
3004
|
+
}
|
|
3005
|
+
catch (err) {
|
|
3006
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
3007
|
+
return c.json({ error: message }, 500);
|
|
3008
|
+
}
|
|
3009
|
+
});
|
|
2838
3010
|
/** POST /api/workspaces/:id/mark-read — mark workspace as read (clear unread indicator). */
|
|
2839
3011
|
app.post('/:id/mark-read', (c) => {
|
|
2840
3012
|
try {
|
|
@@ -303,6 +303,7 @@ function readEffectiveSettingsSafe(projectPath) {
|
|
|
303
303
|
dangerouslySkipPermissions: true,
|
|
304
304
|
prPromptTemplate: '',
|
|
305
305
|
reviewPromptTemplate: '',
|
|
306
|
+
ciFixPromptTemplate: '',
|
|
306
307
|
notionInitialPromptTemplate: '',
|
|
307
308
|
sentryInitialPromptTemplate: '',
|
|
308
309
|
gitConventions: '',
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
function formatFailedJobs(checks) {
|
|
3
|
+
if (checks.length === 0)
|
|
4
|
+
return '(no failed jobs reported)';
|
|
5
|
+
return checks.map((c) => (c.detailsUrl ? `- ${c.name} — ${c.detailsUrl}` : `- ${c.name}`)).join('\n');
|
|
6
|
+
}
|
|
7
|
+
function buildVariableMap(ctx) {
|
|
8
|
+
return {
|
|
9
|
+
pr_number: ctx.prNumber != null ? String(ctx.prNumber) : '',
|
|
10
|
+
pr_url: ctx.prUrl ?? '',
|
|
11
|
+
pr_title: ctx.prTitle ?? '',
|
|
12
|
+
branch_name: ctx.workspace.workingBranch,
|
|
13
|
+
source_branch: ctx.workspace.sourceBranch,
|
|
14
|
+
workspace_name: ctx.workspace.name,
|
|
15
|
+
workspace_id: ctx.workspace.id,
|
|
16
|
+
project_name: path.basename(ctx.workspace.projectPath),
|
|
17
|
+
failed_jobs: formatFailedJobs(ctx.failedChecks),
|
|
18
|
+
ci_run_url: ctx.ciRunUrl ?? '',
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Render a CI-fix prompt template by substituting {{variable}} placeholders.
|
|
23
|
+
* Pure function — no I/O. Unknown variables are left as-is so user-defined
|
|
24
|
+
* placeholders can be resolved downstream by the agent itself.
|
|
25
|
+
*/
|
|
26
|
+
export function renderCiFixTemplate(template, ctx) {
|
|
27
|
+
const vars = buildVariableMap(ctx);
|
|
28
|
+
return template.replace(/\{\{(\w+)\}\}/g, (match, name) => {
|
|
29
|
+
if (Object.hasOwn(vars, name)) {
|
|
30
|
+
return vars[name];
|
|
31
|
+
}
|
|
32
|
+
return match;
|
|
33
|
+
});
|
|
34
|
+
}
|
|
@@ -5,7 +5,7 @@ import { resolveForge } from './forge/resolve.js';
|
|
|
5
5
|
import { computeGitStats } from './git-stats-service.js';
|
|
6
6
|
import { destroyTerminal } from './terminal-service.js';
|
|
7
7
|
import { emitEphemeral } from './websocket-service.js';
|
|
8
|
-
import { archiveWorkspace, getWorkspace, listWorkspaces, updateWorkspaceSourceBranch } from './workspace-service.js';
|
|
8
|
+
import { archiveWorkspace, getWorkspace, listWorkspaces, markWorkspaceUnread, updateWorkspaceSourceBranch, } from './workspace-service.js';
|
|
9
9
|
// ── PR Watcher ────────────────────────────────────────────────────────────────
|
|
10
10
|
// Polls GitHub every POLL_INTERVAL_MS to detect merged/closed PRs and
|
|
11
11
|
// automatically archive the corresponding workspace.
|
|
@@ -50,6 +50,19 @@ export function _resetForTest() {
|
|
|
50
50
|
lastKnownPr.clear();
|
|
51
51
|
lastKnownGitStats.clear();
|
|
52
52
|
}
|
|
53
|
+
/**
|
|
54
|
+
* Flip a workspace to unread (DB + WS event) on a PR-attention transition.
|
|
55
|
+
* Best-effort: a failure here must never break the watcher loop.
|
|
56
|
+
*/
|
|
57
|
+
function markUnread(workspaceId) {
|
|
58
|
+
try {
|
|
59
|
+
markWorkspaceUnread(workspaceId);
|
|
60
|
+
emitEphemeral(workspaceId, 'workspace:unread', { hasUnread: true });
|
|
61
|
+
}
|
|
62
|
+
catch (err) {
|
|
63
|
+
console.error('[pr-watcher] markUnread failed:', err instanceof Error ? err.message : err);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
53
66
|
export async function checkPrStatuses() {
|
|
54
67
|
const workspaces = listWorkspaces(false); // non-archived only
|
|
55
68
|
// Clean up entries for workspaces that no longer exist
|
|
@@ -117,15 +130,20 @@ export async function checkPrStatuses() {
|
|
|
117
130
|
});
|
|
118
131
|
continue; // do not run base-change detection on a workspace we just archived
|
|
119
132
|
}
|
|
120
|
-
// Review-decision transitions (only on OPEN PRs; first-sight is
|
|
121
|
-
// Reuses the baseline rule from base-change detection:
|
|
122
|
-
//
|
|
133
|
+
// Review-decision and CI transitions (only on OPEN PRs; first-sight is
|
|
134
|
+
// silent). Reuses the baseline rule from base-change detection: act only
|
|
135
|
+
// on an actual transition between two known states. Each notable
|
|
136
|
+
// transition (changes-requested newly raised, CI newly failing) flips
|
|
137
|
+
// `hasUnread` so the workspace card stands out as "something new to
|
|
138
|
+
// look at" in the drawer — the unread bit persists until the user opens
|
|
139
|
+
// the workspace, matching the existing read/unread UX.
|
|
123
140
|
if (pr.state === 'OPEN' && prev) {
|
|
124
141
|
if (prev.reviewDecision !== 'CHANGES_REQUESTED' && pr.reviewDecision === 'CHANGES_REQUESTED') {
|
|
125
142
|
emitEphemeral(ws.id, 'pr:changes-requested', {
|
|
126
143
|
prNumber: pr.number,
|
|
127
144
|
prUrl: pr.url,
|
|
128
145
|
});
|
|
146
|
+
markUnread(ws.id);
|
|
129
147
|
}
|
|
130
148
|
else if (prev.reviewDecision === 'CHANGES_REQUESTED' && pr.reviewDecision === 'APPROVED') {
|
|
131
149
|
emitEphemeral(ws.id, 'pr:approved', {
|
|
@@ -133,6 +151,9 @@ export async function checkPrStatuses() {
|
|
|
133
151
|
prUrl: pr.url,
|
|
134
152
|
});
|
|
135
153
|
}
|
|
154
|
+
if (prev.ci.rollup !== 'FAILURE' && pr.ci.rollup === 'FAILURE') {
|
|
155
|
+
markUnread(ws.id);
|
|
156
|
+
}
|
|
136
157
|
}
|
|
137
158
|
// Base-branch change detection. Only relevant for OPEN PRs — closed/
|
|
138
159
|
// merged PRs don't accept base changes. Skip if the GitHub response
|