@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,140 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import { pathExists, readTextOrNull, writeText } from "./filesystem.js";
|
|
3
|
+
import { VIBEOPS_ENV_FILE } from "./paths.js";
|
|
4
|
+
/**
|
|
5
|
+
* Minimal `.env` parser — handles the subset we need:
|
|
6
|
+
* - blank lines and `#` comments are ignored
|
|
7
|
+
* - `KEY=value` (optional whitespace around `=`)
|
|
8
|
+
* - surrounding single or double quotes are stripped
|
|
9
|
+
* - inline `#` comments after `value ` are stripped (but only when the value
|
|
10
|
+
* itself is not quoted)
|
|
11
|
+
*
|
|
12
|
+
* We intentionally don't depend on `dotenv` to keep the install footprint
|
|
13
|
+
* small and to avoid surprising behaviours (e.g. silent override of
|
|
14
|
+
* `process.env`).
|
|
15
|
+
*/
|
|
16
|
+
export function parseDotenv(text) {
|
|
17
|
+
const out = {};
|
|
18
|
+
for (const rawLine of text.split(/\r?\n/)) {
|
|
19
|
+
const line = rawLine.trim();
|
|
20
|
+
if (line.length === 0 || line.startsWith("#"))
|
|
21
|
+
continue;
|
|
22
|
+
const eq = line.indexOf("=");
|
|
23
|
+
if (eq <= 0)
|
|
24
|
+
continue;
|
|
25
|
+
const key = line.slice(0, eq).trim();
|
|
26
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key))
|
|
27
|
+
continue;
|
|
28
|
+
let val = line.slice(eq + 1).trim();
|
|
29
|
+
const quoted = (val.startsWith('"') && val.endsWith('"')) ||
|
|
30
|
+
(val.startsWith("'") && val.endsWith("'"));
|
|
31
|
+
if (quoted) {
|
|
32
|
+
val = val.slice(1, -1);
|
|
33
|
+
}
|
|
34
|
+
else {
|
|
35
|
+
const hash = val.indexOf(" #");
|
|
36
|
+
if (hash !== -1)
|
|
37
|
+
val = val.slice(0, hash).trim();
|
|
38
|
+
}
|
|
39
|
+
out[key] = val;
|
|
40
|
+
}
|
|
41
|
+
return out;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Resolve the Notion API token. Priority:
|
|
45
|
+
* 1. `.vibeops.env` in the project root (preferred — not committed)
|
|
46
|
+
* 2. `process.env.NOTION_TOKEN`
|
|
47
|
+
*
|
|
48
|
+
* Returns the raw token. Callers MUST mask before printing.
|
|
49
|
+
*/
|
|
50
|
+
export async function loadNotionEnv(cwd) {
|
|
51
|
+
const envPath = join(cwd, VIBEOPS_ENV_FILE);
|
|
52
|
+
const text = await readTextOrNull(envPath);
|
|
53
|
+
if (text !== null) {
|
|
54
|
+
const parsed = parseDotenv(text);
|
|
55
|
+
const fromFile = parsed.NOTION_TOKEN ?? "";
|
|
56
|
+
if (fromFile.length > 0) {
|
|
57
|
+
return { token: fromFile, source: ".vibeops.env" };
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
const fromProcess = process.env.NOTION_TOKEN ?? "";
|
|
61
|
+
if (fromProcess.length > 0) {
|
|
62
|
+
return { token: fromProcess, source: "process.env" };
|
|
63
|
+
}
|
|
64
|
+
return { token: null, source: "none" };
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Token-source probe for `vibeops status` and similar read-only flows.
|
|
68
|
+
*
|
|
69
|
+
* Returns only whether a token is reachable and where it came from. The token
|
|
70
|
+
* VALUE never leaves this function — callers cannot accidentally leak it via
|
|
71
|
+
* logs.
|
|
72
|
+
*/
|
|
73
|
+
export async function getNotionTokenSource(cwd) {
|
|
74
|
+
const env = await loadNotionEnv(cwd);
|
|
75
|
+
return { hasToken: env.token !== null, source: env.source };
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Mask a secret for safe display. Keeps the first 4 and last 4 characters so
|
|
79
|
+
* the user can sanity-check which token is loaded without revealing the body.
|
|
80
|
+
*/
|
|
81
|
+
export function maskToken(value) {
|
|
82
|
+
if (value.length <= 8)
|
|
83
|
+
return "*".repeat(value.length);
|
|
84
|
+
return `${value.slice(0, 4)}…${value.slice(-4)} (len=${value.length})`;
|
|
85
|
+
}
|
|
86
|
+
export async function inspectEnvFile(cwd) {
|
|
87
|
+
const path = join(cwd, VIBEOPS_ENV_FILE);
|
|
88
|
+
const raw = await readTextOrNull(path);
|
|
89
|
+
if (raw === null)
|
|
90
|
+
return { exists: false, raw: null, currentToken: null };
|
|
91
|
+
const parsed = parseDotenv(raw);
|
|
92
|
+
const value = typeof parsed.NOTION_TOKEN === "string" ? parsed.NOTION_TOKEN : null;
|
|
93
|
+
return { exists: true, raw, currentToken: value };
|
|
94
|
+
}
|
|
95
|
+
const DEFAULT_ENV_HEADER = `# VibeOps · local environment (DO NOT COMMIT)
|
|
96
|
+
# This file is .gitignored. Holds the Notion integration secret VibeOps reads.
|
|
97
|
+
`;
|
|
98
|
+
/**
|
|
99
|
+
* Write or update the \`NOTION_TOKEN=...\` line inside the project's
|
|
100
|
+
* \`.vibeops.env\`.
|
|
101
|
+
*
|
|
102
|
+
* Rules:
|
|
103
|
+
* - We never echo the token value anywhere — caller is responsible for
|
|
104
|
+
* keeping it out of logs.
|
|
105
|
+
* - If the file doesn't exist, we create it with a header (no other keys).
|
|
106
|
+
* - If the file exists, we preserve every other line. We replace the first
|
|
107
|
+
* \`NOTION_TOKEN=...\` line we find, or append one to the end otherwise.
|
|
108
|
+
*/
|
|
109
|
+
export async function writeNotionTokenToEnvFile(cwd, token) {
|
|
110
|
+
const path = join(cwd, VIBEOPS_ENV_FILE);
|
|
111
|
+
const exists = await pathExists(path);
|
|
112
|
+
const line = `NOTION_TOKEN=${token}`;
|
|
113
|
+
if (!exists) {
|
|
114
|
+
await writeText(path, `${DEFAULT_ENV_HEADER}\n${line}\n`);
|
|
115
|
+
return { path, created: true, replaced: false };
|
|
116
|
+
}
|
|
117
|
+
const raw = (await readTextOrNull(path)) ?? "";
|
|
118
|
+
const lines = raw.split(/\r?\n/);
|
|
119
|
+
let replaced = false;
|
|
120
|
+
for (let i = 0; i < lines.length; i++) {
|
|
121
|
+
const trimmed = lines[i].trimStart();
|
|
122
|
+
if (trimmed.startsWith("NOTION_TOKEN=") || trimmed.startsWith("NOTION_TOKEN =")) {
|
|
123
|
+
lines[i] = line;
|
|
124
|
+
replaced = true;
|
|
125
|
+
break;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
let next;
|
|
129
|
+
if (replaced) {
|
|
130
|
+
next = lines.join("\n");
|
|
131
|
+
if (!next.endsWith("\n"))
|
|
132
|
+
next += "\n";
|
|
133
|
+
}
|
|
134
|
+
else {
|
|
135
|
+
const trailing = raw.endsWith("\n") ? "" : "\n";
|
|
136
|
+
next = `${raw}${trailing}${line}\n`;
|
|
137
|
+
}
|
|
138
|
+
await writeText(path, next);
|
|
139
|
+
return { path, created: false, replaced };
|
|
140
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure mappers between VibeOps local data (TASK markdown / project docs /
|
|
3
|
+
* config) and Notion API property objects.
|
|
4
|
+
*
|
|
5
|
+
* Side-effect free: every function in this file is a synchronous data
|
|
6
|
+
* transformation that the caller can unit-test or dry-run without ever
|
|
7
|
+
* touching the network.
|
|
8
|
+
*
|
|
9
|
+
* Property names match `PROJECTS_DB_PROPERTIES` / `TASKS_DB_PROPERTIES` in
|
|
10
|
+
* `notion-schema.ts` exactly (case-sensitive).
|
|
11
|
+
*/
|
|
12
|
+
import { statusDisplay } from "./task.js";
|
|
13
|
+
/**
|
|
14
|
+
* Notion `rich_text` and most string-like properties hard-limit a single
|
|
15
|
+
* text run at 2000 characters. We pick a smaller 1500-char ceiling so we
|
|
16
|
+
* don't bump into multi-byte edge cases or implicit 0-pad on the way back.
|
|
17
|
+
*/
|
|
18
|
+
export const NOTION_TEXT_LIMIT = 1500;
|
|
19
|
+
export function truncate(text, limit = NOTION_TEXT_LIMIT) {
|
|
20
|
+
if (text.length <= limit)
|
|
21
|
+
return text;
|
|
22
|
+
return `${text.slice(0, limit - 1)}…`;
|
|
23
|
+
}
|
|
24
|
+
// ─── property builders ────────────────────────────────────────────────────
|
|
25
|
+
export function titleProperty(text) {
|
|
26
|
+
return {
|
|
27
|
+
title: [{ type: "text", text: { content: truncate(text || "(untitled)") } }],
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
export function richTextProperty(text) {
|
|
31
|
+
if (text.length === 0) {
|
|
32
|
+
return { rich_text: [] };
|
|
33
|
+
}
|
|
34
|
+
return {
|
|
35
|
+
rich_text: [{ type: "text", text: { content: truncate(text) } }],
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
export function urlProperty(text) {
|
|
39
|
+
return { url: text.length > 0 ? text : null };
|
|
40
|
+
}
|
|
41
|
+
export function selectProperty(name) {
|
|
42
|
+
if (name.length === 0)
|
|
43
|
+
return { select: null };
|
|
44
|
+
return { select: { name: truncate(name, 100) } };
|
|
45
|
+
}
|
|
46
|
+
export function statusProperty(name) {
|
|
47
|
+
if (name.length === 0)
|
|
48
|
+
return { status: null };
|
|
49
|
+
return { status: { name: truncate(name, 100) } };
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Build a property for "Git Repo" — a value the user's Notion DB might have
|
|
53
|
+
* declared as either `rich_text` *or* `url`. The caller passes the type it
|
|
54
|
+
* actually saw from `databases.retrieve()`.
|
|
55
|
+
*/
|
|
56
|
+
export function gitRepoProperty(url, propertyType) {
|
|
57
|
+
if (propertyType === "url")
|
|
58
|
+
return urlProperty(url);
|
|
59
|
+
return richTextProperty(url);
|
|
60
|
+
}
|
|
61
|
+
// ─── status mapping (TASK markdown ↔ Notion) ──────────────────────────────
|
|
62
|
+
const TASK_TO_NOTION = {
|
|
63
|
+
planned: "Planned",
|
|
64
|
+
in_progress: "In Progress",
|
|
65
|
+
review: "Review",
|
|
66
|
+
blocked: "Blocked",
|
|
67
|
+
done: "Done",
|
|
68
|
+
};
|
|
69
|
+
const NOTION_NAME_TO_TASK = {
|
|
70
|
+
planned: "planned",
|
|
71
|
+
ready: "planned",
|
|
72
|
+
todo: "planned",
|
|
73
|
+
"in progress": "in_progress",
|
|
74
|
+
in_progress: "in_progress",
|
|
75
|
+
doing: "in_progress",
|
|
76
|
+
review: "review",
|
|
77
|
+
"in review": "review",
|
|
78
|
+
blocked: "blocked",
|
|
79
|
+
done: "done",
|
|
80
|
+
closed: "done",
|
|
81
|
+
};
|
|
82
|
+
export function mapTaskStatusToNotion(status) {
|
|
83
|
+
return TASK_TO_NOTION[status] ?? "Planned";
|
|
84
|
+
}
|
|
85
|
+
export function mapNotionStatusNameToTask(notionName) {
|
|
86
|
+
const key = notionName.trim().toLowerCase();
|
|
87
|
+
return NOTION_NAME_TO_TASK[key] ?? "planned";
|
|
88
|
+
}
|
|
89
|
+
export function taskStatusDisplay(status) {
|
|
90
|
+
return statusDisplay(status);
|
|
91
|
+
}
|
|
92
|
+
// ─── readers (Notion API response → string) ───────────────────────────────
|
|
93
|
+
export function readTitle(prop) {
|
|
94
|
+
const arr = prop?.title;
|
|
95
|
+
if (!Array.isArray(arr) || arr.length === 0)
|
|
96
|
+
return "";
|
|
97
|
+
return arr
|
|
98
|
+
.map((seg) => seg.plain_text ?? "")
|
|
99
|
+
.join("")
|
|
100
|
+
.trim();
|
|
101
|
+
}
|
|
102
|
+
export function readRichText(prop) {
|
|
103
|
+
const arr = prop?.rich_text;
|
|
104
|
+
if (!Array.isArray(arr) || arr.length === 0)
|
|
105
|
+
return "";
|
|
106
|
+
return arr
|
|
107
|
+
.map((seg) => seg.plain_text ?? "")
|
|
108
|
+
.join("")
|
|
109
|
+
.trim();
|
|
110
|
+
}
|
|
111
|
+
export function readUrl(prop) {
|
|
112
|
+
const v = prop?.url;
|
|
113
|
+
return typeof v === "string" ? v : "";
|
|
114
|
+
}
|
|
115
|
+
export function readSelect(prop) {
|
|
116
|
+
const v = prop?.select?.name;
|
|
117
|
+
return typeof v === "string" ? v : "";
|
|
118
|
+
}
|
|
119
|
+
export function readStatus(prop) {
|
|
120
|
+
const v = prop?.status?.name;
|
|
121
|
+
return typeof v === "string" ? v : "";
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* Read either `rich_text` or `url` and return whichever has content. Useful
|
|
125
|
+
* for the "Git Repo" property which the user may have declared as either type.
|
|
126
|
+
*/
|
|
127
|
+
export function readUrlOrRichText(prop) {
|
|
128
|
+
const url = readUrl(prop);
|
|
129
|
+
if (url.length > 0)
|
|
130
|
+
return url;
|
|
131
|
+
return readRichText(prop);
|
|
132
|
+
}
|
|
133
|
+
// ─── filter builders ──────────────────────────────────────────────────────
|
|
134
|
+
export function richTextEqualsFilter(propertyName, value) {
|
|
135
|
+
return {
|
|
136
|
+
property: propertyName,
|
|
137
|
+
rich_text: { equals: value },
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
export function statusEqualsFilter(propertyName, value) {
|
|
141
|
+
return {
|
|
142
|
+
property: propertyName,
|
|
143
|
+
status: { equals: value },
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
export function andFilter(filters) {
|
|
147
|
+
return { and: [...filters] };
|
|
148
|
+
}
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Required Notion property schema for the **Projects DB** and **Tasks DB**.
|
|
3
|
+
*
|
|
4
|
+
* VibeOps validates the shape with `databases.retrieve` (read-only). It
|
|
5
|
+
* never tries to create or migrate the schema — humans manage Notion.
|
|
6
|
+
*
|
|
7
|
+
* Property names are matched case-sensitively, exactly as users see them in
|
|
8
|
+
* the Notion UI. If your Notion property has a different name or type, your
|
|
9
|
+
* `notion test` will fail with a clear error.
|
|
10
|
+
*/
|
|
11
|
+
/**
|
|
12
|
+
* Status option names VibeOps writes to the Projects DB. Add/rename these in
|
|
13
|
+
* Notion (`Status` property → `Edit options`). Anything missing causes a
|
|
14
|
+
* pre-flight `status-options-missing` violation; nothing is auto-created.
|
|
15
|
+
*/
|
|
16
|
+
export const PROJECTS_STATUS_REQUIRED_OPTIONS = [
|
|
17
|
+
"Building",
|
|
18
|
+
"Planning",
|
|
19
|
+
"Paused",
|
|
20
|
+
"Done",
|
|
21
|
+
"Archived",
|
|
22
|
+
];
|
|
23
|
+
/**
|
|
24
|
+
* Status option names VibeOps writes to the Tasks DB. Mirrors the TASK
|
|
25
|
+
* lifecycle (`Planned → In Progress → Review → Done`) plus `Blocked`.
|
|
26
|
+
*/
|
|
27
|
+
export const TASKS_STATUS_REQUIRED_OPTIONS = [
|
|
28
|
+
"Planned",
|
|
29
|
+
"In Progress",
|
|
30
|
+
"Review",
|
|
31
|
+
"Done",
|
|
32
|
+
"Blocked",
|
|
33
|
+
];
|
|
34
|
+
export const PROJECTS_DB_PROPERTIES = [
|
|
35
|
+
{ name: "Name", allowedTypes: ["title"], description: "Project page title (Notion 'Title' column)" },
|
|
36
|
+
{ name: "Project ID", allowedTypes: ["rich_text"], description: "Matches .vibeops.json projectId" },
|
|
37
|
+
{ name: "Status", allowedTypes: ["status"], description: "Project status (Notion 'Status' type; not select)", requiredOptions: PROJECTS_STATUS_REQUIRED_OPTIONS },
|
|
38
|
+
{ name: "Local Path", allowedTypes: ["rich_text"], description: "Local repository path" },
|
|
39
|
+
{ name: "Git Repo", allowedTypes: ["rich_text", "url"], description: "Remote repository URL (rich_text or url allowed)" },
|
|
40
|
+
{ name: "Current Phase", allowedTypes: ["select"], description: "Current phase label" },
|
|
41
|
+
{ name: "Docs Path", allowedTypes: ["rich_text"], description: "docs/project path" },
|
|
42
|
+
{ name: "Summary", allowedTypes: ["rich_text"], description: "Summary of 00-overview.md" },
|
|
43
|
+
];
|
|
44
|
+
export const TASKS_DB_PROPERTIES = [
|
|
45
|
+
{ name: "Name", allowedTypes: ["title"], description: "TASK page title" },
|
|
46
|
+
{ name: "Task ID", allowedTypes: ["rich_text"], description: "TASK-NNN" },
|
|
47
|
+
{ name: "Project ID", allowedTypes: ["rich_text"], description: "Owning project id" },
|
|
48
|
+
{ name: "Status", allowedTypes: ["status"], description: "Planned / In Progress / Review / Done / Blocked (Notion 'Status' type)", requiredOptions: TASKS_STATUS_REQUIRED_OPTIONS },
|
|
49
|
+
{ name: "Priority", allowedTypes: ["select"], description: "P0 / P1 / P2 / …" },
|
|
50
|
+
{ name: "MVP Phase", allowedTypes: ["select"], description: "Phase label (free-form select; compatibility name)" },
|
|
51
|
+
{ name: "Git Branch", allowedTypes: ["rich_text"], description: "task/TASK-NNN-slug" },
|
|
52
|
+
{ name: "Docs Path", allowedTypes: ["rich_text"], description: "docs/tasks/TASK-NNN-*.md" },
|
|
53
|
+
{ name: "Summary", allowedTypes: ["rich_text"], description: "TASK Goal summary" },
|
|
54
|
+
{ name: "Result Summary", allowedTypes: ["rich_text"], description: "TASK Result summary (set on Review/Done)" },
|
|
55
|
+
];
|
|
56
|
+
/**
|
|
57
|
+
* Friendly prefix shown in CLI output / 4xx hints whenever a status option
|
|
58
|
+
* is missing. Surface it verbatim so user knows it's a Notion-side action.
|
|
59
|
+
*/
|
|
60
|
+
export const STATUS_OPTIONS_HINT = "Add missing Status options to the Notion database, then rerun `vibeops notion test`.";
|
|
61
|
+
/**
|
|
62
|
+
* Friendly bilingual hint reused by `notion test` and `notion sync` when the
|
|
63
|
+
* retrieve response does not expose a `properties` object. Surface it
|
|
64
|
+
* verbatim in CLI error output — it is referenced from TASK-011 docs.
|
|
65
|
+
*/
|
|
66
|
+
export const MISSING_PROPERTIES_HINT = "Could not read Notion database properties. " +
|
|
67
|
+
"This may happen if the selected ID is not a database/data source ID, " +
|
|
68
|
+
"or the integration does not have access to the database itself. " +
|
|
69
|
+
"Open the database as a full page and share it with the VibeOps integration, " +
|
|
70
|
+
"then run `vibeops notion test`.";
|
|
71
|
+
/**
|
|
72
|
+
* Best-effort extraction of a Notion `properties` map from a value that may be:
|
|
73
|
+
* - `undefined` / `null` / non-object → `null`
|
|
74
|
+
* - a `databases.retrieve()` response → the nested `properties` object
|
|
75
|
+
* - a `data_source` retrieve response → the nested `properties` object
|
|
76
|
+
* - a bare `properties` map already → the value itself
|
|
77
|
+
*
|
|
78
|
+
* VibeOps schema validators must funnel every input through this helper so
|
|
79
|
+
* a malformed response (legacy SDK, partial mock, wrong id) yields a clean
|
|
80
|
+
* `missing-properties` violation instead of a `TypeError`.
|
|
81
|
+
*/
|
|
82
|
+
export function getNotionProperties(input) {
|
|
83
|
+
if (input === null || input === undefined || typeof input !== "object") {
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
const obj = input;
|
|
87
|
+
const props = obj.properties;
|
|
88
|
+
if (props !== undefined && props !== null && typeof props === "object") {
|
|
89
|
+
return props;
|
|
90
|
+
}
|
|
91
|
+
// Already a properties map? Look for at least one well-known VibeOps key.
|
|
92
|
+
if ("Name" in obj ||
|
|
93
|
+
"Task ID" in obj ||
|
|
94
|
+
"Project ID" in obj ||
|
|
95
|
+
"Status" in obj) {
|
|
96
|
+
return obj;
|
|
97
|
+
}
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Best-effort extraction of the Notion API object kind:
|
|
102
|
+
* - `databases.retrieve()` → "database"
|
|
103
|
+
* - data_source retrieve → "data_source"
|
|
104
|
+
* - anything else / missing → "(unknown)"
|
|
105
|
+
*
|
|
106
|
+
* Used by callers to render diagnostic detail without leaking any token.
|
|
107
|
+
*/
|
|
108
|
+
export function readNotionObjectKind(input) {
|
|
109
|
+
if (input === null || input === undefined || typeof input !== "object") {
|
|
110
|
+
return "(unknown)";
|
|
111
|
+
}
|
|
112
|
+
const obj = input;
|
|
113
|
+
return typeof obj.object === "string" ? obj.object : "(unknown)";
|
|
114
|
+
}
|
|
115
|
+
export function validateDatabaseSchema(inputs) {
|
|
116
|
+
const properties = getNotionProperties(inputs.retrieveResponse);
|
|
117
|
+
if (properties === null) {
|
|
118
|
+
return [
|
|
119
|
+
{
|
|
120
|
+
db: inputs.db,
|
|
121
|
+
property: "(properties)",
|
|
122
|
+
kind: "missing-properties",
|
|
123
|
+
allowedTypes: [],
|
|
124
|
+
description: MISSING_PROPERTIES_HINT,
|
|
125
|
+
},
|
|
126
|
+
];
|
|
127
|
+
}
|
|
128
|
+
const violations = [];
|
|
129
|
+
for (const req of inputs.required) {
|
|
130
|
+
const prop = properties[req.name];
|
|
131
|
+
if (prop === undefined || prop === null) {
|
|
132
|
+
violations.push({
|
|
133
|
+
db: inputs.db,
|
|
134
|
+
property: req.name,
|
|
135
|
+
kind: "missing",
|
|
136
|
+
allowedTypes: req.allowedTypes,
|
|
137
|
+
description: req.description,
|
|
138
|
+
});
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
const actual = typeof prop.type === "string"
|
|
142
|
+
? prop.type
|
|
143
|
+
: "(unknown)";
|
|
144
|
+
if (!req.allowedTypes.includes(actual)) {
|
|
145
|
+
violations.push({
|
|
146
|
+
db: inputs.db,
|
|
147
|
+
property: req.name,
|
|
148
|
+
kind: "type-mismatch",
|
|
149
|
+
actualType: actual,
|
|
150
|
+
allowedTypes: req.allowedTypes,
|
|
151
|
+
description: req.description,
|
|
152
|
+
});
|
|
153
|
+
// skip option check — type is wrong already
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
// status options check — only if (a) the requirement has them, (b) the
|
|
157
|
+
// observed type is `status` (we don't check select/multi_select yet —
|
|
158
|
+
// VibeOps doesn't write to them at runtime in this MVP).
|
|
159
|
+
if (req.requiredOptions !== undefined &&
|
|
160
|
+
req.requiredOptions.length > 0 &&
|
|
161
|
+
actual === "status") {
|
|
162
|
+
const extracted = extractStatusOptionNames(prop);
|
|
163
|
+
if (extracted === null) {
|
|
164
|
+
violations.push({
|
|
165
|
+
db: inputs.db,
|
|
166
|
+
property: req.name,
|
|
167
|
+
kind: "status-options-unreadable",
|
|
168
|
+
allowedTypes: req.allowedTypes,
|
|
169
|
+
description: req.description,
|
|
170
|
+
requiredOptions: req.requiredOptions,
|
|
171
|
+
});
|
|
172
|
+
continue;
|
|
173
|
+
}
|
|
174
|
+
const found = new Set(extracted);
|
|
175
|
+
const missing = req.requiredOptions.filter((n) => !found.has(n));
|
|
176
|
+
if (missing.length > 0) {
|
|
177
|
+
violations.push({
|
|
178
|
+
db: inputs.db,
|
|
179
|
+
property: req.name,
|
|
180
|
+
kind: "status-options-missing",
|
|
181
|
+
allowedTypes: req.allowedTypes,
|
|
182
|
+
description: req.description,
|
|
183
|
+
requiredOptions: req.requiredOptions,
|
|
184
|
+
missingOptions: missing,
|
|
185
|
+
foundOptions: extracted,
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
return violations;
|
|
191
|
+
}
|
|
192
|
+
/**
|
|
193
|
+
* Extract `name` strings from a Notion `status` property's `options` array.
|
|
194
|
+
* Notion's status response shape (API `2025-09-03`) looks like:
|
|
195
|
+
*
|
|
196
|
+
* {
|
|
197
|
+
* id: "...",
|
|
198
|
+
* name: "Status",
|
|
199
|
+
* type: "status",
|
|
200
|
+
* status: {
|
|
201
|
+
* options: [{ id, name, color, description, ... }, …],
|
|
202
|
+
* groups: [{ id, name, color, option_ids: [...] }, …]
|
|
203
|
+
* }
|
|
204
|
+
* }
|
|
205
|
+
*
|
|
206
|
+
* We also defensively look at `groups[].option_names` (some response shapes)
|
|
207
|
+
* and the top-level `options` / `status_options` fallbacks. Returns `null`
|
|
208
|
+
* when the property is shaped in a way we cannot recognise — callers should
|
|
209
|
+
* surface that as `status-options-unreadable` so the user knows VibeOps
|
|
210
|
+
* could not pre-flight check the option set rather than silently passing.
|
|
211
|
+
*
|
|
212
|
+
* IMPORTANT: never throws.
|
|
213
|
+
*/
|
|
214
|
+
export function extractStatusOptionNames(prop) {
|
|
215
|
+
if (prop === null || prop === undefined || typeof prop !== "object") {
|
|
216
|
+
return null;
|
|
217
|
+
}
|
|
218
|
+
const names = [];
|
|
219
|
+
const seen = new Set();
|
|
220
|
+
const pushName = (raw) => {
|
|
221
|
+
if (typeof raw !== "string")
|
|
222
|
+
return;
|
|
223
|
+
const trimmed = raw.trim();
|
|
224
|
+
if (trimmed.length === 0)
|
|
225
|
+
return;
|
|
226
|
+
if (seen.has(trimmed))
|
|
227
|
+
return;
|
|
228
|
+
seen.add(trimmed);
|
|
229
|
+
names.push(trimmed);
|
|
230
|
+
};
|
|
231
|
+
const collectFromOptions = (arr) => {
|
|
232
|
+
if (!Array.isArray(arr))
|
|
233
|
+
return;
|
|
234
|
+
for (const item of arr) {
|
|
235
|
+
if (item === null || typeof item !== "object")
|
|
236
|
+
continue;
|
|
237
|
+
pushName(item.name);
|
|
238
|
+
}
|
|
239
|
+
};
|
|
240
|
+
const obj = prop;
|
|
241
|
+
// (a) modern shape: prop.status.options[].name
|
|
242
|
+
const statusBody = obj.status;
|
|
243
|
+
if (statusBody !== null && typeof statusBody === "object") {
|
|
244
|
+
const sb = statusBody;
|
|
245
|
+
collectFromOptions(sb.options);
|
|
246
|
+
// some response shapes carry `groups[].options[].name` directly.
|
|
247
|
+
if (Array.isArray(sb.groups)) {
|
|
248
|
+
for (const g of sb.groups) {
|
|
249
|
+
if (g !== null && typeof g === "object") {
|
|
250
|
+
collectFromOptions(g.options);
|
|
251
|
+
const optionNames = g.option_names;
|
|
252
|
+
if (Array.isArray(optionNames)) {
|
|
253
|
+
for (const n of optionNames)
|
|
254
|
+
pushName(n);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
// (b) flat fallback: prop.options[].name (older / partial shapes)
|
|
261
|
+
collectFromOptions(obj.options);
|
|
262
|
+
// (c) some legacy responses spell it `status_options`.
|
|
263
|
+
collectFromOptions(obj.status_options);
|
|
264
|
+
if (names.length === 0) {
|
|
265
|
+
// Distinguish "we found the property but couldn't read any option names"
|
|
266
|
+
// from "we read 0 options" — both look the same here, so return null to
|
|
267
|
+
// tell the caller to surface `status-options-unreadable`. A real Notion
|
|
268
|
+
// workspace always has at least 1 status option.
|
|
269
|
+
return null;
|
|
270
|
+
}
|
|
271
|
+
return names;
|
|
272
|
+
}
|