Skip to content

Commit e10e0db

Browse files
committed
add repo, branch, and author metadata to shared plan links
Server extracts git remote, branch, and user.name from the plan file's directory. Metadata travels in the compressed share URL and displays in the reviewer banner with a link to the repo. Made-with: Cursor
1 parent 888c8e3 commit e10e0db

6 files changed

Lines changed: 106 additions & 6 deletions

File tree

src/server.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,9 @@ import { createServer, type IncomingMessage, type ServerResponse } from "http";
22
import { readFileSync, existsSync, writeFileSync, statSync } from "fs";
33
import { join, dirname, extname } from "path";
44
import { fileURLToPath } from "url";
5+
import { execSync } from "child_process";
56
import { parsePlanFile } from "./parser.js";
7+
import type { PlanMeta } from "./types.js";
68

79
const __dirname = dirname(fileURLToPath(import.meta.url));
810

@@ -50,11 +52,36 @@ export interface ServerOptions {
5052
port?: number;
5153
}
5254

55+
function gitCmd(cwd: string, args: string): string | undefined {
56+
try {
57+
return execSync(`git ${args}`, { cwd, encoding: "utf-8" }).trim();
58+
} catch {
59+
return undefined;
60+
}
61+
}
62+
63+
function getGitMeta(planFile: string): PlanMeta {
64+
const cwd = dirname(planFile);
65+
const meta: PlanMeta = {};
66+
67+
const remoteUrl = gitCmd(cwd, "remote get-url origin");
68+
if (remoteUrl) {
69+
const match = remoteUrl.match(/[/:]([^/]+\/[^/.]+?)(?:\.git)?$/);
70+
meta.repo = match?.[1] ?? remoteUrl;
71+
}
72+
73+
meta.branch = gitCmd(cwd, "rev-parse --abbrev-ref HEAD");
74+
meta.sharedBy = gitCmd(cwd, "config user.name");
75+
76+
return meta;
77+
}
78+
5379
export function startServer(
5480
options: ServerOptions,
5581
): Promise<{ port: number; url: string }> {
5682
return new Promise((resolve, reject) => {
5783
const plan = parsePlanFile(options.planFile);
84+
plan.meta = getGitMeta(options.planFile);
5885
const uiDir = getUiDir();
5986

6087
const server = createServer((req: IncomingMessage, res: ServerResponse) => {

src/types.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,18 @@ export interface PlanTodo {
44
status: "pending" | "in_progress" | "completed" | "cancelled";
55
}
66

7+
export interface PlanMeta {
8+
repo?: string;
9+
branch?: string;
10+
sharedBy?: string;
11+
}
12+
713
export interface ParsedPlan {
814
name: string;
915
overview: string;
1016
todos: PlanTodo[];
1117
isProject: boolean;
1218
body: string;
1319
filePath: string;
20+
meta?: PlanMeta;
1421
}

ui/src/App.tsx

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -930,8 +930,35 @@ export default function App() {
930930

931931
{sharedMode && (
932932
<div className="reviewer-banner">
933-
You're reviewing <strong>{plan.name}</strong>. Add your notes below,
934-
then click <strong>Finish Review</strong> to send it back.
933+
<span>
934+
You're reviewing <strong>{plan.name}</strong>
935+
{plan.meta?.repo && (
936+
<span className="reviewer-meta">
937+
{" "}
938+
in{" "}
939+
<a
940+
href={`https://github.com/${plan.meta.repo}`}
941+
target="_blank"
942+
rel="noopener"
943+
>
944+
{plan.meta.repo}
945+
</a>
946+
{plan.meta.branch && plan.meta.branch !== "main" && (
947+
<> ({plan.meta.branch})</>
948+
)}
949+
</span>
950+
)}
951+
{plan.meta?.sharedBy && (
952+
<span className="reviewer-meta">
953+
{" "}
954+
— shared by {plan.meta.sharedBy}
955+
</span>
956+
)}
957+
</span>
958+
<span>
959+
. Add your notes below, then click <strong>Finish Review</strong> to
960+
send it back.
961+
</span>
935962
</div>
936963
)}
937964

ui/src/app.css

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -229,6 +229,16 @@ body {
229229
color: var(--amber);
230230
}
231231

232+
.reviewer-meta a {
233+
color: var(--blue);
234+
text-decoration: none;
235+
font-weight: 600;
236+
}
237+
238+
.reviewer-meta a:hover {
239+
text-decoration: underline;
240+
}
241+
232242
/* Toast */
233243
/* Finish Review Modal */
234244
.finish-review-backdrop {

ui/src/types.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,20 @@ export interface PlanTodo {
44
status: "pending" | "in_progress" | "completed" | "cancelled";
55
}
66

7+
export interface PlanMeta {
8+
repo?: string;
9+
branch?: string;
10+
sharedBy?: string;
11+
}
12+
713
export interface ParsedPlan {
814
name: string;
915
overview: string;
1016
todos: PlanTodo[];
1117
isProject: boolean;
1218
body: string;
1319
filePath: string;
20+
meta?: PlanMeta;
1421
}
1522

1623
export type AnnotationType =
@@ -37,6 +44,7 @@ export interface SharePayload {
3744
n: string;
3845
o: string;
3946
a: SerializedAnnotation[];
47+
m?: { r?: string; b?: string; s?: string };
4048
}
4149

4250
export type SerializedAnnotation = [string, ...string[]];

ui/src/utils/sharing.ts

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,9 @@
1-
import type { Annotation, SharePayload, SerializedAnnotation } from "../types";
1+
import type {
2+
Annotation,
3+
PlanMeta,
4+
SharePayload,
5+
SerializedAnnotation,
6+
} from "../types";
27

38
function toBase64Url(bytes: Uint8Array): string {
49
return btoa(String.fromCharCode(...bytes))
@@ -134,7 +139,7 @@ function deserializeAnnotation(s: SerializedAnnotation): Annotation {
134139
const SHARE_BASE_URL = "https://ofershap.github.io/cursor-plan-preview/";
135140

136141
export async function encodeShareUrl(
137-
plan: { name: string; overview: string; body: string },
142+
plan: { name: string; overview: string; body: string; meta?: PlanMeta },
138143
annotations: Annotation[],
139144
): Promise<string> {
140145
const payload: SharePayload = {
@@ -143,14 +148,21 @@ export async function encodeShareUrl(
143148
o: plan.overview,
144149
a: annotations.map(serializeAnnotation),
145150
};
151+
if (plan.meta) {
152+
const m: SharePayload["m"] = {};
153+
if (plan.meta.repo) m.r = plan.meta.repo;
154+
if (plan.meta.branch) m.b = plan.meta.branch;
155+
if (plan.meta.sharedBy) m.s = plan.meta.sharedBy;
156+
if (Object.keys(m).length > 0) payload.m = m;
157+
}
146158
const json = JSON.stringify(payload);
147159
const compressed = await compress(json);
148160
const encoded = toBase64Url(compressed);
149161
return `${SHARE_BASE_URL}#${encoded}`;
150162
}
151163

152164
export async function decodeShareUrl(hash: string): Promise<{
153-
plan: { name: string; overview: string; body: string };
165+
plan: { name: string; overview: string; body: string; meta?: PlanMeta };
154166
annotations: Annotation[];
155167
} | null> {
156168
try {
@@ -159,8 +171,17 @@ export async function decodeShareUrl(hash: string): Promise<{
159171
const bytes = fromBase64Url(clean);
160172
const json = await decompress(bytes);
161173
const payload = JSON.parse(json) as SharePayload;
174+
const meta: PlanMeta = {};
175+
if (payload.m?.r) meta.repo = payload.m.r;
176+
if (payload.m?.b) meta.branch = payload.m.b;
177+
if (payload.m?.s) meta.sharedBy = payload.m.s;
162178
return {
163-
plan: { name: payload.n, overview: payload.o, body: payload.p },
179+
plan: {
180+
name: payload.n,
181+
overview: payload.o,
182+
body: payload.p,
183+
...(Object.keys(meta).length > 0 ? { meta } : {}),
184+
},
164185
annotations: payload.a.map(deserializeAnnotation),
165186
};
166187
} catch {

0 commit comments

Comments
 (0)