pm-skill 1.0.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/.env.example +17 -0
- package/AGENTS.md +87 -0
- package/LICENSE +21 -0
- package/README.md +144 -0
- package/SKILL.md +134 -0
- package/config.yml +82 -0
- package/dist/config.d.ts +40 -0
- package/dist/config.js +144 -0
- package/dist/env.d.ts +31 -0
- package/dist/env.js +153 -0
- package/dist/linear.d.ts +48 -0
- package/dist/linear.js +138 -0
- package/dist/notion.d.ts +34 -0
- package/dist/notion.js +225 -0
- package/dist/workflows.d.ts +2 -0
- package/dist/workflows.js +405 -0
- package/package.json +61 -0
package/dist/env.js
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { resolve, dirname } from "path";
|
|
2
|
+
import { fileURLToPath } from "url";
|
|
3
|
+
import { existsSync, readFileSync, mkdirSync, writeFileSync } from "fs";
|
|
4
|
+
import { homedir } from "os";
|
|
5
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
6
|
+
const PKG_ROOT = resolve(__dirname, "..");
|
|
7
|
+
export const GLOBAL_DIR = resolve(homedir(), ".pm-skill");
|
|
8
|
+
/**
|
|
9
|
+
* Resolve a file by checking multiple directories in order:
|
|
10
|
+
* 1. CWD (project-local override)
|
|
11
|
+
* 2. ~/.pm-skill/ (user-global config)
|
|
12
|
+
* 3. Package root (bundled defaults)
|
|
13
|
+
*/
|
|
14
|
+
export function resolveFile(filename) {
|
|
15
|
+
const candidates = [
|
|
16
|
+
resolve(process.cwd(), filename),
|
|
17
|
+
resolve(GLOBAL_DIR, filename),
|
|
18
|
+
resolve(PKG_ROOT, filename),
|
|
19
|
+
];
|
|
20
|
+
for (const p of candidates) {
|
|
21
|
+
if (existsSync(p))
|
|
22
|
+
return p;
|
|
23
|
+
}
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
const REQUIRED_KEYS = ["LINEAR_API_KEY", "LINEAR_DEFAULT_TEAM_ID"];
|
|
27
|
+
const NOTION_KEYS = ["NOTION_API_KEY", "NOTION_ROOT_PAGE_ID"];
|
|
28
|
+
const KEY_HELP = {
|
|
29
|
+
LINEAR_API_KEY: "Linear > Settings > API > Personal API Keys",
|
|
30
|
+
LINEAR_DEFAULT_TEAM_ID: "'pm-skill init' or 'pm-skill setup' to discover your team ID",
|
|
31
|
+
LINEAR_DEFAULT_PROJECT_ID: "Linear > Project Settings (optional)",
|
|
32
|
+
NOTION_API_KEY: "https://www.notion.so/my-integrations",
|
|
33
|
+
NOTION_ROOT_PAGE_ID: "Parent page ID where Notion docs will be created",
|
|
34
|
+
NOTION_BUG_DB_ID: "Notion DB ID for bug tracking (optional — falls back to page creation)",
|
|
35
|
+
};
|
|
36
|
+
/**
|
|
37
|
+
* Parse a single .env file and set values into process.env.
|
|
38
|
+
* Does NOT overwrite existing values (earlier loads take precedence).
|
|
39
|
+
*/
|
|
40
|
+
function parseEnvFile(filePath) {
|
|
41
|
+
const content = readFileSync(filePath, "utf-8");
|
|
42
|
+
for (const line of content.split("\n")) {
|
|
43
|
+
const trimmed = line.trim();
|
|
44
|
+
if (!trimmed || trimmed.startsWith("#"))
|
|
45
|
+
continue;
|
|
46
|
+
const eqIdx = trimmed.indexOf("=");
|
|
47
|
+
if (eqIdx === -1)
|
|
48
|
+
continue;
|
|
49
|
+
const key = trimmed.slice(0, eqIdx).trim();
|
|
50
|
+
const val = trimmed.slice(eqIdx + 1).trim();
|
|
51
|
+
if (!process.env[key]) {
|
|
52
|
+
process.env[key] = val;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Hierarchical .env loading:
|
|
58
|
+
* 1. CWD/.env (project-specific, highest priority)
|
|
59
|
+
* 2. ~/.pm-skill/.env (global defaults, fills in missing keys)
|
|
60
|
+
*
|
|
61
|
+
* Both files are loaded. CWD values take precedence over global.
|
|
62
|
+
*/
|
|
63
|
+
export function loadEnvFiles() {
|
|
64
|
+
const cwdEnv = resolve(process.cwd(), ".env");
|
|
65
|
+
const globalEnv = resolve(GLOBAL_DIR, ".env");
|
|
66
|
+
// CWD first — these values win
|
|
67
|
+
if (existsSync(cwdEnv))
|
|
68
|
+
parseEnvFile(cwdEnv);
|
|
69
|
+
// Global second — fills in anything CWD didn't set
|
|
70
|
+
if (existsSync(globalEnv))
|
|
71
|
+
parseEnvFile(globalEnv);
|
|
72
|
+
}
|
|
73
|
+
export function validateEnv(command) {
|
|
74
|
+
loadEnvFiles();
|
|
75
|
+
const missing = [];
|
|
76
|
+
if (command === "setup" || command === "init") {
|
|
77
|
+
if (!process.env.LINEAR_API_KEY) {
|
|
78
|
+
missing.push("LINEAR_API_KEY");
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
else {
|
|
82
|
+
for (const key of REQUIRED_KEYS) {
|
|
83
|
+
if (!process.env[key])
|
|
84
|
+
missing.push(key);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
const notionCommands = ["start-feature", "report-bug"];
|
|
88
|
+
if (notionCommands.includes(command)) {
|
|
89
|
+
for (const key of NOTION_KEYS) {
|
|
90
|
+
if (!process.env[key])
|
|
91
|
+
missing.push(key);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
if (missing.length > 0) {
|
|
95
|
+
const hints = missing
|
|
96
|
+
.map((k) => ` ${k}: ${KEY_HELP[k] ?? ""}`)
|
|
97
|
+
.join("\n");
|
|
98
|
+
throw new Error(`Required environment variables are not set:\n${hints}\n\n` +
|
|
99
|
+
`Run 'pm-skill init' to set up, or create .env manually.\n` +
|
|
100
|
+
`Config lookup: CWD/.env → ~/.pm-skill/.env (both loaded, CWD wins).`);
|
|
101
|
+
}
|
|
102
|
+
return {
|
|
103
|
+
LINEAR_API_KEY: process.env.LINEAR_API_KEY,
|
|
104
|
+
LINEAR_DEFAULT_TEAM_ID: process.env.LINEAR_DEFAULT_TEAM_ID,
|
|
105
|
+
LINEAR_DEFAULT_PROJECT_ID: process.env.LINEAR_DEFAULT_PROJECT_ID,
|
|
106
|
+
NOTION_API_KEY: process.env.NOTION_API_KEY,
|
|
107
|
+
NOTION_ROOT_PAGE_ID: process.env.NOTION_ROOT_PAGE_ID,
|
|
108
|
+
NOTION_BUG_DB_ID: process.env.NOTION_BUG_DB_ID,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* Write key=value pairs to a .env file.
|
|
113
|
+
* If the file exists, updates existing keys and appends new ones.
|
|
114
|
+
* If not, creates it fresh.
|
|
115
|
+
*/
|
|
116
|
+
export function writeEnvFile(targetDir, entries) {
|
|
117
|
+
mkdirSync(targetDir, { recursive: true });
|
|
118
|
+
const envPath = resolve(targetDir, ".env");
|
|
119
|
+
const existing = new Map();
|
|
120
|
+
const lines = [];
|
|
121
|
+
if (existsSync(envPath)) {
|
|
122
|
+
const content = readFileSync(envPath, "utf-8");
|
|
123
|
+
for (const line of content.split("\n")) {
|
|
124
|
+
const trimmed = line.trim();
|
|
125
|
+
if (!trimmed || trimmed.startsWith("#")) {
|
|
126
|
+
lines.push(line);
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
const eqIdx = trimmed.indexOf("=");
|
|
130
|
+
if (eqIdx === -1) {
|
|
131
|
+
lines.push(line);
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
const key = trimmed.slice(0, eqIdx).trim();
|
|
135
|
+
existing.set(key, trimmed);
|
|
136
|
+
// Will be replaced or kept
|
|
137
|
+
if (key in entries) {
|
|
138
|
+
lines.push(`${key}=${entries[key]}`);
|
|
139
|
+
}
|
|
140
|
+
else {
|
|
141
|
+
lines.push(line);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
// Append new keys not already in file
|
|
146
|
+
for (const [key, val] of Object.entries(entries)) {
|
|
147
|
+
if (!existing.has(key)) {
|
|
148
|
+
lines.push(`${key}=${val}`);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
writeFileSync(envPath, lines.join("\n") + "\n", "utf-8");
|
|
152
|
+
return envPath;
|
|
153
|
+
}
|
package/dist/linear.d.ts
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { LinearClient, type Issue, type IssueLabel, type WorkflowState, type Team } from "@linear/sdk";
|
|
2
|
+
export declare function getLinearClient(apiKey: string): LinearClient;
|
|
3
|
+
/**
|
|
4
|
+
* Validate a Linear API key by calling viewer endpoint.
|
|
5
|
+
* Returns user info on success, throws on failure.
|
|
6
|
+
*/
|
|
7
|
+
export declare function validateLinearKey(apiKey: string): Promise<{
|
|
8
|
+
id: string;
|
|
9
|
+
name: string;
|
|
10
|
+
email: string;
|
|
11
|
+
}>;
|
|
12
|
+
export interface CreateIssueOpts {
|
|
13
|
+
teamId: string;
|
|
14
|
+
title: string;
|
|
15
|
+
description?: string;
|
|
16
|
+
priority?: number;
|
|
17
|
+
labelIds?: string[];
|
|
18
|
+
parentId?: string;
|
|
19
|
+
projectId?: string;
|
|
20
|
+
stateId?: string;
|
|
21
|
+
}
|
|
22
|
+
export interface IssueDetail {
|
|
23
|
+
issue: Issue;
|
|
24
|
+
children: Issue[];
|
|
25
|
+
relations: Array<{
|
|
26
|
+
type: string;
|
|
27
|
+
issue: Issue;
|
|
28
|
+
}>;
|
|
29
|
+
attachments: Array<{
|
|
30
|
+
title: string;
|
|
31
|
+
url: string;
|
|
32
|
+
subtitle?: string | null;
|
|
33
|
+
}>;
|
|
34
|
+
}
|
|
35
|
+
export declare function createIssue(client: LinearClient, opts: CreateIssueOpts): Promise<Issue>;
|
|
36
|
+
export declare function updateIssue(client: LinearClient, id: string, input: Record<string, unknown>): Promise<void>;
|
|
37
|
+
export declare function getIssue(client: LinearClient, identifier: string): Promise<Issue>;
|
|
38
|
+
export declare function getIssueDetail(client: LinearClient, identifier: string): Promise<IssueDetail>;
|
|
39
|
+
export declare function createRelation(client: LinearClient, issueId: string, relatedIssueId: string, type: "blocks" | "related" | "similar"): Promise<void>;
|
|
40
|
+
export declare function createAttachment(client: LinearClient, issueId: string, url: string, title: string, subtitle?: string): Promise<void>;
|
|
41
|
+
export declare function getTeams(client: LinearClient): Promise<Team[]>;
|
|
42
|
+
export declare function getTeamStates(client: LinearClient, teamId: string): Promise<WorkflowState[]>;
|
|
43
|
+
export declare function getTeamLabels(client: LinearClient, teamId: string): Promise<IssueLabel[]>;
|
|
44
|
+
/**
|
|
45
|
+
* config의 label name으로 Linear 워크스페이스 라벨 ID를 매칭합니다.
|
|
46
|
+
* 대소문자 무시 매칭. 미매칭 시 에러.
|
|
47
|
+
*/
|
|
48
|
+
export declare function resolveLabels(configLabelNames: string[], teamLabels: IssueLabel[]): string[];
|
package/dist/linear.js
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { LinearClient, IssueRelationType } from "@linear/sdk";
|
|
2
|
+
// ── Client ──
|
|
3
|
+
let _client = null;
|
|
4
|
+
export function getLinearClient(apiKey) {
|
|
5
|
+
if (!_client) {
|
|
6
|
+
_client = new LinearClient({ apiKey });
|
|
7
|
+
}
|
|
8
|
+
return _client;
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Validate a Linear API key by calling viewer endpoint.
|
|
12
|
+
* Returns user info on success, throws on failure.
|
|
13
|
+
*/
|
|
14
|
+
export async function validateLinearKey(apiKey) {
|
|
15
|
+
const client = new LinearClient({ apiKey });
|
|
16
|
+
try {
|
|
17
|
+
const viewer = await client.viewer;
|
|
18
|
+
return { id: viewer.id, name: viewer.name, email: viewer.email };
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
throw new Error("Linear API key validation failed. Check your key at: Linear > Settings > API > Personal API Keys");
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
// ── Issue CRUD ──
|
|
25
|
+
export async function createIssue(client, opts) {
|
|
26
|
+
const payload = await client.createIssue({
|
|
27
|
+
teamId: opts.teamId,
|
|
28
|
+
title: opts.title,
|
|
29
|
+
description: opts.description,
|
|
30
|
+
priority: opts.priority,
|
|
31
|
+
labelIds: opts.labelIds,
|
|
32
|
+
parentId: opts.parentId,
|
|
33
|
+
projectId: opts.projectId,
|
|
34
|
+
stateId: opts.stateId,
|
|
35
|
+
});
|
|
36
|
+
const issue = await payload.issue;
|
|
37
|
+
if (!issue) {
|
|
38
|
+
throw new Error("이슈 생성에 실패했습니다.");
|
|
39
|
+
}
|
|
40
|
+
return issue;
|
|
41
|
+
}
|
|
42
|
+
export async function updateIssue(client, id, input) {
|
|
43
|
+
await client.updateIssue(id, input);
|
|
44
|
+
}
|
|
45
|
+
export async function getIssue(client, identifier) {
|
|
46
|
+
try {
|
|
47
|
+
return await client.issue(identifier);
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
throw new Error(`이슈 '${identifier}'을 찾을 수 없습니다.`);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
export async function getIssueDetail(client, identifier) {
|
|
54
|
+
const issue = await getIssue(client, identifier);
|
|
55
|
+
const [childrenConn, relationsConn, attachmentsConn] = await Promise.all([
|
|
56
|
+
issue.children(),
|
|
57
|
+
issue.relations(),
|
|
58
|
+
issue.attachments(),
|
|
59
|
+
]);
|
|
60
|
+
const relations = [];
|
|
61
|
+
for (const rel of relationsConn.nodes) {
|
|
62
|
+
const relatedIssue = await rel.relatedIssue;
|
|
63
|
+
if (relatedIssue) {
|
|
64
|
+
relations.push({ type: rel.type, issue: relatedIssue });
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return {
|
|
68
|
+
issue,
|
|
69
|
+
children: childrenConn.nodes,
|
|
70
|
+
relations,
|
|
71
|
+
attachments: attachmentsConn.nodes.map((a) => ({
|
|
72
|
+
title: a.title,
|
|
73
|
+
url: a.url,
|
|
74
|
+
subtitle: a.subtitle,
|
|
75
|
+
})),
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
// ── Relations ──
|
|
79
|
+
const RELATION_TYPE_MAP = {
|
|
80
|
+
blocks: IssueRelationType.Blocks,
|
|
81
|
+
related: IssueRelationType.Related,
|
|
82
|
+
similar: IssueRelationType.Similar,
|
|
83
|
+
duplicate: IssueRelationType.Duplicate,
|
|
84
|
+
};
|
|
85
|
+
export async function createRelation(client, issueId, relatedIssueId, type) {
|
|
86
|
+
const relationType = RELATION_TYPE_MAP[type];
|
|
87
|
+
if (!relationType) {
|
|
88
|
+
throw new Error(`알 수 없는 관계 유형: '${type}'`);
|
|
89
|
+
}
|
|
90
|
+
await client.createIssueRelation({
|
|
91
|
+
issueId,
|
|
92
|
+
relatedIssueId,
|
|
93
|
+
type: relationType,
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
// ── Attachments ──
|
|
97
|
+
export async function createAttachment(client, issueId, url, title, subtitle) {
|
|
98
|
+
await client.createAttachment({
|
|
99
|
+
issueId,
|
|
100
|
+
url,
|
|
101
|
+
title,
|
|
102
|
+
subtitle,
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
// ── Team / Labels / States ──
|
|
106
|
+
export async function getTeams(client) {
|
|
107
|
+
const conn = await client.teams();
|
|
108
|
+
return conn.nodes;
|
|
109
|
+
}
|
|
110
|
+
export async function getTeamStates(client, teamId) {
|
|
111
|
+
const team = await client.team(teamId);
|
|
112
|
+
const conn = await team.states();
|
|
113
|
+
return conn.nodes;
|
|
114
|
+
}
|
|
115
|
+
export async function getTeamLabels(client, teamId) {
|
|
116
|
+
const team = await client.team(teamId);
|
|
117
|
+
const conn = await team.labels();
|
|
118
|
+
return conn.nodes;
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* config의 label name으로 Linear 워크스페이스 라벨 ID를 매칭합니다.
|
|
122
|
+
* 대소문자 무시 매칭. 미매칭 시 에러.
|
|
123
|
+
*/
|
|
124
|
+
export function resolveLabels(configLabelNames, teamLabels) {
|
|
125
|
+
const ids = [];
|
|
126
|
+
const labelMap = new Map(teamLabels.map((l) => [l.name.toLowerCase(), l.id]));
|
|
127
|
+
for (const name of configLabelNames) {
|
|
128
|
+
const id = labelMap.get(name.toLowerCase());
|
|
129
|
+
if (!id) {
|
|
130
|
+
const available = teamLabels.map((l) => l.name).join(", ");
|
|
131
|
+
throw new Error(`Linear 워크스페이스에서 라벨 '${name}'을 찾을 수 없습니다.\n` +
|
|
132
|
+
`사용 가능한 라벨: ${available}\n` +
|
|
133
|
+
`'setup' 커맨드로 라벨 매칭 상태를 확인하세요.`);
|
|
134
|
+
}
|
|
135
|
+
ids.push(id);
|
|
136
|
+
}
|
|
137
|
+
return ids;
|
|
138
|
+
}
|
package/dist/notion.d.ts
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { Client } from "@notionhq/client";
|
|
2
|
+
import type { BlockObjectRequest } from "@notionhq/client/build/src/api-endpoints.js";
|
|
3
|
+
export declare function getNotionClient(apiKey: string): Client;
|
|
4
|
+
/**
|
|
5
|
+
* Validate a Notion API key by calling users.me endpoint.
|
|
6
|
+
* Returns bot info on success, throws on failure.
|
|
7
|
+
*/
|
|
8
|
+
export declare function validateNotionKey(apiKey: string): Promise<{
|
|
9
|
+
id: string;
|
|
10
|
+
name: string;
|
|
11
|
+
}>;
|
|
12
|
+
export declare function buildFeaturePRD(title: string, linearUrl: string): BlockObjectRequest[];
|
|
13
|
+
export declare function buildBugReport(title: string, severity: string, linearUrl: string): BlockObjectRequest[];
|
|
14
|
+
export declare function buildDesignDoc(title: string, linearUrl: string): BlockObjectRequest[];
|
|
15
|
+
export declare function buildImprovement(title: string, linearUrl: string): BlockObjectRequest[];
|
|
16
|
+
export declare function buildRefactor(title: string, linearUrl: string): BlockObjectRequest[];
|
|
17
|
+
export declare function getTemplateBlocks(notionTemplate: string, title: string, linearUrl: string, severity?: string): BlockObjectRequest[];
|
|
18
|
+
export declare function createPage(client: Client, parentPageId: string, title: string, blocks: BlockObjectRequest[]): Promise<{
|
|
19
|
+
id: string;
|
|
20
|
+
url: string;
|
|
21
|
+
}>;
|
|
22
|
+
export declare function createDatabaseEntry(client: Client, databaseId: string, properties: Record<string, unknown>): Promise<{
|
|
23
|
+
id: string;
|
|
24
|
+
url: string;
|
|
25
|
+
}>;
|
|
26
|
+
export declare function createTemplatedPage(client: Client, parentPageId: string, notionTemplate: string, title: string, linearUrl: string, severity?: string): Promise<{
|
|
27
|
+
id: string;
|
|
28
|
+
url: string;
|
|
29
|
+
}>;
|
|
30
|
+
export declare function getPage(client: Client, pageId: string): Promise<Record<string, unknown>>;
|
|
31
|
+
export declare function searchPages(client: Client, query: string): Promise<Array<{
|
|
32
|
+
id: string;
|
|
33
|
+
title: string;
|
|
34
|
+
}>>;
|
package/dist/notion.js
ADDED
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
import { Client } from "@notionhq/client";
|
|
2
|
+
// ── Client ──
|
|
3
|
+
let _client = null;
|
|
4
|
+
export function getNotionClient(apiKey) {
|
|
5
|
+
if (!_client) {
|
|
6
|
+
_client = new Client({ auth: apiKey });
|
|
7
|
+
}
|
|
8
|
+
return _client;
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Validate a Notion API key by calling users.me endpoint.
|
|
12
|
+
* Returns bot info on success, throws on failure.
|
|
13
|
+
*/
|
|
14
|
+
export async function validateNotionKey(apiKey) {
|
|
15
|
+
const client = new Client({ auth: apiKey });
|
|
16
|
+
try {
|
|
17
|
+
const me = await client.users.me({});
|
|
18
|
+
return { id: me.id, name: me.name ?? "(unnamed integration)" };
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
throw new Error("Notion API key validation failed. Check your key at: https://www.notion.so/my-integrations");
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
// ── Block Builders ──
|
|
25
|
+
function text(content) {
|
|
26
|
+
return [{ type: "text", text: { content } }];
|
|
27
|
+
}
|
|
28
|
+
function heading1(content) {
|
|
29
|
+
return {
|
|
30
|
+
object: "block",
|
|
31
|
+
type: "heading_1",
|
|
32
|
+
heading_1: { rich_text: text(content) },
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
function heading2(content) {
|
|
36
|
+
return {
|
|
37
|
+
object: "block",
|
|
38
|
+
type: "heading_2",
|
|
39
|
+
heading_2: { rich_text: text(content) },
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
function paragraph(content) {
|
|
43
|
+
return {
|
|
44
|
+
object: "block",
|
|
45
|
+
type: "paragraph",
|
|
46
|
+
paragraph: { rich_text: text(content) },
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
function todo(content, checked = false) {
|
|
50
|
+
return {
|
|
51
|
+
object: "block",
|
|
52
|
+
type: "to_do",
|
|
53
|
+
to_do: { rich_text: text(content), checked },
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
function numberedItem(content) {
|
|
57
|
+
return {
|
|
58
|
+
object: "block",
|
|
59
|
+
type: "numbered_list_item",
|
|
60
|
+
numbered_list_item: { rich_text: text(content) },
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
function bookmark(url) {
|
|
64
|
+
return {
|
|
65
|
+
object: "block",
|
|
66
|
+
type: "bookmark",
|
|
67
|
+
bookmark: { url },
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
function divider() {
|
|
71
|
+
return {
|
|
72
|
+
object: "block",
|
|
73
|
+
type: "divider",
|
|
74
|
+
divider: {},
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
// ── Template Builders ──
|
|
78
|
+
export function buildFeaturePRD(title, linearUrl) {
|
|
79
|
+
return [
|
|
80
|
+
heading2(`📋 ${title}`),
|
|
81
|
+
bookmark(linearUrl),
|
|
82
|
+
divider(),
|
|
83
|
+
heading1("목표"),
|
|
84
|
+
paragraph(""),
|
|
85
|
+
heading1("배경"),
|
|
86
|
+
paragraph(""),
|
|
87
|
+
heading1("요구사항"),
|
|
88
|
+
todo("요구사항 1"),
|
|
89
|
+
todo("요구사항 2"),
|
|
90
|
+
todo("요구사항 3"),
|
|
91
|
+
heading1("설계"),
|
|
92
|
+
paragraph(""),
|
|
93
|
+
heading1("테스트 계획"),
|
|
94
|
+
paragraph(""),
|
|
95
|
+
];
|
|
96
|
+
}
|
|
97
|
+
export function buildBugReport(title, severity, linearUrl) {
|
|
98
|
+
return [
|
|
99
|
+
heading2(`🐛 ${title} (${severity})`),
|
|
100
|
+
bookmark(linearUrl),
|
|
101
|
+
divider(),
|
|
102
|
+
heading1("재현 단계"),
|
|
103
|
+
numberedItem("단계 1"),
|
|
104
|
+
numberedItem("단계 2"),
|
|
105
|
+
numberedItem("단계 3"),
|
|
106
|
+
heading1("예상 동작"),
|
|
107
|
+
paragraph(""),
|
|
108
|
+
heading1("실제 동작"),
|
|
109
|
+
paragraph(""),
|
|
110
|
+
heading1("환경"),
|
|
111
|
+
paragraph("OS / 브라우저 / 디바이스"),
|
|
112
|
+
heading1("해결 방안"),
|
|
113
|
+
paragraph(""),
|
|
114
|
+
];
|
|
115
|
+
}
|
|
116
|
+
export function buildDesignDoc(title, linearUrl) {
|
|
117
|
+
return [
|
|
118
|
+
heading2(`📐 ${title}`),
|
|
119
|
+
bookmark(linearUrl),
|
|
120
|
+
divider(),
|
|
121
|
+
heading1("개요"),
|
|
122
|
+
paragraph(""),
|
|
123
|
+
heading1("제약 조건"),
|
|
124
|
+
paragraph(""),
|
|
125
|
+
heading1("옵션 비교"),
|
|
126
|
+
paragraph(""),
|
|
127
|
+
heading1("결정"),
|
|
128
|
+
paragraph(""),
|
|
129
|
+
heading1("후속 작업"),
|
|
130
|
+
todo("후속 작업 1"),
|
|
131
|
+
todo("후속 작업 2"),
|
|
132
|
+
];
|
|
133
|
+
}
|
|
134
|
+
export function buildImprovement(title, linearUrl) {
|
|
135
|
+
return [
|
|
136
|
+
heading2(`✨ ${title}`),
|
|
137
|
+
bookmark(linearUrl),
|
|
138
|
+
divider(),
|
|
139
|
+
heading1("현재 상태"),
|
|
140
|
+
paragraph(""),
|
|
141
|
+
heading1("개선 목표"),
|
|
142
|
+
paragraph(""),
|
|
143
|
+
heading1("변경 사항"),
|
|
144
|
+
todo("변경 1"),
|
|
145
|
+
todo("변경 2"),
|
|
146
|
+
todo("변경 3"),
|
|
147
|
+
heading1("영향 범위"),
|
|
148
|
+
paragraph(""),
|
|
149
|
+
];
|
|
150
|
+
}
|
|
151
|
+
export function buildRefactor(title, linearUrl) {
|
|
152
|
+
return [
|
|
153
|
+
heading2(`🔧 ${title}`),
|
|
154
|
+
bookmark(linearUrl),
|
|
155
|
+
divider(),
|
|
156
|
+
heading1("리팩토링 대상"),
|
|
157
|
+
paragraph(""),
|
|
158
|
+
heading1("현재 문제점"),
|
|
159
|
+
paragraph(""),
|
|
160
|
+
heading1("변경 계획"),
|
|
161
|
+
todo("단계 1"),
|
|
162
|
+
todo("단계 2"),
|
|
163
|
+
heading1("검증 방법"),
|
|
164
|
+
paragraph(""),
|
|
165
|
+
];
|
|
166
|
+
}
|
|
167
|
+
// ── Template Dispatcher ──
|
|
168
|
+
const TEMPLATE_BUILDERS = {
|
|
169
|
+
"feature-prd": buildFeaturePRD,
|
|
170
|
+
"bug-report": (t, u, s) => buildBugReport(t, s ?? "medium", u),
|
|
171
|
+
"design-doc": buildDesignDoc,
|
|
172
|
+
improvement: buildImprovement,
|
|
173
|
+
refactor: buildRefactor,
|
|
174
|
+
};
|
|
175
|
+
export function getTemplateBlocks(notionTemplate, title, linearUrl, severity) {
|
|
176
|
+
const builder = TEMPLATE_BUILDERS[notionTemplate];
|
|
177
|
+
if (!builder) {
|
|
178
|
+
const available = Object.keys(TEMPLATE_BUILDERS).join(", ");
|
|
179
|
+
throw new Error(`notion_template '${notionTemplate}'에 대한 빌더가 없습니다.\n등록된 템플릿: ${available}`);
|
|
180
|
+
}
|
|
181
|
+
return builder(title, linearUrl, severity);
|
|
182
|
+
}
|
|
183
|
+
// ── Page CRUD ──
|
|
184
|
+
export async function createPage(client, parentPageId, title, blocks) {
|
|
185
|
+
const response = await client.pages.create({
|
|
186
|
+
parent: { page_id: parentPageId },
|
|
187
|
+
properties: {
|
|
188
|
+
title: { title: [{ text: { content: title } }] },
|
|
189
|
+
},
|
|
190
|
+
children: blocks,
|
|
191
|
+
});
|
|
192
|
+
return {
|
|
193
|
+
id: response.id,
|
|
194
|
+
url: `https://notion.so/${response.id.replace(/-/g, "")}`,
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
export async function createDatabaseEntry(client, databaseId, properties) {
|
|
198
|
+
const response = await client.pages.create({
|
|
199
|
+
parent: { database_id: databaseId },
|
|
200
|
+
properties: properties,
|
|
201
|
+
});
|
|
202
|
+
return {
|
|
203
|
+
id: response.id,
|
|
204
|
+
url: `https://notion.so/${response.id.replace(/-/g, "")}`,
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
export async function createTemplatedPage(client, parentPageId, notionTemplate, title, linearUrl, severity) {
|
|
208
|
+
const blocks = getTemplateBlocks(notionTemplate, title, linearUrl, severity);
|
|
209
|
+
return createPage(client, parentPageId, title, blocks);
|
|
210
|
+
}
|
|
211
|
+
export async function getPage(client, pageId) {
|
|
212
|
+
return (await client.pages.retrieve({ page_id: pageId }));
|
|
213
|
+
}
|
|
214
|
+
export async function searchPages(client, query) {
|
|
215
|
+
const response = await client.search({
|
|
216
|
+
query,
|
|
217
|
+
filter: { property: "object", value: "page" },
|
|
218
|
+
});
|
|
219
|
+
return response.results.map((page) => ({
|
|
220
|
+
id: page.id,
|
|
221
|
+
title: page.properties?.title?.title?.[0]?.text?.content ??
|
|
222
|
+
page.properties?.Name?.title?.[0]?.text?.content ??
|
|
223
|
+
"(untitled)",
|
|
224
|
+
}));
|
|
225
|
+
}
|