prjct-cli 0.28.0 → 0.28.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +64 -12
- package/CLAUDE.md +48 -22
- package/core/agentic/agent-router.ts +15 -0
- package/core/agentic/command-executor.ts +53 -5
- package/core/agentic/prompt-builder.ts +83 -2
- package/core/agentic/template-loader.ts +107 -32
- package/core/commands/command-data.ts +0 -33
- package/core/commands/commands.ts +4 -12
- package/core/commands/registry.ts +0 -37
- package/core/domain/agent-loader.ts +7 -9
- package/core/domain/context-estimator.ts +15 -15
- package/core/index.ts +0 -2
- package/core/infrastructure/config-manager.ts +25 -4
- package/core/infrastructure/setup.ts +0 -99
- package/core/session/session-log-manager.ts +17 -0
- package/core/types/config.ts +1 -1
- package/core/types/index.ts +0 -2
- package/core/types/integrations.ts +2 -47
- package/core/types/storage.ts +0 -8
- package/core/types/task.ts +0 -4
- package/dist/bin/prjct.mjs +341 -316
- package/package.json +1 -1
- package/templates/agentic/subagent-generation.md +14 -1
- package/templates/commands/cleanup.md +15 -74
- package/templates/commands/init.md +1 -44
- package/templates/commands/ship.md +92 -12
- package/templates/commands/sync.md +25 -10
- package/templates/commands/task.md +41 -0
- package/templates/global/CLAUDE.md +196 -25
- package/templates/mcp-config.json +0 -28
- package/core/integrations/notion/client.ts +0 -413
- package/core/integrations/notion/index.ts +0 -46
- package/core/integrations/notion/setup.ts +0 -235
- package/core/integrations/notion/sync.ts +0 -818
- package/core/integrations/notion/templates.ts +0 -246
- package/core/plugin/builtin/notion.ts +0 -178
- package/templates/commands/feature.md +0 -46
- package/templates/commands/now.md +0 -53
- package/templates/hooks/prjct-session-start.sh +0 -50
- package/templates/skills/notion-push.md +0 -116
- package/templates/skills/notion-setup.md +0 -199
- package/templates/skills/notion-sync.md +0 -290
- package/templates/skills/prjct-done/SKILL.md +0 -97
- package/templates/skills/prjct-ship/SKILL.md +0 -150
- package/templates/skills/prjct-sync/SKILL.md +0 -108
- package/templates/skills/prjct-task/SKILL.md +0 -101
|
@@ -1,818 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Notion Sync Logic
|
|
3
|
-
* Sync prjct data to Notion databases.
|
|
4
|
-
*/
|
|
5
|
-
|
|
6
|
-
import type { ShippedFeature, Idea } from '../../types/storage'
|
|
7
|
-
import type { NotionIntegrationConfig } from '../../types/integrations'
|
|
8
|
-
import { notionClient } from './client'
|
|
9
|
-
import { getDashboardContent, type DashboardMetrics } from './templates'
|
|
10
|
-
|
|
11
|
-
// =============================================================================
|
|
12
|
-
// Types
|
|
13
|
-
// =============================================================================
|
|
14
|
-
|
|
15
|
-
export interface SyncResult {
|
|
16
|
-
success: boolean
|
|
17
|
-
action: 'created' | 'updated' | 'skipped'
|
|
18
|
-
pageId?: string
|
|
19
|
-
url?: string
|
|
20
|
-
error?: string
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
// =============================================================================
|
|
24
|
-
// Property Builders
|
|
25
|
-
// =============================================================================
|
|
26
|
-
|
|
27
|
-
/**
|
|
28
|
-
* Build Notion properties for a shipped feature
|
|
29
|
-
* Includes ALL fields for comprehensive sync
|
|
30
|
-
*/
|
|
31
|
-
function buildShippedProperties(
|
|
32
|
-
feature: ShippedFeature,
|
|
33
|
-
_projectId: string
|
|
34
|
-
): Record<string, unknown> {
|
|
35
|
-
const now = new Date().toISOString()
|
|
36
|
-
|
|
37
|
-
// Note: Project field removed - each project now has its own database
|
|
38
|
-
const props: Record<string, unknown> = {
|
|
39
|
-
Name: {
|
|
40
|
-
title: [{ text: { content: feature.name } }],
|
|
41
|
-
},
|
|
42
|
-
// Sync tracking fields
|
|
43
|
-
prjctId: {
|
|
44
|
-
rich_text: [{ text: { content: feature.id } }],
|
|
45
|
-
},
|
|
46
|
-
'Last Updated': {
|
|
47
|
-
date: { start: now.split('T')[0] },
|
|
48
|
-
},
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
if (feature.version) {
|
|
52
|
-
props.Version = {
|
|
53
|
-
rich_text: [{ text: { content: feature.version } }],
|
|
54
|
-
}
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
if (feature.type) {
|
|
58
|
-
props.Type = {
|
|
59
|
-
select: { name: feature.type },
|
|
60
|
-
}
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
if (feature.shippedAt) {
|
|
64
|
-
props['Shipped Date'] = {
|
|
65
|
-
date: { start: feature.shippedAt.split('T')[0] },
|
|
66
|
-
}
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
// Full code metrics
|
|
70
|
-
if (feature.codeMetrics) {
|
|
71
|
-
const metrics = feature.codeMetrics
|
|
72
|
-
props['Lines Added'] = {
|
|
73
|
-
number: metrics.linesAdded || 0,
|
|
74
|
-
}
|
|
75
|
-
props['Lines Removed'] = {
|
|
76
|
-
number: metrics.linesRemoved || 0,
|
|
77
|
-
}
|
|
78
|
-
props['Files Changed'] = {
|
|
79
|
-
number: metrics.filesChanged || 0,
|
|
80
|
-
}
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
// Commit info
|
|
84
|
-
if (feature.commit && typeof feature.commit === 'object') {
|
|
85
|
-
props.Commit = {
|
|
86
|
-
rich_text: [{ text: { content: feature.commit.hash || '' } }],
|
|
87
|
-
}
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
if (feature.duration) {
|
|
91
|
-
props.Duration = {
|
|
92
|
-
rich_text: [{ text: { content: feature.duration } }],
|
|
93
|
-
}
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
if (feature.description) {
|
|
97
|
-
props.Description = {
|
|
98
|
-
rich_text: [{ text: { content: feature.description.slice(0, 2000) } }],
|
|
99
|
-
}
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
// Impact level based on metrics
|
|
103
|
-
const impact = feature.codeMetrics?.linesAdded && feature.codeMetrics.linesAdded > 500 ? 'High' :
|
|
104
|
-
feature.codeMetrics?.linesAdded && feature.codeMetrics.linesAdded > 100 ? 'Medium' : 'Low'
|
|
105
|
-
props.Impact = {
|
|
106
|
-
select: { name: impact },
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
return props
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
/**
|
|
113
|
-
* Build Notion properties for an idea
|
|
114
|
-
* Includes ALL fields for comprehensive sync
|
|
115
|
-
*/
|
|
116
|
-
function buildIdeaProperties(
|
|
117
|
-
idea: Idea,
|
|
118
|
-
_projectId: string
|
|
119
|
-
): Record<string, unknown> {
|
|
120
|
-
const now = new Date().toISOString()
|
|
121
|
-
|
|
122
|
-
// Note: Project field removed - each project now has its own database
|
|
123
|
-
const props: Record<string, unknown> = {
|
|
124
|
-
Idea: {
|
|
125
|
-
title: [{ text: { content: idea.text } }],
|
|
126
|
-
},
|
|
127
|
-
// Sync tracking fields
|
|
128
|
-
prjctId: {
|
|
129
|
-
rich_text: [{ text: { content: idea.id } }],
|
|
130
|
-
},
|
|
131
|
-
'Last Updated': {
|
|
132
|
-
date: { start: now.split('T')[0] },
|
|
133
|
-
},
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
if (idea.status) {
|
|
137
|
-
const statusMap: Record<string, string> = {
|
|
138
|
-
pending: 'Pending',
|
|
139
|
-
converted: 'Converted',
|
|
140
|
-
completed: 'Converted',
|
|
141
|
-
archived: 'Archived',
|
|
142
|
-
}
|
|
143
|
-
props.Status = {
|
|
144
|
-
status: { name: statusMap[idea.status] || 'Pending' },
|
|
145
|
-
}
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
if (idea.priority) {
|
|
149
|
-
props.Priority = {
|
|
150
|
-
select: { name: idea.priority },
|
|
151
|
-
}
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
if (idea.tags && idea.tags.length > 0) {
|
|
155
|
-
props.Tags = {
|
|
156
|
-
multi_select: idea.tags.map((tag) => ({ name: tag })),
|
|
157
|
-
}
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
const createdDate = idea.createdAt || idea.addedAt
|
|
161
|
-
if (createdDate) {
|
|
162
|
-
props.Created = {
|
|
163
|
-
date: { start: createdDate.split('T')[0] },
|
|
164
|
-
}
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
if (idea.convertedTo) {
|
|
168
|
-
props['Converted To'] = {
|
|
169
|
-
rich_text: [{ text: { content: idea.convertedTo } }],
|
|
170
|
-
}
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
// Details/notes
|
|
174
|
-
if (idea.details) {
|
|
175
|
-
props.Details = {
|
|
176
|
-
rich_text: [{ text: { content: idea.details.slice(0, 2000) } }],
|
|
177
|
-
}
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
// Impact/Effort matrix
|
|
181
|
-
if (idea.impactEffort) {
|
|
182
|
-
props.Impact = {
|
|
183
|
-
select: { name: idea.impactEffort.impact },
|
|
184
|
-
}
|
|
185
|
-
props.Effort = {
|
|
186
|
-
select: { name: idea.impactEffort.effort },
|
|
187
|
-
}
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
// Pain points as text
|
|
191
|
-
if (idea.painPoints && idea.painPoints.length > 0) {
|
|
192
|
-
props['Pain Points'] = {
|
|
193
|
-
rich_text: [{ text: { content: idea.painPoints.join(', ').slice(0, 2000) } }],
|
|
194
|
-
}
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
// Solutions as text
|
|
198
|
-
if (idea.solutions && idea.solutions.length > 0) {
|
|
199
|
-
props.Solutions = {
|
|
200
|
-
rich_text: [{ text: { content: idea.solutions.join(', ').slice(0, 2000) } }],
|
|
201
|
-
}
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
// Files affected
|
|
205
|
-
if (idea.filesAffected && idea.filesAffected.length > 0) {
|
|
206
|
-
props['Files Affected'] = {
|
|
207
|
-
rich_text: [{ text: { content: idea.filesAffected.join(', ').slice(0, 2000) } }],
|
|
208
|
-
}
|
|
209
|
-
}
|
|
210
|
-
|
|
211
|
-
// Risks count
|
|
212
|
-
if (idea.risksCount !== undefined) {
|
|
213
|
-
props.Risks = {
|
|
214
|
-
number: idea.risksCount,
|
|
215
|
-
}
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
return props
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
// =============================================================================
|
|
222
|
-
// Property Parsers (Notion → prjct)
|
|
223
|
-
// =============================================================================
|
|
224
|
-
|
|
225
|
-
/**
|
|
226
|
-
* Extract text from Notion rich_text property
|
|
227
|
-
*/
|
|
228
|
-
function extractText(prop: unknown): string | undefined {
|
|
229
|
-
if (!prop || typeof prop !== 'object') return undefined
|
|
230
|
-
const p = prop as { rich_text?: Array<{ plain_text?: string }> }
|
|
231
|
-
if (!p.rich_text || p.rich_text.length === 0) return undefined
|
|
232
|
-
return p.rich_text.map((t) => t.plain_text || '').join('')
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
/**
|
|
236
|
-
* Extract title from Notion title property
|
|
237
|
-
*/
|
|
238
|
-
function extractTitle(prop: unknown): string {
|
|
239
|
-
if (!prop || typeof prop !== 'object') return ''
|
|
240
|
-
const p = prop as { title?: Array<{ plain_text?: string }> }
|
|
241
|
-
if (!p.title || p.title.length === 0) return ''
|
|
242
|
-
return p.title.map((t) => t.plain_text || '').join('')
|
|
243
|
-
}
|
|
244
|
-
|
|
245
|
-
/**
|
|
246
|
-
* Extract date from Notion date property
|
|
247
|
-
*/
|
|
248
|
-
function extractDate(prop: unknown): string | undefined {
|
|
249
|
-
if (!prop || typeof prop !== 'object') return undefined
|
|
250
|
-
const p = prop as { date?: { start?: string } }
|
|
251
|
-
return p.date?.start
|
|
252
|
-
}
|
|
253
|
-
|
|
254
|
-
/**
|
|
255
|
-
* Extract select value from Notion select property
|
|
256
|
-
*/
|
|
257
|
-
function extractSelect(prop: unknown): string | undefined {
|
|
258
|
-
if (!prop || typeof prop !== 'object') return undefined
|
|
259
|
-
const p = prop as { select?: { name?: string } }
|
|
260
|
-
return p.select?.name
|
|
261
|
-
}
|
|
262
|
-
|
|
263
|
-
/**
|
|
264
|
-
* Extract status value from Notion status property
|
|
265
|
-
*/
|
|
266
|
-
function extractStatus(prop: unknown): string | undefined {
|
|
267
|
-
if (!prop || typeof prop !== 'object') return undefined
|
|
268
|
-
const p = prop as { status?: { name?: string } }
|
|
269
|
-
return p.status?.name
|
|
270
|
-
}
|
|
271
|
-
|
|
272
|
-
/**
|
|
273
|
-
* Extract number from Notion number property
|
|
274
|
-
*/
|
|
275
|
-
function extractNumber(prop: unknown): number | undefined {
|
|
276
|
-
if (!prop || typeof prop !== 'object') return undefined
|
|
277
|
-
const p = prop as { number?: number | null }
|
|
278
|
-
return p.number ?? undefined
|
|
279
|
-
}
|
|
280
|
-
|
|
281
|
-
/**
|
|
282
|
-
* Extract multi-select as string array
|
|
283
|
-
*/
|
|
284
|
-
function extractMultiSelect(prop: unknown): string[] {
|
|
285
|
-
if (!prop || typeof prop !== 'object') return []
|
|
286
|
-
const p = prop as { multi_select?: Array<{ name?: string }> }
|
|
287
|
-
if (!p.multi_select) return []
|
|
288
|
-
return p.multi_select.map((s) => s.name || '').filter(Boolean)
|
|
289
|
-
}
|
|
290
|
-
|
|
291
|
-
/**
|
|
292
|
-
* Parse Notion page to ShippedFeature
|
|
293
|
-
*/
|
|
294
|
-
function parseShippedFeature(
|
|
295
|
-
pageId: string,
|
|
296
|
-
props: Record<string, unknown>
|
|
297
|
-
): Partial<ShippedFeature> & { notionPageId: string } {
|
|
298
|
-
return {
|
|
299
|
-
notionPageId: pageId,
|
|
300
|
-
id: extractText(props.prjctId) || '',
|
|
301
|
-
name: extractTitle(props.Name) || '',
|
|
302
|
-
version: extractText(props.Version) || '',
|
|
303
|
-
shippedAt: extractDate(props['Shipped Date']) || new Date().toISOString(),
|
|
304
|
-
description: extractText(props.Description),
|
|
305
|
-
type: extractSelect(props.Type) as ShippedFeature['type'],
|
|
306
|
-
duration: extractText(props.Duration),
|
|
307
|
-
codeMetrics: {
|
|
308
|
-
linesAdded: extractNumber(props['Lines Added']) || 0,
|
|
309
|
-
linesRemoved: extractNumber(props['Lines Removed']) || 0,
|
|
310
|
-
filesChanged: extractNumber(props['Files Changed']) || 0,
|
|
311
|
-
commits: 0,
|
|
312
|
-
},
|
|
313
|
-
lastSyncedAt: new Date().toISOString(),
|
|
314
|
-
}
|
|
315
|
-
}
|
|
316
|
-
|
|
317
|
-
/**
|
|
318
|
-
* Parse Notion page to Idea
|
|
319
|
-
*/
|
|
320
|
-
function parseIdea(
|
|
321
|
-
pageId: string,
|
|
322
|
-
props: Record<string, unknown>
|
|
323
|
-
): Partial<Idea> & { notionPageId: string } {
|
|
324
|
-
const statusMap: Record<string, Idea['status']> = {
|
|
325
|
-
Pending: 'pending',
|
|
326
|
-
Converted: 'converted',
|
|
327
|
-
Archived: 'archived',
|
|
328
|
-
}
|
|
329
|
-
|
|
330
|
-
const priorityMap: Record<string, Idea['priority']> = {
|
|
331
|
-
high: 'high',
|
|
332
|
-
medium: 'medium',
|
|
333
|
-
low: 'low',
|
|
334
|
-
High: 'high',
|
|
335
|
-
Medium: 'medium',
|
|
336
|
-
Low: 'low',
|
|
337
|
-
}
|
|
338
|
-
|
|
339
|
-
return {
|
|
340
|
-
notionPageId: pageId,
|
|
341
|
-
id: extractText(props.prjctId) || '',
|
|
342
|
-
text: extractTitle(props.Idea) || '',
|
|
343
|
-
status: statusMap[extractStatus(props.Status) || ''] || 'pending',
|
|
344
|
-
priority: priorityMap[extractSelect(props.Priority) || ''] || 'medium',
|
|
345
|
-
tags: extractMultiSelect(props.Tags),
|
|
346
|
-
details: extractText(props.Details),
|
|
347
|
-
addedAt: extractDate(props.Created) || new Date().toISOString(),
|
|
348
|
-
convertedTo: extractText(props['Converted To']),
|
|
349
|
-
lastSyncedAt: new Date().toISOString(),
|
|
350
|
-
}
|
|
351
|
-
}
|
|
352
|
-
|
|
353
|
-
// =============================================================================
|
|
354
|
-
// Sync Functions
|
|
355
|
-
// =============================================================================
|
|
356
|
-
|
|
357
|
-
/**
|
|
358
|
-
* Sync a shipped feature to Notion
|
|
359
|
-
*/
|
|
360
|
-
export async function syncShippedFeature(
|
|
361
|
-
projectId: string,
|
|
362
|
-
feature: ShippedFeature,
|
|
363
|
-
config: NotionIntegrationConfig
|
|
364
|
-
): Promise<SyncResult> {
|
|
365
|
-
if (!config.enabled || !config.databases.shipped) {
|
|
366
|
-
return { success: false, action: 'skipped', error: 'Notion not configured' }
|
|
367
|
-
}
|
|
368
|
-
|
|
369
|
-
if (!notionClient.isReady()) {
|
|
370
|
-
return { success: false, action: 'skipped', error: 'Notion client not ready' }
|
|
371
|
-
}
|
|
372
|
-
|
|
373
|
-
try {
|
|
374
|
-
const databaseId = config.databases.shipped
|
|
375
|
-
const properties = buildShippedProperties(feature, projectId)
|
|
376
|
-
|
|
377
|
-
// Check if already exists (upsert)
|
|
378
|
-
const existingPageId = await notionClient.findPageByProjectAndName(
|
|
379
|
-
databaseId,
|
|
380
|
-
projectId,
|
|
381
|
-
feature.name
|
|
382
|
-
)
|
|
383
|
-
|
|
384
|
-
if (existingPageId) {
|
|
385
|
-
const page = await notionClient.updatePage(existingPageId, properties)
|
|
386
|
-
if (page) {
|
|
387
|
-
return {
|
|
388
|
-
success: true,
|
|
389
|
-
action: 'updated',
|
|
390
|
-
pageId: page.id,
|
|
391
|
-
url: page.url,
|
|
392
|
-
}
|
|
393
|
-
}
|
|
394
|
-
} else {
|
|
395
|
-
const page = await notionClient.createPage(databaseId, properties)
|
|
396
|
-
if (page) {
|
|
397
|
-
return {
|
|
398
|
-
success: true,
|
|
399
|
-
action: 'created',
|
|
400
|
-
pageId: page.id,
|
|
401
|
-
url: page.url,
|
|
402
|
-
}
|
|
403
|
-
}
|
|
404
|
-
}
|
|
405
|
-
|
|
406
|
-
return { success: false, action: 'skipped', error: 'Failed to sync' }
|
|
407
|
-
} catch (error) {
|
|
408
|
-
return {
|
|
409
|
-
success: false,
|
|
410
|
-
action: 'skipped',
|
|
411
|
-
error: (error as Error).message,
|
|
412
|
-
}
|
|
413
|
-
}
|
|
414
|
-
}
|
|
415
|
-
|
|
416
|
-
/**
|
|
417
|
-
* Sync an idea to Notion
|
|
418
|
-
*/
|
|
419
|
-
export async function syncIdea(
|
|
420
|
-
projectId: string,
|
|
421
|
-
idea: Idea,
|
|
422
|
-
config: NotionIntegrationConfig
|
|
423
|
-
): Promise<SyncResult> {
|
|
424
|
-
if (!config.enabled || !config.databases.ideas) {
|
|
425
|
-
return { success: false, action: 'skipped', error: 'Notion not configured' }
|
|
426
|
-
}
|
|
427
|
-
|
|
428
|
-
if (!notionClient.isReady()) {
|
|
429
|
-
return { success: false, action: 'skipped', error: 'Notion client not ready' }
|
|
430
|
-
}
|
|
431
|
-
|
|
432
|
-
try {
|
|
433
|
-
const databaseId = config.databases.ideas
|
|
434
|
-
const properties = buildIdeaProperties(idea, projectId)
|
|
435
|
-
|
|
436
|
-
// Check if already exists (by ID in text)
|
|
437
|
-
const existingPageId = await notionClient.findPageByProjectAndName(
|
|
438
|
-
databaseId,
|
|
439
|
-
projectId,
|
|
440
|
-
idea.text
|
|
441
|
-
)
|
|
442
|
-
|
|
443
|
-
if (existingPageId) {
|
|
444
|
-
const page = await notionClient.updatePage(existingPageId, properties)
|
|
445
|
-
if (page) {
|
|
446
|
-
return {
|
|
447
|
-
success: true,
|
|
448
|
-
action: 'updated',
|
|
449
|
-
pageId: page.id,
|
|
450
|
-
url: page.url,
|
|
451
|
-
}
|
|
452
|
-
}
|
|
453
|
-
} else {
|
|
454
|
-
const page = await notionClient.createPage(databaseId, properties)
|
|
455
|
-
if (page) {
|
|
456
|
-
return {
|
|
457
|
-
success: true,
|
|
458
|
-
action: 'created',
|
|
459
|
-
pageId: page.id,
|
|
460
|
-
url: page.url,
|
|
461
|
-
}
|
|
462
|
-
}
|
|
463
|
-
}
|
|
464
|
-
|
|
465
|
-
return { success: false, action: 'skipped', error: 'Failed to sync' }
|
|
466
|
-
} catch (error) {
|
|
467
|
-
return {
|
|
468
|
-
success: false,
|
|
469
|
-
action: 'skipped',
|
|
470
|
-
error: (error as Error).message,
|
|
471
|
-
}
|
|
472
|
-
}
|
|
473
|
-
}
|
|
474
|
-
|
|
475
|
-
/**
|
|
476
|
-
* Full sync - sync all data to Notion
|
|
477
|
-
* Used for initial setup or manual resync
|
|
478
|
-
*/
|
|
479
|
-
export async function fullSync(
|
|
480
|
-
projectId: string,
|
|
481
|
-
config: NotionIntegrationConfig,
|
|
482
|
-
data: {
|
|
483
|
-
shipped?: ShippedFeature[]
|
|
484
|
-
ideas?: Idea[]
|
|
485
|
-
}
|
|
486
|
-
): Promise<{
|
|
487
|
-
shipped: { synced: number; failed: number }
|
|
488
|
-
ideas: { synced: number; failed: number }
|
|
489
|
-
}> {
|
|
490
|
-
const results = {
|
|
491
|
-
shipped: { synced: 0, failed: 0 },
|
|
492
|
-
ideas: { synced: 0, failed: 0 },
|
|
493
|
-
}
|
|
494
|
-
|
|
495
|
-
// Sync shipped features
|
|
496
|
-
if (data.shipped && config.databases.shipped) {
|
|
497
|
-
for (const feature of data.shipped) {
|
|
498
|
-
const result = await syncShippedFeature(projectId, feature, config)
|
|
499
|
-
if (result.success) {
|
|
500
|
-
results.shipped.synced++
|
|
501
|
-
} else {
|
|
502
|
-
results.shipped.failed++
|
|
503
|
-
}
|
|
504
|
-
}
|
|
505
|
-
}
|
|
506
|
-
|
|
507
|
-
// Sync ideas
|
|
508
|
-
if (data.ideas && config.databases.ideas) {
|
|
509
|
-
for (const idea of data.ideas) {
|
|
510
|
-
const result = await syncIdea(projectId, idea, config)
|
|
511
|
-
if (result.success) {
|
|
512
|
-
results.ideas.synced++
|
|
513
|
-
} else {
|
|
514
|
-
results.ideas.failed++
|
|
515
|
-
}
|
|
516
|
-
}
|
|
517
|
-
}
|
|
518
|
-
|
|
519
|
-
return results
|
|
520
|
-
}
|
|
521
|
-
|
|
522
|
-
// =============================================================================
|
|
523
|
-
// Pull Sync Functions (Notion → prjct)
|
|
524
|
-
// =============================================================================
|
|
525
|
-
|
|
526
|
-
export interface PullResult<T> {
|
|
527
|
-
items: T[]
|
|
528
|
-
newCount: number
|
|
529
|
-
updatedCount: number
|
|
530
|
-
errors: string[]
|
|
531
|
-
}
|
|
532
|
-
|
|
533
|
-
/**
|
|
534
|
-
* Pull shipped features from Notion
|
|
535
|
-
* Returns new and updated features from Notion that don't exist or are newer in Notion
|
|
536
|
-
*/
|
|
537
|
-
export async function pullShippedFeatures(
|
|
538
|
-
config: NotionIntegrationConfig,
|
|
539
|
-
existingFeatures: ShippedFeature[]
|
|
540
|
-
): Promise<PullResult<ShippedFeature>> {
|
|
541
|
-
const result: PullResult<ShippedFeature> = {
|
|
542
|
-
items: [],
|
|
543
|
-
newCount: 0,
|
|
544
|
-
updatedCount: 0,
|
|
545
|
-
errors: [],
|
|
546
|
-
}
|
|
547
|
-
|
|
548
|
-
if (!config.enabled || !config.databases.shipped) {
|
|
549
|
-
return result
|
|
550
|
-
}
|
|
551
|
-
|
|
552
|
-
if (!notionClient.isReady()) {
|
|
553
|
-
result.errors.push('Notion client not ready')
|
|
554
|
-
return result
|
|
555
|
-
}
|
|
556
|
-
|
|
557
|
-
try {
|
|
558
|
-
const pages = await notionClient.queryDatabase(config.databases.shipped)
|
|
559
|
-
|
|
560
|
-
// Create lookup map by prjctId and notionPageId
|
|
561
|
-
const existingByPrjctId = new Map(
|
|
562
|
-
existingFeatures.filter((f) => f.id).map((f) => [f.id, f])
|
|
563
|
-
)
|
|
564
|
-
const existingByNotionId = new Map(
|
|
565
|
-
existingFeatures.filter((f) => f.notionPageId).map((f) => [f.notionPageId, f])
|
|
566
|
-
)
|
|
567
|
-
|
|
568
|
-
for (const page of pages) {
|
|
569
|
-
const parsed = parseShippedFeature(page.id, page.properties)
|
|
570
|
-
|
|
571
|
-
// Skip if no name (invalid entry)
|
|
572
|
-
if (!parsed.name) continue
|
|
573
|
-
|
|
574
|
-
// Check if exists locally
|
|
575
|
-
const existingByPrjct = parsed.id ? existingByPrjctId.get(parsed.id) : undefined
|
|
576
|
-
const existingByNotion = existingByNotionId.get(page.id)
|
|
577
|
-
const existing = existingByPrjct || existingByNotion
|
|
578
|
-
|
|
579
|
-
if (existing) {
|
|
580
|
-
// Update existing - merge with local data
|
|
581
|
-
const merged: ShippedFeature = {
|
|
582
|
-
...existing,
|
|
583
|
-
...parsed,
|
|
584
|
-
id: existing.id || parsed.id || crypto.randomUUID(),
|
|
585
|
-
notionPageId: page.id,
|
|
586
|
-
lastSyncedAt: new Date().toISOString(),
|
|
587
|
-
}
|
|
588
|
-
result.items.push(merged)
|
|
589
|
-
result.updatedCount++
|
|
590
|
-
} else {
|
|
591
|
-
// New from Notion - create new entry
|
|
592
|
-
const newFeature: ShippedFeature = {
|
|
593
|
-
id: parsed.id || crypto.randomUUID(),
|
|
594
|
-
name: parsed.name,
|
|
595
|
-
shippedAt: parsed.shippedAt || new Date().toISOString(),
|
|
596
|
-
version: parsed.version || '0.0.0',
|
|
597
|
-
description: parsed.description,
|
|
598
|
-
type: parsed.type,
|
|
599
|
-
duration: parsed.duration,
|
|
600
|
-
codeMetrics: parsed.codeMetrics,
|
|
601
|
-
notionPageId: page.id,
|
|
602
|
-
lastSyncedAt: new Date().toISOString(),
|
|
603
|
-
}
|
|
604
|
-
result.items.push(newFeature)
|
|
605
|
-
result.newCount++
|
|
606
|
-
}
|
|
607
|
-
}
|
|
608
|
-
|
|
609
|
-
return result
|
|
610
|
-
} catch (error) {
|
|
611
|
-
result.errors.push((error as Error).message)
|
|
612
|
-
return result
|
|
613
|
-
}
|
|
614
|
-
}
|
|
615
|
-
|
|
616
|
-
/**
|
|
617
|
-
* Pull ideas from Notion
|
|
618
|
-
* Returns new and updated ideas from Notion
|
|
619
|
-
*/
|
|
620
|
-
export async function pullIdeas(
|
|
621
|
-
config: NotionIntegrationConfig,
|
|
622
|
-
existingIdeas: Idea[]
|
|
623
|
-
): Promise<PullResult<Idea>> {
|
|
624
|
-
const result: PullResult<Idea> = {
|
|
625
|
-
items: [],
|
|
626
|
-
newCount: 0,
|
|
627
|
-
updatedCount: 0,
|
|
628
|
-
errors: [],
|
|
629
|
-
}
|
|
630
|
-
|
|
631
|
-
if (!config.enabled || !config.databases.ideas) {
|
|
632
|
-
return result
|
|
633
|
-
}
|
|
634
|
-
|
|
635
|
-
if (!notionClient.isReady()) {
|
|
636
|
-
result.errors.push('Notion client not ready')
|
|
637
|
-
return result
|
|
638
|
-
}
|
|
639
|
-
|
|
640
|
-
try {
|
|
641
|
-
const pages = await notionClient.queryDatabase(config.databases.ideas)
|
|
642
|
-
|
|
643
|
-
// Create lookup map
|
|
644
|
-
const existingByPrjctId = new Map(
|
|
645
|
-
existingIdeas.filter((i) => i.id).map((i) => [i.id, i])
|
|
646
|
-
)
|
|
647
|
-
const existingByNotionId = new Map(
|
|
648
|
-
existingIdeas.filter((i) => i.notionPageId).map((i) => [i.notionPageId, i])
|
|
649
|
-
)
|
|
650
|
-
|
|
651
|
-
for (const page of pages) {
|
|
652
|
-
const parsed = parseIdea(page.id, page.properties)
|
|
653
|
-
|
|
654
|
-
// Skip if no text (invalid entry)
|
|
655
|
-
if (!parsed.text) continue
|
|
656
|
-
|
|
657
|
-
// Check if exists locally
|
|
658
|
-
const existingByPrjct = parsed.id ? existingByPrjctId.get(parsed.id) : undefined
|
|
659
|
-
const existingByNotion = existingByNotionId.get(page.id)
|
|
660
|
-
const existing = existingByPrjct || existingByNotion
|
|
661
|
-
|
|
662
|
-
if (existing) {
|
|
663
|
-
// Update existing
|
|
664
|
-
const merged: Idea = {
|
|
665
|
-
...existing,
|
|
666
|
-
...parsed,
|
|
667
|
-
id: existing.id || parsed.id || crypto.randomUUID(),
|
|
668
|
-
notionPageId: page.id,
|
|
669
|
-
lastSyncedAt: new Date().toISOString(),
|
|
670
|
-
}
|
|
671
|
-
result.items.push(merged)
|
|
672
|
-
result.updatedCount++
|
|
673
|
-
} else {
|
|
674
|
-
// New from Notion
|
|
675
|
-
const newIdea: Idea = {
|
|
676
|
-
id: parsed.id || crypto.randomUUID(),
|
|
677
|
-
text: parsed.text,
|
|
678
|
-
status: parsed.status || 'pending',
|
|
679
|
-
priority: parsed.priority || 'medium',
|
|
680
|
-
tags: parsed.tags || [],
|
|
681
|
-
addedAt: parsed.addedAt || new Date().toISOString(),
|
|
682
|
-
details: parsed.details,
|
|
683
|
-
convertedTo: parsed.convertedTo,
|
|
684
|
-
notionPageId: page.id,
|
|
685
|
-
lastSyncedAt: new Date().toISOString(),
|
|
686
|
-
}
|
|
687
|
-
result.items.push(newIdea)
|
|
688
|
-
result.newCount++
|
|
689
|
-
}
|
|
690
|
-
}
|
|
691
|
-
|
|
692
|
-
return result
|
|
693
|
-
} catch (error) {
|
|
694
|
-
result.errors.push((error as Error).message)
|
|
695
|
-
return result
|
|
696
|
-
}
|
|
697
|
-
}
|
|
698
|
-
|
|
699
|
-
/**
|
|
700
|
-
* Bidirectional sync - combines push and pull
|
|
701
|
-
* Uses "last edit wins" strategy based on lastSyncedAt
|
|
702
|
-
*/
|
|
703
|
-
export async function bidirectionalSync(
|
|
704
|
-
projectId: string,
|
|
705
|
-
config: NotionIntegrationConfig,
|
|
706
|
-
localData: {
|
|
707
|
-
shipped?: ShippedFeature[]
|
|
708
|
-
ideas?: Idea[]
|
|
709
|
-
}
|
|
710
|
-
): Promise<{
|
|
711
|
-
pushed: { shipped: number; ideas: number }
|
|
712
|
-
pulled: { shipped: PullResult<ShippedFeature>; ideas: PullResult<Idea> }
|
|
713
|
-
}> {
|
|
714
|
-
const results = {
|
|
715
|
-
pushed: { shipped: 0, ideas: 0 },
|
|
716
|
-
pulled: {
|
|
717
|
-
shipped: { items: [], newCount: 0, updatedCount: 0, errors: [] } as PullResult<ShippedFeature>,
|
|
718
|
-
ideas: { items: [], newCount: 0, updatedCount: 0, errors: [] } as PullResult<Idea>,
|
|
719
|
-
},
|
|
720
|
-
}
|
|
721
|
-
|
|
722
|
-
// 1. Pull from Notion first (to get latest)
|
|
723
|
-
if (localData.shipped) {
|
|
724
|
-
results.pulled.shipped = await pullShippedFeatures(config, localData.shipped)
|
|
725
|
-
}
|
|
726
|
-
if (localData.ideas) {
|
|
727
|
-
results.pulled.ideas = await pullIdeas(config, localData.ideas)
|
|
728
|
-
}
|
|
729
|
-
|
|
730
|
-
// 2. Push local items that don't have notionPageId (new local items)
|
|
731
|
-
if (localData.shipped && config.databases.shipped) {
|
|
732
|
-
for (const feature of localData.shipped) {
|
|
733
|
-
if (!feature.notionPageId) {
|
|
734
|
-
const result = await syncShippedFeature(projectId, feature, config)
|
|
735
|
-
if (result.success) {
|
|
736
|
-
results.pushed.shipped++
|
|
737
|
-
}
|
|
738
|
-
}
|
|
739
|
-
}
|
|
740
|
-
}
|
|
741
|
-
|
|
742
|
-
if (localData.ideas && config.databases.ideas) {
|
|
743
|
-
for (const idea of localData.ideas) {
|
|
744
|
-
if (!idea.notionPageId) {
|
|
745
|
-
const result = await syncIdea(projectId, idea, config)
|
|
746
|
-
if (result.success) {
|
|
747
|
-
results.pushed.ideas++
|
|
748
|
-
}
|
|
749
|
-
}
|
|
750
|
-
}
|
|
751
|
-
}
|
|
752
|
-
|
|
753
|
-
return results
|
|
754
|
-
}
|
|
755
|
-
|
|
756
|
-
// =============================================================================
|
|
757
|
-
// Dashboard Metrics Update
|
|
758
|
-
// =============================================================================
|
|
759
|
-
|
|
760
|
-
/**
|
|
761
|
-
* Update dashboard page with current metrics
|
|
762
|
-
* Called after sync operations
|
|
763
|
-
*/
|
|
764
|
-
export async function updateDashboardMetrics(
|
|
765
|
-
config: NotionIntegrationConfig,
|
|
766
|
-
projectName: string,
|
|
767
|
-
metrics: DashboardMetrics
|
|
768
|
-
): Promise<{ success: boolean; error?: string }> {
|
|
769
|
-
if (!config.enabled || !config.dashboardPageId) {
|
|
770
|
-
return { success: false, error: 'Dashboard not configured' }
|
|
771
|
-
}
|
|
772
|
-
|
|
773
|
-
if (!notionClient.isReady()) {
|
|
774
|
-
return { success: false, error: 'Notion client not ready' }
|
|
775
|
-
}
|
|
776
|
-
|
|
777
|
-
try {
|
|
778
|
-
const content = getDashboardContent(projectName, config.databases, metrics)
|
|
779
|
-
|
|
780
|
-
// Update dashboard page content
|
|
781
|
-
await notionClient.updatePageContent(config.dashboardPageId, content)
|
|
782
|
-
|
|
783
|
-
return { success: true }
|
|
784
|
-
} catch (error) {
|
|
785
|
-
return {
|
|
786
|
-
success: false,
|
|
787
|
-
error: (error as Error).message,
|
|
788
|
-
}
|
|
789
|
-
}
|
|
790
|
-
}
|
|
791
|
-
|
|
792
|
-
/**
|
|
793
|
-
* Calculate metrics from local data
|
|
794
|
-
*/
|
|
795
|
-
export function calculateMetrics(data: {
|
|
796
|
-
shipped?: ShippedFeature[]
|
|
797
|
-
ideas?: Idea[]
|
|
798
|
-
tasks?: Array<{ status?: string; completed?: boolean }>
|
|
799
|
-
roadmap?: Array<{ progress?: number }>
|
|
800
|
-
}): DashboardMetrics {
|
|
801
|
-
const shippedCount = data.shipped?.length || 0
|
|
802
|
-
const ideasPending = data.ideas?.filter((i) => i.status === 'pending').length || 0
|
|
803
|
-
const tasksActive = data.tasks?.filter((t) => !t.completed && t.status !== 'completed').length || 0
|
|
804
|
-
|
|
805
|
-
// Calculate roadmap progress as average of all features
|
|
806
|
-
let roadmapProgress = 0
|
|
807
|
-
if (data.roadmap && data.roadmap.length > 0) {
|
|
808
|
-
const totalProgress = data.roadmap.reduce((sum, f) => sum + (f.progress || 0), 0)
|
|
809
|
-
roadmapProgress = Math.round(totalProgress / data.roadmap.length)
|
|
810
|
-
}
|
|
811
|
-
|
|
812
|
-
return {
|
|
813
|
-
shippedCount,
|
|
814
|
-
ideasPending,
|
|
815
|
-
tasksActive,
|
|
816
|
-
roadmapProgress,
|
|
817
|
-
}
|
|
818
|
-
}
|