@zzclub/pipeline 0.10.2 → 0.11.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.
@@ -1,240 +0,0 @@
1
- import { readFile } from "fs/promises";
2
- import { dirname, isAbsolute, resolve } from "path";
3
- import { ensureDb } from "../db";
4
- import { AnalyticsSchema, type Analytics } from "../schema/analytics";
5
- import { parseArgs, requireArg, optionalArg } from "../args";
6
- import { printResult } from "../output";
7
-
8
- export interface RecordAnalyticsInput {
9
- statePath: string;
10
- reads?: number;
11
- likes?: number;
12
- favorites?: number;
13
- shares?: number;
14
- comments?: number;
15
- notes?: string;
16
- }
17
-
18
- function resolveWorkspace(statePath: string): string {
19
- const postsIdx = statePath.lastIndexOf("/posts/");
20
- if (postsIdx !== -1) {
21
- return statePath.substring(0, postsIdx);
22
- }
23
- return dirname(statePath);
24
- }
25
-
26
- export async function recordAnalytics(
27
- input: RecordAnalyticsInput,
28
- ): Promise<Analytics> {
29
- const requestedPath = resolve(input.statePath);
30
- let statePath = requestedPath;
31
- let state = JSON.parse(await readFile(statePath, "utf-8"));
32
- if (typeof state.state_path === "string" && state.state_path.trim()) {
33
- const candidatePath = isAbsolute(state.state_path)
34
- ? resolve(state.state_path)
35
- : resolve(dirname(statePath), state.state_path);
36
- if (candidatePath !== statePath) {
37
- try {
38
- state = JSON.parse(await readFile(candidatePath, "utf-8"));
39
- statePath = candidatePath;
40
- } catch (error) {
41
- const code = error instanceof Error && "code" in error
42
- ? String((error as NodeJS.ErrnoException).code)
43
- : "";
44
- if (code !== "ENOENT") throw error;
45
- }
46
- }
47
- }
48
-
49
- const workspace = state.workspace_root || resolveWorkspace(statePath);
50
- const db = ensureDb(workspace);
51
-
52
- try {
53
- const now = new Date().toISOString();
54
- const publishDate = state.metadata?.date || now.split("T")[0];
55
- const title = state.metadata?.title || "Untitled";
56
- const topicId = state.topic_id || null;
57
-
58
- db.prepare(
59
- `INSERT OR REPLACE INTO analytics (
60
- run_id, topic_id, title, publish_date,
61
- reads, likes, favorites, shares, comments,
62
- notes, recorded_at
63
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
64
- ).run(
65
- state.run_id,
66
- topicId,
67
- title,
68
- publishDate,
69
- input.reads ?? 0,
70
- input.likes ?? 0,
71
- input.favorites ?? 0,
72
- input.shares ?? 0,
73
- input.comments ?? 0,
74
- input.notes ?? null,
75
- now,
76
- );
77
-
78
- const row = db
79
- .prepare("SELECT * FROM analytics WHERE run_id = ?")
80
- .get(state.run_id) as Record<string, unknown>;
81
- return AnalyticsSchema.parse(row);
82
- } finally {
83
- db.close();
84
- }
85
- }
86
-
87
- export interface ListAnalyticsFilter {
88
- days?: number;
89
- sort?: string;
90
- limit?: number;
91
- }
92
-
93
- const ALLOWED_SORT_FIELDS = new Set([
94
- "publish_date",
95
- "reads",
96
- "likes",
97
- "favorites",
98
- "shares",
99
- "comments",
100
- "recorded_at",
101
- "title",
102
- ]);
103
-
104
- export async function listAnalytics(
105
- workspace: string,
106
- filter: ListAnalyticsFilter,
107
- ): Promise<Analytics[]> {
108
- const db = ensureDb(workspace);
109
-
110
- try {
111
- const conditions: string[] = [];
112
- const params: (string | number)[] = [];
113
-
114
- if (filter.days) {
115
- conditions.push("publish_date >= date('now', ?)");
116
- params.push(`-${filter.days} days`);
117
- }
118
-
119
- const where =
120
- conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
121
-
122
- const sortField = filter.sort || "publish_date";
123
- if (!ALLOWED_SORT_FIELDS.has(sortField)) {
124
- throw new Error(
125
- `Invalid sort field: ${sortField}. Allowed: ${[...ALLOWED_SORT_FIELDS].join(", ")}`,
126
- );
127
- }
128
-
129
- const limit = filter.limit ?? 50;
130
-
131
- const sql = `SELECT * FROM analytics ${where} ORDER BY ${sortField} DESC LIMIT ?`;
132
- params.push(limit);
133
-
134
- const rows = db
135
- .prepare(sql)
136
- .all(...params) as Record<string, unknown>[];
137
- return rows.map((row) => AnalyticsSchema.parse(row));
138
- } finally {
139
- db.close();
140
- }
141
- }
142
-
143
- // CLI handler
144
- export async function analytics(args: string[]): Promise<void> {
145
- const parsed = parseArgs(args);
146
-
147
- if (parsed.help || args.length === 0) {
148
- console.log(`
149
- Usage: zzhub-pipeline analytics <subcommand> [options]
150
-
151
- Subcommands:
152
- record Record post-publish metrics
153
- list List analytics history
154
-
155
- Run 'zzhub-pipeline analytics <subcommand> --help' for details.
156
- `.trim());
157
- return;
158
- }
159
-
160
- const subcommand = args[0];
161
- const subArgs = args.slice(1);
162
-
163
- switch (subcommand) {
164
- case "record":
165
- return analyticsRecord(subArgs);
166
- case "list":
167
- return analyticsList(subArgs);
168
- default:
169
- throw new Error(`Unknown analytics subcommand: ${subcommand}`);
170
- }
171
- }
172
-
173
- async function analyticsRecord(args: string[]): Promise<void> {
174
- const parsed = parseArgs(args);
175
-
176
- if (parsed.help) {
177
- console.log(`
178
- Usage: zzhub-pipeline analytics record [options]
179
-
180
- Options:
181
- --state State file path (required)
182
- --reads Read count
183
- --likes Like count
184
- --favorites Favorite count
185
- --shares Share count
186
- --comments Comment count
187
- --notes Notes
188
- `.trim());
189
- return;
190
- }
191
-
192
- const statePath = requireArg(parsed, "state", "State file path");
193
- const readsStr = optionalArg(parsed, "reads");
194
- const likesStr = optionalArg(parsed, "likes");
195
- const favoritesStr = optionalArg(parsed, "favorites");
196
- const sharesStr = optionalArg(parsed, "shares");
197
- const commentsStr = optionalArg(parsed, "comments");
198
- const notes = optionalArg(parsed, "notes");
199
-
200
- const result = await recordAnalytics({
201
- statePath,
202
- reads: readsStr ? parseInt(readsStr, 10) : undefined,
203
- likes: likesStr ? parseInt(likesStr, 10) : undefined,
204
- favorites: favoritesStr ? parseInt(favoritesStr, 10) : undefined,
205
- shares: sharesStr ? parseInt(sharesStr, 10) : undefined,
206
- comments: commentsStr ? parseInt(commentsStr, 10) : undefined,
207
- notes,
208
- });
209
-
210
- printResult(result);
211
- }
212
-
213
- async function analyticsList(args: string[]): Promise<void> {
214
- const parsed = parseArgs(args);
215
-
216
- if (parsed.help) {
217
- console.log(`
218
- Usage: zzhub-pipeline analytics list [options]
219
-
220
- Options:
221
- --workspace Workspace path (required)
222
- --days Show last N days (default: all)
223
- --sort Sort field: publish_date/reads/likes (default: publish_date)
224
- --limit Max results (default: 50)
225
- --view Output format: json/markdown/agent (default: json)
226
- `.trim());
227
- return;
228
- }
229
-
230
- const workspace = requireArg(parsed, "workspace", "Workspace path");
231
- const daysStr = optionalArg(parsed, "days");
232
- const days = daysStr ? parseInt(daysStr, 10) : undefined;
233
- const sort = optionalArg(parsed, "sort");
234
- const limitStr = optionalArg(parsed, "limit");
235
- const limit = limitStr ? parseInt(limitStr, 10) : undefined;
236
-
237
- const result = await listAnalytics(workspace, { days, sort, limit });
238
-
239
- printResult(result);
240
- }
@@ -1,146 +0,0 @@
1
- import { describe, test, expect, beforeEach, afterEach } from "bun:test";
2
- import { mkdtemp, rm } from "fs/promises";
3
- import { join } from "path";
4
- import { tmpdir } from "os";
5
- import { addTopic, listTopics, updateTopic, scheduleTopic, retroTopic, abandonTopic } from "./topic";
6
-
7
- describe("topic commands", () => {
8
- let workspace: string;
9
-
10
- beforeEach(async () => {
11
- workspace = await mkdtemp(join(tmpdir(), "zzhub-test-"));
12
- });
13
-
14
- afterEach(async () => {
15
- await rm(workspace, { recursive: true, force: true });
16
- });
17
-
18
- describe("addTopic", () => {
19
- test("creates a new topic with defaults", async () => {
20
- const result = await addTopic(workspace, {
21
- title: "Test Topic",
22
- });
23
-
24
- expect(result.topic_id).toMatch(/^topic_/);
25
- expect(result.title).toBe("Test Topic");
26
- expect(result.status).toBe("backlog");
27
- expect(result.priority).toBe("medium");
28
- });
29
-
30
- test("creates topic with all fields", async () => {
31
- const result = await addTopic(workspace, {
32
- title: "AI Tools",
33
- description: "About AI",
34
- priority: "high",
35
- tags: ["AI", "tools"],
36
- notes: "Some notes",
37
- });
38
-
39
- expect(result.priority).toBe("high");
40
- expect(result.tags).toEqual(["AI", "tools"]);
41
- expect(result.notes).toBe("Some notes");
42
- });
43
- });
44
-
45
- describe("listTopics", () => {
46
- test("lists all topics", async () => {
47
- await addTopic(workspace, { title: "Topic 1" });
48
- await addTopic(workspace, { title: "Topic 2" });
49
-
50
- const topics = await listTopics(workspace, {});
51
- expect(topics.length).toBe(2);
52
- });
53
-
54
- test("filters by status", async () => {
55
- await addTopic(workspace, { title: "Topic 1", status: "backlog" });
56
- await addTopic(workspace, { title: "Topic 2", status: "scheduled" });
57
-
58
- const topics = await listTopics(workspace, { status: "backlog" });
59
- expect(topics.length).toBe(1);
60
- expect(topics[0].title).toBe("Topic 1");
61
- });
62
-
63
- test("filters by priority", async () => {
64
- await addTopic(workspace, { title: "Topic 1", priority: "high" });
65
- await addTopic(workspace, { title: "Topic 2", priority: "low" });
66
-
67
- const topics = await listTopics(workspace, { priority: "high" });
68
- expect(topics.length).toBe(1);
69
- });
70
-
71
- test("limits results", async () => {
72
- await addTopic(workspace, { title: "Topic 1" });
73
- await addTopic(workspace, { title: "Topic 2" });
74
- await addTopic(workspace, { title: "Topic 3" });
75
-
76
- const topics = await listTopics(workspace, { limit: 2 });
77
- expect(topics.length).toBe(2);
78
- });
79
- });
80
-
81
- describe("updateTopic", () => {
82
- test("updates topic fields", async () => {
83
- const topic = await addTopic(workspace, { title: "Original" });
84
-
85
- const updated = await updateTopic(workspace, topic.topic_id, {
86
- status: "evaluating",
87
- ai_score: 85,
88
- ai_reason: "Good topic",
89
- });
90
-
91
- expect(updated.status).toBe("evaluating");
92
- expect(updated.ai_score).toBe(85);
93
- expect(updated.ai_reason).toBe("Good topic");
94
- });
95
-
96
- test("throws if topic not found", async () => {
97
- expect(
98
- updateTopic(workspace, "nonexistent", { status: "scheduled" })
99
- ).rejects.toThrow("Topic not found");
100
- });
101
- });
102
-
103
- describe("scheduleTopic", () => {
104
- test("schedules topic with date", async () => {
105
- const topic = await addTopic(workspace, { title: "Test" });
106
-
107
- const scheduled = await scheduleTopic(workspace, topic.topic_id, {
108
- scheduled_date: "2026-06-15",
109
- target_account: "default",
110
- });
111
-
112
- expect(scheduled.status).toBe("scheduled");
113
- expect(scheduled.scheduled_date).toBe("2026-06-15");
114
- expect(scheduled.target_account).toBe("default");
115
- });
116
- });
117
-
118
- describe("retroTopic", () => {
119
- test("adds retro and marks published", async () => {
120
- const topic = await addTopic(workspace, { title: "Test" });
121
-
122
- const retroed = await retroTopic(workspace, topic.topic_id, {
123
- performance: "good",
124
- lessons: "Learned something",
125
- metrics_snapshot: { reads: 1500 },
126
- });
127
-
128
- expect(retroed.status).toBe("published");
129
- expect(retroed.retro_performance).toBe("good");
130
- expect(retroed.retro_lessons).toBe("Learned something");
131
- });
132
- });
133
-
134
- describe("abandonTopic", () => {
135
- test("marks topic as abandoned", async () => {
136
- const topic = await addTopic(workspace, { title: "Test" });
137
-
138
- const abandoned = await abandonTopic(workspace, topic.topic_id, {
139
- reason: "Topic outdated",
140
- });
141
-
142
- expect(abandoned.status).toBe("abandoned");
143
- expect(abandoned.notes).toContain("Topic outdated");
144
- });
145
- });
146
- });