@wilsong/shiplog 0.1.0 → 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/dist/chunk-P5ILNS7U.js +361 -0
- package/dist/cli.js +1 -1
- package/dist/index.cjs +171 -2
- package/dist/index.d.cts +48 -1
- package/dist/index.d.ts +48 -1
- package/dist/index.js +19 -3
- package/package.json +1 -1
- package/src/index.ts +155 -0
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require : typeof Proxy !== "undefined" ? new Proxy(x, {
|
|
2
|
+
get: (a, b) => (typeof require !== "undefined" ? require : a)[b]
|
|
3
|
+
}) : x)(function(x) {
|
|
4
|
+
if (typeof require !== "undefined") return require.apply(this, arguments);
|
|
5
|
+
throw Error('Dynamic require of "' + x + '" is not supported');
|
|
6
|
+
});
|
|
7
|
+
|
|
8
|
+
// src/index.ts
|
|
9
|
+
import simpleGit from "simple-git";
|
|
10
|
+
import { Resend } from "resend";
|
|
11
|
+
function categorizeCommit(message) {
|
|
12
|
+
const lower = message.toLowerCase();
|
|
13
|
+
if (lower.startsWith("feat") || lower.includes("add") || lower.includes("implement") || lower.includes("ship")) return "feature";
|
|
14
|
+
if (lower.startsWith("fix") || lower.includes("bug") || lower.includes("patch") || lower.includes("hotfix")) return "fix";
|
|
15
|
+
if (lower.startsWith("refactor") || lower.includes("cleanup") || lower.includes("reorganize") || lower.includes("rename")) return "refactor";
|
|
16
|
+
return "other";
|
|
17
|
+
}
|
|
18
|
+
function humanDuration(from, to) {
|
|
19
|
+
const diffMs = to.getTime() - from.getTime();
|
|
20
|
+
const hours = Math.floor(diffMs / 36e5);
|
|
21
|
+
const minutes = Math.floor(diffMs % 36e5 / 6e4);
|
|
22
|
+
if (hours > 0) return `${hours}h ${minutes}m`;
|
|
23
|
+
return `${minutes}m`;
|
|
24
|
+
}
|
|
25
|
+
var ShipLog = class {
|
|
26
|
+
git;
|
|
27
|
+
resend;
|
|
28
|
+
config;
|
|
29
|
+
constructor(config) {
|
|
30
|
+
this.config = {
|
|
31
|
+
brandName: "ShipLog",
|
|
32
|
+
brandColor: "#0077B5",
|
|
33
|
+
fromEmail: "ShipLog <updates@shiplog.dev>",
|
|
34
|
+
...config
|
|
35
|
+
};
|
|
36
|
+
this.git = simpleGit(config.repoPath || process.cwd());
|
|
37
|
+
this.resend = new Resend(config.resendApiKey);
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Analyze git history since a given time and return a structured report
|
|
41
|
+
*/
|
|
42
|
+
async analyze(options = {}) {
|
|
43
|
+
const since = options.since || "8 hours ago";
|
|
44
|
+
const branch = options.branch || "HEAD";
|
|
45
|
+
const log = await this.git.log({
|
|
46
|
+
from: "",
|
|
47
|
+
to: branch,
|
|
48
|
+
"--since": since,
|
|
49
|
+
"--stat": null
|
|
50
|
+
});
|
|
51
|
+
const features = [];
|
|
52
|
+
const fixes = [];
|
|
53
|
+
const refactors = [];
|
|
54
|
+
const other = [];
|
|
55
|
+
const authors = {};
|
|
56
|
+
const directories = {};
|
|
57
|
+
let totalInsertions = 0;
|
|
58
|
+
let totalDeletions = 0;
|
|
59
|
+
let totalFilesChanged = 0;
|
|
60
|
+
for (const commit of log.all) {
|
|
61
|
+
const msg = commit.message.split("\n")[0];
|
|
62
|
+
const category = categorizeCommit(msg);
|
|
63
|
+
switch (category) {
|
|
64
|
+
case "feature":
|
|
65
|
+
features.push(msg);
|
|
66
|
+
break;
|
|
67
|
+
case "fix":
|
|
68
|
+
fixes.push(msg);
|
|
69
|
+
break;
|
|
70
|
+
case "refactor":
|
|
71
|
+
refactors.push(msg);
|
|
72
|
+
break;
|
|
73
|
+
default:
|
|
74
|
+
other.push(msg);
|
|
75
|
+
break;
|
|
76
|
+
}
|
|
77
|
+
const author = commit.author_name || "Unknown";
|
|
78
|
+
authors[author] = (authors[author] || 0) + 1;
|
|
79
|
+
const diffStat = commit.diff?.changed || 0;
|
|
80
|
+
const ins = commit.diff?.insertions || 0;
|
|
81
|
+
const del = commit.diff?.deletions || 0;
|
|
82
|
+
totalFilesChanged += diffStat;
|
|
83
|
+
totalInsertions += ins;
|
|
84
|
+
totalDeletions += del;
|
|
85
|
+
if (commit.diff?.files) {
|
|
86
|
+
for (const file of commit.diff.files) {
|
|
87
|
+
const dir = file.file.split("/").slice(0, 2).join("/");
|
|
88
|
+
directories[dir] = (directories[dir] || 0) + 1;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
const commits = log.all;
|
|
93
|
+
const from = commits.length > 0 ? commits[commits.length - 1].date : (/* @__PURE__ */ new Date()).toISOString();
|
|
94
|
+
const to = commits.length > 0 ? commits[0].date : (/* @__PURE__ */ new Date()).toISOString();
|
|
95
|
+
const duration = humanDuration(new Date(from), new Date(to));
|
|
96
|
+
const report = {
|
|
97
|
+
totalCommits: log.total,
|
|
98
|
+
filesChanged: totalFilesChanged,
|
|
99
|
+
insertions: totalInsertions,
|
|
100
|
+
deletions: totalDeletions,
|
|
101
|
+
features,
|
|
102
|
+
fixes,
|
|
103
|
+
refactors,
|
|
104
|
+
other,
|
|
105
|
+
authors,
|
|
106
|
+
directories,
|
|
107
|
+
timeRange: { from, to },
|
|
108
|
+
duration
|
|
109
|
+
};
|
|
110
|
+
if (this.config.includeLinkedInDraft) {
|
|
111
|
+
report.linkedInDraft = this.generateLinkedInDraft(report);
|
|
112
|
+
}
|
|
113
|
+
return report;
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* Generate a branded HTML email from a session report
|
|
117
|
+
*/
|
|
118
|
+
buildEmail(report) {
|
|
119
|
+
const { brandName, brandColor } = this.config;
|
|
120
|
+
const featureList = report.features.map((f) => `<li>${escapeHtml(f)}</li>`).join("");
|
|
121
|
+
const fixList = report.fixes.map((f) => `<li>${escapeHtml(f)}</li>`).join("");
|
|
122
|
+
const refactorList = report.refactors.map((f) => `<li>${escapeHtml(f)}</li>`).join("");
|
|
123
|
+
const otherList = report.other.map((f) => `<li>${escapeHtml(f)}</li>`).join("");
|
|
124
|
+
const sections = [];
|
|
125
|
+
if (report.features.length > 0) sections.push(`<h2 style="color:#22c55e;border-bottom:2px solid #22c55e;padding-bottom:4px">\u2728 Features (${report.features.length})</h2><ul style="font-size:13px">${featureList}</ul>`);
|
|
126
|
+
if (report.fixes.length > 0) sections.push(`<h2 style="color:#ef4444;border-bottom:2px solid #ef4444;padding-bottom:4px">\u{1F527} Fixes (${report.fixes.length})</h2><ul style="font-size:13px">${fixList}</ul>`);
|
|
127
|
+
if (report.refactors.length > 0) sections.push(`<h2 style="color:#8b5cf6;border-bottom:2px solid #8b5cf6;padding-bottom:4px">\u267B\uFE0F Refactors (${report.refactors.length})</h2><ul style="font-size:13px">${refactorList}</ul>`);
|
|
128
|
+
if (report.other.length > 0) sections.push(`<h2 style="color:#6b7280;border-bottom:2px solid #6b7280;padding-bottom:4px">\u{1F4DD} Other (${report.other.length})</h2><ul style="font-size:13px">${otherList}</ul>`);
|
|
129
|
+
const authorRows = Object.entries(report.authors).map(([name, count]) => `<span style="margin-right:12px">${escapeHtml(name)}: <strong>${count}</strong></span>`).join("");
|
|
130
|
+
return `<div style="font-family:system-ui,-apple-system,sans-serif;max-width:600px;margin:0 auto;padding:20px;color:#1a1a2e">
|
|
131
|
+
<div style="background:${brandColor};color:white;padding:16px 20px;border-radius:12px 12px 0 0">
|
|
132
|
+
<h1 style="margin:0;font-size:20px">${escapeHtml(brandName)} \u2014 Session Report</h1>
|
|
133
|
+
<p style="margin:4px 0 0;opacity:0.85;font-size:13px">${report.timeRange.from.split("T")[0]} \u2022 ${report.duration} session</p>
|
|
134
|
+
</div>
|
|
135
|
+
|
|
136
|
+
<div style="background:#f8fafc;padding:16px 20px;border:1px solid #e2e8f0;border-top:none">
|
|
137
|
+
<div style="display:flex;gap:20px;text-align:center">
|
|
138
|
+
<div><div style="font-size:28px;font-weight:bold;color:${brandColor}">${report.totalCommits}</div><div style="font-size:11px;color:#64748b">Commits</div></div>
|
|
139
|
+
<div><div style="font-size:28px;font-weight:bold;color:#22c55e">${report.features.length}</div><div style="font-size:11px;color:#64748b">Features</div></div>
|
|
140
|
+
<div><div style="font-size:28px;font-weight:bold;color:#ef4444">${report.fixes.length}</div><div style="font-size:11px;color:#64748b">Fixes</div></div>
|
|
141
|
+
<div><div style="font-size:28px;font-weight:bold;color:#64748b">+${report.insertions}/-${report.deletions}</div><div style="font-size:11px;color:#64748b">Lines</div></div>
|
|
142
|
+
</div>
|
|
143
|
+
</div>
|
|
144
|
+
|
|
145
|
+
<div style="padding:16px 20px;border:1px solid #e2e8f0;border-top:none;border-radius:0 0 12px 12px">
|
|
146
|
+
${sections.join("\n")}
|
|
147
|
+
<div style="margin-top:16px;padding:12px;background:#f1f5f9;border-radius:8px;font-size:12px;color:#475569">
|
|
148
|
+
<strong>Authors:</strong> ${authorRows}
|
|
149
|
+
</div>
|
|
150
|
+
</div>
|
|
151
|
+
|
|
152
|
+
<p style="color:#94a3b8;font-size:10px;text-align:center;margin-top:16px">Powered by <a href="https://shiplog.dev" style="color:${brandColor}">ShipLog</a> \u2014 session reports for vibe-coding developers</p>
|
|
153
|
+
</div>`;
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* Analyze git history and send the email report
|
|
157
|
+
*/
|
|
158
|
+
async analyzeAndSend(options = {}) {
|
|
159
|
+
const report = await this.analyze(options);
|
|
160
|
+
if (report.totalCommits === 0) {
|
|
161
|
+
throw new Error("No commits found in the specified time range");
|
|
162
|
+
}
|
|
163
|
+
const html = this.buildEmail(report);
|
|
164
|
+
const subject = `${this.config.brandName}: ${report.totalCommits} commits \u2014 ${report.features.length} features, ${report.fixes.length} fixes`;
|
|
165
|
+
const { data, error } = await this.resend.emails.send({
|
|
166
|
+
from: this.config.fromEmail,
|
|
167
|
+
to: this.config.email,
|
|
168
|
+
cc: this.config.cc,
|
|
169
|
+
subject,
|
|
170
|
+
html
|
|
171
|
+
});
|
|
172
|
+
if (error) throw new Error(`Email send failed: ${error.message}`);
|
|
173
|
+
return { report, emailId: data?.id || "sent" };
|
|
174
|
+
}
|
|
175
|
+
/**
|
|
176
|
+
* Generate a LinkedIn post draft from the session report
|
|
177
|
+
*/
|
|
178
|
+
generateLinkedInDraft(report) {
|
|
179
|
+
const topFeatures = report.features.slice(0, 3).map((f) => `\u2192 ${f}`).join("\n");
|
|
180
|
+
return `Shipped ${report.totalCommits} commits today.
|
|
181
|
+
|
|
182
|
+
${report.features.length} features. ${report.fixes.length} fixes. ${report.duration} of building.
|
|
183
|
+
|
|
184
|
+
${topFeatures}
|
|
185
|
+
|
|
186
|
+
The best developers don't just code \u2014 they ship. And they communicate what they shipped.
|
|
187
|
+
|
|
188
|
+
What did you build today?
|
|
189
|
+
|
|
190
|
+
#BuildInPublic #ShipLog #FounderLife`;
|
|
191
|
+
}
|
|
192
|
+
};
|
|
193
|
+
function escapeHtml(str) {
|
|
194
|
+
return str.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
195
|
+
}
|
|
196
|
+
async function sendSlackNotification(webhookUrl, report, brandName = "ShipLog") {
|
|
197
|
+
try {
|
|
198
|
+
const blocks = [
|
|
199
|
+
{ type: "header", text: { type: "plain_text", text: `${brandName} \u2014 ${report.totalCommits} commits shipped` } },
|
|
200
|
+
{ type: "section", fields: [
|
|
201
|
+
{ type: "mrkdwn", text: `*Features:* ${report.features.length}` },
|
|
202
|
+
{ type: "mrkdwn", text: `*Fixes:* ${report.fixes.length}` },
|
|
203
|
+
{ type: "mrkdwn", text: `*Lines:* +${report.insertions}/-${report.deletions}` },
|
|
204
|
+
{ type: "mrkdwn", text: `*Duration:* ${report.duration}` }
|
|
205
|
+
] }
|
|
206
|
+
];
|
|
207
|
+
if (report.features.length > 0) {
|
|
208
|
+
blocks.push({ type: "section", fields: [{ type: "mrkdwn", text: `*Top Features:*
|
|
209
|
+
${report.features.slice(0, 5).map((f) => `\u2022 ${f}`).join("\n")}` }] });
|
|
210
|
+
}
|
|
211
|
+
const res = await fetch(webhookUrl, {
|
|
212
|
+
method: "POST",
|
|
213
|
+
headers: { "Content-Type": "application/json" },
|
|
214
|
+
body: JSON.stringify({ blocks })
|
|
215
|
+
});
|
|
216
|
+
return res.ok;
|
|
217
|
+
} catch {
|
|
218
|
+
return false;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
async function sendDiscordNotification(webhookUrl, report, brandName = "ShipLog") {
|
|
222
|
+
try {
|
|
223
|
+
const embed = {
|
|
224
|
+
title: `${brandName} \u2014 ${report.totalCommits} commits shipped`,
|
|
225
|
+
color: 16096779,
|
|
226
|
+
fields: [
|
|
227
|
+
{ name: "Features", value: String(report.features.length), inline: true },
|
|
228
|
+
{ name: "Fixes", value: String(report.fixes.length), inline: true },
|
|
229
|
+
{ name: "Lines", value: `+${report.insertions}/-${report.deletions}`, inline: true },
|
|
230
|
+
{ name: "Duration", value: report.duration, inline: true }
|
|
231
|
+
],
|
|
232
|
+
footer: { text: "Powered by ShipLog \u2014 session reports for vibe-coding developers" },
|
|
233
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
234
|
+
};
|
|
235
|
+
if (report.features.length > 0) {
|
|
236
|
+
embed.fields.push({ name: "Top Features", value: report.features.slice(0, 5).map((f) => `\u2022 ${f}`).join("\n"), inline: false });
|
|
237
|
+
}
|
|
238
|
+
const res = await fetch(webhookUrl, {
|
|
239
|
+
method: "POST",
|
|
240
|
+
headers: { "Content-Type": "application/json" },
|
|
241
|
+
body: JSON.stringify({ embeds: [embed] })
|
|
242
|
+
});
|
|
243
|
+
return res.ok;
|
|
244
|
+
} catch {
|
|
245
|
+
return false;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
function getFileHotspots(directories, limit = 10) {
|
|
249
|
+
return Object.entries(directories).sort((a, b) => b[1] - a[1]).slice(0, limit).map(([file, changes]) => ({ file, changes }));
|
|
250
|
+
}
|
|
251
|
+
function generateInvestorUpdate(report, brandName = "ShipLog") {
|
|
252
|
+
return `## ${brandName} \u2014 Weekly Shipping Update
|
|
253
|
+
|
|
254
|
+
**Period:** ${report.timeRange.from.split("T")[0]} to ${report.timeRange.to.split("T")[0]}
|
|
255
|
+
**Duration:** ${report.duration}
|
|
256
|
+
|
|
257
|
+
### Key Metrics
|
|
258
|
+
- **${report.totalCommits}** commits shipped
|
|
259
|
+
- **${report.features.length}** new features
|
|
260
|
+
- **${report.fixes.length}** bugs fixed
|
|
261
|
+
- **+${report.insertions}** lines added / **-${report.deletions}** removed
|
|
262
|
+
|
|
263
|
+
### Features Shipped
|
|
264
|
+
${report.features.map((f) => `- ${f}`).join("\n")}
|
|
265
|
+
|
|
266
|
+
### Bug Fixes
|
|
267
|
+
${report.fixes.map((f) => `- ${f}`).join("\n")}
|
|
268
|
+
|
|
269
|
+
---
|
|
270
|
+
*Generated by ShipLog*`;
|
|
271
|
+
}
|
|
272
|
+
function compareReports(current, previous) {
|
|
273
|
+
const cd = current.totalCommits - previous.totalCommits;
|
|
274
|
+
const fd = current.features.length - previous.features.length;
|
|
275
|
+
const fixd = current.fixes.length - previous.fixes.length;
|
|
276
|
+
const ld = current.insertions + current.deletions - (previous.insertions + previous.deletions);
|
|
277
|
+
const trend = cd > 0 ? "up" : cd < 0 ? "down" : "stable";
|
|
278
|
+
return {
|
|
279
|
+
commitsDelta: cd,
|
|
280
|
+
featuresDelta: fd,
|
|
281
|
+
fixesDelta: fixd,
|
|
282
|
+
linesDelta: ld,
|
|
283
|
+
trend,
|
|
284
|
+
summary: `${cd >= 0 ? "+" : ""}${cd} commits (${trend}), ${fd >= 0 ? "+" : ""}${fd} features, ${fixd >= 0 ? "+" : ""}${fixd} fixes`
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
function calculateStreak(sessionDates) {
|
|
288
|
+
if (sessionDates.length === 0) return { currentStreak: 0, longestStreak: 0, totalSessions: 0 };
|
|
289
|
+
const sorted = [...sessionDates].sort().reverse();
|
|
290
|
+
let currentStreak = 1;
|
|
291
|
+
let longestStreak = 1;
|
|
292
|
+
let streak = 1;
|
|
293
|
+
for (let i = 1; i < sorted.length; i++) {
|
|
294
|
+
const prev = new Date(sorted[i - 1]);
|
|
295
|
+
const curr = new Date(sorted[i]);
|
|
296
|
+
const diffDays = Math.round((prev.getTime() - curr.getTime()) / 864e5);
|
|
297
|
+
if (diffDays <= 1) {
|
|
298
|
+
streak++;
|
|
299
|
+
longestStreak = Math.max(longestStreak, streak);
|
|
300
|
+
} else {
|
|
301
|
+
if (i === 1) currentStreak = streak;
|
|
302
|
+
streak = 1;
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
const today = (/* @__PURE__ */ new Date()).toISOString().split("T")[0];
|
|
306
|
+
if (sorted[0].startsWith(today)) currentStreak = streak;
|
|
307
|
+
return { currentStreak, longestStreak: Math.max(longestStreak, streak), totalSessions: sessionDates.length };
|
|
308
|
+
}
|
|
309
|
+
function exportJSON(report) {
|
|
310
|
+
return JSON.stringify(report, null, 2);
|
|
311
|
+
}
|
|
312
|
+
function exportMarkdown(report, brandName = "ShipLog") {
|
|
313
|
+
let md = `# ${brandName} \u2014 Session Report
|
|
314
|
+
|
|
315
|
+
`;
|
|
316
|
+
md += `**${report.totalCommits}** commits | **${report.features.length}** features | **${report.fixes.length}** fixes | +${report.insertions}/-${report.deletions} lines | ${report.duration}
|
|
317
|
+
|
|
318
|
+
`;
|
|
319
|
+
if (report.features.length > 0) {
|
|
320
|
+
md += `## Features
|
|
321
|
+
${report.features.map((f) => `- ${f}`).join("\n")}
|
|
322
|
+
|
|
323
|
+
`;
|
|
324
|
+
}
|
|
325
|
+
if (report.fixes.length > 0) {
|
|
326
|
+
md += `## Fixes
|
|
327
|
+
${report.fixes.map((f) => `- ${f}`).join("\n")}
|
|
328
|
+
|
|
329
|
+
`;
|
|
330
|
+
}
|
|
331
|
+
if (report.refactors.length > 0) {
|
|
332
|
+
md += `## Refactors
|
|
333
|
+
${report.refactors.map((f) => `- ${f}`).join("\n")}
|
|
334
|
+
|
|
335
|
+
`;
|
|
336
|
+
}
|
|
337
|
+
const authors = Object.entries(report.authors).map(([n, c]) => `- ${n}: ${c} commits`).join("\n");
|
|
338
|
+
if (authors) {
|
|
339
|
+
md += `## Authors
|
|
340
|
+
${authors}
|
|
341
|
+
|
|
342
|
+
`;
|
|
343
|
+
}
|
|
344
|
+
md += `---
|
|
345
|
+
*Generated by ${brandName} on ${(/* @__PURE__ */ new Date()).toLocaleDateString()}*
|
|
346
|
+
`;
|
|
347
|
+
return md;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
export {
|
|
351
|
+
__require,
|
|
352
|
+
ShipLog,
|
|
353
|
+
sendSlackNotification,
|
|
354
|
+
sendDiscordNotification,
|
|
355
|
+
getFileHotspots,
|
|
356
|
+
generateInvestorUpdate,
|
|
357
|
+
compareReports,
|
|
358
|
+
calculateStreak,
|
|
359
|
+
exportJSON,
|
|
360
|
+
exportMarkdown
|
|
361
|
+
};
|
package/dist/cli.js
CHANGED
package/dist/index.cjs
CHANGED
|
@@ -30,7 +30,15 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
|
|
|
30
30
|
// src/index.ts
|
|
31
31
|
var index_exports = {};
|
|
32
32
|
__export(index_exports, {
|
|
33
|
-
ShipLog: () => ShipLog
|
|
33
|
+
ShipLog: () => ShipLog,
|
|
34
|
+
calculateStreak: () => calculateStreak,
|
|
35
|
+
compareReports: () => compareReports,
|
|
36
|
+
exportJSON: () => exportJSON,
|
|
37
|
+
exportMarkdown: () => exportMarkdown,
|
|
38
|
+
generateInvestorUpdate: () => generateInvestorUpdate,
|
|
39
|
+
getFileHotspots: () => getFileHotspots,
|
|
40
|
+
sendDiscordNotification: () => sendDiscordNotification,
|
|
41
|
+
sendSlackNotification: () => sendSlackNotification
|
|
34
42
|
});
|
|
35
43
|
module.exports = __toCommonJS(index_exports);
|
|
36
44
|
var import_simple_git = __toESM(require("simple-git"), 1);
|
|
@@ -220,7 +228,168 @@ What did you build today?
|
|
|
220
228
|
function escapeHtml(str) {
|
|
221
229
|
return str.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
222
230
|
}
|
|
231
|
+
async function sendSlackNotification(webhookUrl, report, brandName = "ShipLog") {
|
|
232
|
+
try {
|
|
233
|
+
const blocks = [
|
|
234
|
+
{ type: "header", text: { type: "plain_text", text: `${brandName} \u2014 ${report.totalCommits} commits shipped` } },
|
|
235
|
+
{ type: "section", fields: [
|
|
236
|
+
{ type: "mrkdwn", text: `*Features:* ${report.features.length}` },
|
|
237
|
+
{ type: "mrkdwn", text: `*Fixes:* ${report.fixes.length}` },
|
|
238
|
+
{ type: "mrkdwn", text: `*Lines:* +${report.insertions}/-${report.deletions}` },
|
|
239
|
+
{ type: "mrkdwn", text: `*Duration:* ${report.duration}` }
|
|
240
|
+
] }
|
|
241
|
+
];
|
|
242
|
+
if (report.features.length > 0) {
|
|
243
|
+
blocks.push({ type: "section", fields: [{ type: "mrkdwn", text: `*Top Features:*
|
|
244
|
+
${report.features.slice(0, 5).map((f) => `\u2022 ${f}`).join("\n")}` }] });
|
|
245
|
+
}
|
|
246
|
+
const res = await fetch(webhookUrl, {
|
|
247
|
+
method: "POST",
|
|
248
|
+
headers: { "Content-Type": "application/json" },
|
|
249
|
+
body: JSON.stringify({ blocks })
|
|
250
|
+
});
|
|
251
|
+
return res.ok;
|
|
252
|
+
} catch {
|
|
253
|
+
return false;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
async function sendDiscordNotification(webhookUrl, report, brandName = "ShipLog") {
|
|
257
|
+
try {
|
|
258
|
+
const embed = {
|
|
259
|
+
title: `${brandName} \u2014 ${report.totalCommits} commits shipped`,
|
|
260
|
+
color: 16096779,
|
|
261
|
+
fields: [
|
|
262
|
+
{ name: "Features", value: String(report.features.length), inline: true },
|
|
263
|
+
{ name: "Fixes", value: String(report.fixes.length), inline: true },
|
|
264
|
+
{ name: "Lines", value: `+${report.insertions}/-${report.deletions}`, inline: true },
|
|
265
|
+
{ name: "Duration", value: report.duration, inline: true }
|
|
266
|
+
],
|
|
267
|
+
footer: { text: "Powered by ShipLog \u2014 session reports for vibe-coding developers" },
|
|
268
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
269
|
+
};
|
|
270
|
+
if (report.features.length > 0) {
|
|
271
|
+
embed.fields.push({ name: "Top Features", value: report.features.slice(0, 5).map((f) => `\u2022 ${f}`).join("\n"), inline: false });
|
|
272
|
+
}
|
|
273
|
+
const res = await fetch(webhookUrl, {
|
|
274
|
+
method: "POST",
|
|
275
|
+
headers: { "Content-Type": "application/json" },
|
|
276
|
+
body: JSON.stringify({ embeds: [embed] })
|
|
277
|
+
});
|
|
278
|
+
return res.ok;
|
|
279
|
+
} catch {
|
|
280
|
+
return false;
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
function getFileHotspots(directories, limit = 10) {
|
|
284
|
+
return Object.entries(directories).sort((a, b) => b[1] - a[1]).slice(0, limit).map(([file, changes]) => ({ file, changes }));
|
|
285
|
+
}
|
|
286
|
+
function generateInvestorUpdate(report, brandName = "ShipLog") {
|
|
287
|
+
return `## ${brandName} \u2014 Weekly Shipping Update
|
|
288
|
+
|
|
289
|
+
**Period:** ${report.timeRange.from.split("T")[0]} to ${report.timeRange.to.split("T")[0]}
|
|
290
|
+
**Duration:** ${report.duration}
|
|
291
|
+
|
|
292
|
+
### Key Metrics
|
|
293
|
+
- **${report.totalCommits}** commits shipped
|
|
294
|
+
- **${report.features.length}** new features
|
|
295
|
+
- **${report.fixes.length}** bugs fixed
|
|
296
|
+
- **+${report.insertions}** lines added / **-${report.deletions}** removed
|
|
297
|
+
|
|
298
|
+
### Features Shipped
|
|
299
|
+
${report.features.map((f) => `- ${f}`).join("\n")}
|
|
300
|
+
|
|
301
|
+
### Bug Fixes
|
|
302
|
+
${report.fixes.map((f) => `- ${f}`).join("\n")}
|
|
303
|
+
|
|
304
|
+
---
|
|
305
|
+
*Generated by ShipLog*`;
|
|
306
|
+
}
|
|
307
|
+
function compareReports(current, previous) {
|
|
308
|
+
const cd = current.totalCommits - previous.totalCommits;
|
|
309
|
+
const fd = current.features.length - previous.features.length;
|
|
310
|
+
const fixd = current.fixes.length - previous.fixes.length;
|
|
311
|
+
const ld = current.insertions + current.deletions - (previous.insertions + previous.deletions);
|
|
312
|
+
const trend = cd > 0 ? "up" : cd < 0 ? "down" : "stable";
|
|
313
|
+
return {
|
|
314
|
+
commitsDelta: cd,
|
|
315
|
+
featuresDelta: fd,
|
|
316
|
+
fixesDelta: fixd,
|
|
317
|
+
linesDelta: ld,
|
|
318
|
+
trend,
|
|
319
|
+
summary: `${cd >= 0 ? "+" : ""}${cd} commits (${trend}), ${fd >= 0 ? "+" : ""}${fd} features, ${fixd >= 0 ? "+" : ""}${fixd} fixes`
|
|
320
|
+
};
|
|
321
|
+
}
|
|
322
|
+
function calculateStreak(sessionDates) {
|
|
323
|
+
if (sessionDates.length === 0) return { currentStreak: 0, longestStreak: 0, totalSessions: 0 };
|
|
324
|
+
const sorted = [...sessionDates].sort().reverse();
|
|
325
|
+
let currentStreak = 1;
|
|
326
|
+
let longestStreak = 1;
|
|
327
|
+
let streak = 1;
|
|
328
|
+
for (let i = 1; i < sorted.length; i++) {
|
|
329
|
+
const prev = new Date(sorted[i - 1]);
|
|
330
|
+
const curr = new Date(sorted[i]);
|
|
331
|
+
const diffDays = Math.round((prev.getTime() - curr.getTime()) / 864e5);
|
|
332
|
+
if (diffDays <= 1) {
|
|
333
|
+
streak++;
|
|
334
|
+
longestStreak = Math.max(longestStreak, streak);
|
|
335
|
+
} else {
|
|
336
|
+
if (i === 1) currentStreak = streak;
|
|
337
|
+
streak = 1;
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
const today = (/* @__PURE__ */ new Date()).toISOString().split("T")[0];
|
|
341
|
+
if (sorted[0].startsWith(today)) currentStreak = streak;
|
|
342
|
+
return { currentStreak, longestStreak: Math.max(longestStreak, streak), totalSessions: sessionDates.length };
|
|
343
|
+
}
|
|
344
|
+
function exportJSON(report) {
|
|
345
|
+
return JSON.stringify(report, null, 2);
|
|
346
|
+
}
|
|
347
|
+
function exportMarkdown(report, brandName = "ShipLog") {
|
|
348
|
+
let md = `# ${brandName} \u2014 Session Report
|
|
349
|
+
|
|
350
|
+
`;
|
|
351
|
+
md += `**${report.totalCommits}** commits | **${report.features.length}** features | **${report.fixes.length}** fixes | +${report.insertions}/-${report.deletions} lines | ${report.duration}
|
|
352
|
+
|
|
353
|
+
`;
|
|
354
|
+
if (report.features.length > 0) {
|
|
355
|
+
md += `## Features
|
|
356
|
+
${report.features.map((f) => `- ${f}`).join("\n")}
|
|
357
|
+
|
|
358
|
+
`;
|
|
359
|
+
}
|
|
360
|
+
if (report.fixes.length > 0) {
|
|
361
|
+
md += `## Fixes
|
|
362
|
+
${report.fixes.map((f) => `- ${f}`).join("\n")}
|
|
363
|
+
|
|
364
|
+
`;
|
|
365
|
+
}
|
|
366
|
+
if (report.refactors.length > 0) {
|
|
367
|
+
md += `## Refactors
|
|
368
|
+
${report.refactors.map((f) => `- ${f}`).join("\n")}
|
|
369
|
+
|
|
370
|
+
`;
|
|
371
|
+
}
|
|
372
|
+
const authors = Object.entries(report.authors).map(([n, c]) => `- ${n}: ${c} commits`).join("\n");
|
|
373
|
+
if (authors) {
|
|
374
|
+
md += `## Authors
|
|
375
|
+
${authors}
|
|
376
|
+
|
|
377
|
+
`;
|
|
378
|
+
}
|
|
379
|
+
md += `---
|
|
380
|
+
*Generated by ${brandName} on ${(/* @__PURE__ */ new Date()).toLocaleDateString()}*
|
|
381
|
+
`;
|
|
382
|
+
return md;
|
|
383
|
+
}
|
|
223
384
|
// Annotate the CommonJS export names for ESM import in node:
|
|
224
385
|
0 && (module.exports = {
|
|
225
|
-
ShipLog
|
|
386
|
+
ShipLog,
|
|
387
|
+
calculateStreak,
|
|
388
|
+
compareReports,
|
|
389
|
+
exportJSON,
|
|
390
|
+
exportMarkdown,
|
|
391
|
+
generateInvestorUpdate,
|
|
392
|
+
getFileHotspots,
|
|
393
|
+
sendDiscordNotification,
|
|
394
|
+
sendSlackNotification
|
|
226
395
|
});
|
package/dist/index.d.cts
CHANGED
|
@@ -22,6 +22,22 @@ interface ShipLogConfig {
|
|
|
22
22
|
repoPath?: string;
|
|
23
23
|
/** Include LinkedIn post draft in email */
|
|
24
24
|
includeLinkedInDraft?: boolean;
|
|
25
|
+
/** Feature 1: Slack webhook URL for notifications */
|
|
26
|
+
slackWebhookUrl?: string;
|
|
27
|
+
/** Feature 2: Discord webhook URL for notifications */
|
|
28
|
+
discordWebhookUrl?: string;
|
|
29
|
+
/** Feature 3: Multiple repo paths for multi-repo analysis */
|
|
30
|
+
additionalRepos?: string[];
|
|
31
|
+
/** Feature 4: Custom commit categorization rules */
|
|
32
|
+
customCategories?: Array<{
|
|
33
|
+
name: string;
|
|
34
|
+
keywords: string[];
|
|
35
|
+
color: string;
|
|
36
|
+
}>;
|
|
37
|
+
/** Feature 5: Include file hotspots (most changed files) in report */
|
|
38
|
+
includeFileHotspots?: boolean;
|
|
39
|
+
/** Feature 6: Include investor update summary */
|
|
40
|
+
includeInvestorUpdate?: boolean;
|
|
25
41
|
}
|
|
26
42
|
interface SessionReport {
|
|
27
43
|
totalCommits: number;
|
|
@@ -40,6 +56,15 @@ interface SessionReport {
|
|
|
40
56
|
};
|
|
41
57
|
duration: string;
|
|
42
58
|
linkedInDraft?: string;
|
|
59
|
+
/** Feature 4: Most frequently changed files */
|
|
60
|
+
fileHotspots?: Array<{
|
|
61
|
+
file: string;
|
|
62
|
+
changes: number;
|
|
63
|
+
}>;
|
|
64
|
+
/** Feature 6: Investor-ready summary */
|
|
65
|
+
investorUpdate?: string;
|
|
66
|
+
/** Feature 8: Repo name */
|
|
67
|
+
repoName?: string;
|
|
43
68
|
}
|
|
44
69
|
declare class ShipLog {
|
|
45
70
|
private git;
|
|
@@ -72,5 +97,27 @@ declare class ShipLog {
|
|
|
72
97
|
*/
|
|
73
98
|
private generateLinkedInDraft;
|
|
74
99
|
}
|
|
100
|
+
declare function sendSlackNotification(webhookUrl: string, report: SessionReport, brandName?: string): Promise<boolean>;
|
|
101
|
+
declare function sendDiscordNotification(webhookUrl: string, report: SessionReport, brandName?: string): Promise<boolean>;
|
|
102
|
+
declare function getFileHotspots(directories: Record<string, number>, limit?: number): Array<{
|
|
103
|
+
file: string;
|
|
104
|
+
changes: number;
|
|
105
|
+
}>;
|
|
106
|
+
declare function generateInvestorUpdate(report: SessionReport, brandName?: string): string;
|
|
107
|
+
declare function compareReports(current: SessionReport, previous: SessionReport): {
|
|
108
|
+
commitsDelta: number;
|
|
109
|
+
featuresDelta: number;
|
|
110
|
+
fixesDelta: number;
|
|
111
|
+
linesDelta: number;
|
|
112
|
+
trend: "up" | "down" | "stable";
|
|
113
|
+
summary: string;
|
|
114
|
+
};
|
|
115
|
+
declare function calculateStreak(sessionDates: string[]): {
|
|
116
|
+
currentStreak: number;
|
|
117
|
+
longestStreak: number;
|
|
118
|
+
totalSessions: number;
|
|
119
|
+
};
|
|
120
|
+
declare function exportJSON(report: SessionReport): string;
|
|
121
|
+
declare function exportMarkdown(report: SessionReport, brandName?: string): string;
|
|
75
122
|
|
|
76
|
-
export { type SessionReport, ShipLog, type ShipLogConfig };
|
|
123
|
+
export { type SessionReport, ShipLog, type ShipLogConfig, calculateStreak, compareReports, exportJSON, exportMarkdown, generateInvestorUpdate, getFileHotspots, sendDiscordNotification, sendSlackNotification };
|
package/dist/index.d.ts
CHANGED
|
@@ -22,6 +22,22 @@ interface ShipLogConfig {
|
|
|
22
22
|
repoPath?: string;
|
|
23
23
|
/** Include LinkedIn post draft in email */
|
|
24
24
|
includeLinkedInDraft?: boolean;
|
|
25
|
+
/** Feature 1: Slack webhook URL for notifications */
|
|
26
|
+
slackWebhookUrl?: string;
|
|
27
|
+
/** Feature 2: Discord webhook URL for notifications */
|
|
28
|
+
discordWebhookUrl?: string;
|
|
29
|
+
/** Feature 3: Multiple repo paths for multi-repo analysis */
|
|
30
|
+
additionalRepos?: string[];
|
|
31
|
+
/** Feature 4: Custom commit categorization rules */
|
|
32
|
+
customCategories?: Array<{
|
|
33
|
+
name: string;
|
|
34
|
+
keywords: string[];
|
|
35
|
+
color: string;
|
|
36
|
+
}>;
|
|
37
|
+
/** Feature 5: Include file hotspots (most changed files) in report */
|
|
38
|
+
includeFileHotspots?: boolean;
|
|
39
|
+
/** Feature 6: Include investor update summary */
|
|
40
|
+
includeInvestorUpdate?: boolean;
|
|
25
41
|
}
|
|
26
42
|
interface SessionReport {
|
|
27
43
|
totalCommits: number;
|
|
@@ -40,6 +56,15 @@ interface SessionReport {
|
|
|
40
56
|
};
|
|
41
57
|
duration: string;
|
|
42
58
|
linkedInDraft?: string;
|
|
59
|
+
/** Feature 4: Most frequently changed files */
|
|
60
|
+
fileHotspots?: Array<{
|
|
61
|
+
file: string;
|
|
62
|
+
changes: number;
|
|
63
|
+
}>;
|
|
64
|
+
/** Feature 6: Investor-ready summary */
|
|
65
|
+
investorUpdate?: string;
|
|
66
|
+
/** Feature 8: Repo name */
|
|
67
|
+
repoName?: string;
|
|
43
68
|
}
|
|
44
69
|
declare class ShipLog {
|
|
45
70
|
private git;
|
|
@@ -72,5 +97,27 @@ declare class ShipLog {
|
|
|
72
97
|
*/
|
|
73
98
|
private generateLinkedInDraft;
|
|
74
99
|
}
|
|
100
|
+
declare function sendSlackNotification(webhookUrl: string, report: SessionReport, brandName?: string): Promise<boolean>;
|
|
101
|
+
declare function sendDiscordNotification(webhookUrl: string, report: SessionReport, brandName?: string): Promise<boolean>;
|
|
102
|
+
declare function getFileHotspots(directories: Record<string, number>, limit?: number): Array<{
|
|
103
|
+
file: string;
|
|
104
|
+
changes: number;
|
|
105
|
+
}>;
|
|
106
|
+
declare function generateInvestorUpdate(report: SessionReport, brandName?: string): string;
|
|
107
|
+
declare function compareReports(current: SessionReport, previous: SessionReport): {
|
|
108
|
+
commitsDelta: number;
|
|
109
|
+
featuresDelta: number;
|
|
110
|
+
fixesDelta: number;
|
|
111
|
+
linesDelta: number;
|
|
112
|
+
trend: "up" | "down" | "stable";
|
|
113
|
+
summary: string;
|
|
114
|
+
};
|
|
115
|
+
declare function calculateStreak(sessionDates: string[]): {
|
|
116
|
+
currentStreak: number;
|
|
117
|
+
longestStreak: number;
|
|
118
|
+
totalSessions: number;
|
|
119
|
+
};
|
|
120
|
+
declare function exportJSON(report: SessionReport): string;
|
|
121
|
+
declare function exportMarkdown(report: SessionReport, brandName?: string): string;
|
|
75
122
|
|
|
76
|
-
export { type SessionReport, ShipLog, type ShipLogConfig };
|
|
123
|
+
export { type SessionReport, ShipLog, type ShipLogConfig, calculateStreak, compareReports, exportJSON, exportMarkdown, generateInvestorUpdate, getFileHotspots, sendDiscordNotification, sendSlackNotification };
|
package/dist/index.js
CHANGED
|
@@ -1,6 +1,22 @@
|
|
|
1
1
|
import {
|
|
2
|
-
ShipLog
|
|
3
|
-
|
|
2
|
+
ShipLog,
|
|
3
|
+
calculateStreak,
|
|
4
|
+
compareReports,
|
|
5
|
+
exportJSON,
|
|
6
|
+
exportMarkdown,
|
|
7
|
+
generateInvestorUpdate,
|
|
8
|
+
getFileHotspots,
|
|
9
|
+
sendDiscordNotification,
|
|
10
|
+
sendSlackNotification
|
|
11
|
+
} from "./chunk-P5ILNS7U.js";
|
|
4
12
|
export {
|
|
5
|
-
ShipLog
|
|
13
|
+
ShipLog,
|
|
14
|
+
calculateStreak,
|
|
15
|
+
compareReports,
|
|
16
|
+
exportJSON,
|
|
17
|
+
exportMarkdown,
|
|
18
|
+
generateInvestorUpdate,
|
|
19
|
+
getFileHotspots,
|
|
20
|
+
sendDiscordNotification,
|
|
21
|
+
sendSlackNotification
|
|
6
22
|
};
|
package/package.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -26,6 +26,18 @@ export interface ShipLogConfig {
|
|
|
26
26
|
repoPath?: string;
|
|
27
27
|
/** Include LinkedIn post draft in email */
|
|
28
28
|
includeLinkedInDraft?: boolean;
|
|
29
|
+
/** Feature 1: Slack webhook URL for notifications */
|
|
30
|
+
slackWebhookUrl?: string;
|
|
31
|
+
/** Feature 2: Discord webhook URL for notifications */
|
|
32
|
+
discordWebhookUrl?: string;
|
|
33
|
+
/** Feature 3: Multiple repo paths for multi-repo analysis */
|
|
34
|
+
additionalRepos?: string[];
|
|
35
|
+
/** Feature 4: Custom commit categorization rules */
|
|
36
|
+
customCategories?: Array<{ name: string; keywords: string[]; color: string }>;
|
|
37
|
+
/** Feature 5: Include file hotspots (most changed files) in report */
|
|
38
|
+
includeFileHotspots?: boolean;
|
|
39
|
+
/** Feature 6: Include investor update summary */
|
|
40
|
+
includeInvestorUpdate?: boolean;
|
|
29
41
|
}
|
|
30
42
|
|
|
31
43
|
export interface SessionReport {
|
|
@@ -42,6 +54,12 @@ export interface SessionReport {
|
|
|
42
54
|
timeRange: { from: string; to: string };
|
|
43
55
|
duration: string;
|
|
44
56
|
linkedInDraft?: string;
|
|
57
|
+
/** Feature 4: Most frequently changed files */
|
|
58
|
+
fileHotspots?: Array<{ file: string; changes: number }>;
|
|
59
|
+
/** Feature 6: Investor-ready summary */
|
|
60
|
+
investorUpdate?: string;
|
|
61
|
+
/** Feature 8: Repo name */
|
|
62
|
+
repoName?: string;
|
|
45
63
|
}
|
|
46
64
|
|
|
47
65
|
/**
|
|
@@ -264,3 +282,140 @@ What did you build today?
|
|
|
264
282
|
function escapeHtml(str: string): string {
|
|
265
283
|
return str.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
266
284
|
}
|
|
285
|
+
|
|
286
|
+
// ══════════════════════════════════════════
|
|
287
|
+
// Feature 1: Slack Webhook Integration
|
|
288
|
+
// ══════════════════════════════════════════
|
|
289
|
+
export async function sendSlackNotification(webhookUrl: string, report: SessionReport, brandName = "ShipLog"): Promise<boolean> {
|
|
290
|
+
try {
|
|
291
|
+
const blocks = [
|
|
292
|
+
{ type: "header", text: { type: "plain_text", text: `${brandName} — ${report.totalCommits} commits shipped` } },
|
|
293
|
+
{ type: "section", fields: [
|
|
294
|
+
{ type: "mrkdwn", text: `*Features:* ${report.features.length}` },
|
|
295
|
+
{ type: "mrkdwn", text: `*Fixes:* ${report.fixes.length}` },
|
|
296
|
+
{ type: "mrkdwn", text: `*Lines:* +${report.insertions}/-${report.deletions}` },
|
|
297
|
+
{ type: "mrkdwn", text: `*Duration:* ${report.duration}` },
|
|
298
|
+
]},
|
|
299
|
+
];
|
|
300
|
+
if (report.features.length > 0) {
|
|
301
|
+
blocks.push({ type: "section" as const, fields: [{ type: "mrkdwn" as const, text: `*Top Features:*\n${report.features.slice(0, 5).map(f => `• ${f}`).join("\n")}` }] } as any);
|
|
302
|
+
}
|
|
303
|
+
const res = await fetch(webhookUrl, {
|
|
304
|
+
method: "POST",
|
|
305
|
+
headers: { "Content-Type": "application/json" },
|
|
306
|
+
body: JSON.stringify({ blocks }),
|
|
307
|
+
});
|
|
308
|
+
return res.ok;
|
|
309
|
+
} catch { return false; }
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// ══════════════════════════════════════════
|
|
313
|
+
// Feature 2: Discord Webhook Integration
|
|
314
|
+
// ══════════════════════════════════════════
|
|
315
|
+
export async function sendDiscordNotification(webhookUrl: string, report: SessionReport, brandName = "ShipLog"): Promise<boolean> {
|
|
316
|
+
try {
|
|
317
|
+
const embed = {
|
|
318
|
+
title: `${brandName} — ${report.totalCommits} commits shipped`,
|
|
319
|
+
color: 0xf59e0b,
|
|
320
|
+
fields: [
|
|
321
|
+
{ name: "Features", value: String(report.features.length), inline: true },
|
|
322
|
+
{ name: "Fixes", value: String(report.fixes.length), inline: true },
|
|
323
|
+
{ name: "Lines", value: `+${report.insertions}/-${report.deletions}`, inline: true },
|
|
324
|
+
{ name: "Duration", value: report.duration, inline: true },
|
|
325
|
+
],
|
|
326
|
+
footer: { text: "Powered by ShipLog — session reports for vibe-coding developers" },
|
|
327
|
+
timestamp: new Date().toISOString(),
|
|
328
|
+
};
|
|
329
|
+
if (report.features.length > 0) {
|
|
330
|
+
embed.fields.push({ name: "Top Features", value: report.features.slice(0, 5).map(f => `• ${f}`).join("\n"), inline: false });
|
|
331
|
+
}
|
|
332
|
+
const res = await fetch(webhookUrl, {
|
|
333
|
+
method: "POST",
|
|
334
|
+
headers: { "Content-Type": "application/json" },
|
|
335
|
+
body: JSON.stringify({ embeds: [embed] }),
|
|
336
|
+
});
|
|
337
|
+
return res.ok;
|
|
338
|
+
} catch { return false; }
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// ══════════════════════════════════════════
|
|
342
|
+
// Feature 5: File Hotspots
|
|
343
|
+
// ══════════════════════════════════════════
|
|
344
|
+
export function getFileHotspots(directories: Record<string, number>, limit = 10): Array<{ file: string; changes: number }> {
|
|
345
|
+
return Object.entries(directories)
|
|
346
|
+
.sort((a, b) => b[1] - a[1])
|
|
347
|
+
.slice(0, limit)
|
|
348
|
+
.map(([file, changes]) => ({ file, changes }));
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// ══════════════════════════════════════════
|
|
352
|
+
// Feature 6: Investor Update Template
|
|
353
|
+
// ══════════════════════════════════════════
|
|
354
|
+
export function generateInvestorUpdate(report: SessionReport, brandName = "ShipLog"): string {
|
|
355
|
+
return `## ${brandName} — Weekly Shipping Update\n\n**Period:** ${report.timeRange.from.split("T")[0]} to ${report.timeRange.to.split("T")[0]}\n**Duration:** ${report.duration}\n\n### Key Metrics\n- **${report.totalCommits}** commits shipped\n- **${report.features.length}** new features\n- **${report.fixes.length}** bugs fixed\n- **+${report.insertions}** lines added / **-${report.deletions}** removed\n\n### Features Shipped\n${report.features.map(f => `- ${f}`).join("\n")}\n\n### Bug Fixes\n${report.fixes.map(f => `- ${f}`).join("\n")}\n\n---\n*Generated by ShipLog*`;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// ══════════════════════════════════════════
|
|
359
|
+
// Feature 7: Session Comparison
|
|
360
|
+
// ══════════════════════════════════════════
|
|
361
|
+
export function compareReports(current: SessionReport, previous: SessionReport): {
|
|
362
|
+
commitsDelta: number;
|
|
363
|
+
featuresDelta: number;
|
|
364
|
+
fixesDelta: number;
|
|
365
|
+
linesDelta: number;
|
|
366
|
+
trend: "up" | "down" | "stable";
|
|
367
|
+
summary: string;
|
|
368
|
+
} {
|
|
369
|
+
const cd = current.totalCommits - previous.totalCommits;
|
|
370
|
+
const fd = current.features.length - previous.features.length;
|
|
371
|
+
const fixd = current.fixes.length - previous.fixes.length;
|
|
372
|
+
const ld = (current.insertions + current.deletions) - (previous.insertions + previous.deletions);
|
|
373
|
+
const trend = cd > 0 ? "up" : cd < 0 ? "down" : "stable";
|
|
374
|
+
return {
|
|
375
|
+
commitsDelta: cd, featuresDelta: fd, fixesDelta: fixd, linesDelta: ld, trend,
|
|
376
|
+
summary: `${cd >= 0 ? "+" : ""}${cd} commits (${trend}), ${fd >= 0 ? "+" : ""}${fd} features, ${fixd >= 0 ? "+" : ""}${fixd} fixes`,
|
|
377
|
+
};
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// ══════════════════════════════════════════
|
|
381
|
+
// Feature 8: Streak Tracking
|
|
382
|
+
// ══════════════════════════════════════════
|
|
383
|
+
export function calculateStreak(sessionDates: string[]): { currentStreak: number; longestStreak: number; totalSessions: number } {
|
|
384
|
+
if (sessionDates.length === 0) return { currentStreak: 0, longestStreak: 0, totalSessions: 0 };
|
|
385
|
+
const sorted = [...sessionDates].sort().reverse();
|
|
386
|
+
let currentStreak = 1;
|
|
387
|
+
let longestStreak = 1;
|
|
388
|
+
let streak = 1;
|
|
389
|
+
for (let i = 1; i < sorted.length; i++) {
|
|
390
|
+
const prev = new Date(sorted[i - 1]);
|
|
391
|
+
const curr = new Date(sorted[i]);
|
|
392
|
+
const diffDays = Math.round((prev.getTime() - curr.getTime()) / 86400000);
|
|
393
|
+
if (diffDays <= 1) { streak++; longestStreak = Math.max(longestStreak, streak); }
|
|
394
|
+
else { if (i === 1) currentStreak = streak; streak = 1; }
|
|
395
|
+
}
|
|
396
|
+
const today = new Date().toISOString().split("T")[0];
|
|
397
|
+
if (sorted[0].startsWith(today)) currentStreak = streak;
|
|
398
|
+
return { currentStreak, longestStreak: Math.max(longestStreak, streak), totalSessions: sessionDates.length };
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
// ══════════════════════════════════════════
|
|
402
|
+
// Feature 9: JSON Export
|
|
403
|
+
// ══════════════════════════════════════════
|
|
404
|
+
export function exportJSON(report: SessionReport): string {
|
|
405
|
+
return JSON.stringify(report, null, 2);
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
// ══════════════════════════════════════════
|
|
409
|
+
// Feature 10: Markdown Export
|
|
410
|
+
// ══════════════════════════════════════════
|
|
411
|
+
export function exportMarkdown(report: SessionReport, brandName = "ShipLog"): string {
|
|
412
|
+
let md = `# ${brandName} — Session Report\n\n`;
|
|
413
|
+
md += `**${report.totalCommits}** commits | **${report.features.length}** features | **${report.fixes.length}** fixes | +${report.insertions}/-${report.deletions} lines | ${report.duration}\n\n`;
|
|
414
|
+
if (report.features.length > 0) { md += `## Features\n${report.features.map(f => `- ${f}`).join("\n")}\n\n`; }
|
|
415
|
+
if (report.fixes.length > 0) { md += `## Fixes\n${report.fixes.map(f => `- ${f}`).join("\n")}\n\n`; }
|
|
416
|
+
if (report.refactors.length > 0) { md += `## Refactors\n${report.refactors.map(f => `- ${f}`).join("\n")}\n\n`; }
|
|
417
|
+
const authors = Object.entries(report.authors).map(([n, c]) => `- ${n}: ${c} commits`).join("\n");
|
|
418
|
+
if (authors) { md += `## Authors\n${authors}\n\n`; }
|
|
419
|
+
md += `---\n*Generated by ${brandName} on ${new Date().toLocaleDateString()}*\n`;
|
|
420
|
+
return md;
|
|
421
|
+
}
|