prjct-cli 0.20.1 → 0.21.0

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.
@@ -0,0 +1,311 @@
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
+
10
+ // =============================================================================
11
+ // Types
12
+ // =============================================================================
13
+
14
+ export interface SyncResult {
15
+ success: boolean
16
+ action: 'created' | 'updated' | 'skipped'
17
+ pageId?: string
18
+ url?: string
19
+ error?: string
20
+ }
21
+
22
+ // =============================================================================
23
+ // Property Builders
24
+ // =============================================================================
25
+
26
+ /**
27
+ * Build Notion properties for a shipped feature
28
+ */
29
+ function buildShippedProperties(
30
+ feature: ShippedFeature,
31
+ projectId: string
32
+ ): Record<string, unknown> {
33
+ const props: Record<string, unknown> = {
34
+ Name: {
35
+ title: [{ text: { content: feature.name } }],
36
+ },
37
+ Project: {
38
+ rich_text: [{ text: { content: projectId } }],
39
+ },
40
+ }
41
+
42
+ if (feature.version) {
43
+ props.Version = {
44
+ rich_text: [{ text: { content: feature.version } }],
45
+ }
46
+ }
47
+
48
+ if (feature.type) {
49
+ props.Type = {
50
+ select: { name: feature.type },
51
+ }
52
+ }
53
+
54
+ if (feature.shippedAt) {
55
+ props['Shipped Date'] = {
56
+ date: { start: feature.shippedAt.split('T')[0] },
57
+ }
58
+ }
59
+
60
+ if (feature.codeMetrics) {
61
+ const metrics = feature.codeMetrics
62
+ props['Lines Changed'] = {
63
+ number: (metrics.linesAdded || 0) + (metrics.linesRemoved || 0),
64
+ }
65
+ props['Files Changed'] = {
66
+ number: metrics.filesChanged || 0,
67
+ }
68
+ props.Commits = {
69
+ number: metrics.commits || 0,
70
+ }
71
+ }
72
+
73
+ if (feature.duration) {
74
+ props.Duration = {
75
+ rich_text: [{ text: { content: feature.duration } }],
76
+ }
77
+ }
78
+
79
+ if (feature.description) {
80
+ props.Description = {
81
+ rich_text: [{ text: { content: feature.description.slice(0, 2000) } }],
82
+ }
83
+ }
84
+
85
+ return props
86
+ }
87
+
88
+ /**
89
+ * Build Notion properties for an idea
90
+ */
91
+ function buildIdeaProperties(
92
+ idea: Idea,
93
+ projectId: string
94
+ ): Record<string, unknown> {
95
+ const props: Record<string, unknown> = {
96
+ Idea: {
97
+ title: [{ text: { content: idea.text } }],
98
+ },
99
+ Project: {
100
+ rich_text: [{ text: { content: projectId } }],
101
+ },
102
+ }
103
+
104
+ if (idea.status) {
105
+ const statusMap: Record<string, string> = {
106
+ pending: 'Pending',
107
+ converted: 'Converted',
108
+ completed: 'Converted',
109
+ archived: 'Archived',
110
+ }
111
+ props.Status = {
112
+ status: { name: statusMap[idea.status] || 'Pending' },
113
+ }
114
+ }
115
+
116
+ if (idea.priority) {
117
+ props.Priority = {
118
+ select: { name: idea.priority },
119
+ }
120
+ }
121
+
122
+ if (idea.tags && idea.tags.length > 0) {
123
+ props.Tags = {
124
+ multi_select: idea.tags.map((tag) => ({ name: tag })),
125
+ }
126
+ }
127
+
128
+ const createdDate = idea.createdAt || idea.addedAt
129
+ if (createdDate) {
130
+ props.Created = {
131
+ date: { start: createdDate.split('T')[0] },
132
+ }
133
+ }
134
+
135
+ if (idea.convertedTo) {
136
+ props['Converted To'] = {
137
+ rich_text: [{ text: { content: idea.convertedTo } }],
138
+ }
139
+ }
140
+
141
+ return props
142
+ }
143
+
144
+ // =============================================================================
145
+ // Sync Functions
146
+ // =============================================================================
147
+
148
+ /**
149
+ * Sync a shipped feature to Notion
150
+ */
151
+ export async function syncShippedFeature(
152
+ projectId: string,
153
+ feature: ShippedFeature,
154
+ config: NotionIntegrationConfig
155
+ ): Promise<SyncResult> {
156
+ if (!config.enabled || !config.databases.shipped) {
157
+ return { success: false, action: 'skipped', error: 'Notion not configured' }
158
+ }
159
+
160
+ if (!notionClient.isReady()) {
161
+ return { success: false, action: 'skipped', error: 'Notion client not ready' }
162
+ }
163
+
164
+ try {
165
+ const databaseId = config.databases.shipped
166
+ const properties = buildShippedProperties(feature, projectId)
167
+
168
+ // Check if already exists (upsert)
169
+ const existingPageId = await notionClient.findPageByProjectAndName(
170
+ databaseId,
171
+ projectId,
172
+ feature.name
173
+ )
174
+
175
+ if (existingPageId) {
176
+ const page = await notionClient.updatePage(existingPageId, properties)
177
+ if (page) {
178
+ return {
179
+ success: true,
180
+ action: 'updated',
181
+ pageId: page.id,
182
+ url: page.url,
183
+ }
184
+ }
185
+ } else {
186
+ const page = await notionClient.createPage(databaseId, properties)
187
+ if (page) {
188
+ return {
189
+ success: true,
190
+ action: 'created',
191
+ pageId: page.id,
192
+ url: page.url,
193
+ }
194
+ }
195
+ }
196
+
197
+ return { success: false, action: 'skipped', error: 'Failed to sync' }
198
+ } catch (error) {
199
+ return {
200
+ success: false,
201
+ action: 'skipped',
202
+ error: (error as Error).message,
203
+ }
204
+ }
205
+ }
206
+
207
+ /**
208
+ * Sync an idea to Notion
209
+ */
210
+ export async function syncIdea(
211
+ projectId: string,
212
+ idea: Idea,
213
+ config: NotionIntegrationConfig
214
+ ): Promise<SyncResult> {
215
+ if (!config.enabled || !config.databases.ideas) {
216
+ return { success: false, action: 'skipped', error: 'Notion not configured' }
217
+ }
218
+
219
+ if (!notionClient.isReady()) {
220
+ return { success: false, action: 'skipped', error: 'Notion client not ready' }
221
+ }
222
+
223
+ try {
224
+ const databaseId = config.databases.ideas
225
+ const properties = buildIdeaProperties(idea, projectId)
226
+
227
+ // Check if already exists (by ID in text)
228
+ const existingPageId = await notionClient.findPageByProjectAndName(
229
+ databaseId,
230
+ projectId,
231
+ idea.text
232
+ )
233
+
234
+ if (existingPageId) {
235
+ const page = await notionClient.updatePage(existingPageId, properties)
236
+ if (page) {
237
+ return {
238
+ success: true,
239
+ action: 'updated',
240
+ pageId: page.id,
241
+ url: page.url,
242
+ }
243
+ }
244
+ } else {
245
+ const page = await notionClient.createPage(databaseId, properties)
246
+ if (page) {
247
+ return {
248
+ success: true,
249
+ action: 'created',
250
+ pageId: page.id,
251
+ url: page.url,
252
+ }
253
+ }
254
+ }
255
+
256
+ return { success: false, action: 'skipped', error: 'Failed to sync' }
257
+ } catch (error) {
258
+ return {
259
+ success: false,
260
+ action: 'skipped',
261
+ error: (error as Error).message,
262
+ }
263
+ }
264
+ }
265
+
266
+ /**
267
+ * Full sync - sync all data to Notion
268
+ * Used for initial setup or manual resync
269
+ */
270
+ export async function fullSync(
271
+ projectId: string,
272
+ config: NotionIntegrationConfig,
273
+ data: {
274
+ shipped?: ShippedFeature[]
275
+ ideas?: Idea[]
276
+ }
277
+ ): Promise<{
278
+ shipped: { synced: number; failed: number }
279
+ ideas: { synced: number; failed: number }
280
+ }> {
281
+ const results = {
282
+ shipped: { synced: 0, failed: 0 },
283
+ ideas: { synced: 0, failed: 0 },
284
+ }
285
+
286
+ // Sync shipped features
287
+ if (data.shipped && config.databases.shipped) {
288
+ for (const feature of data.shipped) {
289
+ const result = await syncShippedFeature(projectId, feature, config)
290
+ if (result.success) {
291
+ results.shipped.synced++
292
+ } else {
293
+ results.shipped.failed++
294
+ }
295
+ }
296
+ }
297
+
298
+ // Sync ideas
299
+ if (data.ideas && config.databases.ideas) {
300
+ for (const idea of data.ideas) {
301
+ const result = await syncIdea(projectId, idea, config)
302
+ if (result.success) {
303
+ results.ideas.synced++
304
+ } else {
305
+ results.ideas.failed++
306
+ }
307
+ }
308
+ }
309
+
310
+ return results
311
+ }
@@ -0,0 +1,234 @@
1
+ /**
2
+ * Notion Database Templates
3
+ * Schemas for auto-creating prjct databases in Notion.
4
+ */
5
+
6
+ import type { NotionDatabaseSchema } from './client'
7
+
8
+ // =============================================================================
9
+ // Database Schemas
10
+ // =============================================================================
11
+
12
+ /**
13
+ * Shipped Features Database Schema
14
+ * Tracks shipped features with metrics
15
+ */
16
+ export const SHIPPED_DATABASE_SCHEMA: NotionDatabaseSchema = {
17
+ title: 'prjct: Shipped Features',
18
+ properties: {
19
+ Name: { type: 'title', title: {} },
20
+ Version: { type: 'rich_text', rich_text: {} },
21
+ Type: {
22
+ type: 'select',
23
+ select: {
24
+ options: [
25
+ { name: 'feature', color: 'blue' },
26
+ { name: 'fix', color: 'red' },
27
+ { name: 'improvement', color: 'green' },
28
+ { name: 'refactor', color: 'purple' },
29
+ ],
30
+ },
31
+ },
32
+ 'Shipped Date': { type: 'date', date: {} },
33
+ 'Lines Changed': { type: 'number', number: { format: 'number' } },
34
+ 'Files Changed': { type: 'number', number: { format: 'number' } },
35
+ Commits: { type: 'number', number: { format: 'number' } },
36
+ Duration: { type: 'rich_text', rich_text: {} },
37
+ Description: { type: 'rich_text', rich_text: {} },
38
+ Project: { type: 'rich_text', rich_text: {} },
39
+ },
40
+ }
41
+
42
+ /**
43
+ * Roadmap Database Schema
44
+ * Tracks feature roadmap and progress
45
+ */
46
+ export const ROADMAP_DATABASE_SCHEMA: NotionDatabaseSchema = {
47
+ title: 'prjct: Roadmap',
48
+ properties: {
49
+ Feature: { type: 'title', title: {} },
50
+ Status: {
51
+ type: 'status',
52
+ status: {
53
+ options: [
54
+ { name: 'Planned', color: 'gray' },
55
+ { name: 'Active', color: 'blue' },
56
+ { name: 'Completed', color: 'green' },
57
+ { name: 'Shipped', color: 'purple' },
58
+ ],
59
+ groups: [
60
+ { name: 'To Do', option_ids: [], color: 'gray' },
61
+ { name: 'In Progress', option_ids: [], color: 'blue' },
62
+ { name: 'Done', option_ids: [], color: 'green' },
63
+ ],
64
+ },
65
+ },
66
+ Priority: {
67
+ type: 'select',
68
+ select: {
69
+ options: [
70
+ { name: 'High', color: 'red' },
71
+ { name: 'Medium', color: 'yellow' },
72
+ { name: 'Low', color: 'gray' },
73
+ ],
74
+ },
75
+ },
76
+ Progress: { type: 'number', number: { format: 'percent' } },
77
+ Phase: { type: 'rich_text', rich_text: {} },
78
+ 'Target Date': { type: 'date', date: {} },
79
+ Description: { type: 'rich_text', rich_text: {} },
80
+ Tasks: { type: 'number', number: { format: 'number' } },
81
+ Project: { type: 'rich_text', rich_text: {} },
82
+ },
83
+ }
84
+
85
+ /**
86
+ * Ideas Database Schema
87
+ * Captures and tracks ideas
88
+ */
89
+ export const IDEAS_DATABASE_SCHEMA: NotionDatabaseSchema = {
90
+ title: 'prjct: Ideas',
91
+ properties: {
92
+ Idea: { type: 'title', title: {} },
93
+ Status: {
94
+ type: 'status',
95
+ status: {
96
+ options: [
97
+ { name: 'Pending', color: 'gray' },
98
+ { name: 'Converted', color: 'green' },
99
+ { name: 'Archived', color: 'default' },
100
+ ],
101
+ groups: [
102
+ { name: 'Not Started', option_ids: [], color: 'gray' },
103
+ { name: 'Done', option_ids: [], color: 'green' },
104
+ ],
105
+ },
106
+ },
107
+ Priority: {
108
+ type: 'select',
109
+ select: {
110
+ options: [
111
+ { name: 'high', color: 'red' },
112
+ { name: 'medium', color: 'yellow' },
113
+ { name: 'low', color: 'gray' },
114
+ ],
115
+ },
116
+ },
117
+ Tags: { type: 'multi_select', multi_select: { options: [] } },
118
+ Created: { type: 'date', date: {} },
119
+ 'Converted To': { type: 'rich_text', rich_text: {} },
120
+ Project: { type: 'rich_text', rich_text: {} },
121
+ },
122
+ }
123
+
124
+ /**
125
+ * Active Tasks Database Schema
126
+ * Tracks current task queue
127
+ */
128
+ export const TASKS_DATABASE_SCHEMA: NotionDatabaseSchema = {
129
+ title: 'prjct: Active Tasks',
130
+ properties: {
131
+ Task: { type: 'title', title: {} },
132
+ Priority: {
133
+ type: 'select',
134
+ select: {
135
+ options: [
136
+ { name: 'critical', color: 'red' },
137
+ { name: 'high', color: 'orange' },
138
+ { name: 'medium', color: 'yellow' },
139
+ { name: 'low', color: 'gray' },
140
+ ],
141
+ },
142
+ },
143
+ Type: {
144
+ type: 'select',
145
+ select: {
146
+ options: [
147
+ { name: 'feature', color: 'blue' },
148
+ { name: 'bug', color: 'red' },
149
+ { name: 'improvement', color: 'green' },
150
+ { name: 'chore', color: 'gray' },
151
+ ],
152
+ },
153
+ },
154
+ Section: {
155
+ type: 'select',
156
+ select: {
157
+ options: [
158
+ { name: 'active', color: 'blue' },
159
+ { name: 'backlog', color: 'gray' },
160
+ ],
161
+ },
162
+ },
163
+ Completed: { type: 'checkbox', checkbox: {} },
164
+ Agent: { type: 'rich_text', rich_text: {} },
165
+ Feature: { type: 'rich_text', rich_text: {} },
166
+ Created: { type: 'date', date: {} },
167
+ 'Completed At': { type: 'date', date: {} },
168
+ Project: { type: 'rich_text', rich_text: {} },
169
+ },
170
+ }
171
+
172
+ // =============================================================================
173
+ // Dashboard Template
174
+ // =============================================================================
175
+
176
+ /**
177
+ * Dashboard page content template
178
+ */
179
+ export function getDashboardContent(
180
+ projectName: string,
181
+ databases: {
182
+ shipped?: string
183
+ roadmap?: string
184
+ ideas?: string
185
+ tasks?: string
186
+ }
187
+ ): string {
188
+ const sections = []
189
+
190
+ sections.push(`# ${projectName} Dashboard`)
191
+ sections.push('')
192
+ sections.push('This dashboard is automatically synced by prjct-cli.')
193
+ sections.push('')
194
+
195
+ if (databases.shipped) {
196
+ sections.push('## Shipped Features')
197
+ sections.push(`View all shipped features and metrics.`)
198
+ sections.push('')
199
+ }
200
+
201
+ if (databases.roadmap) {
202
+ sections.push('## Roadmap')
203
+ sections.push(`Track feature progress and planning.`)
204
+ sections.push('')
205
+ }
206
+
207
+ if (databases.ideas) {
208
+ sections.push('## Ideas')
209
+ sections.push(`Captured ideas and their status.`)
210
+ sections.push('')
211
+ }
212
+
213
+ if (databases.tasks) {
214
+ sections.push('## Active Tasks')
215
+ sections.push(`Current task queue and completion status.`)
216
+ sections.push('')
217
+ }
218
+
219
+ sections.push('---')
220
+ sections.push('Powered by [prjct-cli](https://prjct.app)')
221
+
222
+ return sections.join('\n')
223
+ }
224
+
225
+ // =============================================================================
226
+ // All Schemas Export
227
+ // =============================================================================
228
+
229
+ export const ALL_DATABASE_SCHEMAS = {
230
+ shipped: SHIPPED_DATABASE_SCHEMA,
231
+ roadmap: ROADMAP_DATABASE_SCHEMA,
232
+ ideas: IDEAS_DATABASE_SCHEMA,
233
+ tasks: TASKS_DATABASE_SCHEMA,
234
+ } as const
@@ -0,0 +1,178 @@
1
+ /**
2
+ * Notion Plugin for prjct-cli
3
+ *
4
+ * Syncs prjct data to Notion databases on events.
5
+ * Activated when project has integrations.notion.enabled = true
6
+ *
7
+ * @version 1.0.0
8
+ *
9
+ * Configuration in GlobalConfig.integrations.notion:
10
+ * {
11
+ * "enabled": true,
12
+ * "databases": {
13
+ * "shipped": "notion-db-id",
14
+ * "ideas": "notion-db-id"
15
+ * },
16
+ * "syncOn": { "ship": true, "idea": true }
17
+ * }
18
+ */
19
+
20
+ import { HookPoints } from '../hooks'
21
+ import type { NotionIntegrationConfig } from '../../types/integrations'
22
+ import type { ShippedFeature, Idea } from '../../types/storage'
23
+ import { notionClient, syncShippedFeature, syncIdea } from '../../integrations/notion'
24
+
25
+ interface NotionPluginContext {
26
+ projectId: string
27
+ config: NotionIntegrationConfig
28
+ apiToken?: string
29
+ }
30
+
31
+ const plugin = {
32
+ name: 'notion',
33
+ version: '1.0.0',
34
+ description: 'Sync prjct data to Notion',
35
+
36
+ // Plugin state
37
+ enabled: false,
38
+ projectId: null as string | null,
39
+ config: null as NotionIntegrationConfig | null,
40
+
41
+ /**
42
+ * Activate plugin
43
+ */
44
+ async activate(context: NotionPluginContext): Promise<void> {
45
+ const { projectId, config, apiToken } = context
46
+
47
+ if (!config?.enabled) {
48
+ return
49
+ }
50
+
51
+ // Initialize Notion client
52
+ notionClient.initialize(config, apiToken || process.env.NOTION_TOKEN)
53
+
54
+ if (!notionClient.isReady()) {
55
+ console.warn('[notion] API token not configured, plugin disabled')
56
+ return
57
+ }
58
+
59
+ plugin.enabled = true
60
+ plugin.projectId = projectId
61
+ plugin.config = config
62
+ },
63
+
64
+ /**
65
+ * Deactivate plugin
66
+ */
67
+ async deactivate(): Promise<void> {
68
+ plugin.enabled = false
69
+ plugin.projectId = null
70
+ plugin.config = null
71
+ },
72
+
73
+ /**
74
+ * Hook handlers
75
+ */
76
+ hooks: {
77
+ /**
78
+ * Sync shipped feature to Notion after ship
79
+ */
80
+ [HookPoints.AFTER_FEATURE_SHIP]: async function (data: {
81
+ feature: ShippedFeature
82
+ projectId?: string
83
+ }): Promise<void> {
84
+ if (!plugin.enabled || !plugin.config || !plugin.projectId) return
85
+ if (!plugin.config.syncOn?.ship) return
86
+
87
+ try {
88
+ const result = await syncShippedFeature(
89
+ data.projectId || plugin.projectId,
90
+ data.feature,
91
+ plugin.config
92
+ )
93
+
94
+ if (result.success) {
95
+ console.log(`[notion] Synced: ${data.feature.name} (${result.action})`)
96
+ } else if (result.error) {
97
+ console.warn(`[notion] Sync failed: ${result.error}`)
98
+ }
99
+ } catch (error) {
100
+ // Graceful degradation - don't fail the ship
101
+ console.warn('[notion] Sync error:', (error as Error).message)
102
+ }
103
+ },
104
+
105
+ /**
106
+ * Sync idea to Notion after capture
107
+ */
108
+ [HookPoints.AFTER_IDEA_CAPTURE]: async function (data: {
109
+ idea: Idea
110
+ projectId?: string
111
+ }): Promise<void> {
112
+ if (!plugin.enabled || !plugin.config || !plugin.projectId) return
113
+ if (!plugin.config.syncOn?.idea) return
114
+
115
+ try {
116
+ const result = await syncIdea(
117
+ data.projectId || plugin.projectId,
118
+ data.idea,
119
+ plugin.config
120
+ )
121
+
122
+ if (result.success) {
123
+ console.log(`[notion] Synced idea: ${data.idea.text.slice(0, 30)}... (${result.action})`)
124
+ } else if (result.error) {
125
+ console.warn(`[notion] Sync failed: ${result.error}`)
126
+ }
127
+ } catch (error) {
128
+ // Graceful degradation - don't fail the idea capture
129
+ console.warn('[notion] Sync error:', (error as Error).message)
130
+ }
131
+ },
132
+
133
+ /**
134
+ * Sync task completion (optional)
135
+ */
136
+ [HookPoints.AFTER_TASK_COMPLETE]: async function (data: {
137
+ taskName?: string
138
+ projectId?: string
139
+ }): Promise<void> {
140
+ if (!plugin.enabled || !plugin.config || !plugin.projectId) return
141
+ if (!plugin.config.syncOn?.done) return
142
+
143
+ // Task sync is optional and less critical
144
+ // Could update task status in Notion if database exists
145
+ if (data.taskName) {
146
+ console.log(`[notion] Task completed: ${data.taskName}`)
147
+ }
148
+ },
149
+ },
150
+
151
+ /**
152
+ * Check if plugin is active
153
+ */
154
+ isActive(): boolean {
155
+ return plugin.enabled && plugin.config?.enabled === true
156
+ },
157
+
158
+ /**
159
+ * Get current sync status
160
+ */
161
+ getStatus(): {
162
+ enabled: boolean
163
+ databases: number
164
+ lastSync?: string
165
+ } {
166
+ const dbCount = plugin.config?.databases
167
+ ? Object.values(plugin.config.databases).filter(Boolean).length
168
+ : 0
169
+
170
+ return {
171
+ enabled: plugin.enabled,
172
+ databases: dbCount,
173
+ lastSync: plugin.config?.lastSyncAt,
174
+ }
175
+ },
176
+ }
177
+
178
+ export default plugin