@a5c-ai/adapters-gateway 5.1.1-staging.52898ebfc24f
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/README.md +20 -0
- package/dist/auth/bootstrap.d.ts +89 -0
- package/dist/auth/bootstrap.d.ts.map +1 -0
- package/dist/auth/bootstrap.js +222 -0
- package/dist/auth/bootstrap.js.map +1 -0
- package/dist/auth/hashing.d.ts +4 -0
- package/dist/auth/hashing.d.ts.map +1 -0
- package/dist/auth/hashing.js +27 -0
- package/dist/auth/hashing.js.map +1 -0
- package/dist/auth/middleware.d.ts +3 -0
- package/dist/auth/middleware.d.ts.map +1 -0
- package/dist/auth/middleware.js +17 -0
- package/dist/auth/middleware.js.map +1 -0
- package/dist/auth/tokens.d.ts +45 -0
- package/dist/auth/tokens.d.ts.map +1 -0
- package/dist/auth/tokens.js +186 -0
- package/dist/auth/tokens.js.map +1 -0
- package/dist/builtin-adapters.d.ts +17 -0
- package/dist/builtin-adapters.d.ts.map +1 -0
- package/dist/builtin-adapters.js +119 -0
- package/dist/builtin-adapters.js.map +1 -0
- package/dist/config.d.ts +37 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +97 -0
- package/dist/config.js.map +1 -0
- package/dist/fanout/client-conn.d.ts +20 -0
- package/dist/fanout/client-conn.d.ts.map +1 -0
- package/dist/fanout/client-conn.js +53 -0
- package/dist/fanout/client-conn.js.map +1 -0
- package/dist/fanout/subscriber.d.ts +12 -0
- package/dist/fanout/subscriber.d.ts.map +1 -0
- package/dist/fanout/subscriber.js +40 -0
- package/dist/fanout/subscriber.js.map +1 -0
- package/dist/index.d.ts +30 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +52 -0
- package/dist/index.js.map +1 -0
- package/dist/kanban/lib/config-loader.d.ts +29 -0
- package/dist/kanban/lib/config-loader.d.ts.map +1 -0
- package/dist/kanban/lib/config-loader.js +166 -0
- package/dist/kanban/lib/config-loader.js.map +1 -0
- package/dist/kanban/lib/config.d.ts +3 -0
- package/dist/kanban/lib/config.d.ts.map +1 -0
- package/dist/kanban/lib/config.js +6 -0
- package/dist/kanban/lib/config.js.map +1 -0
- package/dist/kanban/lib/create-global-registry.d.ts +28 -0
- package/dist/kanban/lib/create-global-registry.d.ts.map +1 -0
- package/dist/kanban/lib/create-global-registry.js +53 -0
- package/dist/kanban/lib/create-global-registry.js.map +1 -0
- package/dist/kanban/lib/dispatch-context-audit.d.ts +12 -0
- package/dist/kanban/lib/dispatch-context-audit.d.ts.map +1 -0
- package/dist/kanban/lib/dispatch-context-audit.js +44 -0
- package/dist/kanban/lib/dispatch-context-audit.js.map +1 -0
- package/dist/kanban/lib/error-handler.d.ts +28 -0
- package/dist/kanban/lib/error-handler.d.ts.map +1 -0
- package/dist/kanban/lib/error-handler.js +61 -0
- package/dist/kanban/lib/error-handler.js.map +1 -0
- package/dist/kanban/lib/global-registry.d.ts +49 -0
- package/dist/kanban/lib/global-registry.d.ts.map +1 -0
- package/dist/kanban/lib/global-registry.js +18 -0
- package/dist/kanban/lib/global-registry.js.map +1 -0
- package/dist/kanban/lib/parser.d.ts +36 -0
- package/dist/kanban/lib/parser.d.ts.map +1 -0
- package/dist/kanban/lib/parser.js +585 -0
- package/dist/kanban/lib/parser.js.map +1 -0
- package/dist/kanban/lib/path-resolver.d.ts +2 -0
- package/dist/kanban/lib/path-resolver.d.ts.map +1 -0
- package/dist/kanban/lib/path-resolver.js +16 -0
- package/dist/kanban/lib/path-resolver.js.map +1 -0
- package/dist/kanban/lib/review-service.d.ts +63 -0
- package/dist/kanban/lib/review-service.d.ts.map +1 -0
- package/dist/kanban/lib/review-service.js +571 -0
- package/dist/kanban/lib/review-service.js.map +1 -0
- package/dist/kanban/lib/run-cache.d.ts +36 -0
- package/dist/kanban/lib/run-cache.d.ts.map +1 -0
- package/dist/kanban/lib/run-cache.js +313 -0
- package/dist/kanban/lib/run-cache.js.map +1 -0
- package/dist/kanban/lib/server-init.d.ts +26 -0
- package/dist/kanban/lib/server-init.d.ts.map +1 -0
- package/dist/kanban/lib/server-init.js +179 -0
- package/dist/kanban/lib/server-init.js.map +1 -0
- package/dist/kanban/lib/services/automation-rule-service.d.ts +97 -0
- package/dist/kanban/lib/services/automation-rule-service.d.ts.map +1 -0
- package/dist/kanban/lib/services/automation-rule-service.js +806 -0
- package/dist/kanban/lib/services/automation-rule-service.js.map +1 -0
- package/dist/kanban/lib/services/automation-webhook-service.d.ts +44 -0
- package/dist/kanban/lib/services/automation-webhook-service.d.ts.map +1 -0
- package/dist/kanban/lib/services/automation-webhook-service.js +405 -0
- package/dist/kanban/lib/services/automation-webhook-service.js.map +1 -0
- package/dist/kanban/lib/services/backlog-query-service.d.ts +130 -0
- package/dist/kanban/lib/services/backlog-query-service.d.ts.map +1 -0
- package/dist/kanban/lib/services/backlog-query-service.js +1972 -0
- package/dist/kanban/lib/services/backlog-query-service.js.map +1 -0
- package/dist/kanban/lib/services/dispatch-context-label-service.d.ts +39 -0
- package/dist/kanban/lib/services/dispatch-context-label-service.d.ts.map +1 -0
- package/dist/kanban/lib/services/dispatch-context-label-service.js +160 -0
- package/dist/kanban/lib/services/dispatch-context-label-service.js.map +1 -0
- package/dist/kanban/lib/services/kanban-storage.d.ts +36 -0
- package/dist/kanban/lib/services/kanban-storage.d.ts.map +1 -0
- package/dist/kanban/lib/services/kanban-storage.js +26 -0
- package/dist/kanban/lib/services/kanban-storage.js.map +1 -0
- package/dist/kanban/lib/services/run-query-service.d.ts +79 -0
- package/dist/kanban/lib/services/run-query-service.d.ts.map +1 -0
- package/dist/kanban/lib/services/run-query-service.js +202 -0
- package/dist/kanban/lib/services/run-query-service.js.map +1 -0
- package/dist/kanban/lib/services/task-tag-service.d.ts +39 -0
- package/dist/kanban/lib/services/task-tag-service.d.ts.map +1 -0
- package/dist/kanban/lib/services/task-tag-service.js +145 -0
- package/dist/kanban/lib/services/task-tag-service.js.map +1 -0
- package/dist/kanban/lib/settings-section-storage.d.ts +13 -0
- package/dist/kanban/lib/settings-section-storage.d.ts.map +1 -0
- package/dist/kanban/lib/settings-section-storage.js +38 -0
- package/dist/kanban/lib/settings-section-storage.js.map +1 -0
- package/dist/kanban/lib/source-discovery.d.ts +10 -0
- package/dist/kanban/lib/source-discovery.d.ts.map +1 -0
- package/dist/kanban/lib/source-discovery.js +201 -0
- package/dist/kanban/lib/source-discovery.js.map +1 -0
- package/dist/kanban/lib/utils.d.ts +8 -0
- package/dist/kanban/lib/utils.d.ts.map +1 -0
- package/dist/kanban/lib/utils.js +116 -0
- package/dist/kanban/lib/utils.js.map +1 -0
- package/dist/kanban/lib/watcher.d.ts +14 -0
- package/dist/kanban/lib/watcher.d.ts.map +1 -0
- package/dist/kanban/lib/watcher.js +221 -0
- package/dist/kanban/lib/watcher.js.map +1 -0
- package/dist/kanban/lib/workspace-lifecycle.d.ts +68 -0
- package/dist/kanban/lib/workspace-lifecycle.d.ts.map +1 -0
- package/dist/kanban/lib/workspace-lifecycle.js +1085 -0
- package/dist/kanban/lib/workspace-lifecycle.js.map +1 -0
- package/dist/kanban/routes.d.ts +2 -0
- package/dist/kanban/routes.d.ts.map +1 -0
- package/dist/kanban/routes.js +1358 -0
- package/dist/kanban/routes.js.map +1 -0
- package/dist/kanban/types/breakpoint.d.ts +13 -0
- package/dist/kanban/types/breakpoint.d.ts.map +1 -0
- package/dist/kanban/types/breakpoint.js +3 -0
- package/dist/kanban/types/breakpoint.js.map +1 -0
- package/dist/kanban/types/index.d.ts +173 -0
- package/dist/kanban/types/index.d.ts.map +1 -0
- package/dist/kanban/types/index.js +3 -0
- package/dist/kanban/types/index.js.map +1 -0
- package/dist/logging.d.ts +7 -0
- package/dist/logging.d.ts.map +1 -0
- package/dist/logging.js +22 -0
- package/dist/logging.js.map +1 -0
- package/dist/notifications/types.d.ts +18 -0
- package/dist/notifications/types.d.ts.map +1 -0
- package/dist/notifications/types.js +2 -0
- package/dist/notifications/types.js.map +1 -0
- package/dist/notifications/webhook-out.d.ts +3 -0
- package/dist/notifications/webhook-out.d.ts.map +1 -0
- package/dist/notifications/webhook-out.js +55 -0
- package/dist/notifications/webhook-out.js.map +1 -0
- package/dist/pairing/short-code.d.ts +20 -0
- package/dist/pairing/short-code.d.ts.map +1 -0
- package/dist/pairing/short-code.js +50 -0
- package/dist/pairing/short-code.js.map +1 -0
- package/dist/protocol/errors.d.ts +10 -0
- package/dist/protocol/errors.d.ts.map +1 -0
- package/dist/protocol/errors.js +15 -0
- package/dist/protocol/errors.js.map +1 -0
- package/dist/protocol/frames.d.ts +107 -0
- package/dist/protocol/frames.d.ts.map +1 -0
- package/dist/protocol/frames.js +146 -0
- package/dist/protocol/frames.js.map +1 -0
- package/dist/protocol/v1.d.ts +111 -0
- package/dist/protocol/v1.d.ts.map +1 -0
- package/dist/protocol/v1.js +2 -0
- package/dist/protocol/v1.js.map +1 -0
- package/dist/runs/event-log-index.d.ts +29 -0
- package/dist/runs/event-log-index.d.ts.map +1 -0
- package/dist/runs/event-log-index.js +210 -0
- package/dist/runs/event-log-index.js.map +1 -0
- package/dist/runs/event-log.d.ts +25 -0
- package/dist/runs/event-log.d.ts.map +1 -0
- package/dist/runs/event-log.js +104 -0
- package/dist/runs/event-log.js.map +1 -0
- package/dist/runs/hook-broker.d.ts +18 -0
- package/dist/runs/hook-broker.d.ts.map +1 -0
- package/dist/runs/hook-broker.js +110 -0
- package/dist/runs/hook-broker.js.map +1 -0
- package/dist/runs/manager.d.ts +57 -0
- package/dist/runs/manager.d.ts.map +1 -0
- package/dist/runs/manager.js +757 -0
- package/dist/runs/manager.js.map +1 -0
- package/dist/runs/session-runtime.d.ts +8 -0
- package/dist/runs/session-runtime.d.ts.map +1 -0
- package/dist/runs/session-runtime.js +291 -0
- package/dist/runs/session-runtime.js.map +1 -0
- package/dist/runs/types.d.ts +55 -0
- package/dist/runs/types.d.ts.map +1 -0
- package/dist/runs/types.js +2 -0
- package/dist/runs/types.js.map +1 -0
- package/dist/server.d.ts +15 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/server.js +702 -0
- package/dist/server.js.map +1 -0
- package/dist/static/webui-server.d.ts +2 -0
- package/dist/static/webui-server.d.ts.map +1 -0
- package/dist/static/webui-server.js +97 -0
- package/dist/static/webui-server.js.map +1 -0
- package/package.json +68 -0
|
@@ -0,0 +1,1972 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { buildKanbanBoardSnapshot, buildKanbanBacklogSnapshot, createKanbanIssuePullRequest, evaluateKanbanIssueMove, linkKanbanIssueRepository, upsertKanbanProjectRepository, updateKanbanProjectRepositorySettings, summarizeKanbanReviewArtifact, } from '@a5c-ai/comm-adapter/kanban';
|
|
3
|
+
import { AppError } from '../error-handler.js';
|
|
4
|
+
import { ReviewService } from '../review-service.js';
|
|
5
|
+
import { RunQueryService } from './run-query-service.js';
|
|
6
|
+
import { KANBAN_BACKLOG_FILE_PATH, defaultKanbanStorageDeps, readKanbanStorageFile, writeKanbanStorageFile, } from './kanban-storage.js';
|
|
7
|
+
const SOURCE_PATH = 'packages/adapters/webui/src/kanban/gaps-and-debt.md';
|
|
8
|
+
const PROJECT_ID = 'kanban-app';
|
|
9
|
+
function normalizeDispatchContextLabelKey(value) {
|
|
10
|
+
return value
|
|
11
|
+
.trim()
|
|
12
|
+
.toLowerCase()
|
|
13
|
+
.replace(/[^a-z0-9]+/g, '_')
|
|
14
|
+
.replace(/^_+|_+$/g, '')
|
|
15
|
+
.replace(/_+/g, '_');
|
|
16
|
+
}
|
|
17
|
+
function normalizeDispatchContextLabels(labels) {
|
|
18
|
+
return labels
|
|
19
|
+
.map((label) => {
|
|
20
|
+
const normalizedLabel = label.label.trim();
|
|
21
|
+
const normalizedKey = normalizeDispatchContextLabelKey(label.key ?? normalizedLabel);
|
|
22
|
+
return {
|
|
23
|
+
...label,
|
|
24
|
+
label: normalizedLabel,
|
|
25
|
+
key: normalizedKey || normalizeDispatchContextLabelKey(label.id),
|
|
26
|
+
instruction: label.instruction.replace(/\r\n?/g, '\n').trim(),
|
|
27
|
+
description: label.description?.trim() || undefined,
|
|
28
|
+
order: typeof label.order === 'number' && Number.isFinite(label.order)
|
|
29
|
+
? Math.max(0, Math.floor(label.order))
|
|
30
|
+
: 0,
|
|
31
|
+
};
|
|
32
|
+
})
|
|
33
|
+
.sort((left, right) => left.order - right.order ||
|
|
34
|
+
left.label.localeCompare(right.label) ||
|
|
35
|
+
left.key.localeCompare(right.key) ||
|
|
36
|
+
left.id.localeCompare(right.id));
|
|
37
|
+
}
|
|
38
|
+
function normalizeDispatchContextLabelRefs(refs) {
|
|
39
|
+
return Array.from(new Set(refs
|
|
40
|
+
.map((ref) => ref.labelId?.trim() || '')
|
|
41
|
+
.filter(Boolean))).map((labelId) => ({ labelId }));
|
|
42
|
+
}
|
|
43
|
+
const debtLabel = {
|
|
44
|
+
id: 'label-debt',
|
|
45
|
+
name: 'debt',
|
|
46
|
+
description: 'Work tracked to close parity or structural debt.',
|
|
47
|
+
};
|
|
48
|
+
const defaultDispatchContextLabels = [
|
|
49
|
+
{
|
|
50
|
+
id: 'dispatch-context-label-tests-first',
|
|
51
|
+
key: 'tests_first',
|
|
52
|
+
label: 'Tests First',
|
|
53
|
+
instruction: 'Write or update deterministic verification before implementation changes.',
|
|
54
|
+
description: 'Keep delivery anchored to reproducible checks instead of post-hoc inspection.',
|
|
55
|
+
order: 0,
|
|
56
|
+
createdAt: '2026-04-24T00:00:00.000Z',
|
|
57
|
+
updatedAt: '2026-04-24T00:00:00.000Z',
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
id: 'dispatch-context-label-preserve-release-contract',
|
|
61
|
+
key: 'preserve_release_contract',
|
|
62
|
+
label: 'Preserve Release Contract',
|
|
63
|
+
instruction: 'Do not regress published package assets, files[] entries, verify:release, or CI/release/staging publish compatibility.',
|
|
64
|
+
description: 'Use for work that touches package surfaces or published artifacts.',
|
|
65
|
+
order: 1,
|
|
66
|
+
createdAt: '2026-04-24T00:00:00.000Z',
|
|
67
|
+
updatedAt: '2026-04-24T00:00:00.000Z',
|
|
68
|
+
},
|
|
69
|
+
{
|
|
70
|
+
id: 'dispatch-context-label-ui-copy-review',
|
|
71
|
+
key: 'ui_copy_review',
|
|
72
|
+
label: 'UI Copy Review',
|
|
73
|
+
instruction: 'Keep labels, prompts, and reviewer-facing text inspectable before and after dispatch.',
|
|
74
|
+
description: 'Use when dispatch context needs to stay visible in UI and audit surfaces.',
|
|
75
|
+
order: 2,
|
|
76
|
+
createdAt: '2026-04-24T00:00:00.000Z',
|
|
77
|
+
updatedAt: '2026-04-24T00:00:00.000Z',
|
|
78
|
+
},
|
|
79
|
+
];
|
|
80
|
+
const defaultCollaborators = [
|
|
81
|
+
{
|
|
82
|
+
id: 'tal',
|
|
83
|
+
displayName: 'Tal Muskal',
|
|
84
|
+
email: 'tal@a5c.ai',
|
|
85
|
+
role: 'owner',
|
|
86
|
+
},
|
|
87
|
+
{
|
|
88
|
+
id: 'nora',
|
|
89
|
+
displayName: 'Nora PM',
|
|
90
|
+
email: 'nora@a5c.ai',
|
|
91
|
+
role: 'maintainer',
|
|
92
|
+
},
|
|
93
|
+
{
|
|
94
|
+
id: 'marc',
|
|
95
|
+
displayName: 'Marc Design',
|
|
96
|
+
email: 'marc@a5c.ai',
|
|
97
|
+
role: 'contributor',
|
|
98
|
+
},
|
|
99
|
+
{
|
|
100
|
+
id: 'ivy',
|
|
101
|
+
displayName: 'Ivy QA',
|
|
102
|
+
email: 'ivy@a5c.ai',
|
|
103
|
+
role: 'viewer',
|
|
104
|
+
},
|
|
105
|
+
];
|
|
106
|
+
const defaultPermissionMatrix = [
|
|
107
|
+
{
|
|
108
|
+
action: 'manage-project-settings',
|
|
109
|
+
roles: ['owner', 'maintainer'],
|
|
110
|
+
description: 'Only elevated roles can change project-wide policy and visibility.',
|
|
111
|
+
},
|
|
112
|
+
{
|
|
113
|
+
action: 'manage-team-members',
|
|
114
|
+
roles: ['owner', 'maintainer'],
|
|
115
|
+
description: 'Roster and role changes stay with owners and maintainers.',
|
|
116
|
+
},
|
|
117
|
+
{
|
|
118
|
+
action: 'edit-board',
|
|
119
|
+
roles: ['owner', 'maintainer', 'contributor'],
|
|
120
|
+
description: 'Contributors can shape board flow without full admin access.',
|
|
121
|
+
},
|
|
122
|
+
{
|
|
123
|
+
action: 'assign-issues',
|
|
124
|
+
roles: ['owner', 'maintainer', 'contributor'],
|
|
125
|
+
description: 'Assignee and collaborator changes are shared team operations.',
|
|
126
|
+
},
|
|
127
|
+
{
|
|
128
|
+
action: 'review-work',
|
|
129
|
+
roles: ['owner', 'maintainer', 'contributor', 'viewer'],
|
|
130
|
+
description: 'All collaborators can see and participate in shared review context.',
|
|
131
|
+
},
|
|
132
|
+
{
|
|
133
|
+
action: 'manage-workspaces',
|
|
134
|
+
roles: ['owner', 'maintainer'],
|
|
135
|
+
description: 'Workspace lifecycle remains restricted beyond possession of a gateway token.',
|
|
136
|
+
},
|
|
137
|
+
];
|
|
138
|
+
const defaultTeamSettings = {
|
|
139
|
+
visibility: 'team',
|
|
140
|
+
defaultRole: 'contributor',
|
|
141
|
+
allowSelfAssign: true,
|
|
142
|
+
};
|
|
143
|
+
const defaultProjectSettings = {
|
|
144
|
+
reviewRequiredForDone: true,
|
|
145
|
+
activityScope: 'all-board-entities',
|
|
146
|
+
workspaceProvisioning: 'owners-maintainers',
|
|
147
|
+
};
|
|
148
|
+
function integrationProviderLabel(provider) {
|
|
149
|
+
return provider === 'azure-repos' ? 'Azure Repos' : 'GitHub';
|
|
150
|
+
}
|
|
151
|
+
function buildIntegrationConnection(provider, input) {
|
|
152
|
+
const blocked = input.status === 'disconnected' ||
|
|
153
|
+
input.status === 'expired-auth' ||
|
|
154
|
+
input.status === 'missing-scopes' ||
|
|
155
|
+
input.status === 'failing';
|
|
156
|
+
return {
|
|
157
|
+
provider,
|
|
158
|
+
label: integrationProviderLabel(provider),
|
|
159
|
+
status: input.status,
|
|
160
|
+
accountLabel: input.accountLabel,
|
|
161
|
+
connectedAt: input.connectedAt,
|
|
162
|
+
failureMessage: input.failureMessage,
|
|
163
|
+
missingScopes: input.missingScopes,
|
|
164
|
+
prerequisites: input.prerequisites,
|
|
165
|
+
guidance: input.guidance,
|
|
166
|
+
actions: {
|
|
167
|
+
canCreatePullRequest: input.actions?.canCreatePullRequest ?? !blocked,
|
|
168
|
+
canManagePullRequest: input.actions?.canManagePullRequest ?? !blocked,
|
|
169
|
+
canApproveFromReview: input.actions?.canApproveFromReview ?? !blocked,
|
|
170
|
+
reason: input.actions?.reason,
|
|
171
|
+
},
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
function buildRepositoryIntegrationState(provider, input) {
|
|
175
|
+
const blocked = input.status === 'disconnected' ||
|
|
176
|
+
input.status === 'expired-auth' ||
|
|
177
|
+
input.status === 'missing-scopes' ||
|
|
178
|
+
input.status === 'failing';
|
|
179
|
+
return {
|
|
180
|
+
provider,
|
|
181
|
+
status: input.status,
|
|
182
|
+
linkState: input.linkState,
|
|
183
|
+
failureMessage: input.failureMessage,
|
|
184
|
+
guidance: input.guidance,
|
|
185
|
+
missingScopes: input.missingScopes,
|
|
186
|
+
prerequisites: input.prerequisites,
|
|
187
|
+
actions: {
|
|
188
|
+
canCreatePullRequest: input.actions?.canCreatePullRequest ?? !blocked,
|
|
189
|
+
canManagePullRequest: input.actions?.canManagePullRequest ?? !blocked,
|
|
190
|
+
canApproveFromReview: input.actions?.canApproveFromReview ?? !blocked,
|
|
191
|
+
reason: input.actions?.reason,
|
|
192
|
+
},
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
const systemActor = {
|
|
196
|
+
kind: 'system',
|
|
197
|
+
id: 'kanban-seed',
|
|
198
|
+
displayName: 'Kanban seed data',
|
|
199
|
+
};
|
|
200
|
+
const defaultGithubIntegration = buildIntegrationConnection('github', {
|
|
201
|
+
status: 'connected',
|
|
202
|
+
accountLabel: 'a5c-ai',
|
|
203
|
+
connectedAt: '2026-04-24T00:00:00.000Z',
|
|
204
|
+
guidance: 'GitHub is ready for repository linking, linked PR state, and in-review approval flows.',
|
|
205
|
+
prerequisites: [
|
|
206
|
+
{
|
|
207
|
+
key: 'github-auth',
|
|
208
|
+
label: 'Signed in with a repository-capable GitHub account',
|
|
209
|
+
satisfied: true,
|
|
210
|
+
},
|
|
211
|
+
{
|
|
212
|
+
key: 'github-scopes',
|
|
213
|
+
label: 'Granted repo, pull request, and checks scopes',
|
|
214
|
+
satisfied: true,
|
|
215
|
+
},
|
|
216
|
+
{
|
|
217
|
+
key: 'github-default-org',
|
|
218
|
+
label: 'Selected a default GitHub org/repository context',
|
|
219
|
+
satisfied: true,
|
|
220
|
+
},
|
|
221
|
+
],
|
|
222
|
+
});
|
|
223
|
+
const defaultAzureReposIntegration = buildIntegrationConnection('azure-repos', {
|
|
224
|
+
status: 'partial-setup',
|
|
225
|
+
accountLabel: 'a5c-ai / Boards Platform',
|
|
226
|
+
guidance: 'Azure Repos is visible in the kanban setup surface, but repository/project binding still needs to be completed before linked PR actions can be enabled.',
|
|
227
|
+
prerequisites: [
|
|
228
|
+
{
|
|
229
|
+
key: 'azure-auth',
|
|
230
|
+
label: 'Connected an Azure DevOps organization',
|
|
231
|
+
satisfied: true,
|
|
232
|
+
},
|
|
233
|
+
{
|
|
234
|
+
key: 'azure-project',
|
|
235
|
+
label: 'Selected a default Azure DevOps project',
|
|
236
|
+
satisfied: false,
|
|
237
|
+
guidance: 'Choose the Azure DevOps project that owns the repo before linking work items.',
|
|
238
|
+
},
|
|
239
|
+
{
|
|
240
|
+
key: 'azure-scopes',
|
|
241
|
+
label: 'Granted code read/write and pull request scopes',
|
|
242
|
+
satisfied: false,
|
|
243
|
+
guidance: 'Grant Code (Read & Write) plus pull request scopes for linked PR creation.',
|
|
244
|
+
},
|
|
245
|
+
],
|
|
246
|
+
actions: {
|
|
247
|
+
canCreatePullRequest: false,
|
|
248
|
+
canManagePullRequest: false,
|
|
249
|
+
canApproveFromReview: false,
|
|
250
|
+
reason: 'Azure Repos setup is incomplete.',
|
|
251
|
+
},
|
|
252
|
+
});
|
|
253
|
+
function buildRepositoryId(provider, owner, name) {
|
|
254
|
+
return `repo-${provider}-${owner}-${name}`.toLowerCase().replace(/[^a-z0-9-]+/g, '-');
|
|
255
|
+
}
|
|
256
|
+
function buildRepositoryUrl(provider, owner, name) {
|
|
257
|
+
const fullName = `${owner}/${name}`;
|
|
258
|
+
switch (provider) {
|
|
259
|
+
case 'github':
|
|
260
|
+
return `https://github.com/${fullName}`;
|
|
261
|
+
case 'azure-repos':
|
|
262
|
+
return `https://dev.azure.com/${owner}/_git/${name}`;
|
|
263
|
+
case 'gitlab':
|
|
264
|
+
return `https://gitlab.com/${fullName}`;
|
|
265
|
+
case 'bitbucket':
|
|
266
|
+
return `https://bitbucket.org/${fullName}`;
|
|
267
|
+
default:
|
|
268
|
+
return undefined;
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
function parseReviewerList(value) {
|
|
272
|
+
return Array.from(new Set(value
|
|
273
|
+
.split(',')
|
|
274
|
+
.map((entry) => entry.trim())
|
|
275
|
+
.filter(Boolean)));
|
|
276
|
+
}
|
|
277
|
+
function parseRole(value) {
|
|
278
|
+
switch (value) {
|
|
279
|
+
case 'owner':
|
|
280
|
+
case 'maintainer':
|
|
281
|
+
case 'contributor':
|
|
282
|
+
case 'viewer':
|
|
283
|
+
return value;
|
|
284
|
+
default:
|
|
285
|
+
return 'contributor';
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
function buildActivityEntry(id, entityType, entityId, action, summary, actor, createdAt) {
|
|
289
|
+
return {
|
|
290
|
+
id,
|
|
291
|
+
entityType,
|
|
292
|
+
entityId,
|
|
293
|
+
action,
|
|
294
|
+
summary,
|
|
295
|
+
actor,
|
|
296
|
+
createdAt,
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
function appendUniqueIssueId(issueIds, issueId) {
|
|
300
|
+
return issueIds.includes(issueId) ? [...issueIds] : [...issueIds, issueId];
|
|
301
|
+
}
|
|
302
|
+
function appendUniqueString(values, value) {
|
|
303
|
+
const normalized = value?.trim() ?? '';
|
|
304
|
+
const current = values ? [...values] : [];
|
|
305
|
+
if (!normalized) {
|
|
306
|
+
return current;
|
|
307
|
+
}
|
|
308
|
+
return current.includes(normalized) ? current : [...current, normalized];
|
|
309
|
+
}
|
|
310
|
+
function arrayEquals(left, right) {
|
|
311
|
+
return left.length === right.length && left.every((value, index) => value === right[index]);
|
|
312
|
+
}
|
|
313
|
+
function issueDependencyEquals(left, right) {
|
|
314
|
+
return (left.length === right.length &&
|
|
315
|
+
left.every((value, index) => value.issueId === right[index]?.issueId && value.type === right[index]?.type));
|
|
316
|
+
}
|
|
317
|
+
function acceptanceCriteriaEquals(left, right) {
|
|
318
|
+
return (left.length === right.length &&
|
|
319
|
+
left.every((value, index) => value.id === right[index]?.id &&
|
|
320
|
+
value.title === right[index]?.title &&
|
|
321
|
+
value.satisfied === right[index]?.satisfied &&
|
|
322
|
+
value.notes === right[index]?.notes));
|
|
323
|
+
}
|
|
324
|
+
function normalizeWorkspacePath(value) {
|
|
325
|
+
return path.resolve(value);
|
|
326
|
+
}
|
|
327
|
+
function wouldCreateParentChildCycle(issues, parentIssueId, childIssueId) {
|
|
328
|
+
let currentIssueId = parentIssueId;
|
|
329
|
+
while (currentIssueId) {
|
|
330
|
+
if (currentIssueId === childIssueId) {
|
|
331
|
+
return true;
|
|
332
|
+
}
|
|
333
|
+
currentIssueId = issues.find((candidate) => candidate.id === currentIssueId)?.parentIssueId;
|
|
334
|
+
}
|
|
335
|
+
return false;
|
|
336
|
+
}
|
|
337
|
+
function resolveCollaboratorsById(members, ids) {
|
|
338
|
+
const roster = new Map(members.map((member) => [member.id, member]));
|
|
339
|
+
return ids
|
|
340
|
+
.map((id) => roster.get(id))
|
|
341
|
+
.filter((member) => Boolean(member));
|
|
342
|
+
}
|
|
343
|
+
function normalizeStoredIssueDispatchContextLabelRefs(refs, dispatchContextLabels) {
|
|
344
|
+
const knownLabelIds = new Set(dispatchContextLabels.map((label) => label.id));
|
|
345
|
+
return normalizeDispatchContextLabelRefs(refs ?? []).filter((ref) => knownLabelIds.has(ref.labelId));
|
|
346
|
+
}
|
|
347
|
+
function sanitizeStoredIssue(issue, dispatchContextLabels) {
|
|
348
|
+
if (!issue.dispatch) {
|
|
349
|
+
return issue;
|
|
350
|
+
}
|
|
351
|
+
return {
|
|
352
|
+
...issue,
|
|
353
|
+
dispatch: {
|
|
354
|
+
...issue.dispatch,
|
|
355
|
+
contextLabels: normalizeStoredIssueDispatchContextLabelRefs(issue.dispatch.contextLabels, dispatchContextLabels),
|
|
356
|
+
},
|
|
357
|
+
};
|
|
358
|
+
}
|
|
359
|
+
function resolveDispatchContextLabelRefs(dispatchContextLabels, dispatchContextLabelIds) {
|
|
360
|
+
const refs = normalizeDispatchContextLabelRefs(dispatchContextLabelIds.map((labelId) => ({ labelId })));
|
|
361
|
+
const definitionMap = new Map(dispatchContextLabels.map((label) => [label.id, label]));
|
|
362
|
+
const missingLabelIds = refs
|
|
363
|
+
.map((ref) => ref.labelId)
|
|
364
|
+
.filter((labelId) => !definitionMap.has(labelId));
|
|
365
|
+
if (missingLabelIds.length > 0) {
|
|
366
|
+
throw new AppError(`Dispatch Context Label definitions not found: ${missingLabelIds.join(', ')}.`, 'BAD_REQUEST', 400);
|
|
367
|
+
}
|
|
368
|
+
return refs;
|
|
369
|
+
}
|
|
370
|
+
function stripDerivedDispatchState(issue, dispatchContextLabels) {
|
|
371
|
+
if (!issue.dispatch) {
|
|
372
|
+
return issue;
|
|
373
|
+
}
|
|
374
|
+
return {
|
|
375
|
+
...issue,
|
|
376
|
+
dispatch: {
|
|
377
|
+
readiness: issue.dispatch.readiness,
|
|
378
|
+
blockedReasons: issue.dispatch.blockedReasons ?? [],
|
|
379
|
+
runIds: issue.dispatch.runIds ?? [],
|
|
380
|
+
sessionIds: issue.dispatch.sessionIds ?? [],
|
|
381
|
+
contextLabels: normalizeStoredIssueDispatchContextLabelRefs(issue.dispatch.contextLabels, dispatchContextLabels),
|
|
382
|
+
lastDispatchedAt: issue.dispatch.lastDispatchedAt,
|
|
383
|
+
},
|
|
384
|
+
};
|
|
385
|
+
}
|
|
386
|
+
function nextPullRequestNumber(issues) {
|
|
387
|
+
return (Math.max(0, ...issues.map((issue) => issue.repositoryLifecycle?.pullRequest?.number ?? 0)) + 1);
|
|
388
|
+
}
|
|
389
|
+
function nextAutomationIssueKey(project, issues) {
|
|
390
|
+
const prefix = `${project.key}-AUTO-`;
|
|
391
|
+
const nextNumber = Math.max(0, ...issues.map((issue) => {
|
|
392
|
+
if (!issue.key.startsWith(prefix)) {
|
|
393
|
+
return 0;
|
|
394
|
+
}
|
|
395
|
+
const suffix = Number.parseInt(issue.key.slice(prefix.length), 10);
|
|
396
|
+
return Number.isFinite(suffix) ? suffix : 0;
|
|
397
|
+
})) + 1;
|
|
398
|
+
return `${prefix}${String(nextNumber).padStart(3, '0')}`;
|
|
399
|
+
}
|
|
400
|
+
function readProjectLabels(project, labelIds) {
|
|
401
|
+
return labelIds.map((labelId) => {
|
|
402
|
+
const label = project.labels.find((candidate) => candidate.id === labelId);
|
|
403
|
+
if (!label) {
|
|
404
|
+
throw new AppError(`Label ${labelId} not found on project ${project.id}.`, 'BAD_REQUEST', 400);
|
|
405
|
+
}
|
|
406
|
+
return label;
|
|
407
|
+
});
|
|
408
|
+
}
|
|
409
|
+
function readProjectAssignees(project, assigneeIds) {
|
|
410
|
+
return assigneeIds.map((assigneeId) => {
|
|
411
|
+
const assignee = project.assignees.find((candidate) => candidate.id === assigneeId);
|
|
412
|
+
if (!assignee) {
|
|
413
|
+
throw new AppError(`Assignee ${assigneeId} not found on project ${project.id}.`, 'BAD_REQUEST', 400);
|
|
414
|
+
}
|
|
415
|
+
return assignee;
|
|
416
|
+
});
|
|
417
|
+
}
|
|
418
|
+
function normalizeDependencyType(type) {
|
|
419
|
+
if (type === 'blocks' || type === 'related') {
|
|
420
|
+
return type;
|
|
421
|
+
}
|
|
422
|
+
return 'blocked-by';
|
|
423
|
+
}
|
|
424
|
+
function normalizeIssueDependencies(payload, issue, dependencies) {
|
|
425
|
+
const seen = new Set();
|
|
426
|
+
return dependencies.map((dependency) => {
|
|
427
|
+
const issueId = dependency.issueId.trim();
|
|
428
|
+
const type = normalizeDependencyType(dependency.type);
|
|
429
|
+
if (!issueId) {
|
|
430
|
+
throw new AppError('Dependency issueId is required.', 'BAD_REQUEST', 400);
|
|
431
|
+
}
|
|
432
|
+
if (issueId === issue.id) {
|
|
433
|
+
throw new AppError(`Issue ${issue.key} cannot depend on itself.`, 'BAD_REQUEST', 400);
|
|
434
|
+
}
|
|
435
|
+
const target = payload.issues.find((candidate) => candidate.id === issueId);
|
|
436
|
+
if (!target) {
|
|
437
|
+
throw new AppError(`Dependency issue ${issueId} not found.`, 'BAD_REQUEST', 400);
|
|
438
|
+
}
|
|
439
|
+
if (target.projectId !== issue.projectId) {
|
|
440
|
+
throw new AppError(`Dependency issue ${issueId} must belong to project ${issue.projectId}.`, 'BAD_REQUEST', 400);
|
|
441
|
+
}
|
|
442
|
+
const dedupeKey = `${type}:${issueId}`;
|
|
443
|
+
if (seen.has(dedupeKey)) {
|
|
444
|
+
return null;
|
|
445
|
+
}
|
|
446
|
+
seen.add(dedupeKey);
|
|
447
|
+
return {
|
|
448
|
+
issueId,
|
|
449
|
+
type,
|
|
450
|
+
};
|
|
451
|
+
}).filter((dependency) => Boolean(dependency));
|
|
452
|
+
}
|
|
453
|
+
function normalizeAcceptanceCriteria(issue, acceptanceCriteria) {
|
|
454
|
+
const unavailableIds = new Set(issue.acceptanceCriteria.map((criterion) => criterion.id));
|
|
455
|
+
const assignedIds = new Set();
|
|
456
|
+
let nextSequence = 1;
|
|
457
|
+
function nextCriterionId() {
|
|
458
|
+
while (unavailableIds.has(`${issue.key}-ac-${nextSequence}`) || assignedIds.has(`${issue.key}-ac-${nextSequence}`)) {
|
|
459
|
+
nextSequence += 1;
|
|
460
|
+
}
|
|
461
|
+
const id = `${issue.key}-ac-${nextSequence}`;
|
|
462
|
+
assignedIds.add(id);
|
|
463
|
+
nextSequence += 1;
|
|
464
|
+
return id;
|
|
465
|
+
}
|
|
466
|
+
return acceptanceCriteria.map((criterion) => {
|
|
467
|
+
const title = criterion.title.trim();
|
|
468
|
+
if (!title) {
|
|
469
|
+
throw new AppError('Acceptance criterion title is required.', 'BAD_REQUEST', 400);
|
|
470
|
+
}
|
|
471
|
+
const explicitId = criterion.id?.trim();
|
|
472
|
+
if (explicitId) {
|
|
473
|
+
if (assignedIds.has(explicitId)) {
|
|
474
|
+
throw new AppError(`Duplicate acceptance criterion id ${explicitId}.`, 'BAD_REQUEST', 400);
|
|
475
|
+
}
|
|
476
|
+
assignedIds.add(explicitId);
|
|
477
|
+
}
|
|
478
|
+
return {
|
|
479
|
+
id: explicitId || nextCriterionId(),
|
|
480
|
+
title,
|
|
481
|
+
satisfied: Boolean(criterion.satisfied),
|
|
482
|
+
notes: criterion.notes?.trim() || undefined,
|
|
483
|
+
};
|
|
484
|
+
});
|
|
485
|
+
}
|
|
486
|
+
const defaultProjects = [
|
|
487
|
+
{
|
|
488
|
+
id: PROJECT_ID,
|
|
489
|
+
key: 'KANBAN',
|
|
490
|
+
name: 'Kanban App',
|
|
491
|
+
description: 'Board-, issue-, and workspace-first orchestration surface for Babysitter and adapters.',
|
|
492
|
+
issueIds: [
|
|
493
|
+
'KANBAN-DEBT-003',
|
|
494
|
+
'KANBAN-GAP-001',
|
|
495
|
+
'KANBAN-GAP-001-A',
|
|
496
|
+
'KANBAN-GAP-001-B',
|
|
497
|
+
'KANBAN-GAP-001-C',
|
|
498
|
+
'KANBAN-GAP-001-D',
|
|
499
|
+
'KANBAN-GAP-002',
|
|
500
|
+
'KANBAN-GAP-003',
|
|
501
|
+
'KANBAN-GAP-004',
|
|
502
|
+
'KANBAN-GAP-005',
|
|
503
|
+
'KANBAN-GAP-006',
|
|
504
|
+
'KANBAN-GAP-007',
|
|
505
|
+
],
|
|
506
|
+
labels: [debtLabel],
|
|
507
|
+
assignees: defaultCollaborators,
|
|
508
|
+
team: {
|
|
509
|
+
id: 'team-kanban',
|
|
510
|
+
name: 'Kanban Core',
|
|
511
|
+
members: defaultCollaborators,
|
|
512
|
+
settings: defaultTeamSettings,
|
|
513
|
+
},
|
|
514
|
+
settings: defaultProjectSettings,
|
|
515
|
+
permissions: defaultPermissionMatrix,
|
|
516
|
+
activity: [
|
|
517
|
+
buildActivityEntry('activity-project-seed', 'project', PROJECT_ID, 'seeded-project-model', 'Initialized a shared team, project settings surface, and permission policy for the kanban app.', systemActor, '2026-04-24T00:00:00.000Z'),
|
|
518
|
+
],
|
|
519
|
+
statuses: [],
|
|
520
|
+
integrations: [defaultGithubIntegration, defaultAzureReposIntegration],
|
|
521
|
+
repositories: [
|
|
522
|
+
{
|
|
523
|
+
id: buildRepositoryId('github', 'a5c-ai', 'babysitter'),
|
|
524
|
+
owner: 'a5c-ai',
|
|
525
|
+
name: 'babysitter',
|
|
526
|
+
fullName: 'a5c-ai/babysitter',
|
|
527
|
+
provider: 'github',
|
|
528
|
+
url: 'https://github.com/a5c-ai/babysitter',
|
|
529
|
+
defaultBranch: 'main',
|
|
530
|
+
linkedAt: '2026-04-24T00:00:00.000Z',
|
|
531
|
+
settings: {
|
|
532
|
+
baseBranch: 'main',
|
|
533
|
+
autoMerge: false,
|
|
534
|
+
requiredApprovals: 2,
|
|
535
|
+
ciProvider: 'GitHub Actions',
|
|
536
|
+
publishTarget: 'npm',
|
|
537
|
+
},
|
|
538
|
+
},
|
|
539
|
+
],
|
|
540
|
+
linkedRunProjectName: 'kanban',
|
|
541
|
+
},
|
|
542
|
+
];
|
|
543
|
+
const defaultIssues = [
|
|
544
|
+
{
|
|
545
|
+
id: 'KANBAN-DEBT-003',
|
|
546
|
+
key: 'KANBAN-DEBT-003',
|
|
547
|
+
projectId: PROJECT_ID,
|
|
548
|
+
title: 'Align the kanban package contract to a board-, issue-, and workspace-first product model',
|
|
549
|
+
summary: 'Define the target product model explicitly and track the remaining work as board-product capabilities instead of treating the package as observability-first.',
|
|
550
|
+
description: 'The browser product shell now lives in `packages/adapters/webui`, with shared runtime and API ownership in the surrounding adapters packages. The unresolved work is deeper product capability, not cosmetic renaming.',
|
|
551
|
+
status: 'review',
|
|
552
|
+
priority: 'high',
|
|
553
|
+
labels: [debtLabel],
|
|
554
|
+
assignees: [],
|
|
555
|
+
dependencies: [],
|
|
556
|
+
acceptanceCriteria: [
|
|
557
|
+
{
|
|
558
|
+
id: 'KANBAN-DEBT-003-ac-1',
|
|
559
|
+
title: 'Document the target product model for packages/adapters/webui.',
|
|
560
|
+
satisfied: true,
|
|
561
|
+
},
|
|
562
|
+
{
|
|
563
|
+
id: 'KANBAN-DEBT-003-ac-2',
|
|
564
|
+
title: 'Track board, issue, and workspace concepts as first-class work rather than observer-dashboard follow-ons.',
|
|
565
|
+
satisfied: true,
|
|
566
|
+
},
|
|
567
|
+
{
|
|
568
|
+
id: 'KANBAN-DEBT-003-ac-3',
|
|
569
|
+
title: 'Frame remaining gaps as missing board-product capabilities instead of a naming mismatch.',
|
|
570
|
+
satisfied: true,
|
|
571
|
+
},
|
|
572
|
+
],
|
|
573
|
+
decomposition: [
|
|
574
|
+
{
|
|
575
|
+
id: 'KANBAN-DEBT-003-decomp-1',
|
|
576
|
+
title: 'Define the target product model and package contract.',
|
|
577
|
+
kind: 'coordination',
|
|
578
|
+
status: 'done',
|
|
579
|
+
issueId: 'KANBAN-GAP-001',
|
|
580
|
+
},
|
|
581
|
+
{
|
|
582
|
+
id: 'KANBAN-DEBT-003-decomp-2',
|
|
583
|
+
title: 'Keep deepening first-class board semantics.',
|
|
584
|
+
kind: 'implementation',
|
|
585
|
+
status: 'ready',
|
|
586
|
+
issueId: 'KANBAN-GAP-002',
|
|
587
|
+
},
|
|
588
|
+
{
|
|
589
|
+
id: 'KANBAN-DEBT-003-decomp-3',
|
|
590
|
+
title: 'Keep deepening first-class workspace execution flows.',
|
|
591
|
+
kind: 'implementation',
|
|
592
|
+
status: 'ready',
|
|
593
|
+
issueId: 'KANBAN-GAP-003',
|
|
594
|
+
},
|
|
595
|
+
],
|
|
596
|
+
childIssueIds: ['KANBAN-GAP-001', 'KANBAN-GAP-002', 'KANBAN-GAP-003'],
|
|
597
|
+
createdAt: '2026-04-24T00:00:00.000Z',
|
|
598
|
+
updatedAt: '2026-04-24T00:00:00.000Z',
|
|
599
|
+
source: { kind: 'seed', path: SOURCE_PATH, externalId: 'KANBAN-DEBT-003' },
|
|
600
|
+
},
|
|
601
|
+
{
|
|
602
|
+
id: 'KANBAN-GAP-001',
|
|
603
|
+
key: 'KANBAN-GAP-001',
|
|
604
|
+
projectId: PROJECT_ID,
|
|
605
|
+
title: 'Add a first-class issue and project model to the kanban app',
|
|
606
|
+
summary: 'Deepen the shared project and issue model so the board uses a real system of record instead of seeded backlog data.',
|
|
607
|
+
description: 'The app now has first-class issue and project primitives, but the model still needs to mature from seeded local data into a true shared system of record with richer authoring for priorities, labels, assignees, dependencies, and acceptance criteria.',
|
|
608
|
+
status: 'in-progress',
|
|
609
|
+
priority: 'high',
|
|
610
|
+
labels: [debtLabel],
|
|
611
|
+
assignees: [],
|
|
612
|
+
dependencies: [],
|
|
613
|
+
acceptanceCriteria: [
|
|
614
|
+
{
|
|
615
|
+
id: 'KANBAN-GAP-001-ac-1',
|
|
616
|
+
title: 'Define first-class project and issue entities.',
|
|
617
|
+
satisfied: false,
|
|
618
|
+
},
|
|
619
|
+
{
|
|
620
|
+
id: 'KANBAN-GAP-001-ac-2',
|
|
621
|
+
title: 'Support backlog metadata including priority, labels, assignees, and dependencies.',
|
|
622
|
+
satisfied: false,
|
|
623
|
+
},
|
|
624
|
+
{
|
|
625
|
+
id: 'KANBAN-GAP-001-ac-3',
|
|
626
|
+
title: 'Support issue decomposition before dispatch.',
|
|
627
|
+
satisfied: false,
|
|
628
|
+
},
|
|
629
|
+
{
|
|
630
|
+
id: 'KANBAN-GAP-001-ac-4',
|
|
631
|
+
title: 'Prefer shared adapters or service-layer primitives instead of kanban-only models.',
|
|
632
|
+
satisfied: false,
|
|
633
|
+
},
|
|
634
|
+
],
|
|
635
|
+
decomposition: [
|
|
636
|
+
{
|
|
637
|
+
id: 'KANBAN-GAP-001-decomp-1',
|
|
638
|
+
title: 'Define canonical project and issue entities.',
|
|
639
|
+
kind: 'implementation',
|
|
640
|
+
status: 'ready',
|
|
641
|
+
issueId: 'KANBAN-GAP-001-A',
|
|
642
|
+
},
|
|
643
|
+
{
|
|
644
|
+
id: 'KANBAN-GAP-001-decomp-2',
|
|
645
|
+
title: 'Add backlog metadata fields and validation.',
|
|
646
|
+
kind: 'implementation',
|
|
647
|
+
status: 'ready',
|
|
648
|
+
issueId: 'KANBAN-GAP-001-B',
|
|
649
|
+
},
|
|
650
|
+
{
|
|
651
|
+
id: 'KANBAN-GAP-001-decomp-3',
|
|
652
|
+
title: 'Gate dispatch on decomposition readiness.',
|
|
653
|
+
kind: 'validation',
|
|
654
|
+
status: 'ready',
|
|
655
|
+
issueId: 'KANBAN-GAP-001-C',
|
|
656
|
+
},
|
|
657
|
+
{
|
|
658
|
+
id: 'KANBAN-GAP-001-decomp-4',
|
|
659
|
+
title: 'Land the model in a shared seam that kanban can consume.',
|
|
660
|
+
kind: 'coordination',
|
|
661
|
+
status: 'ready',
|
|
662
|
+
issueId: 'KANBAN-GAP-001-D',
|
|
663
|
+
},
|
|
664
|
+
],
|
|
665
|
+
childIssueIds: [
|
|
666
|
+
'KANBAN-GAP-001-A',
|
|
667
|
+
'KANBAN-GAP-001-B',
|
|
668
|
+
'KANBAN-GAP-001-C',
|
|
669
|
+
'KANBAN-GAP-001-D',
|
|
670
|
+
],
|
|
671
|
+
parentIssueId: 'KANBAN-DEBT-003',
|
|
672
|
+
createdAt: '2026-04-24T00:00:00.000Z',
|
|
673
|
+
updatedAt: '2026-04-24T00:00:00.000Z',
|
|
674
|
+
source: { kind: 'seed', path: SOURCE_PATH, externalId: 'KANBAN-GAP-001' },
|
|
675
|
+
},
|
|
676
|
+
{
|
|
677
|
+
id: 'KANBAN-GAP-001-A',
|
|
678
|
+
key: 'KANBAN-GAP-001-A',
|
|
679
|
+
projectId: PROJECT_ID,
|
|
680
|
+
title: 'Define canonical project and issue entities',
|
|
681
|
+
status: 'ready',
|
|
682
|
+
priority: 'high',
|
|
683
|
+
labels: [debtLabel],
|
|
684
|
+
assignees: [],
|
|
685
|
+
dependencies: [],
|
|
686
|
+
acceptanceCriteria: [],
|
|
687
|
+
decomposition: [],
|
|
688
|
+
childIssueIds: [],
|
|
689
|
+
parentIssueId: 'KANBAN-GAP-001',
|
|
690
|
+
createdAt: '2026-04-24T00:00:00.000Z',
|
|
691
|
+
updatedAt: '2026-04-24T00:00:00.000Z',
|
|
692
|
+
source: { kind: 'seed', path: SOURCE_PATH },
|
|
693
|
+
},
|
|
694
|
+
{
|
|
695
|
+
id: 'KANBAN-GAP-001-B',
|
|
696
|
+
key: 'KANBAN-GAP-001-B',
|
|
697
|
+
projectId: PROJECT_ID,
|
|
698
|
+
title: 'Support priority, labels, assignees, and dependencies',
|
|
699
|
+
status: 'ready',
|
|
700
|
+
priority: 'high',
|
|
701
|
+
labels: [debtLabel],
|
|
702
|
+
assignees: [],
|
|
703
|
+
dependencies: [],
|
|
704
|
+
acceptanceCriteria: [],
|
|
705
|
+
decomposition: [],
|
|
706
|
+
childIssueIds: [],
|
|
707
|
+
parentIssueId: 'KANBAN-GAP-001',
|
|
708
|
+
createdAt: '2026-04-24T00:00:00.000Z',
|
|
709
|
+
updatedAt: '2026-04-24T00:00:00.000Z',
|
|
710
|
+
source: { kind: 'seed', path: SOURCE_PATH },
|
|
711
|
+
},
|
|
712
|
+
{
|
|
713
|
+
id: 'KANBAN-GAP-001-C',
|
|
714
|
+
key: 'KANBAN-GAP-001-C',
|
|
715
|
+
projectId: PROJECT_ID,
|
|
716
|
+
title: 'Support issue decomposition before dispatch',
|
|
717
|
+
status: 'ready',
|
|
718
|
+
priority: 'high',
|
|
719
|
+
labels: [debtLabel],
|
|
720
|
+
assignees: [],
|
|
721
|
+
dependencies: [],
|
|
722
|
+
acceptanceCriteria: [],
|
|
723
|
+
decomposition: [],
|
|
724
|
+
childIssueIds: [],
|
|
725
|
+
parentIssueId: 'KANBAN-GAP-001',
|
|
726
|
+
createdAt: '2026-04-24T00:00:00.000Z',
|
|
727
|
+
updatedAt: '2026-04-24T00:00:00.000Z',
|
|
728
|
+
source: { kind: 'seed', path: SOURCE_PATH },
|
|
729
|
+
},
|
|
730
|
+
{
|
|
731
|
+
id: 'KANBAN-GAP-001-D',
|
|
732
|
+
key: 'KANBAN-GAP-001-D',
|
|
733
|
+
projectId: PROJECT_ID,
|
|
734
|
+
title: 'Prefer a shared adapters service seam for the model',
|
|
735
|
+
status: 'ready',
|
|
736
|
+
priority: 'high',
|
|
737
|
+
labels: [debtLabel],
|
|
738
|
+
assignees: [],
|
|
739
|
+
dependencies: [],
|
|
740
|
+
acceptanceCriteria: [],
|
|
741
|
+
decomposition: [],
|
|
742
|
+
childIssueIds: [],
|
|
743
|
+
parentIssueId: 'KANBAN-GAP-001',
|
|
744
|
+
createdAt: '2026-04-24T00:00:00.000Z',
|
|
745
|
+
updatedAt: '2026-04-24T00:00:00.000Z',
|
|
746
|
+
source: { kind: 'seed', path: SOURCE_PATH },
|
|
747
|
+
},
|
|
748
|
+
{
|
|
749
|
+
id: 'KANBAN-GAP-002',
|
|
750
|
+
key: 'KANBAN-GAP-002',
|
|
751
|
+
projectId: PROJECT_ID,
|
|
752
|
+
title: 'Add actual kanban board mechanics',
|
|
753
|
+
summary: 'Introduce board columns, issue movement semantics, and policies instead of a pure observability dashboard.',
|
|
754
|
+
description: 'The dashboard should become a real issue board with shared kanban primitives, workflow state transitions, WIP limits, swimlanes, and policy hooks instead of a UI-only observability model.',
|
|
755
|
+
status: 'backlog',
|
|
756
|
+
priority: 'medium',
|
|
757
|
+
labels: [debtLabel],
|
|
758
|
+
assignees: [],
|
|
759
|
+
dependencies: [{ issueId: 'KANBAN-GAP-001', type: 'blocked-by' }],
|
|
760
|
+
acceptanceCriteria: [
|
|
761
|
+
{
|
|
762
|
+
id: 'KANBAN-GAP-002-ac-1',
|
|
763
|
+
title: 'Expose shared board columns and card movement semantics.',
|
|
764
|
+
satisfied: false,
|
|
765
|
+
},
|
|
766
|
+
{
|
|
767
|
+
id: 'KANBAN-GAP-002-ac-2',
|
|
768
|
+
title: 'Support todo, in-progress, review, and done workflow transitions.',
|
|
769
|
+
satisfied: false,
|
|
770
|
+
},
|
|
771
|
+
{
|
|
772
|
+
id: 'KANBAN-GAP-002-ac-3',
|
|
773
|
+
title: 'Show WIP limits, swimlanes, and policy hooks on the board.',
|
|
774
|
+
satisfied: false,
|
|
775
|
+
},
|
|
776
|
+
{
|
|
777
|
+
id: 'KANBAN-GAP-002-ac-4',
|
|
778
|
+
title: 'Anchor board state in shared primitives instead of a local UI model.',
|
|
779
|
+
satisfied: false,
|
|
780
|
+
},
|
|
781
|
+
],
|
|
782
|
+
decomposition: [
|
|
783
|
+
{
|
|
784
|
+
id: 'KANBAN-GAP-002-decomp-1',
|
|
785
|
+
title: 'Add shared board primitives in adapters core.',
|
|
786
|
+
kind: 'implementation',
|
|
787
|
+
status: 'ready',
|
|
788
|
+
},
|
|
789
|
+
{
|
|
790
|
+
id: 'KANBAN-GAP-002-decomp-2',
|
|
791
|
+
title: 'Persist workflow moves through the backlog service.',
|
|
792
|
+
kind: 'implementation',
|
|
793
|
+
status: 'ready',
|
|
794
|
+
},
|
|
795
|
+
{
|
|
796
|
+
id: 'KANBAN-GAP-002-decomp-3',
|
|
797
|
+
title: 'Render the dashboard as a real board with policy feedback.',
|
|
798
|
+
kind: 'validation',
|
|
799
|
+
status: 'ready',
|
|
800
|
+
},
|
|
801
|
+
],
|
|
802
|
+
childIssueIds: [],
|
|
803
|
+
parentIssueId: 'KANBAN-DEBT-003',
|
|
804
|
+
createdAt: '2026-04-24T00:00:00.000Z',
|
|
805
|
+
updatedAt: '2026-04-24T00:00:00.000Z',
|
|
806
|
+
source: { kind: 'seed', path: SOURCE_PATH, externalId: 'KANBAN-GAP-002' },
|
|
807
|
+
},
|
|
808
|
+
{
|
|
809
|
+
id: 'KANBAN-GAP-003',
|
|
810
|
+
key: 'KANBAN-GAP-003',
|
|
811
|
+
projectId: PROJECT_ID,
|
|
812
|
+
title: 'Add workspace lifecycle controls',
|
|
813
|
+
status: 'backlog',
|
|
814
|
+
priority: 'medium',
|
|
815
|
+
labels: [debtLabel],
|
|
816
|
+
assignees: [],
|
|
817
|
+
dependencies: [{ issueId: 'KANBAN-GAP-001', type: 'blocked-by' }],
|
|
818
|
+
acceptanceCriteria: [],
|
|
819
|
+
decomposition: [],
|
|
820
|
+
childIssueIds: [],
|
|
821
|
+
parentIssueId: 'KANBAN-DEBT-003',
|
|
822
|
+
createdAt: '2026-04-24T00:00:00.000Z',
|
|
823
|
+
updatedAt: '2026-04-24T00:00:00.000Z',
|
|
824
|
+
source: { kind: 'seed', path: SOURCE_PATH, externalId: 'KANBAN-GAP-003' },
|
|
825
|
+
},
|
|
826
|
+
{
|
|
827
|
+
id: 'KANBAN-GAP-004',
|
|
828
|
+
key: 'KANBAN-GAP-004',
|
|
829
|
+
projectId: PROJECT_ID,
|
|
830
|
+
title: 'Expose review and diff workflow primitives',
|
|
831
|
+
summary: 'Add shared review artifacts, inline comments, approval state, and diff viewing for issues and workspaces.',
|
|
832
|
+
description: 'The kanban surface needs Vibe Kanban style review loops: work item and workspace diffs, inline comments mapped back to agent feedback, a review queue, and approval state carried through shared APIs.',
|
|
833
|
+
status: 'review',
|
|
834
|
+
priority: 'medium',
|
|
835
|
+
labels: [debtLabel],
|
|
836
|
+
assignees: [],
|
|
837
|
+
dependencies: [{ issueId: 'KANBAN-GAP-001', type: 'blocked-by' }],
|
|
838
|
+
acceptanceCriteria: [
|
|
839
|
+
{
|
|
840
|
+
id: 'KANBAN-GAP-004-ac-1',
|
|
841
|
+
title: 'Add diff viewing for work items and workspaces.',
|
|
842
|
+
satisfied: false,
|
|
843
|
+
},
|
|
844
|
+
{
|
|
845
|
+
id: 'KANBAN-GAP-004-ac-2',
|
|
846
|
+
title: 'Support inline review comments mapped back to agent feedback.',
|
|
847
|
+
satisfied: false,
|
|
848
|
+
},
|
|
849
|
+
{
|
|
850
|
+
id: 'KANBAN-GAP-004-ac-3',
|
|
851
|
+
title: 'Add review queue and approval state for issues and workspaces.',
|
|
852
|
+
satisfied: false,
|
|
853
|
+
},
|
|
854
|
+
{
|
|
855
|
+
id: 'KANBAN-GAP-004-ac-4',
|
|
856
|
+
title: 'Expose review artifacts and actions through shared APIs, then compose the UX in packages/adapters/webui.',
|
|
857
|
+
satisfied: false,
|
|
858
|
+
},
|
|
859
|
+
],
|
|
860
|
+
decomposition: [
|
|
861
|
+
{
|
|
862
|
+
id: 'KANBAN-GAP-004-decomp-1',
|
|
863
|
+
title: 'Extend shared review and diff types in adapters core.',
|
|
864
|
+
kind: 'implementation',
|
|
865
|
+
status: 'ready',
|
|
866
|
+
},
|
|
867
|
+
{
|
|
868
|
+
id: 'KANBAN-GAP-004-decomp-2',
|
|
869
|
+
title: 'Persist review artifacts and approval actions in a shared kanban service.',
|
|
870
|
+
kind: 'implementation',
|
|
871
|
+
status: 'ready',
|
|
872
|
+
},
|
|
873
|
+
{
|
|
874
|
+
id: 'KANBAN-GAP-004-decomp-3',
|
|
875
|
+
title: 'Compose the review queue and diff viewer in dashboard and workspace surfaces.',
|
|
876
|
+
kind: 'validation',
|
|
877
|
+
status: 'ready',
|
|
878
|
+
},
|
|
879
|
+
],
|
|
880
|
+
childIssueIds: [],
|
|
881
|
+
createdAt: '2026-04-24T00:00:00.000Z',
|
|
882
|
+
updatedAt: '2026-04-24T00:00:00.000Z',
|
|
883
|
+
dispatch: {
|
|
884
|
+
readiness: 'ready',
|
|
885
|
+
blockedReasons: [],
|
|
886
|
+
runIds: ['run-kanban-gap-004'],
|
|
887
|
+
sessionIds: ['session-kanban-gap-004'],
|
|
888
|
+
contextLabels: [
|
|
889
|
+
{ labelId: 'dispatch-context-label-tests-first' },
|
|
890
|
+
{ labelId: 'dispatch-context-label-preserve-release-contract' },
|
|
891
|
+
],
|
|
892
|
+
lastDispatchedAt: '2026-04-24T00:00:00.000Z',
|
|
893
|
+
},
|
|
894
|
+
source: { kind: 'seed', path: SOURCE_PATH, externalId: 'KANBAN-GAP-004' },
|
|
895
|
+
},
|
|
896
|
+
{
|
|
897
|
+
id: 'KANBAN-GAP-005',
|
|
898
|
+
key: 'KANBAN-GAP-005',
|
|
899
|
+
projectId: PROJECT_ID,
|
|
900
|
+
title: 'Expose preview, terminal, and dev-server surfaces',
|
|
901
|
+
status: 'backlog',
|
|
902
|
+
priority: 'medium',
|
|
903
|
+
labels: [debtLabel],
|
|
904
|
+
assignees: [],
|
|
905
|
+
dependencies: [{ issueId: 'KANBAN-GAP-001', type: 'blocked-by' }],
|
|
906
|
+
acceptanceCriteria: [],
|
|
907
|
+
decomposition: [],
|
|
908
|
+
childIssueIds: [],
|
|
909
|
+
createdAt: '2026-04-24T00:00:00.000Z',
|
|
910
|
+
updatedAt: '2026-04-24T00:00:00.000Z',
|
|
911
|
+
source: { kind: 'seed', path: SOURCE_PATH, externalId: 'KANBAN-GAP-005' },
|
|
912
|
+
},
|
|
913
|
+
{
|
|
914
|
+
id: 'KANBAN-GAP-006',
|
|
915
|
+
key: 'KANBAN-GAP-006',
|
|
916
|
+
projectId: PROJECT_ID,
|
|
917
|
+
title: 'Add repository and PR lifecycle support',
|
|
918
|
+
summary: 'Expose repository context, PR creation, review linkage, CI gates, and publish readiness directly on issue cards.',
|
|
919
|
+
status: 'review',
|
|
920
|
+
priority: 'medium',
|
|
921
|
+
labels: [debtLabel],
|
|
922
|
+
assignees: [],
|
|
923
|
+
dependencies: [],
|
|
924
|
+
acceptanceCriteria: [
|
|
925
|
+
{
|
|
926
|
+
id: 'KANBAN-GAP-006-ac-1',
|
|
927
|
+
title: 'Link work items to a shared repository context.',
|
|
928
|
+
satisfied: true,
|
|
929
|
+
},
|
|
930
|
+
{
|
|
931
|
+
id: 'KANBAN-GAP-006-ac-2',
|
|
932
|
+
title: 'Create PRs with review linkage from the board surface.',
|
|
933
|
+
satisfied: false,
|
|
934
|
+
},
|
|
935
|
+
{
|
|
936
|
+
id: 'KANBAN-GAP-006-ac-3',
|
|
937
|
+
title: 'Show merge status, CI gates, and publish status per work item.',
|
|
938
|
+
satisfied: false,
|
|
939
|
+
},
|
|
940
|
+
],
|
|
941
|
+
decomposition: [
|
|
942
|
+
{
|
|
943
|
+
id: 'KANBAN-GAP-006-decomp-1',
|
|
944
|
+
title: 'Add shared repo and PR state below packages/adapters/webui.',
|
|
945
|
+
kind: 'implementation',
|
|
946
|
+
status: 'done',
|
|
947
|
+
},
|
|
948
|
+
{
|
|
949
|
+
id: 'KANBAN-GAP-006-decomp-2',
|
|
950
|
+
title: 'Render repository context and PR actions in the board UI.',
|
|
951
|
+
kind: 'implementation',
|
|
952
|
+
status: 'ready',
|
|
953
|
+
},
|
|
954
|
+
{
|
|
955
|
+
id: 'KANBAN-GAP-006-decomp-3',
|
|
956
|
+
title: 'Surface CI, merge, and publish gates on the work item.',
|
|
957
|
+
kind: 'validation',
|
|
958
|
+
status: 'ready',
|
|
959
|
+
},
|
|
960
|
+
],
|
|
961
|
+
childIssueIds: [],
|
|
962
|
+
createdAt: '2026-04-24T00:00:00.000Z',
|
|
963
|
+
updatedAt: '2026-04-24T00:00:00.000Z',
|
|
964
|
+
repositoryLifecycle: {
|
|
965
|
+
repositoryId: buildRepositoryId('github', 'a5c-ai', 'babysitter'),
|
|
966
|
+
branchName: 'feat/kanban-gap-006-pr-lifecycle',
|
|
967
|
+
reviewStatus: 'pending',
|
|
968
|
+
mergeStatus: 'blocked',
|
|
969
|
+
publishStatus: 'not-ready',
|
|
970
|
+
integration: buildRepositoryIntegrationState('github', {
|
|
971
|
+
status: 'missing-scopes',
|
|
972
|
+
linkState: 'partially-linked',
|
|
973
|
+
guidance: 'The PR is linked, but GitHub write scopes are missing for sync and approval actions. Reconnect GitHub with pull request and checks scopes.',
|
|
974
|
+
missingScopes: ['pull_requests:write', 'checks:read'],
|
|
975
|
+
prerequisites: [
|
|
976
|
+
{
|
|
977
|
+
key: 'github-auth',
|
|
978
|
+
label: 'GitHub connection is present',
|
|
979
|
+
satisfied: true,
|
|
980
|
+
},
|
|
981
|
+
{
|
|
982
|
+
key: 'github-pr-scope',
|
|
983
|
+
label: 'Pull request write scope is granted',
|
|
984
|
+
satisfied: false,
|
|
985
|
+
guidance: 'Reconnect GitHub and grant pull request write scope.',
|
|
986
|
+
},
|
|
987
|
+
{
|
|
988
|
+
key: 'github-checks-scope',
|
|
989
|
+
label: 'Checks scope is granted for CI linkage',
|
|
990
|
+
satisfied: false,
|
|
991
|
+
guidance: 'Grant checks scope so linked status checks can stay current.',
|
|
992
|
+
},
|
|
993
|
+
],
|
|
994
|
+
actions: {
|
|
995
|
+
canCreatePullRequest: false,
|
|
996
|
+
canManagePullRequest: false,
|
|
997
|
+
canApproveFromReview: false,
|
|
998
|
+
reason: 'GitHub scopes are incomplete for linked PR actions.',
|
|
999
|
+
},
|
|
1000
|
+
}),
|
|
1001
|
+
ciGates: [
|
|
1002
|
+
{
|
|
1003
|
+
id: 'ci-lint',
|
|
1004
|
+
name: 'Lint',
|
|
1005
|
+
provider: 'GitHub Actions',
|
|
1006
|
+
required: true,
|
|
1007
|
+
status: 'passing',
|
|
1008
|
+
summary: 'Static checks are green.',
|
|
1009
|
+
},
|
|
1010
|
+
{
|
|
1011
|
+
id: 'ci-kanban-tests',
|
|
1012
|
+
name: 'Kanban tests',
|
|
1013
|
+
provider: 'GitHub Actions',
|
|
1014
|
+
required: true,
|
|
1015
|
+
status: 'pending',
|
|
1016
|
+
summary: 'Targeted vitest run is still in flight.',
|
|
1017
|
+
},
|
|
1018
|
+
],
|
|
1019
|
+
pullRequest: {
|
|
1020
|
+
id: 'pr-612',
|
|
1021
|
+
number: 612,
|
|
1022
|
+
title: 'Add repository lifecycle UX to the kanban surface',
|
|
1023
|
+
status: 'in-review',
|
|
1024
|
+
branchName: 'feat/kanban-gap-006-pr-lifecycle',
|
|
1025
|
+
baseBranch: 'main',
|
|
1026
|
+
mergeStatus: 'blocked',
|
|
1027
|
+
linkState: 'partially-linked',
|
|
1028
|
+
reviewLinks: [
|
|
1029
|
+
{
|
|
1030
|
+
id: 'review-design',
|
|
1031
|
+
label: 'Design review',
|
|
1032
|
+
reviewer: 'Product design',
|
|
1033
|
+
status: 'approved',
|
|
1034
|
+
},
|
|
1035
|
+
{
|
|
1036
|
+
id: 'review-codeowners',
|
|
1037
|
+
label: 'Codeowners',
|
|
1038
|
+
reviewer: 'Kanban maintainers',
|
|
1039
|
+
status: 'pending',
|
|
1040
|
+
},
|
|
1041
|
+
],
|
|
1042
|
+
url: 'https://github.com/a5c-ai/babysitter/pull/612',
|
|
1043
|
+
createdAt: '2026-04-24T00:00:00.000Z',
|
|
1044
|
+
updatedAt: '2026-04-24T00:00:00.000Z',
|
|
1045
|
+
},
|
|
1046
|
+
},
|
|
1047
|
+
source: { kind: 'seed', path: SOURCE_PATH, externalId: 'KANBAN-GAP-006' },
|
|
1048
|
+
},
|
|
1049
|
+
{
|
|
1050
|
+
id: 'KANBAN-GAP-007',
|
|
1051
|
+
key: 'KANBAN-GAP-007',
|
|
1052
|
+
projectId: PROJECT_ID,
|
|
1053
|
+
title: 'Add team and collaboration primitives',
|
|
1054
|
+
status: 'backlog',
|
|
1055
|
+
priority: 'medium',
|
|
1056
|
+
labels: [debtLabel],
|
|
1057
|
+
assignees: [defaultCollaborators[0], defaultCollaborators[1]],
|
|
1058
|
+
collaborators: [defaultCollaborators[0], defaultCollaborators[1], defaultCollaborators[2]],
|
|
1059
|
+
dependencies: [{ issueId: 'KANBAN-GAP-001', type: 'blocked-by' }],
|
|
1060
|
+
acceptanceCriteria: [
|
|
1061
|
+
{
|
|
1062
|
+
id: 'KANBAN-GAP-007-ac-1',
|
|
1063
|
+
title: 'Expose team and project settings as first-class shared surfaces.',
|
|
1064
|
+
satisfied: false,
|
|
1065
|
+
},
|
|
1066
|
+
{
|
|
1067
|
+
id: 'KANBAN-GAP-007-ac-2',
|
|
1068
|
+
title: 'Support collaborators and assignees on issue cards.',
|
|
1069
|
+
satisfied: false,
|
|
1070
|
+
},
|
|
1071
|
+
{
|
|
1072
|
+
id: 'KANBAN-GAP-007-ac-3',
|
|
1073
|
+
title: 'Persist shared activity feeds scoped to project and issue entities.',
|
|
1074
|
+
satisfied: false,
|
|
1075
|
+
},
|
|
1076
|
+
{
|
|
1077
|
+
id: 'KANBAN-GAP-007-ac-4',
|
|
1078
|
+
title: 'Define permission policy beyond gateway-token possession.',
|
|
1079
|
+
satisfied: false,
|
|
1080
|
+
},
|
|
1081
|
+
],
|
|
1082
|
+
decomposition: [],
|
|
1083
|
+
childIssueIds: [],
|
|
1084
|
+
createdAt: '2026-04-24T00:00:00.000Z',
|
|
1085
|
+
updatedAt: '2026-04-24T00:00:00.000Z',
|
|
1086
|
+
activity: [
|
|
1087
|
+
buildActivityEntry('activity-gap-007-seed', 'issue', 'KANBAN-GAP-007', 'seeded-collaboration-gap', 'Tracked the missing collaboration primitives for team settings, assignees, activity, and permissions.', systemActor, '2026-04-24T00:00:00.000Z'),
|
|
1088
|
+
],
|
|
1089
|
+
source: { kind: 'seed', path: SOURCE_PATH, externalId: 'KANBAN-GAP-007' },
|
|
1090
|
+
},
|
|
1091
|
+
];
|
|
1092
|
+
const defaultDeps = {
|
|
1093
|
+
...defaultKanbanStorageDeps,
|
|
1094
|
+
runQueryService: new RunQueryService(),
|
|
1095
|
+
reviewService: new ReviewService(),
|
|
1096
|
+
backlogFilePath: KANBAN_BACKLOG_FILE_PATH,
|
|
1097
|
+
now: () => new Date().toISOString(),
|
|
1098
|
+
};
|
|
1099
|
+
function buildSummary(snapshot) {
|
|
1100
|
+
return snapshot.projects.reduce((summary, project) => ({
|
|
1101
|
+
projectCount: summary.projectCount + 1,
|
|
1102
|
+
issueCount: summary.issueCount + project.metrics.totalIssues,
|
|
1103
|
+
readyCount: summary.readyCount + project.metrics.readyIssues,
|
|
1104
|
+
blockedCount: summary.blockedCount + project.metrics.blockedIssues,
|
|
1105
|
+
dispatchedCount: summary.dispatchedCount + project.metrics.dispatchedIssues,
|
|
1106
|
+
completedCount: summary.completedCount + project.metrics.completedIssues,
|
|
1107
|
+
needsDecompositionCount: summary.needsDecompositionCount + project.metrics.needsDecompositionIssues,
|
|
1108
|
+
inProgressCount: summary.inProgressCount + project.metrics.inProgressIssues,
|
|
1109
|
+
}), {
|
|
1110
|
+
projectCount: 0,
|
|
1111
|
+
issueCount: 0,
|
|
1112
|
+
readyCount: 0,
|
|
1113
|
+
blockedCount: 0,
|
|
1114
|
+
dispatchedCount: 0,
|
|
1115
|
+
completedCount: 0,
|
|
1116
|
+
needsDecompositionCount: 0,
|
|
1117
|
+
inProgressCount: 0,
|
|
1118
|
+
});
|
|
1119
|
+
}
|
|
1120
|
+
function attachRunSummaries(snapshot, runSummaries) {
|
|
1121
|
+
const runSummaryByName = new Map(runSummaries.map((summary) => [summary.projectName, summary]));
|
|
1122
|
+
return {
|
|
1123
|
+
...snapshot,
|
|
1124
|
+
projects: snapshot.projects.map((project) => ({
|
|
1125
|
+
...project,
|
|
1126
|
+
linkedRunSummary: project.linkedRunProjectName
|
|
1127
|
+
? runSummaryByName.get(project.linkedRunProjectName)
|
|
1128
|
+
: undefined,
|
|
1129
|
+
})),
|
|
1130
|
+
};
|
|
1131
|
+
}
|
|
1132
|
+
function attachReviewSummaries(snapshot, reviewSnapshot) {
|
|
1133
|
+
const reviewSummaryByIssueId = new Map(reviewSnapshot.artifacts
|
|
1134
|
+
.filter((artifact) => artifact.targetType === 'issue')
|
|
1135
|
+
.map((artifact) => [artifact.targetId, summarizeKanbanReviewArtifact(artifact)]));
|
|
1136
|
+
return {
|
|
1137
|
+
...snapshot,
|
|
1138
|
+
issues: snapshot.issues.map((issue) => ({
|
|
1139
|
+
...issue,
|
|
1140
|
+
review: reviewSummaryByIssueId.get(issue.id),
|
|
1141
|
+
})),
|
|
1142
|
+
};
|
|
1143
|
+
}
|
|
1144
|
+
function buildHydratedOverview(input) {
|
|
1145
|
+
const snapshot = buildKanbanBacklogSnapshot({
|
|
1146
|
+
generatedAt: input.generatedAt,
|
|
1147
|
+
projects: input.projects,
|
|
1148
|
+
issues: input.issues,
|
|
1149
|
+
dispatchContextLabels: input.dispatchContextLabels,
|
|
1150
|
+
});
|
|
1151
|
+
const hydratedSnapshot = attachReviewSummaries(attachRunSummaries(snapshot, input.runSummaries), input.reviewSnapshot);
|
|
1152
|
+
return {
|
|
1153
|
+
snapshot: hydratedSnapshot,
|
|
1154
|
+
board: buildKanbanBoardSnapshot(hydratedSnapshot),
|
|
1155
|
+
summary: buildSummary(hydratedSnapshot),
|
|
1156
|
+
};
|
|
1157
|
+
}
|
|
1158
|
+
export class BacklogQueryService {
|
|
1159
|
+
deps;
|
|
1160
|
+
constructor(overrides = {}) {
|
|
1161
|
+
this.deps = { ...defaultDeps, ...overrides };
|
|
1162
|
+
}
|
|
1163
|
+
async readSeedPayload() {
|
|
1164
|
+
const backlogFile = await readKanbanStorageFile(this.deps);
|
|
1165
|
+
const dispatchContextLabels = backlogFile?.dispatchContextLabels?.length
|
|
1166
|
+
? normalizeDispatchContextLabels(backlogFile.dispatchContextLabels)
|
|
1167
|
+
: defaultDispatchContextLabels;
|
|
1168
|
+
return {
|
|
1169
|
+
projects: backlogFile?.projects?.length ? backlogFile.projects : defaultProjects,
|
|
1170
|
+
issues: (backlogFile?.issues?.length ? backlogFile.issues : defaultIssues).map((issue) => sanitizeStoredIssue(issue, dispatchContextLabels)),
|
|
1171
|
+
dispatchContextLabels,
|
|
1172
|
+
};
|
|
1173
|
+
}
|
|
1174
|
+
async listRunSummaries() {
|
|
1175
|
+
const runProjects = await this.deps.runQueryService.listProjects();
|
|
1176
|
+
return runProjects.projects.map((project) => ({
|
|
1177
|
+
projectName: project.projectName,
|
|
1178
|
+
totalRuns: project.totalRuns,
|
|
1179
|
+
activeRuns: project.activeRuns,
|
|
1180
|
+
completedRuns: project.completedRuns,
|
|
1181
|
+
failedRuns: project.failedRuns,
|
|
1182
|
+
staleRuns: project.staleRuns,
|
|
1183
|
+
latestUpdate: project.latestUpdate,
|
|
1184
|
+
}));
|
|
1185
|
+
}
|
|
1186
|
+
async buildOverviewFromPayload(payload) {
|
|
1187
|
+
return buildHydratedOverview({
|
|
1188
|
+
projects: payload.projects,
|
|
1189
|
+
issues: payload.issues,
|
|
1190
|
+
dispatchContextLabels: payload.dispatchContextLabels,
|
|
1191
|
+
runSummaries: await this.listRunSummaries(),
|
|
1192
|
+
reviewSnapshot: await this.deps.reviewService.listReviews({ targetType: 'issue' }),
|
|
1193
|
+
generatedAt: this.deps.now(),
|
|
1194
|
+
});
|
|
1195
|
+
}
|
|
1196
|
+
async persistPayload(payload) {
|
|
1197
|
+
const existingPayload = (await readKanbanStorageFile(this.deps)) ?? {};
|
|
1198
|
+
const normalizedDispatchContextLabels = normalizeDispatchContextLabels(payload.dispatchContextLabels);
|
|
1199
|
+
await writeKanbanStorageFile(this.deps, {
|
|
1200
|
+
...existingPayload,
|
|
1201
|
+
projects: payload.projects,
|
|
1202
|
+
issues: payload.issues.map((issue) => stripDerivedDispatchState(issue, normalizedDispatchContextLabels)),
|
|
1203
|
+
dispatchContextLabels: normalizedDispatchContextLabels,
|
|
1204
|
+
});
|
|
1205
|
+
return this.buildOverviewFromPayload({
|
|
1206
|
+
...payload,
|
|
1207
|
+
issues: payload.issues.map((issue) => stripDerivedDispatchState(issue, normalizedDispatchContextLabels)),
|
|
1208
|
+
dispatchContextLabels: normalizedDispatchContextLabels,
|
|
1209
|
+
});
|
|
1210
|
+
}
|
|
1211
|
+
findIssue(payload, issueId) {
|
|
1212
|
+
const issue = payload.issues.find((candidate) => candidate.id === issueId);
|
|
1213
|
+
if (!issue) {
|
|
1214
|
+
throw new AppError(`Issue ${issueId} not found.`, 'NOT_FOUND', 404);
|
|
1215
|
+
}
|
|
1216
|
+
return issue;
|
|
1217
|
+
}
|
|
1218
|
+
findProject(payload, projectId) {
|
|
1219
|
+
const project = payload.projects.find((candidate) => candidate.id === projectId);
|
|
1220
|
+
if (!project) {
|
|
1221
|
+
throw new AppError(`Project ${projectId} not found.`, 'NOT_FOUND', 404);
|
|
1222
|
+
}
|
|
1223
|
+
return project;
|
|
1224
|
+
}
|
|
1225
|
+
async getOverview() {
|
|
1226
|
+
const { projects, issues, dispatchContextLabels } = await this.readSeedPayload();
|
|
1227
|
+
return this.buildOverviewFromPayload({ projects, issues, dispatchContextLabels });
|
|
1228
|
+
}
|
|
1229
|
+
async createIssue(input) {
|
|
1230
|
+
const payload = await this.readSeedPayload();
|
|
1231
|
+
const parentIssue = input.parentIssueId
|
|
1232
|
+
? this.findIssue(payload, input.parentIssueId)
|
|
1233
|
+
: undefined;
|
|
1234
|
+
const projectId = input.projectId ?? parentIssue?.projectId;
|
|
1235
|
+
if (!projectId) {
|
|
1236
|
+
throw new AppError('projectId is required.', 'BAD_REQUEST', 400);
|
|
1237
|
+
}
|
|
1238
|
+
const project = this.findProject(payload, projectId);
|
|
1239
|
+
if (!input.title.trim()) {
|
|
1240
|
+
throw new AppError('title is required.', 'BAD_REQUEST', 400);
|
|
1241
|
+
}
|
|
1242
|
+
if (parentIssue && parentIssue.projectId !== project.id) {
|
|
1243
|
+
throw new AppError('Sub-issues must stay in the same project as the parent.', 'BAD_REQUEST', 400);
|
|
1244
|
+
}
|
|
1245
|
+
const key = nextAutomationIssueKey({ key: project.key }, payload.issues);
|
|
1246
|
+
const createdAt = this.deps.now();
|
|
1247
|
+
const dependencies = normalizeIssueDependencies(payload, {
|
|
1248
|
+
id: key,
|
|
1249
|
+
key,
|
|
1250
|
+
projectId: project.id,
|
|
1251
|
+
}, input.dependencies ?? []);
|
|
1252
|
+
const issue = {
|
|
1253
|
+
id: key,
|
|
1254
|
+
key,
|
|
1255
|
+
projectId: project.id,
|
|
1256
|
+
title: input.title.trim(),
|
|
1257
|
+
summary: input.summary?.trim() || undefined,
|
|
1258
|
+
description: input.description?.trim() || undefined,
|
|
1259
|
+
status: input.status ?? 'backlog',
|
|
1260
|
+
priority: input.priority ?? 'medium',
|
|
1261
|
+
labels: readProjectLabels(project, input.labelIds ?? []),
|
|
1262
|
+
assignees: readProjectAssignees(project, input.assigneeIds ?? []),
|
|
1263
|
+
dependencies,
|
|
1264
|
+
acceptanceCriteria: normalizeAcceptanceCriteria({ key, acceptanceCriteria: [] }, input.acceptanceCriteria ?? []),
|
|
1265
|
+
decomposition: (input.decomposition ?? []).map((item, index) => ({
|
|
1266
|
+
id: `${key}-decomp-${index + 1}`,
|
|
1267
|
+
title: item.title,
|
|
1268
|
+
kind: item.kind,
|
|
1269
|
+
status: item.status,
|
|
1270
|
+
})),
|
|
1271
|
+
childIssueIds: [],
|
|
1272
|
+
parentIssueId: parentIssue?.id,
|
|
1273
|
+
createdAt,
|
|
1274
|
+
updatedAt: createdAt,
|
|
1275
|
+
dispatch: {
|
|
1276
|
+
readiness: 'ready',
|
|
1277
|
+
blockedReasons: [],
|
|
1278
|
+
runIds: [],
|
|
1279
|
+
sessionIds: [],
|
|
1280
|
+
contextLabels: [],
|
|
1281
|
+
},
|
|
1282
|
+
source: input.source,
|
|
1283
|
+
metadata: input.metadata,
|
|
1284
|
+
activity: parentIssue
|
|
1285
|
+
? [
|
|
1286
|
+
buildActivityEntry(`activity-created-sub-issue-${createdAt}`, 'issue', key, 'created-sub-issue', `Created sub-issue ${key} under ${parentIssue.key}.`, {
|
|
1287
|
+
kind: 'human',
|
|
1288
|
+
id: 'tal',
|
|
1289
|
+
displayName: 'Tal Muskal',
|
|
1290
|
+
role: 'owner',
|
|
1291
|
+
}, createdAt),
|
|
1292
|
+
]
|
|
1293
|
+
: [],
|
|
1294
|
+
};
|
|
1295
|
+
const nextProjects = payload.projects.map((candidate) => candidate.id === project.id
|
|
1296
|
+
? {
|
|
1297
|
+
...candidate,
|
|
1298
|
+
issueIds: appendUniqueIssueId(candidate.issueIds, issue.id),
|
|
1299
|
+
}
|
|
1300
|
+
: candidate);
|
|
1301
|
+
const nextIssues = [
|
|
1302
|
+
...payload.issues.map((candidate) => candidate.id === parentIssue?.id
|
|
1303
|
+
? {
|
|
1304
|
+
...candidate,
|
|
1305
|
+
childIssueIds: appendUniqueIssueId(candidate.childIssueIds, issue.id),
|
|
1306
|
+
updatedAt: createdAt,
|
|
1307
|
+
activity: [
|
|
1308
|
+
buildActivityEntry(`activity-linked-child-${createdAt}`, 'issue', candidate.id, 'linked-child-issue', `Linked child issue ${issue.key}.`, {
|
|
1309
|
+
kind: 'human',
|
|
1310
|
+
id: 'tal',
|
|
1311
|
+
displayName: 'Tal Muskal',
|
|
1312
|
+
role: 'owner',
|
|
1313
|
+
}, createdAt),
|
|
1314
|
+
...(candidate.activity ?? []),
|
|
1315
|
+
],
|
|
1316
|
+
}
|
|
1317
|
+
: candidate),
|
|
1318
|
+
issue,
|
|
1319
|
+
];
|
|
1320
|
+
const overview = await this.persistPayload({
|
|
1321
|
+
projects: nextProjects,
|
|
1322
|
+
issues: nextIssues,
|
|
1323
|
+
dispatchContextLabels: payload.dispatchContextLabels,
|
|
1324
|
+
});
|
|
1325
|
+
const createdIssue = overview.snapshot.issues.find((candidate) => candidate.id === issue.id);
|
|
1326
|
+
if (!createdIssue) {
|
|
1327
|
+
throw new AppError(`Created issue ${issue.id} could not be reloaded.`, 'INTERNAL_ERROR', 500);
|
|
1328
|
+
}
|
|
1329
|
+
return {
|
|
1330
|
+
overview,
|
|
1331
|
+
issue: createdIssue,
|
|
1332
|
+
};
|
|
1333
|
+
}
|
|
1334
|
+
async moveIssue(input) {
|
|
1335
|
+
const payload = await this.readSeedPayload();
|
|
1336
|
+
const overview = await this.buildOverviewFromPayload(payload);
|
|
1337
|
+
const issue = overview.snapshot.issues.find((candidate) => candidate.id === input.issueId);
|
|
1338
|
+
if (!issue) {
|
|
1339
|
+
throw new AppError(`Issue ${input.issueId} not found.`, 'NOT_FOUND', 404);
|
|
1340
|
+
}
|
|
1341
|
+
const project = overview.snapshot.projects.find((candidate) => candidate.id === issue.projectId);
|
|
1342
|
+
if (!project) {
|
|
1343
|
+
throw new AppError(`Project ${issue.projectId} not found.`, 'NOT_FOUND', 404);
|
|
1344
|
+
}
|
|
1345
|
+
const evaluation = evaluateKanbanIssueMove({
|
|
1346
|
+
project,
|
|
1347
|
+
issues: overview.snapshot.issues.filter((candidate) => candidate.projectId === project.id),
|
|
1348
|
+
issueId: issue.id,
|
|
1349
|
+
toState: input.toState,
|
|
1350
|
+
});
|
|
1351
|
+
if (!evaluation.allowed || !evaluation.nextStatus) {
|
|
1352
|
+
throw new AppError(evaluation.signals.map((signal) => signal.message).join(' '), 'KANBAN_POLICY_VIOLATION', 409);
|
|
1353
|
+
}
|
|
1354
|
+
const nextStatus = evaluation.nextStatus;
|
|
1355
|
+
const nextIssues = payload.issues.map((candidate) => candidate.id === input.issueId
|
|
1356
|
+
? {
|
|
1357
|
+
...candidate,
|
|
1358
|
+
status: nextStatus,
|
|
1359
|
+
updatedAt: this.deps.now(),
|
|
1360
|
+
}
|
|
1361
|
+
: candidate);
|
|
1362
|
+
return this.persistPayload({
|
|
1363
|
+
projects: payload.projects,
|
|
1364
|
+
issues: nextIssues,
|
|
1365
|
+
dispatchContextLabels: payload.dispatchContextLabels,
|
|
1366
|
+
});
|
|
1367
|
+
}
|
|
1368
|
+
async linkRepository(input) {
|
|
1369
|
+
const payload = await this.readSeedPayload();
|
|
1370
|
+
const issue = this.findIssue(payload, input.issueId);
|
|
1371
|
+
const project = this.findProject(payload, issue.projectId);
|
|
1372
|
+
const provider = input.provider ?? 'github';
|
|
1373
|
+
const owner = input.owner.trim();
|
|
1374
|
+
const name = input.name.trim();
|
|
1375
|
+
if (!owner || !name || !input.branchName.trim()) {
|
|
1376
|
+
throw new AppError('owner, name, and branchName are required.', 'BAD_REQUEST', 400);
|
|
1377
|
+
}
|
|
1378
|
+
const integrationConnection = provider === 'github' || provider === 'azure-repos'
|
|
1379
|
+
? project.integrations.find((candidate) => candidate.provider === provider)
|
|
1380
|
+
: undefined;
|
|
1381
|
+
const repositoryId = buildRepositoryId(provider, owner, name);
|
|
1382
|
+
const existingRepository = project.repositories.find((candidate) => candidate.id === repositoryId);
|
|
1383
|
+
const repository = existingRepository ?? {
|
|
1384
|
+
id: repositoryId,
|
|
1385
|
+
owner,
|
|
1386
|
+
name,
|
|
1387
|
+
fullName: `${owner}/${name}`,
|
|
1388
|
+
provider,
|
|
1389
|
+
url: buildRepositoryUrl(provider, owner, name),
|
|
1390
|
+
defaultBranch: input.defaultBranch?.trim() || 'main',
|
|
1391
|
+
linkedAt: this.deps.now(),
|
|
1392
|
+
settings: {
|
|
1393
|
+
baseBranch: input.defaultBranch?.trim() || 'main',
|
|
1394
|
+
autoMerge: false,
|
|
1395
|
+
requiredApprovals: 1,
|
|
1396
|
+
ciProvider: provider === 'github' ? 'GitHub Actions' : undefined,
|
|
1397
|
+
publishTarget: 'npm',
|
|
1398
|
+
},
|
|
1399
|
+
};
|
|
1400
|
+
const nextProjects = payload.projects.map((candidate) => candidate.id === project.id ? upsertKanbanProjectRepository(candidate, repository) : candidate);
|
|
1401
|
+
const nextIssues = payload.issues.map((candidate) => candidate.id === issue.id
|
|
1402
|
+
? {
|
|
1403
|
+
...linkKanbanIssueRepository(candidate, {
|
|
1404
|
+
repositoryId,
|
|
1405
|
+
branchName: input.branchName,
|
|
1406
|
+
integration: provider === 'github' || provider === 'azure-repos'
|
|
1407
|
+
? buildRepositoryIntegrationState(provider, {
|
|
1408
|
+
status: integrationConnection?.status ?? 'disconnected',
|
|
1409
|
+
linkState: 'unlinked',
|
|
1410
|
+
guidance: integrationConnection?.guidance ??
|
|
1411
|
+
`${integrationProviderLabel(provider)} must be connected before linked PR actions are available.`,
|
|
1412
|
+
failureMessage: integrationConnection?.failureMessage,
|
|
1413
|
+
missingScopes: integrationConnection?.missingScopes,
|
|
1414
|
+
prerequisites: integrationConnection?.prerequisites ?? [],
|
|
1415
|
+
actions: integrationConnection?.actions,
|
|
1416
|
+
})
|
|
1417
|
+
: undefined,
|
|
1418
|
+
}),
|
|
1419
|
+
updatedAt: this.deps.now(),
|
|
1420
|
+
}
|
|
1421
|
+
: candidate);
|
|
1422
|
+
return this.persistPayload({
|
|
1423
|
+
projects: nextProjects,
|
|
1424
|
+
issues: nextIssues,
|
|
1425
|
+
dispatchContextLabels: payload.dispatchContextLabels,
|
|
1426
|
+
});
|
|
1427
|
+
}
|
|
1428
|
+
async updateRepositorySettings(input) {
|
|
1429
|
+
const payload = await this.readSeedPayload();
|
|
1430
|
+
const issue = this.findIssue(payload, input.issueId);
|
|
1431
|
+
const repositoryId = issue.repositoryLifecycle?.repositoryId;
|
|
1432
|
+
if (!repositoryId) {
|
|
1433
|
+
throw new AppError(`Issue ${input.issueId} is not linked to a repository.`, 'BAD_REQUEST', 400);
|
|
1434
|
+
}
|
|
1435
|
+
const nextProjects = payload.projects.map((candidate) => candidate.id === issue.projectId
|
|
1436
|
+
? updateKanbanProjectRepositorySettings(candidate, {
|
|
1437
|
+
repositoryId,
|
|
1438
|
+
settings: input.settings,
|
|
1439
|
+
})
|
|
1440
|
+
: candidate);
|
|
1441
|
+
return this.persistPayload({
|
|
1442
|
+
projects: nextProjects,
|
|
1443
|
+
issues: payload.issues,
|
|
1444
|
+
dispatchContextLabels: payload.dispatchContextLabels,
|
|
1445
|
+
});
|
|
1446
|
+
}
|
|
1447
|
+
async updateProjectCollaboration(input) {
|
|
1448
|
+
const payload = await this.readSeedPayload();
|
|
1449
|
+
const project = this.findProject(payload, input.projectId);
|
|
1450
|
+
const updatedAt = this.deps.now();
|
|
1451
|
+
const nextMembers = input.members?.map((member) => ({
|
|
1452
|
+
id: member.id.trim(),
|
|
1453
|
+
displayName: member.displayName.trim(),
|
|
1454
|
+
email: member.email?.trim() || undefined,
|
|
1455
|
+
role: parseRole(member.role),
|
|
1456
|
+
})) ?? project.team?.members ?? defaultCollaborators;
|
|
1457
|
+
const nextProjects = payload.projects.map((candidate) => candidate.id === input.projectId
|
|
1458
|
+
? {
|
|
1459
|
+
...candidate,
|
|
1460
|
+
assignees: nextMembers,
|
|
1461
|
+
team: {
|
|
1462
|
+
id: candidate.team?.id ?? `team-${candidate.id}`,
|
|
1463
|
+
name: input.teamName?.trim() || candidate.team?.name || `${candidate.name} Team`,
|
|
1464
|
+
members: nextMembers,
|
|
1465
|
+
settings: {
|
|
1466
|
+
visibility: input.visibility ?? candidate.team?.settings?.visibility ?? defaultTeamSettings.visibility,
|
|
1467
|
+
defaultRole: parseRole(input.defaultRole ?? candidate.team?.settings?.defaultRole),
|
|
1468
|
+
allowSelfAssign: input.allowSelfAssign ??
|
|
1469
|
+
candidate.team?.settings?.allowSelfAssign ??
|
|
1470
|
+
defaultTeamSettings.allowSelfAssign,
|
|
1471
|
+
},
|
|
1472
|
+
},
|
|
1473
|
+
settings: {
|
|
1474
|
+
reviewRequiredForDone: input.reviewRequiredForDone ??
|
|
1475
|
+
candidate.settings?.reviewRequiredForDone ??
|
|
1476
|
+
defaultProjectSettings.reviewRequiredForDone,
|
|
1477
|
+
activityScope: input.activityScope ??
|
|
1478
|
+
candidate.settings?.activityScope ??
|
|
1479
|
+
defaultProjectSettings.activityScope,
|
|
1480
|
+
workspaceProvisioning: input.workspaceProvisioning ??
|
|
1481
|
+
candidate.settings?.workspaceProvisioning ??
|
|
1482
|
+
defaultProjectSettings.workspaceProvisioning,
|
|
1483
|
+
},
|
|
1484
|
+
permissions: input.permissions?.length ? input.permissions : candidate.permissions ?? defaultPermissionMatrix,
|
|
1485
|
+
activity: [
|
|
1486
|
+
buildActivityEntry(`activity-project-collab-${updatedAt}`, 'project', candidate.id, 'updated-project-collaboration', 'Updated shared team settings, roster, and permission policy.', {
|
|
1487
|
+
kind: 'human',
|
|
1488
|
+
id: 'tal',
|
|
1489
|
+
displayName: 'Tal Muskal',
|
|
1490
|
+
role: 'owner',
|
|
1491
|
+
}, updatedAt),
|
|
1492
|
+
...(candidate.activity ?? []),
|
|
1493
|
+
],
|
|
1494
|
+
}
|
|
1495
|
+
: candidate);
|
|
1496
|
+
return this.persistPayload({
|
|
1497
|
+
projects: nextProjects,
|
|
1498
|
+
issues: payload.issues,
|
|
1499
|
+
dispatchContextLabels: payload.dispatchContextLabels,
|
|
1500
|
+
});
|
|
1501
|
+
}
|
|
1502
|
+
async updateIssueCollaboration(input) {
|
|
1503
|
+
const payload = await this.readSeedPayload();
|
|
1504
|
+
const issue = this.findIssue(payload, input.issueId);
|
|
1505
|
+
const project = this.findProject(payload, issue.projectId);
|
|
1506
|
+
const members = project.team?.members ?? defaultCollaborators;
|
|
1507
|
+
const assignees = resolveCollaboratorsById(members, input.assigneeIds);
|
|
1508
|
+
const collaborators = resolveCollaboratorsById(members, input.collaboratorIds);
|
|
1509
|
+
const updatedAt = this.deps.now();
|
|
1510
|
+
const nextIssues = payload.issues.map((candidate) => candidate.id === input.issueId
|
|
1511
|
+
? {
|
|
1512
|
+
...candidate,
|
|
1513
|
+
assignees,
|
|
1514
|
+
collaborators,
|
|
1515
|
+
updatedAt,
|
|
1516
|
+
activity: [
|
|
1517
|
+
buildActivityEntry(`activity-issue-collab-${updatedAt}`, 'issue', candidate.id, 'updated-issue-collaboration', `Set ${assignees.length} assignees and ${collaborators.length} collaborators for ${candidate.key}.`, {
|
|
1518
|
+
kind: 'human',
|
|
1519
|
+
id: 'tal',
|
|
1520
|
+
displayName: 'Tal Muskal',
|
|
1521
|
+
role: 'owner',
|
|
1522
|
+
}, updatedAt),
|
|
1523
|
+
...(candidate.activity ?? []),
|
|
1524
|
+
],
|
|
1525
|
+
}
|
|
1526
|
+
: candidate);
|
|
1527
|
+
const nextProjects = payload.projects.map((candidate) => candidate.id === project.id
|
|
1528
|
+
? {
|
|
1529
|
+
...candidate,
|
|
1530
|
+
activity: [
|
|
1531
|
+
buildActivityEntry(`activity-project-issue-collab-${updatedAt}`, 'project', candidate.id, 'updated-issue-collaboration', `Updated issue collaboration on ${issue.key}.`, {
|
|
1532
|
+
kind: 'human',
|
|
1533
|
+
id: 'tal',
|
|
1534
|
+
displayName: 'Tal Muskal',
|
|
1535
|
+
role: 'owner',
|
|
1536
|
+
}, updatedAt),
|
|
1537
|
+
...(candidate.activity ?? []),
|
|
1538
|
+
],
|
|
1539
|
+
}
|
|
1540
|
+
: candidate);
|
|
1541
|
+
return this.persistPayload({
|
|
1542
|
+
projects: nextProjects,
|
|
1543
|
+
issues: nextIssues,
|
|
1544
|
+
dispatchContextLabels: payload.dispatchContextLabels,
|
|
1545
|
+
});
|
|
1546
|
+
}
|
|
1547
|
+
async updateIssueDispatchContextLabels(input) {
|
|
1548
|
+
const payload = await this.readSeedPayload();
|
|
1549
|
+
const issue = this.findIssue(payload, input.issueId);
|
|
1550
|
+
const contextLabels = resolveDispatchContextLabelRefs(payload.dispatchContextLabels, input.dispatchContextLabelIds);
|
|
1551
|
+
const updatedAt = this.deps.now();
|
|
1552
|
+
const nextIssues = payload.issues.map((candidate) => candidate.id === issue.id
|
|
1553
|
+
? {
|
|
1554
|
+
...candidate,
|
|
1555
|
+
dispatch: {
|
|
1556
|
+
...candidate.dispatch,
|
|
1557
|
+
contextLabels,
|
|
1558
|
+
},
|
|
1559
|
+
updatedAt,
|
|
1560
|
+
activity: [
|
|
1561
|
+
buildActivityEntry(`activity-issue-dispatch-context-${updatedAt}`, 'issue', candidate.id, 'updated-issue-dispatch-context-labels', `Set ${contextLabels.length} dispatch context label attachment${contextLabels.length === 1 ? '' : 's'} on ${candidate.key}.`, {
|
|
1562
|
+
kind: 'human',
|
|
1563
|
+
id: 'tal',
|
|
1564
|
+
displayName: 'Tal Muskal',
|
|
1565
|
+
role: 'owner',
|
|
1566
|
+
}, updatedAt),
|
|
1567
|
+
...(candidate.activity ?? []),
|
|
1568
|
+
],
|
|
1569
|
+
}
|
|
1570
|
+
: candidate);
|
|
1571
|
+
return this.persistPayload({
|
|
1572
|
+
projects: payload.projects,
|
|
1573
|
+
issues: nextIssues,
|
|
1574
|
+
dispatchContextLabels: payload.dispatchContextLabels,
|
|
1575
|
+
});
|
|
1576
|
+
}
|
|
1577
|
+
async updateIssueDetail(input) {
|
|
1578
|
+
const payload = await this.readSeedPayload();
|
|
1579
|
+
const issue = this.findIssue(payload, input.issueId);
|
|
1580
|
+
const project = this.findProject(payload, issue.projectId);
|
|
1581
|
+
const currentOverview = await this.buildOverviewFromPayload(payload);
|
|
1582
|
+
const currentProject = currentOverview.snapshot.projects.find((candidate) => candidate.id === issue.projectId);
|
|
1583
|
+
if (!currentProject) {
|
|
1584
|
+
throw new AppError(`Project ${issue.projectId} not found.`, 'NOT_FOUND', 404);
|
|
1585
|
+
}
|
|
1586
|
+
if (input.expectedUpdatedAt && issue.updatedAt !== input.expectedUpdatedAt) {
|
|
1587
|
+
throw new AppError(`Issue ${issue.key} changed since this draft was loaded. Reload the latest issue state before saving again.`, 'STALE_WRITE', 409);
|
|
1588
|
+
}
|
|
1589
|
+
const nextTitle = input.title !== undefined ? input.title.trim() : issue.title;
|
|
1590
|
+
if (!nextTitle) {
|
|
1591
|
+
throw new AppError('Issue title is required.', 'BAD_REQUEST', 400);
|
|
1592
|
+
}
|
|
1593
|
+
const nextSummary = input.summary !== undefined ? input.summary.trim() || undefined : issue.summary;
|
|
1594
|
+
const nextDescription = input.description !== undefined ? input.description.trim() || undefined : issue.description;
|
|
1595
|
+
const nextStatus = input.status ?? issue.status;
|
|
1596
|
+
const nextPriority = input.priority ?? issue.priority;
|
|
1597
|
+
const nextLabels = input.labelIds !== undefined ? readProjectLabels(project, input.labelIds) : issue.labels;
|
|
1598
|
+
const nextAssignees = input.assigneeIds !== undefined ? readProjectAssignees(project, input.assigneeIds) : issue.assignees;
|
|
1599
|
+
const nextDependencies = input.dependencies !== undefined
|
|
1600
|
+
? normalizeIssueDependencies(payload, {
|
|
1601
|
+
id: issue.id,
|
|
1602
|
+
key: issue.key,
|
|
1603
|
+
projectId: issue.projectId,
|
|
1604
|
+
}, input.dependencies)
|
|
1605
|
+
: issue.dependencies;
|
|
1606
|
+
const nextAcceptanceCriteria = input.acceptanceCriteria !== undefined
|
|
1607
|
+
? normalizeAcceptanceCriteria({
|
|
1608
|
+
key: issue.key,
|
|
1609
|
+
acceptanceCriteria: issue.acceptanceCriteria,
|
|
1610
|
+
}, input.acceptanceCriteria)
|
|
1611
|
+
: issue.acceptanceCriteria;
|
|
1612
|
+
if (nextStatus !== issue.status &&
|
|
1613
|
+
(nextStatus === 'in-progress' || nextStatus === 'review' || nextStatus === 'done')) {
|
|
1614
|
+
const evaluation = evaluateKanbanIssueMove({
|
|
1615
|
+
project: currentProject,
|
|
1616
|
+
issues: currentOverview.snapshot.issues,
|
|
1617
|
+
issueId: issue.id,
|
|
1618
|
+
toState: nextStatus === 'in-progress' ? 'in-progress' : nextStatus === 'review' ? 'review' : 'done',
|
|
1619
|
+
});
|
|
1620
|
+
if (!evaluation.allowed) {
|
|
1621
|
+
throw new AppError(evaluation.signals.map((signal) => signal.message).join(' '), 'KANBAN_POLICY_VIOLATION', 409);
|
|
1622
|
+
}
|
|
1623
|
+
}
|
|
1624
|
+
const candidateSnapshot = buildKanbanBacklogSnapshot({
|
|
1625
|
+
projects: payload.projects,
|
|
1626
|
+
issues: payload.issues.map((candidate) => candidate.id === issue.id
|
|
1627
|
+
? {
|
|
1628
|
+
...candidate,
|
|
1629
|
+
title: nextTitle,
|
|
1630
|
+
summary: nextSummary,
|
|
1631
|
+
description: nextDescription,
|
|
1632
|
+
status: nextStatus,
|
|
1633
|
+
priority: nextPriority,
|
|
1634
|
+
labels: nextLabels,
|
|
1635
|
+
assignees: nextAssignees,
|
|
1636
|
+
dependencies: nextDependencies,
|
|
1637
|
+
acceptanceCriteria: nextAcceptanceCriteria,
|
|
1638
|
+
}
|
|
1639
|
+
: candidate),
|
|
1640
|
+
dispatchContextLabels: payload.dispatchContextLabels,
|
|
1641
|
+
generatedAt: this.deps.now(),
|
|
1642
|
+
});
|
|
1643
|
+
const candidateIssue = candidateSnapshot.issues.find((candidate) => candidate.id === issue.id);
|
|
1644
|
+
if (!candidateIssue) {
|
|
1645
|
+
throw new AppError(`Issue ${issue.key} could not be evaluated.`, 'INTERNAL_ERROR', 500);
|
|
1646
|
+
}
|
|
1647
|
+
if (nextStatus === 'in-progress' &&
|
|
1648
|
+
candidateIssue.dispatch.readiness !== 'ready' &&
|
|
1649
|
+
candidateIssue.dispatch.readiness !== 'dispatched') {
|
|
1650
|
+
throw new AppError(`${issue.key} is ${candidateIssue.dispatch.readiness} and cannot start active work yet.`, 'KANBAN_POLICY_VIOLATION', 409);
|
|
1651
|
+
}
|
|
1652
|
+
if ((nextStatus === 'review' || nextStatus === 'done') &&
|
|
1653
|
+
(candidateIssue.status === 'blocked' || candidateIssue.dispatch.readiness === 'blocked')) {
|
|
1654
|
+
throw new AppError(`${issue.key} is blocked and cannot advance until the blocking reasons clear.`, 'KANBAN_POLICY_VIOLATION', 409);
|
|
1655
|
+
}
|
|
1656
|
+
if (nextStatus === 'done' &&
|
|
1657
|
+
candidateIssue.acceptanceCriteria.some((criterion) => !criterion.satisfied)) {
|
|
1658
|
+
throw new AppError(`${issue.key} has acceptance checks remaining and cannot move to done yet.`, 'KANBAN_POLICY_VIOLATION', 409);
|
|
1659
|
+
}
|
|
1660
|
+
const changedFields = [];
|
|
1661
|
+
if (issue.title !== nextTitle) {
|
|
1662
|
+
changedFields.push('title');
|
|
1663
|
+
}
|
|
1664
|
+
if ((issue.summary ?? '') !== (nextSummary ?? '')) {
|
|
1665
|
+
changedFields.push('summary');
|
|
1666
|
+
}
|
|
1667
|
+
if ((issue.description ?? '') !== (nextDescription ?? '')) {
|
|
1668
|
+
changedFields.push('description');
|
|
1669
|
+
}
|
|
1670
|
+
if (issue.status !== nextStatus) {
|
|
1671
|
+
changedFields.push('status');
|
|
1672
|
+
}
|
|
1673
|
+
if (issue.priority !== nextPriority) {
|
|
1674
|
+
changedFields.push('priority');
|
|
1675
|
+
}
|
|
1676
|
+
if (!arrayEquals(issue.labels.map((label) => label.id), nextLabels.map((label) => label.id))) {
|
|
1677
|
+
changedFields.push('tags');
|
|
1678
|
+
}
|
|
1679
|
+
if (!arrayEquals(issue.assignees.map((assignee) => assignee.id), nextAssignees.map((assignee) => assignee.id))) {
|
|
1680
|
+
changedFields.push('assignees');
|
|
1681
|
+
}
|
|
1682
|
+
if (!issueDependencyEquals(issue.dependencies, nextDependencies)) {
|
|
1683
|
+
changedFields.push('dependencies');
|
|
1684
|
+
}
|
|
1685
|
+
if (!acceptanceCriteriaEquals(issue.acceptanceCriteria, nextAcceptanceCriteria)) {
|
|
1686
|
+
changedFields.push('acceptance criteria');
|
|
1687
|
+
}
|
|
1688
|
+
if (changedFields.length === 0) {
|
|
1689
|
+
return this.buildOverviewFromPayload(payload);
|
|
1690
|
+
}
|
|
1691
|
+
const updatedAt = this.deps.now();
|
|
1692
|
+
const summary = changedFields.length === 1
|
|
1693
|
+
? `Updated ${changedFields[0]} for ${issue.key}.`
|
|
1694
|
+
: `Updated ${changedFields.slice(0, -1).join(', ')} and ${changedFields.at(-1)} for ${issue.key}.`;
|
|
1695
|
+
const nextIssues = payload.issues.map((candidate) => candidate.id === input.issueId
|
|
1696
|
+
? {
|
|
1697
|
+
...candidate,
|
|
1698
|
+
title: nextTitle,
|
|
1699
|
+
summary: nextSummary,
|
|
1700
|
+
description: nextDescription,
|
|
1701
|
+
status: nextStatus,
|
|
1702
|
+
priority: nextPriority,
|
|
1703
|
+
labels: nextLabels,
|
|
1704
|
+
assignees: nextAssignees,
|
|
1705
|
+
dependencies: nextDependencies,
|
|
1706
|
+
acceptanceCriteria: nextAcceptanceCriteria,
|
|
1707
|
+
updatedAt,
|
|
1708
|
+
activity: [
|
|
1709
|
+
buildActivityEntry(`activity-issue-detail-${updatedAt}`, 'issue', candidate.id, 'updated-issue-detail', summary, {
|
|
1710
|
+
kind: 'human',
|
|
1711
|
+
id: 'tal',
|
|
1712
|
+
displayName: 'Tal Muskal',
|
|
1713
|
+
role: 'owner',
|
|
1714
|
+
}, updatedAt),
|
|
1715
|
+
...(candidate.activity ?? []),
|
|
1716
|
+
],
|
|
1717
|
+
}
|
|
1718
|
+
: candidate);
|
|
1719
|
+
const nextProjects = payload.projects.map((candidate) => candidate.id === project.id
|
|
1720
|
+
? {
|
|
1721
|
+
...candidate,
|
|
1722
|
+
activity: [
|
|
1723
|
+
buildActivityEntry(`activity-project-issue-detail-${updatedAt}`, 'project', candidate.id, 'updated-issue-detail', `Updated issue detail fields on ${issue.key}.`, {
|
|
1724
|
+
kind: 'human',
|
|
1725
|
+
id: 'tal',
|
|
1726
|
+
displayName: 'Tal Muskal',
|
|
1727
|
+
role: 'owner',
|
|
1728
|
+
}, updatedAt),
|
|
1729
|
+
...(candidate.activity ?? []),
|
|
1730
|
+
],
|
|
1731
|
+
}
|
|
1732
|
+
: candidate);
|
|
1733
|
+
return this.persistPayload({
|
|
1734
|
+
projects: nextProjects,
|
|
1735
|
+
issues: nextIssues,
|
|
1736
|
+
dispatchContextLabels: payload.dispatchContextLabels,
|
|
1737
|
+
});
|
|
1738
|
+
}
|
|
1739
|
+
async linkIssueWorkspace(input) {
|
|
1740
|
+
const payload = await this.readSeedPayload();
|
|
1741
|
+
const issue = this.findIssue(payload, input.issueId);
|
|
1742
|
+
const normalizedWorkspacePath = normalizeWorkspacePath(input.workspacePath);
|
|
1743
|
+
const workspaceName = input.workspaceName?.trim() || path.basename(normalizedWorkspacePath);
|
|
1744
|
+
const branchName = input.branchName?.trim() || undefined;
|
|
1745
|
+
if (!normalizedWorkspacePath) {
|
|
1746
|
+
throw new AppError("workspacePath is required.", "BAD_REQUEST", 400);
|
|
1747
|
+
}
|
|
1748
|
+
if ((issue.workspaceLinks ?? []).some((link) => normalizeWorkspacePath(link.workspacePath) === normalizedWorkspacePath)) {
|
|
1749
|
+
throw new AppError(`${issue.key} is already linked to ${normalizedWorkspacePath}.`, "BAD_REQUEST", 409);
|
|
1750
|
+
}
|
|
1751
|
+
const linkedIssue = payload.issues.find((candidate) => candidate.id !== issue.id &&
|
|
1752
|
+
(candidate.workspaceLinks ?? []).some((link) => normalizeWorkspacePath(link.workspacePath) === normalizedWorkspacePath));
|
|
1753
|
+
if (linkedIssue) {
|
|
1754
|
+
throw new AppError(`${normalizedWorkspacePath} is already linked to ${linkedIssue.key}.`, "BAD_REQUEST", 409);
|
|
1755
|
+
}
|
|
1756
|
+
const updatedAt = this.deps.now();
|
|
1757
|
+
const nextIssues = payload.issues.map((candidate) => candidate.id === issue.id
|
|
1758
|
+
? {
|
|
1759
|
+
...candidate,
|
|
1760
|
+
workspaceLinks: [
|
|
1761
|
+
...(candidate.workspaceLinks ?? []),
|
|
1762
|
+
{
|
|
1763
|
+
workspacePath: normalizedWorkspacePath,
|
|
1764
|
+
workspaceName,
|
|
1765
|
+
branchName,
|
|
1766
|
+
linkedAt: updatedAt,
|
|
1767
|
+
source: input.source,
|
|
1768
|
+
},
|
|
1769
|
+
],
|
|
1770
|
+
updatedAt,
|
|
1771
|
+
activity: [
|
|
1772
|
+
buildActivityEntry(`activity-issue-workspace-link-${updatedAt}`, "issue", candidate.id, "linked-workspace", `${input.source === "created-from-issue" ? "Created" : "Linked"} workspace ${workspaceName} on ${candidate.key}.`, {
|
|
1773
|
+
kind: "human",
|
|
1774
|
+
id: "tal",
|
|
1775
|
+
displayName: "Tal Muskal",
|
|
1776
|
+
role: "owner",
|
|
1777
|
+
}, updatedAt),
|
|
1778
|
+
...(candidate.activity ?? []),
|
|
1779
|
+
],
|
|
1780
|
+
}
|
|
1781
|
+
: candidate);
|
|
1782
|
+
const nextProjects = payload.projects.map((candidate) => candidate.id === issue.projectId
|
|
1783
|
+
? {
|
|
1784
|
+
...candidate,
|
|
1785
|
+
activity: [
|
|
1786
|
+
buildActivityEntry(`activity-project-workspace-link-${updatedAt}`, "project", candidate.id, "linked-workspace", `${input.source === "created-from-issue" ? "Created" : "Linked"} workspace ${workspaceName} from ${issue.key}.`, {
|
|
1787
|
+
kind: "human",
|
|
1788
|
+
id: "tal",
|
|
1789
|
+
displayName: "Tal Muskal",
|
|
1790
|
+
role: "owner",
|
|
1791
|
+
}, updatedAt),
|
|
1792
|
+
...(candidate.activity ?? []),
|
|
1793
|
+
],
|
|
1794
|
+
}
|
|
1795
|
+
: candidate);
|
|
1796
|
+
return this.persistPayload({
|
|
1797
|
+
projects: nextProjects,
|
|
1798
|
+
issues: nextIssues,
|
|
1799
|
+
dispatchContextLabels: payload.dispatchContextLabels,
|
|
1800
|
+
});
|
|
1801
|
+
}
|
|
1802
|
+
async linkIssueSession(input) {
|
|
1803
|
+
const payload = await this.readSeedPayload();
|
|
1804
|
+
const issue = this.findIssue(payload, input.issueId);
|
|
1805
|
+
const normalizedSessionId = input.sessionId?.trim() ?? '';
|
|
1806
|
+
const normalizedRunId = input.runId?.trim() ?? '';
|
|
1807
|
+
if (!normalizedSessionId && !normalizedRunId) {
|
|
1808
|
+
throw new AppError('sessionId or runId is required.', 'BAD_REQUEST', 400);
|
|
1809
|
+
}
|
|
1810
|
+
if (normalizedSessionId) {
|
|
1811
|
+
const linkedIssue = payload.issues.find((candidate) => candidate.id !== issue.id &&
|
|
1812
|
+
(candidate.dispatch?.sessionIds ?? []).includes(normalizedSessionId));
|
|
1813
|
+
if (linkedIssue) {
|
|
1814
|
+
throw new AppError(`${normalizedSessionId} is already linked to ${linkedIssue.key}.`, 'BAD_REQUEST', 409);
|
|
1815
|
+
}
|
|
1816
|
+
}
|
|
1817
|
+
const updatedAt = this.deps.now();
|
|
1818
|
+
const nextIssues = payload.issues.map((candidate) => candidate.id === issue.id
|
|
1819
|
+
? {
|
|
1820
|
+
...candidate,
|
|
1821
|
+
dispatch: {
|
|
1822
|
+
...candidate.dispatch,
|
|
1823
|
+
runIds: appendUniqueString(candidate.dispatch?.runIds, normalizedRunId),
|
|
1824
|
+
sessionIds: appendUniqueString(candidate.dispatch?.sessionIds, normalizedSessionId),
|
|
1825
|
+
lastDispatchedAt: updatedAt,
|
|
1826
|
+
},
|
|
1827
|
+
updatedAt,
|
|
1828
|
+
activity: [
|
|
1829
|
+
buildActivityEntry(`activity-issue-session-link-${updatedAt}`, 'issue', candidate.id, 'dispatch-linked', `Linked ${normalizedSessionId ? `session ${normalizedSessionId}` : `dispatch ${normalizedRunId}`} to ${candidate.key}.`, {
|
|
1830
|
+
kind: 'human',
|
|
1831
|
+
id: 'tal',
|
|
1832
|
+
displayName: 'Tal Muskal',
|
|
1833
|
+
role: 'owner',
|
|
1834
|
+
}, updatedAt),
|
|
1835
|
+
...(candidate.activity ?? []),
|
|
1836
|
+
],
|
|
1837
|
+
}
|
|
1838
|
+
: candidate);
|
|
1839
|
+
return this.persistPayload({
|
|
1840
|
+
projects: payload.projects,
|
|
1841
|
+
issues: nextIssues,
|
|
1842
|
+
dispatchContextLabels: payload.dispatchContextLabels,
|
|
1843
|
+
});
|
|
1844
|
+
}
|
|
1845
|
+
async linkChildIssue(input) {
|
|
1846
|
+
if (input.parentIssueId === input.childIssueId) {
|
|
1847
|
+
throw new AppError('An issue cannot be its own child.', 'BAD_REQUEST', 400);
|
|
1848
|
+
}
|
|
1849
|
+
const payload = await this.readSeedPayload();
|
|
1850
|
+
const parentIssue = this.findIssue(payload, input.parentIssueId);
|
|
1851
|
+
const childIssue = this.findIssue(payload, input.childIssueId);
|
|
1852
|
+
if (parentIssue.projectId !== childIssue.projectId) {
|
|
1853
|
+
throw new AppError('Parent and child issues must belong to the same project.', 'BAD_REQUEST', 400);
|
|
1854
|
+
}
|
|
1855
|
+
if (parentIssue.childIssueIds.includes(childIssue.id)) {
|
|
1856
|
+
return this.buildOverviewFromPayload(payload);
|
|
1857
|
+
}
|
|
1858
|
+
if (childIssue.parentIssueId && childIssue.parentIssueId !== parentIssue.id) {
|
|
1859
|
+
throw new AppError(`Issue ${childIssue.key} is already linked to parent ${childIssue.parentIssueId}.`, 'BAD_REQUEST', 400);
|
|
1860
|
+
}
|
|
1861
|
+
if (wouldCreateParentChildCycle(payload.issues, parentIssue.id, childIssue.id)) {
|
|
1862
|
+
throw new AppError('Linking this child would create a parent-child cycle.', 'BAD_REQUEST', 400);
|
|
1863
|
+
}
|
|
1864
|
+
const updatedAt = this.deps.now();
|
|
1865
|
+
const nextIssues = payload.issues.map((candidate) => {
|
|
1866
|
+
if (candidate.id === parentIssue.id) {
|
|
1867
|
+
return {
|
|
1868
|
+
...candidate,
|
|
1869
|
+
childIssueIds: appendUniqueIssueId(candidate.childIssueIds, childIssue.id),
|
|
1870
|
+
updatedAt,
|
|
1871
|
+
activity: [
|
|
1872
|
+
buildActivityEntry(`activity-linked-child-${updatedAt}`, 'issue', candidate.id, 'linked-child-issue', `Linked child issue ${childIssue.key}.`, {
|
|
1873
|
+
kind: 'human',
|
|
1874
|
+
id: 'tal',
|
|
1875
|
+
displayName: 'Tal Muskal',
|
|
1876
|
+
role: 'owner',
|
|
1877
|
+
}, updatedAt),
|
|
1878
|
+
...(candidate.activity ?? []),
|
|
1879
|
+
],
|
|
1880
|
+
};
|
|
1881
|
+
}
|
|
1882
|
+
if (candidate.id === childIssue.id) {
|
|
1883
|
+
return {
|
|
1884
|
+
...candidate,
|
|
1885
|
+
parentIssueId: parentIssue.id,
|
|
1886
|
+
updatedAt,
|
|
1887
|
+
activity: [
|
|
1888
|
+
buildActivityEntry(`activity-linked-parent-${updatedAt}`, 'issue', candidate.id, 'linked-parent-issue', `Linked parent issue ${parentIssue.key}.`, {
|
|
1889
|
+
kind: 'human',
|
|
1890
|
+
id: 'tal',
|
|
1891
|
+
displayName: 'Tal Muskal',
|
|
1892
|
+
role: 'owner',
|
|
1893
|
+
}, updatedAt),
|
|
1894
|
+
...(candidate.activity ?? []),
|
|
1895
|
+
],
|
|
1896
|
+
};
|
|
1897
|
+
}
|
|
1898
|
+
return candidate;
|
|
1899
|
+
});
|
|
1900
|
+
return this.persistPayload({
|
|
1901
|
+
projects: payload.projects,
|
|
1902
|
+
issues: nextIssues,
|
|
1903
|
+
dispatchContextLabels: payload.dispatchContextLabels,
|
|
1904
|
+
});
|
|
1905
|
+
}
|
|
1906
|
+
async createPullRequest(input) {
|
|
1907
|
+
const payload = await this.readSeedPayload();
|
|
1908
|
+
const issue = this.findIssue(payload, input.issueId);
|
|
1909
|
+
const project = this.findProject(payload, issue.projectId);
|
|
1910
|
+
const repositoryId = issue.repositoryLifecycle?.repositoryId;
|
|
1911
|
+
if (!repositoryId || !issue.repositoryLifecycle) {
|
|
1912
|
+
throw new AppError(`Issue ${input.issueId} must be linked to a repository before creating a PR.`, 'BAD_REQUEST', 400);
|
|
1913
|
+
}
|
|
1914
|
+
const repository = project.repositories.find((candidate) => candidate.id === repositoryId);
|
|
1915
|
+
if (!repository) {
|
|
1916
|
+
throw new AppError(`Repository ${repositoryId} not found on project ${project.id}.`, 'NOT_FOUND', 404);
|
|
1917
|
+
}
|
|
1918
|
+
const integration = issue.repositoryLifecycle.integration;
|
|
1919
|
+
if (integration && !integration.actions.canCreatePullRequest) {
|
|
1920
|
+
throw new AppError(integration.actions.reason ?? integration.guidance, 'BAD_REQUEST', 400);
|
|
1921
|
+
}
|
|
1922
|
+
if (!input.title.trim()) {
|
|
1923
|
+
throw new AppError('title is required.', 'BAD_REQUEST', 400);
|
|
1924
|
+
}
|
|
1925
|
+
const number = nextPullRequestNumber(payload.issues);
|
|
1926
|
+
const reviewers = parseReviewerList(input.reviewers ?? '');
|
|
1927
|
+
const nextIssues = payload.issues.map((candidate) => {
|
|
1928
|
+
if (candidate.id !== issue.id) {
|
|
1929
|
+
return candidate;
|
|
1930
|
+
}
|
|
1931
|
+
const nextIssue = createKanbanIssuePullRequest(candidate, {
|
|
1932
|
+
title: input.title,
|
|
1933
|
+
number,
|
|
1934
|
+
now: this.deps.now(),
|
|
1935
|
+
branchName: candidate.repositoryLifecycle?.branchName ?? `feature/${candidate.key.toLowerCase()}`,
|
|
1936
|
+
baseBranch: repository.settings.baseBranch,
|
|
1937
|
+
url: `${repository.url}/pull/${number}`,
|
|
1938
|
+
linkState: candidate.repositoryLifecycle?.integration?.status === 'connected'
|
|
1939
|
+
? 'linked'
|
|
1940
|
+
: 'partially-linked',
|
|
1941
|
+
reviewLinks: reviewers.map((reviewer) => ({
|
|
1942
|
+
label: reviewer,
|
|
1943
|
+
reviewer,
|
|
1944
|
+
status: 'pending',
|
|
1945
|
+
})),
|
|
1946
|
+
});
|
|
1947
|
+
return {
|
|
1948
|
+
...nextIssue,
|
|
1949
|
+
repositoryLifecycle: nextIssue.repositoryLifecycle
|
|
1950
|
+
? {
|
|
1951
|
+
...nextIssue.repositoryLifecycle,
|
|
1952
|
+
integration: nextIssue.repositoryLifecycle.integration
|
|
1953
|
+
? {
|
|
1954
|
+
...nextIssue.repositoryLifecycle.integration,
|
|
1955
|
+
linkState: nextIssue.repositoryLifecycle.integration.status === 'connected'
|
|
1956
|
+
? 'linked'
|
|
1957
|
+
: 'partially-linked',
|
|
1958
|
+
}
|
|
1959
|
+
: nextIssue.repositoryLifecycle.integration,
|
|
1960
|
+
}
|
|
1961
|
+
: nextIssue.repositoryLifecycle,
|
|
1962
|
+
updatedAt: this.deps.now(),
|
|
1963
|
+
};
|
|
1964
|
+
});
|
|
1965
|
+
return this.persistPayload({
|
|
1966
|
+
projects: payload.projects,
|
|
1967
|
+
issues: nextIssues,
|
|
1968
|
+
dispatchContextLabels: payload.dispatchContextLabels,
|
|
1969
|
+
});
|
|
1970
|
+
}
|
|
1971
|
+
}
|
|
1972
|
+
//# sourceMappingURL=backlog-query-service.js.map
|