@goodtek/vibeops 0.2.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.
- package/CHANGELOG.md +28 -0
- package/LICENSE +21 -0
- package/README.md +444 -0
- package/dist/agent/loader.js +71 -0
- package/dist/agent/prompt.js +66 -0
- package/dist/bootstrap/installer.js +149 -0
- package/dist/bootstrap/manifest.js +15 -0
- package/dist/bootstrap/substitute.js +35 -0
- package/dist/cli.js +241 -0
- package/dist/commands/agent-list.js +32 -0
- package/dist/commands/agent-prompt.js +59 -0
- package/dist/commands/agent-show.js +26 -0
- package/dist/commands/github-init.js +554 -0
- package/dist/commands/github-status.js +164 -0
- package/dist/commands/init.js +179 -0
- package/dist/commands/notion-init.js +764 -0
- package/dist/commands/notion-sync.js +405 -0
- package/dist/commands/notion-test.js +595 -0
- package/dist/commands/plan.js +114 -0
- package/dist/commands/status.js +17 -0
- package/dist/commands/task-check.js +155 -0
- package/dist/commands/task-done.js +98 -0
- package/dist/commands/task-generate.js +206 -0
- package/dist/commands/task-pull.js +277 -0
- package/dist/commands/task-rollback.js +174 -0
- package/dist/commands/task-start.js +90 -0
- package/dist/lib/brief.js +349 -0
- package/dist/lib/config.js +158 -0
- package/dist/lib/filesystem.js +67 -0
- package/dist/lib/git.js +237 -0
- package/dist/lib/github-cli.js +247 -0
- package/dist/lib/inquirer-helpers.js +111 -0
- package/dist/lib/logger.js +42 -0
- package/dist/lib/notion-client.js +459 -0
- package/dist/lib/notion-discovery.js +671 -0
- package/dist/lib/notion-env.js +140 -0
- package/dist/lib/notion-mappers.js +148 -0
- package/dist/lib/notion-schema.js +272 -0
- package/dist/lib/notion-sync.js +337 -0
- package/dist/lib/notion-target.js +247 -0
- package/dist/lib/package-json.js +133 -0
- package/dist/lib/paths.js +26 -0
- package/dist/lib/project-docs.js +95 -0
- package/dist/lib/prompt-builder.js +125 -0
- package/dist/lib/task-generator.js +183 -0
- package/dist/lib/task-prompt.js +23 -0
- package/dist/lib/task-pull.js +354 -0
- package/dist/lib/task-scaffold.js +128 -0
- package/dist/lib/task-summary.js +276 -0
- package/dist/lib/task.js +364 -0
- package/dist/status/collector.js +103 -0
- package/dist/status/format.js +177 -0
- package/dist/types/brief.js +126 -0
- package/dist/types/config.js +17 -0
- package/dist/types/task.js +1 -0
- package/dist/version.js +8 -0
- package/package.json +61 -0
- package/templates/.cursor/rules/00-project-governance.mdc +28 -0
- package/templates/.cursor/rules/01-agent-orchestration.mdc +48 -0
- package/templates/.cursor/rules/02-task-workflow.mdc +38 -0
- package/templates/.cursor/rules/03-git-safety.mdc +30 -0
- package/templates/.cursor/rules/04-docs-update.mdc +22 -0
- package/templates/.vibeops/agents/architect.md +47 -0
- package/templates/.vibeops/agents/builder.md +38 -0
- package/templates/.vibeops/agents/docs.md +54 -0
- package/templates/.vibeops/agents/orchestrator.md +40 -0
- package/templates/.vibeops/agents/planner.md +60 -0
- package/templates/.vibeops/agents/recovery.md +49 -0
- package/templates/.vibeops/agents/reviewer.md +47 -0
- package/templates/.vibeops/agents/tester.md +43 -0
- package/templates/.vibeops/prompts/create-plan.md +33 -0
- package/templates/.vibeops/prompts/generate-tasks.md +41 -0
- package/templates/.vibeops/prompts/implement-task.md +39 -0
- package/templates/.vibeops/prompts/review-task.md +34 -0
- package/templates/.vibeops/prompts/rollback.md +32 -0
- package/templates/.vibeops/prompts/start-project.md +39 -0
- package/templates/.vibeops/workflows/notion-sync.md +53 -0
- package/templates/.vibeops/workflows/project-start.md +73 -0
- package/templates/.vibeops/workflows/rollback.md +45 -0
- package/templates/.vibeops/workflows/task-lifecycle.md +71 -0
- package/templates/AGENTS.md +98 -0
- package/templates/docs/logs/README.md +38 -0
- package/templates/docs/project/00-overview.md +27 -0
- package/templates/docs/project/01-requirements.md +30 -0
- package/templates/docs/project/02-mvp-scope.md +36 -0
- package/templates/docs/project/03-architecture.md +34 -0
- package/templates/docs/project/04-tech-stack.md +29 -0
- package/templates/docs/project/05-current-state.md +35 -0
- package/templates/docs/project/06-decisions.md +20 -0
- package/templates/docs/project/07-backlog.md +23 -0
- package/templates/docs/project/08-env.md +29 -0
- package/templates/docs/project/09-deployment.md +28 -0
- package/templates/docs/tasks/TASK-000-template.md +72 -0
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Planning + execution helpers for `vibeops notion sync`.
|
|
3
|
+
*
|
|
4
|
+
* Layers:
|
|
5
|
+
* 1. `loadSyncContext` — read everything we need (config, token, schema,
|
|
6
|
+
* project docs, tasks). Pure I/O. No Notion calls.
|
|
7
|
+
* 2. `buildProjectRow` / `buildTaskRow` — pure synchronous mappers from
|
|
8
|
+
* local data → Notion property objects. Easy to unit-test.
|
|
9
|
+
* 3. `planProjectSync` / `planTaskSync` — given a context + an *existing*
|
|
10
|
+
* Notion DB schema, decide what to push. Returns a plan that includes
|
|
11
|
+
* the Notion property object so the caller can either dry-print or hand
|
|
12
|
+
* it to `executeSync*` for the real API call.
|
|
13
|
+
* 4. `findExistingProject` / `findExistingTask` — query Notion to discover
|
|
14
|
+
* whether the upsert target already exists. These are the only
|
|
15
|
+
* network-bound helpers in this file.
|
|
16
|
+
* 5. `executeProjectUpsert` / `executeTaskUpsert` — perform the actual
|
|
17
|
+
* `pages.create` or `pages.update` call.
|
|
18
|
+
*
|
|
19
|
+
* Notion API mutation lives behind `executeProjectUpsert` /
|
|
20
|
+
* `executeTaskUpsert` only — callers passing `dryRun: true` MUST skip both.
|
|
21
|
+
*/
|
|
22
|
+
import { relative } from "node:path";
|
|
23
|
+
import { gitRemoteUrl } from "./git.js";
|
|
24
|
+
import { PROJECTS_DB_PROPERTIES, TASKS_DB_PROPERTIES, validateDatabaseSchema, } from "./notion-schema.js";
|
|
25
|
+
import { resolveNotionDataSourceTarget, } from "./notion-target.js";
|
|
26
|
+
import { andFilter, gitRepoProperty, mapTaskStatusToNotion, readRichText, readStatus, readTitle, richTextEqualsFilter, richTextProperty, selectProperty, statusProperty, titleProperty, } from "./notion-mappers.js";
|
|
27
|
+
import { loadNotionEnv } from "./notion-env.js";
|
|
28
|
+
import { notionProjectsTargetId, notionTasksTargetId, readConfig, } from "./config.js";
|
|
29
|
+
import { readTextOrNull } from "./filesystem.js";
|
|
30
|
+
import { projectPaths } from "./paths.js";
|
|
31
|
+
import { join } from "node:path";
|
|
32
|
+
import { readGitContext, scanTasks } from "./task.js";
|
|
33
|
+
import { detectCurrentPhase, summarizeGoal, summarizeMarkdownLead, summarizeResult, } from "./task-summary.js";
|
|
34
|
+
import { createNotionClient, notionApiError, } from "./notion-client.js";
|
|
35
|
+
export async function loadSyncContext(cwd) {
|
|
36
|
+
const config = await readConfig(cwd);
|
|
37
|
+
if (config === null) {
|
|
38
|
+
return {
|
|
39
|
+
ok: false,
|
|
40
|
+
reason: "no-config",
|
|
41
|
+
message: ".vibeops.json not found. Run `vibeops init` first.",
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
const notion = config.notion;
|
|
45
|
+
if (notion === undefined || !notion.enabled) {
|
|
46
|
+
return {
|
|
47
|
+
ok: false,
|
|
48
|
+
reason: "notion-not-enabled",
|
|
49
|
+
message: "Notion integration is disabled. Enable it with `vibeops notion init --enable` and set the DB ids.",
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
if (notionProjectsTargetId(notion).length === 0) {
|
|
53
|
+
return {
|
|
54
|
+
ok: false,
|
|
55
|
+
reason: "no-projects-db",
|
|
56
|
+
message: "`.vibeops.json` `notion.projectsTargetId` or `notion.projectsDatabaseId` is empty. Fill it in with `vibeops notion init`.",
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
if (notionTasksTargetId(notion).length === 0) {
|
|
60
|
+
return {
|
|
61
|
+
ok: false,
|
|
62
|
+
reason: "no-tasks-db",
|
|
63
|
+
message: "`.vibeops.json` `notion.tasksTargetId` or `notion.tasksDatabaseId` is empty. Fill it in with `vibeops notion init`.",
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
const env = await loadNotionEnv(cwd);
|
|
67
|
+
if (env.token === null) {
|
|
68
|
+
return {
|
|
69
|
+
ok: false,
|
|
70
|
+
reason: "no-token",
|
|
71
|
+
message: "`NOTION_TOKEN` not found. Set it in `.vibeops.env` or as an environment variable (NOTION_TOKEN=secret_...).",
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
const paths = projectPaths(cwd);
|
|
75
|
+
const overviewPath = join(paths.docsProject, "00-overview.md");
|
|
76
|
+
const overviewRaw = (await readTextOrNull(overviewPath)) ?? "";
|
|
77
|
+
const overviewSummary = summarizeMarkdownLead(overviewRaw);
|
|
78
|
+
// `docs/project/05-current-state.md` is the canonical name; the VibeOps
|
|
79
|
+
// repo itself still uses the legacy `03-current-state.md` filename. Honour both.
|
|
80
|
+
let currentStateRaw = (await readTextOrNull(join(paths.docsProject, "05-current-state.md"))) ?? "";
|
|
81
|
+
if (currentStateRaw.length === 0) {
|
|
82
|
+
currentStateRaw =
|
|
83
|
+
(await readTextOrNull(join(paths.docsProject, "03-current-state.md"))) ?? "";
|
|
84
|
+
}
|
|
85
|
+
const currentPhase = detectCurrentPhase(currentStateRaw) || "MVP";
|
|
86
|
+
const gitRepoUrl = (await gitRemoteUrl(cwd, "origin")) ?? "";
|
|
87
|
+
const tasks = await scanTasks(paths.docsTasks);
|
|
88
|
+
return {
|
|
89
|
+
ok: true,
|
|
90
|
+
cwd,
|
|
91
|
+
notion,
|
|
92
|
+
token: env.token,
|
|
93
|
+
tokenSource: env.source,
|
|
94
|
+
project: {
|
|
95
|
+
config,
|
|
96
|
+
projectId: config.name,
|
|
97
|
+
projectName: config.name,
|
|
98
|
+
cwd,
|
|
99
|
+
docsProjectPath: relative(cwd, paths.docsProject) || "docs/project",
|
|
100
|
+
overviewSummary,
|
|
101
|
+
currentPhase,
|
|
102
|
+
gitRepoUrl,
|
|
103
|
+
},
|
|
104
|
+
tasks,
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Convert a `ResolveResult` into a {@link SchemaReport}.
|
|
109
|
+
*
|
|
110
|
+
* Always returns — `ok: false` paths surface as a `missing-properties`
|
|
111
|
+
* violation so callers can render a meaningful diagnostic without crashing.
|
|
112
|
+
*/
|
|
113
|
+
function reportFromResolved(required, db, resolved, fallbackId) {
|
|
114
|
+
if (!resolved.ok) {
|
|
115
|
+
return {
|
|
116
|
+
inputObject: resolved.partial?.inputObject ?? "(unknown)",
|
|
117
|
+
resolvedObject: "(unresolved)",
|
|
118
|
+
inputId: fallbackId,
|
|
119
|
+
resolvedId: "",
|
|
120
|
+
source: "input-data-source",
|
|
121
|
+
properties: {},
|
|
122
|
+
propertiesMissing: true,
|
|
123
|
+
violations: [
|
|
124
|
+
{
|
|
125
|
+
db,
|
|
126
|
+
property: "(properties)",
|
|
127
|
+
kind: "missing-properties",
|
|
128
|
+
allowedTypes: [],
|
|
129
|
+
description: resolved.message,
|
|
130
|
+
},
|
|
131
|
+
],
|
|
132
|
+
gitRepoType: "",
|
|
133
|
+
warnings: [],
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
const violations = validateDatabaseSchema({
|
|
137
|
+
db,
|
|
138
|
+
required,
|
|
139
|
+
retrieveResponse: resolved.properties,
|
|
140
|
+
});
|
|
141
|
+
const gitRepoProp = resolved.properties["Git Repo"];
|
|
142
|
+
const gitRepoType = gitRepoProp?.type === "url" || gitRepoProp?.type === "rich_text"
|
|
143
|
+
? gitRepoProp.type
|
|
144
|
+
: "";
|
|
145
|
+
return {
|
|
146
|
+
inputObject: resolved.inputObject,
|
|
147
|
+
resolvedObject: resolved.resolvedObject,
|
|
148
|
+
inputId: resolved.inputId,
|
|
149
|
+
resolvedId: resolved.resolvedId,
|
|
150
|
+
source: resolved.source,
|
|
151
|
+
...(resolved.title !== undefined ? { title: resolved.title } : {}),
|
|
152
|
+
...(resolved.parentDatabaseId !== undefined
|
|
153
|
+
? { parentDatabaseId: resolved.parentDatabaseId }
|
|
154
|
+
: {}),
|
|
155
|
+
properties: resolved.properties,
|
|
156
|
+
propertiesMissing: false,
|
|
157
|
+
violations,
|
|
158
|
+
gitRepoType,
|
|
159
|
+
warnings: resolved.warnings,
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
export async function fetchSchemas(client, notion) {
|
|
163
|
+
let projectsResolved;
|
|
164
|
+
try {
|
|
165
|
+
projectsResolved = await resolveNotionDataSourceTarget(client, notionProjectsTargetId(notion), "projects");
|
|
166
|
+
}
|
|
167
|
+
catch (err) {
|
|
168
|
+
return { ok: false, reason: "projects-retrieve", error: notionApiError(err) };
|
|
169
|
+
}
|
|
170
|
+
let tasksResolved;
|
|
171
|
+
try {
|
|
172
|
+
tasksResolved = await resolveNotionDataSourceTarget(client, notionTasksTargetId(notion), "tasks");
|
|
173
|
+
}
|
|
174
|
+
catch (err) {
|
|
175
|
+
return { ok: false, reason: "tasks-retrieve", error: notionApiError(err) };
|
|
176
|
+
}
|
|
177
|
+
// All structured resolver failures (transport / no-data-source /
|
|
178
|
+
// no-properties) funnel through `reportFromResolved` so the CLI can show
|
|
179
|
+
// its rich `${kind} DB target` block + the resolver's actionable hint.
|
|
180
|
+
// We only fast-fail on resolver-side exceptions (caught above).
|
|
181
|
+
return {
|
|
182
|
+
ok: true,
|
|
183
|
+
projects: reportFromResolved(PROJECTS_DB_PROPERTIES, "projects", projectsResolved, notionProjectsTargetId(notion)),
|
|
184
|
+
tasks: reportFromResolved(TASKS_DB_PROPERTIES, "tasks", tasksResolved, notionTasksTargetId(notion)),
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
// ─── property builders ────────────────────────────────────────────────────
|
|
188
|
+
export function buildProjectProperties(project, gitRepoType) {
|
|
189
|
+
return {
|
|
190
|
+
Name: titleProperty(project.projectName),
|
|
191
|
+
"Project ID": richTextProperty(project.projectId),
|
|
192
|
+
Status: statusProperty("Building"),
|
|
193
|
+
"Local Path": richTextProperty(project.cwd),
|
|
194
|
+
"Git Repo": gitRepoProperty(project.gitRepoUrl, gitRepoType === "url" ? "url" : "rich_text"),
|
|
195
|
+
"Current Phase": selectProperty(project.currentPhase),
|
|
196
|
+
"Docs Path": richTextProperty(project.docsProjectPath),
|
|
197
|
+
Summary: richTextProperty(project.overviewSummary),
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
export async function buildTaskRow(task, ctx) {
|
|
201
|
+
const body = (await readTextOrNull(task.filePath)) ?? "";
|
|
202
|
+
const goal = summarizeGoal(body);
|
|
203
|
+
const result = summarizeResult(body);
|
|
204
|
+
const gitContext = await readGitContext(task.filePath).catch(() => null);
|
|
205
|
+
const gitBranch = gitContext?.taskBranch ?? "";
|
|
206
|
+
const docsRelativePath = relative(ctx.cwd, task.filePath) || task.filePath;
|
|
207
|
+
const properties = {
|
|
208
|
+
Name: titleProperty(task.title.length > 0 ? task.title : task.id),
|
|
209
|
+
"Task ID": richTextProperty(task.id),
|
|
210
|
+
"Project ID": richTextProperty(ctx.project.projectId),
|
|
211
|
+
Status: statusProperty(mapTaskStatusToNotion(task.status)),
|
|
212
|
+
Priority: selectProperty(task.priority ?? "P2"),
|
|
213
|
+
"MVP Phase": selectProperty(task.mvpPhase ?? ""),
|
|
214
|
+
"Git Branch": richTextProperty(gitBranch),
|
|
215
|
+
"Docs Path": richTextProperty(docsRelativePath),
|
|
216
|
+
Summary: richTextProperty(goal),
|
|
217
|
+
"Result Summary": richTextProperty(result),
|
|
218
|
+
};
|
|
219
|
+
return {
|
|
220
|
+
taskId: task.id,
|
|
221
|
+
title: task.title,
|
|
222
|
+
docsRelativePath,
|
|
223
|
+
properties,
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
export async function findExistingProject(client, dataSourceId, projectId) {
|
|
227
|
+
const res = await client.queryDataSource(dataSourceId, {
|
|
228
|
+
filter: richTextEqualsFilter("Project ID", projectId),
|
|
229
|
+
pageSize: 1,
|
|
230
|
+
});
|
|
231
|
+
const page = res.results[0];
|
|
232
|
+
return page ? { id: page.id, properties: page.properties } : null;
|
|
233
|
+
}
|
|
234
|
+
export async function findExistingTask(client, dataSourceId, projectId, taskId) {
|
|
235
|
+
const res = await client.queryDataSource(dataSourceId, {
|
|
236
|
+
filter: andFilter([
|
|
237
|
+
richTextEqualsFilter("Task ID", taskId),
|
|
238
|
+
richTextEqualsFilter("Project ID", projectId),
|
|
239
|
+
]),
|
|
240
|
+
pageSize: 1,
|
|
241
|
+
});
|
|
242
|
+
const page = res.results[0];
|
|
243
|
+
return page ? { id: page.id, properties: page.properties } : null;
|
|
244
|
+
}
|
|
245
|
+
/**
|
|
246
|
+
* Task IDs that VibeOps never pushes to Notion. `TASK-000-template.md` is a
|
|
247
|
+
* scaffolding template that lives in `docs/tasks/` only so `task generate`
|
|
248
|
+
* has a stable copy to clone — Notion should not show it as a real TASK row.
|
|
249
|
+
*/
|
|
250
|
+
export const SYNC_EXCLUDED_TASK_IDS = new Set(["TASK-000"]);
|
|
251
|
+
export async function planSync(inputs) {
|
|
252
|
+
const { ctx, schemas, detectExisting, client } = inputs;
|
|
253
|
+
const plan = {
|
|
254
|
+
project: null,
|
|
255
|
+
tasks: [],
|
|
256
|
+
counts: {
|
|
257
|
+
project: { create: 0, update: 0 },
|
|
258
|
+
tasks: { create: 0, update: 0 },
|
|
259
|
+
},
|
|
260
|
+
};
|
|
261
|
+
if (inputs.onlyTasks !== true) {
|
|
262
|
+
const props = buildProjectProperties(ctx.project, schemas.projects.gitRepoType);
|
|
263
|
+
let existing = null;
|
|
264
|
+
if (detectExisting) {
|
|
265
|
+
existing = await findExistingProject(client, schemas.projects.resolvedId, ctx.project.projectId);
|
|
266
|
+
}
|
|
267
|
+
const verb = existing ? "update" : "create";
|
|
268
|
+
plan.project = {
|
|
269
|
+
verb,
|
|
270
|
+
existingPageId: existing?.id ?? null,
|
|
271
|
+
properties: props,
|
|
272
|
+
};
|
|
273
|
+
plan.counts.project[verb]++;
|
|
274
|
+
}
|
|
275
|
+
if (inputs.onlyProject !== true) {
|
|
276
|
+
for (const task of ctx.tasks) {
|
|
277
|
+
if (SYNC_EXCLUDED_TASK_IDS.has(task.id))
|
|
278
|
+
continue;
|
|
279
|
+
const row = await buildTaskRow(task, ctx);
|
|
280
|
+
let existing = null;
|
|
281
|
+
if (detectExisting) {
|
|
282
|
+
existing = await findExistingTask(client, schemas.tasks.resolvedId, ctx.project.projectId, task.id);
|
|
283
|
+
}
|
|
284
|
+
const verb = existing ? "update" : "create";
|
|
285
|
+
plan.tasks.push({
|
|
286
|
+
taskId: task.id,
|
|
287
|
+
title: row.title,
|
|
288
|
+
docsRelativePath: row.docsRelativePath,
|
|
289
|
+
verb,
|
|
290
|
+
existingPageId: existing?.id ?? null,
|
|
291
|
+
properties: row.properties,
|
|
292
|
+
});
|
|
293
|
+
plan.counts.tasks[verb]++;
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
return plan;
|
|
297
|
+
}
|
|
298
|
+
export async function executeProjectUpsert(client, dataSourceId, entry) {
|
|
299
|
+
if (entry.verb === "update" && entry.existingPageId !== null) {
|
|
300
|
+
const res = await client.updatePage({
|
|
301
|
+
pageId: entry.existingPageId,
|
|
302
|
+
properties: entry.properties,
|
|
303
|
+
});
|
|
304
|
+
return { verb: "update", pageId: res.id };
|
|
305
|
+
}
|
|
306
|
+
const res = await client.createPageInDataSource({
|
|
307
|
+
dataSourceId,
|
|
308
|
+
properties: entry.properties,
|
|
309
|
+
});
|
|
310
|
+
return { verb: "create", pageId: res.id };
|
|
311
|
+
}
|
|
312
|
+
export async function executeTaskUpsert(client, dataSourceId, entry) {
|
|
313
|
+
if (entry.verb === "update" && entry.existingPageId !== null) {
|
|
314
|
+
const res = await client.updatePage({
|
|
315
|
+
pageId: entry.existingPageId,
|
|
316
|
+
properties: entry.properties,
|
|
317
|
+
});
|
|
318
|
+
return { verb: "update", pageId: res.id, taskId: entry.taskId };
|
|
319
|
+
}
|
|
320
|
+
const res = await client.createPageInDataSource({
|
|
321
|
+
dataSourceId,
|
|
322
|
+
properties: entry.properties,
|
|
323
|
+
});
|
|
324
|
+
return { verb: "create", pageId: res.id, taskId: entry.taskId };
|
|
325
|
+
}
|
|
326
|
+
// ─── helpers for command output ───────────────────────────────────────────
|
|
327
|
+
export function readExistingProjectIdentity(page) {
|
|
328
|
+
return {
|
|
329
|
+
name: readTitle(page.properties.Name),
|
|
330
|
+
projectId: readRichText(page.properties["Project ID"]),
|
|
331
|
+
};
|
|
332
|
+
}
|
|
333
|
+
export function readExistingTaskStatus(page) {
|
|
334
|
+
return readStatus(page.properties.Status);
|
|
335
|
+
}
|
|
336
|
+
// re-export so commands don't need a second import
|
|
337
|
+
export { createNotionClient };
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolve a user-stored Notion id (saved in `.vibeops.json` as a "database
|
|
3
|
+
* id") to the underlying `data_source` that actually carries the property
|
|
4
|
+
* schema.
|
|
5
|
+
*
|
|
6
|
+
* Why this exists
|
|
7
|
+
* ───────────────
|
|
8
|
+
* In the current Notion API ("2025-09-03" and later) a `database` object is a
|
|
9
|
+
* shell that owns 0…N `data_source` children. The real schema (`properties`)
|
|
10
|
+
* lives on the `data_source`. `databases.retrieve(id).properties` is
|
|
11
|
+
* deprecated and frequently comes back `undefined` on workspaces that have
|
|
12
|
+
* been migrated. Result: VibeOps' `notion test` / `notion sync` saw
|
|
13
|
+
* `object=database`, no `properties`, and emitted `missing-properties`
|
|
14
|
+
* violations even though the user's DB schema is fine.
|
|
15
|
+
*
|
|
16
|
+
* What this does
|
|
17
|
+
* ──────────────
|
|
18
|
+
* `resolveNotionDataSourceTarget(client, id, label)`:
|
|
19
|
+
*
|
|
20
|
+
* A. Try `dataSources.retrieve(id)` first.
|
|
21
|
+
* - If the SDK supports it AND the response has a `properties` map → done.
|
|
22
|
+
* - If the SDK supports it but Notion responds 4xx, fall through.
|
|
23
|
+
* - If the SDK does **not** expose `client.dataSources` (null return),
|
|
24
|
+
* fall through.
|
|
25
|
+
* B. Try `databases.retrieve(id)`.
|
|
26
|
+
* - If the response has `data_sources[]` non-empty, pick `[0]` (warn if
|
|
27
|
+
* the array has more than one), then call `dataSources.retrieve` on
|
|
28
|
+
* that id.
|
|
29
|
+
* - If the response carries a legacy `properties` map (very old SDKs),
|
|
30
|
+
* treat the database id itself as the resolved target.
|
|
31
|
+
* - If `data_sources` is empty and there is no legacy `properties` map,
|
|
32
|
+
* return a structured `missing-properties` error pointing the user
|
|
33
|
+
* at the "share the data source with the integration" fix.
|
|
34
|
+
* C. Any unexpected exception is normalised through `notionApiError` so
|
|
35
|
+
* callers never get a raw SDK Error / TypeError.
|
|
36
|
+
*
|
|
37
|
+
* Read-only. No mutation. Never logs the token. Returns `{ ok: true | false,
|
|
38
|
+
* … }` — no exceptions for the documented failure modes.
|
|
39
|
+
*/
|
|
40
|
+
import { extractDataSourcesFromDatabaseResponse, notionApiError, } from "./notion-client.js";
|
|
41
|
+
import { getNotionProperties, readNotionObjectKind, } from "./notion-schema.js";
|
|
42
|
+
async function tryDataSourcesRetrieve(client, id) {
|
|
43
|
+
try {
|
|
44
|
+
const res = await client.dataSourcesRetrieve(id);
|
|
45
|
+
if (res === null) {
|
|
46
|
+
return { ok: false, sdkMissing: true };
|
|
47
|
+
}
|
|
48
|
+
return { ok: true, response: res };
|
|
49
|
+
}
|
|
50
|
+
catch (err) {
|
|
51
|
+
return { ok: false, apiError: notionApiError(err) };
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
async function tryDatabasesRetrieve(client, id) {
|
|
55
|
+
try {
|
|
56
|
+
const res = (await client.databasesRetrieve(id));
|
|
57
|
+
return { ok: true, response: res };
|
|
58
|
+
}
|
|
59
|
+
catch (err) {
|
|
60
|
+
return { ok: false, apiError: notionApiError(err) };
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
function readTitleText(raw) {
|
|
64
|
+
if (!Array.isArray(raw))
|
|
65
|
+
return undefined;
|
|
66
|
+
const text = raw
|
|
67
|
+
.map((seg) => seg.plain_text ?? "")
|
|
68
|
+
.join("")
|
|
69
|
+
.trim();
|
|
70
|
+
return text.length > 0 ? text : undefined;
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Common tail for resolver error messages — points the user at the new
|
|
74
|
+
* `--debug-shape` diagnostic when they need to inspect what Notion actually
|
|
75
|
+
* returned.
|
|
76
|
+
*/
|
|
77
|
+
const HINT_DEBUG_SHAPE = "Run `vibeops notion test --debug-shape` to inspect the Notion response shape.";
|
|
78
|
+
const HINT_NO_DATA_SOURCE = "Notion returned no data_sources for this database. The integration may " +
|
|
79
|
+
"be connected to the parent page only — open the database as a full page " +
|
|
80
|
+
"in Notion and add the VibeOps integration directly via its " +
|
|
81
|
+
"'⋯ → Connections' menu. " +
|
|
82
|
+
HINT_DEBUG_SHAPE;
|
|
83
|
+
const HINT_NO_PROPERTIES = "Resolved a data_source but it returned no `properties` map. " +
|
|
84
|
+
"This usually means the integration has access to the database shell " +
|
|
85
|
+
"but not the underlying data_source. Open the data source and add the " +
|
|
86
|
+
"VibeOps integration to it. " +
|
|
87
|
+
HINT_DEBUG_SHAPE;
|
|
88
|
+
/**
|
|
89
|
+
* Main entry point.
|
|
90
|
+
*
|
|
91
|
+
* Steps A → B → fail, as documented at the top of this file. `label` is
|
|
92
|
+
* `"projects"` / `"tasks"` etc. and is only used for diagnostic strings.
|
|
93
|
+
*/
|
|
94
|
+
export async function resolveNotionDataSourceTarget(client, id, label) {
|
|
95
|
+
const warnings = [];
|
|
96
|
+
// ── A. Try dataSources.retrieve(id) directly ─────────────────────────
|
|
97
|
+
const dsOutcome = await tryDataSourcesRetrieve(client, id);
|
|
98
|
+
if (dsOutcome.ok && dsOutcome.response) {
|
|
99
|
+
const ds = dsOutcome.response;
|
|
100
|
+
const props = getNotionProperties(ds);
|
|
101
|
+
if (props !== null) {
|
|
102
|
+
return {
|
|
103
|
+
ok: true,
|
|
104
|
+
inputId: id,
|
|
105
|
+
resolvedId: typeof ds.id === "string" && ds.id.length > 0 ? ds.id : id,
|
|
106
|
+
label,
|
|
107
|
+
inputObject: readNotionObjectKind(ds),
|
|
108
|
+
resolvedObject: "data_source",
|
|
109
|
+
source: "input-data-source",
|
|
110
|
+
...(readTitleText(ds.title) !== undefined
|
|
111
|
+
? { title: readTitleText(ds.title) }
|
|
112
|
+
: {}),
|
|
113
|
+
...(typeof ds.parent?.database_id === "string"
|
|
114
|
+
? { parentDatabaseId: ds.parent.database_id }
|
|
115
|
+
: {}),
|
|
116
|
+
properties: props,
|
|
117
|
+
warnings,
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
warnings.push(`dataSources.retrieve(${id}) succeeded but returned no properties map.`);
|
|
121
|
+
}
|
|
122
|
+
else if (dsOutcome.apiError !== undefined &&
|
|
123
|
+
dsOutcome.apiError.code !== "object_not_found" &&
|
|
124
|
+
dsOutcome.apiError.code !== "validation_error" &&
|
|
125
|
+
dsOutcome.apiError.code !== "unknown_error") {
|
|
126
|
+
// unauthorized / restricted_resource / rate_limited / timeout — surface
|
|
127
|
+
// these immediately rather than masquerading as a missing data source.
|
|
128
|
+
return {
|
|
129
|
+
ok: false,
|
|
130
|
+
inputId: id,
|
|
131
|
+
label,
|
|
132
|
+
reason: "transport",
|
|
133
|
+
message: dsOutcome.apiError.message,
|
|
134
|
+
apiError: dsOutcome.apiError,
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
else if (dsOutcome.apiError !== undefined &&
|
|
138
|
+
dsOutcome.apiError.code === "object_not_found") {
|
|
139
|
+
warnings.push(`dataSources.retrieve(${id}) → object_not_found (id is likely a database id, falling back).`);
|
|
140
|
+
}
|
|
141
|
+
// (sdkMissing or 4xx that we want to fall through)
|
|
142
|
+
// ── B. Try databases.retrieve(id) ────────────────────────────────────
|
|
143
|
+
const dbOutcome = await tryDatabasesRetrieve(client, id);
|
|
144
|
+
if (!dbOutcome.ok || dbOutcome.response === undefined) {
|
|
145
|
+
const apiErr = dbOutcome.apiError ?? {
|
|
146
|
+
ok: false,
|
|
147
|
+
code: "unknown_error",
|
|
148
|
+
message: "databases.retrieve failed without a structured error.",
|
|
149
|
+
};
|
|
150
|
+
return {
|
|
151
|
+
ok: false,
|
|
152
|
+
inputId: id,
|
|
153
|
+
label,
|
|
154
|
+
reason: "transport",
|
|
155
|
+
message: apiErr.message,
|
|
156
|
+
apiError: apiErr,
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
const db = dbOutcome.response;
|
|
160
|
+
const inputObject = readNotionObjectKind(db);
|
|
161
|
+
const dbProps = getNotionProperties(db);
|
|
162
|
+
const dbTitle = readTitleText(db.title);
|
|
163
|
+
const dbId = typeof db.id === "string" && (db.id).length > 0
|
|
164
|
+
? (db.id)
|
|
165
|
+
: id;
|
|
166
|
+
// Centralised parser handles `data_sources` / `dataSources` /
|
|
167
|
+
// `child_data_sources` / `childDataSources` + nested `data_source.id`.
|
|
168
|
+
const extracted = extractDataSourcesFromDatabaseResponse(db);
|
|
169
|
+
if (extracted.field !== null && extracted.field !== "data_sources") {
|
|
170
|
+
warnings.push(`database response carried data sources under '${extracted.field}' (non-canonical naming).`);
|
|
171
|
+
}
|
|
172
|
+
const childIds = extracted.items.map((it) => it.id);
|
|
173
|
+
// ── Legacy SDK / very old workspace: properties on database itself ───
|
|
174
|
+
if (dbProps !== null && childIds.length === 0) {
|
|
175
|
+
warnings.push("Legacy database object carries a `properties` map directly — using it (no data_source fallback needed).");
|
|
176
|
+
return {
|
|
177
|
+
ok: true,
|
|
178
|
+
inputId: id,
|
|
179
|
+
resolvedId: dbId,
|
|
180
|
+
label,
|
|
181
|
+
inputObject,
|
|
182
|
+
resolvedObject: "database",
|
|
183
|
+
source: "legacy-database",
|
|
184
|
+
...(dbTitle !== undefined ? { title: dbTitle } : {}),
|
|
185
|
+
properties: dbProps,
|
|
186
|
+
warnings,
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
if (childIds.length === 0) {
|
|
190
|
+
return {
|
|
191
|
+
ok: false,
|
|
192
|
+
inputId: id,
|
|
193
|
+
label,
|
|
194
|
+
reason: "no-data-source",
|
|
195
|
+
message: HINT_NO_DATA_SOURCE,
|
|
196
|
+
partial: { inputObject, childDataSourceIds: [] },
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
if (childIds.length > 1) {
|
|
200
|
+
warnings.push(`database has ${childIds.length} data_sources — used [0]; ids: ${childIds.join(", ")}.`);
|
|
201
|
+
}
|
|
202
|
+
const chosenId = childIds[0];
|
|
203
|
+
const childOutcome = await tryDataSourcesRetrieve(client, chosenId);
|
|
204
|
+
if (!childOutcome.ok || childOutcome.response === undefined || childOutcome.response === null) {
|
|
205
|
+
const apiErr = childOutcome.apiError ?? {
|
|
206
|
+
ok: false,
|
|
207
|
+
code: "unknown_error",
|
|
208
|
+
message: `dataSources.retrieve(${chosenId}) failed without a structured error.`,
|
|
209
|
+
};
|
|
210
|
+
return {
|
|
211
|
+
ok: false,
|
|
212
|
+
inputId: id,
|
|
213
|
+
label,
|
|
214
|
+
reason: "transport",
|
|
215
|
+
message: apiErr.message,
|
|
216
|
+
apiError: apiErr,
|
|
217
|
+
partial: { inputObject, childDataSourceIds: childIds },
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
const child = childOutcome.response;
|
|
221
|
+
const childProps = getNotionProperties(child);
|
|
222
|
+
if (childProps === null) {
|
|
223
|
+
return {
|
|
224
|
+
ok: false,
|
|
225
|
+
inputId: id,
|
|
226
|
+
label,
|
|
227
|
+
reason: "no-properties",
|
|
228
|
+
message: `Resolved data_source ${chosenId} returned no properties map. ${HINT_NO_PROPERTIES}`,
|
|
229
|
+
partial: { inputObject, childDataSourceIds: childIds },
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
return {
|
|
233
|
+
ok: true,
|
|
234
|
+
inputId: id,
|
|
235
|
+
resolvedId: typeof child.id === "string" && child.id.length > 0 ? child.id : chosenId,
|
|
236
|
+
label,
|
|
237
|
+
inputObject,
|
|
238
|
+
resolvedObject: "data_source",
|
|
239
|
+
source: "database-default-data-source",
|
|
240
|
+
...(readTitleText(child.title) !== undefined
|
|
241
|
+
? { title: readTitleText(child.title) }
|
|
242
|
+
: {}),
|
|
243
|
+
parentDatabaseId: dbId,
|
|
244
|
+
properties: childProps,
|
|
245
|
+
warnings,
|
|
246
|
+
};
|
|
247
|
+
}
|