react-linear-feedback 0.1.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/LICENSE +21 -0
- package/README.md +131 -0
- package/dist/react/index.cjs +627 -0
- package/dist/react/index.d.cts +109 -0
- package/dist/react/index.d.ts +109 -0
- package/dist/react/index.js +622 -0
- package/dist/server/index.cjs +170 -0
- package/dist/server/index.d.cts +85 -0
- package/dist/server/index.d.ts +85 -0
- package/dist/server/index.js +165 -0
- package/package.json +76 -0
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
var sdk = require('@linear/sdk');
|
|
4
|
+
|
|
5
|
+
// src/server/core.ts
|
|
6
|
+
var MAX_NOTE = 5e3;
|
|
7
|
+
async function createFeedbackIssue(payload, config) {
|
|
8
|
+
const { apiKey, teamId } = config;
|
|
9
|
+
if (!apiKey || !teamId) throw new Error("not_configured");
|
|
10
|
+
const note = payload?.annotation?.note?.trim();
|
|
11
|
+
if (!note) throw new Error("note_required");
|
|
12
|
+
if (note.length > MAX_NOTE) throw new Error("note_too_long");
|
|
13
|
+
const { annotation, context } = payload;
|
|
14
|
+
const typeLabel = annotation.typeLabel || capitalize(annotation.type || "Feedback");
|
|
15
|
+
const linear = new sdk.LinearClient({ apiKey });
|
|
16
|
+
let assetUrl = null;
|
|
17
|
+
if (payload.screenshot?.startsWith("data:image/")) {
|
|
18
|
+
try {
|
|
19
|
+
assetUrl = await uploadScreenshot(linear, payload.screenshot);
|
|
20
|
+
} catch (err) {
|
|
21
|
+
console.error("[feedback] screenshot upload failed", err);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
const description = [
|
|
25
|
+
note,
|
|
26
|
+
"",
|
|
27
|
+
"---",
|
|
28
|
+
assetUrl ? `})` : "_No screenshot captured._",
|
|
29
|
+
"",
|
|
30
|
+
"**Context**",
|
|
31
|
+
`- Type: ${typeLabel}`,
|
|
32
|
+
context?.url ? `- Page: ${context.url}` : null,
|
|
33
|
+
annotation.name ? `- Reported by: ${annotation.name}` : null,
|
|
34
|
+
context?.userAgent ? `- User agent: ${context.userAgent}` : null,
|
|
35
|
+
context?.timestamp ? `- Submitted: ${context.timestamp}` : null
|
|
36
|
+
].filter(Boolean).join("\n");
|
|
37
|
+
const title = `${typeLabel}: ${note.slice(0, 60)}${note.length > 60 ? "\u2026" : ""}`;
|
|
38
|
+
const labelName = config.labels?.[annotation.type] ?? annotation.type;
|
|
39
|
+
const labelId = labelName ? await resolveLabelId(linear, labelName, teamId) : null;
|
|
40
|
+
if (labelName && !labelId) console.warn(`[feedback] label "${labelName}" not found \u2014 creating issue without it`);
|
|
41
|
+
const create = async (ids) => {
|
|
42
|
+
const created = await linear.createIssue({ teamId, title, description, labelIds: ids.length ? ids : void 0 });
|
|
43
|
+
return await created.issue;
|
|
44
|
+
};
|
|
45
|
+
const issue = await create(labelId ? [labelId] : []).catch((err) => {
|
|
46
|
+
if (!labelId) throw err;
|
|
47
|
+
console.warn("[feedback] create failed with label, retrying without it", err);
|
|
48
|
+
return create([]);
|
|
49
|
+
});
|
|
50
|
+
return { id: issue?.id, identifier: issue?.identifier, url: issue?.url };
|
|
51
|
+
}
|
|
52
|
+
function capitalize(s) {
|
|
53
|
+
return s.charAt(0).toUpperCase() + s.slice(1);
|
|
54
|
+
}
|
|
55
|
+
async function resolveLabelId(linear, name, teamId) {
|
|
56
|
+
try {
|
|
57
|
+
const { nodes } = await linear.issueLabels({ filter: { name: { eqIgnoreCase: name } }, first: 50 });
|
|
58
|
+
if (nodes.length === 0) return null;
|
|
59
|
+
if (nodes.length === 1) return nodes[0].id;
|
|
60
|
+
const scored = await Promise.all(
|
|
61
|
+
nodes.map(async (n) => {
|
|
62
|
+
try {
|
|
63
|
+
const team = await n.team;
|
|
64
|
+
return { id: n.id, teamId: team?.id ?? null };
|
|
65
|
+
} catch {
|
|
66
|
+
return { id: n.id, teamId: null };
|
|
67
|
+
}
|
|
68
|
+
})
|
|
69
|
+
);
|
|
70
|
+
const pick = scored.find((s) => s.teamId === teamId) ?? scored.find((s) => s.teamId === null) ?? scored[0];
|
|
71
|
+
return pick.id;
|
|
72
|
+
} catch (err) {
|
|
73
|
+
console.warn("[feedback] label lookup failed", name, err);
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
async function uploadScreenshot(linear, dataUrl) {
|
|
78
|
+
const [meta, b64] = dataUrl.split(",");
|
|
79
|
+
const contentType = /data:(.*?);base64/.exec(meta)?.[1] ?? "image/jpeg";
|
|
80
|
+
const bytes = Buffer.from(b64, "base64");
|
|
81
|
+
const filename = `feedback-${Date.now()}.jpg`;
|
|
82
|
+
const upload = await linear.fileUpload(contentType, filename, bytes.length);
|
|
83
|
+
if (!upload.success || !upload.uploadFile) throw new Error("failed to request upload URL");
|
|
84
|
+
const headers = new Headers();
|
|
85
|
+
headers.set("Content-Type", contentType);
|
|
86
|
+
headers.set("Cache-Control", "public, max-age=31536000");
|
|
87
|
+
upload.uploadFile.headers.forEach(({ key, value }) => headers.set(key, value));
|
|
88
|
+
const put = await fetch(upload.uploadFile.uploadUrl, { method: "PUT", headers, body: new Uint8Array(bytes) });
|
|
89
|
+
if (!put.ok) throw new Error(`upload PUT failed: ${put.status}`);
|
|
90
|
+
return upload.uploadFile.assetUrl;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// src/server/next.ts
|
|
94
|
+
var BAD_REQUEST = /* @__PURE__ */ new Set(["note_required", "note_too_long", "bad_type", "not_configured"]);
|
|
95
|
+
function json(body, status) {
|
|
96
|
+
return new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } });
|
|
97
|
+
}
|
|
98
|
+
function createNextRoute(config) {
|
|
99
|
+
return async function POST(req) {
|
|
100
|
+
if (config.authorize && !await config.authorize(req)) return json({ error: "unauthorized" }, 404);
|
|
101
|
+
if (config.allowedOrigin) {
|
|
102
|
+
const origin = req.headers.get("origin") ?? "";
|
|
103
|
+
if (origin && origin !== config.allowedOrigin) return json({ error: "forbidden_origin" }, 403);
|
|
104
|
+
}
|
|
105
|
+
let payload;
|
|
106
|
+
try {
|
|
107
|
+
payload = await req.json();
|
|
108
|
+
} catch {
|
|
109
|
+
return json({ error: "bad_json" }, 400);
|
|
110
|
+
}
|
|
111
|
+
try {
|
|
112
|
+
const issue = await createFeedbackIssue(payload, config);
|
|
113
|
+
return json({ ok: true, ...issue }, 200);
|
|
114
|
+
} catch (err) {
|
|
115
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
116
|
+
if (BAD_REQUEST.has(message)) return json({ error: message }, message === "not_configured" ? 500 : 400);
|
|
117
|
+
console.error("[feedback] issue create failed", err);
|
|
118
|
+
return json({ error: "issue_create_failed", message }, 502);
|
|
119
|
+
}
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
function cookieGate(name, value = "1") {
|
|
123
|
+
return (req) => {
|
|
124
|
+
const cookie = req.headers.get("cookie") ?? "";
|
|
125
|
+
return cookie.split(";").map((c) => c.trim()).some((c) => c === `${name}=${value}`);
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// src/server/node.ts
|
|
130
|
+
var BAD_REQUEST2 = /* @__PURE__ */ new Set(["note_required", "note_too_long", "bad_type", "bad_json", "not_configured"]);
|
|
131
|
+
function send(res, status, body) {
|
|
132
|
+
res.statusCode = status;
|
|
133
|
+
res.setHeader("content-type", "application/json");
|
|
134
|
+
res.end(JSON.stringify(body));
|
|
135
|
+
}
|
|
136
|
+
async function readJson(req) {
|
|
137
|
+
const chunks = [];
|
|
138
|
+
for await (const chunk of req) chunks.push(Buffer.from(chunk));
|
|
139
|
+
const raw = Buffer.concat(chunks).toString("utf8");
|
|
140
|
+
if (!raw) throw new Error("bad_json");
|
|
141
|
+
try {
|
|
142
|
+
return JSON.parse(raw);
|
|
143
|
+
} catch {
|
|
144
|
+
throw new Error("bad_json");
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
function createNodeHandler(config) {
|
|
148
|
+
return async function handler(req, res) {
|
|
149
|
+
try {
|
|
150
|
+
if (config.authorize && !await config.authorize(req)) return send(res, 404, { error: "unauthorized" });
|
|
151
|
+
if (config.allowedOrigin) {
|
|
152
|
+
const origin = req.headers.origin ?? "";
|
|
153
|
+
if (origin && origin !== config.allowedOrigin) return send(res, 403, { error: "forbidden_origin" });
|
|
154
|
+
}
|
|
155
|
+
const payload = req.body ?? await readJson(req);
|
|
156
|
+
const issue = await createFeedbackIssue(payload, config);
|
|
157
|
+
send(res, 200, { ok: true, ...issue });
|
|
158
|
+
} catch (err) {
|
|
159
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
160
|
+
if (BAD_REQUEST2.has(message)) return send(res, message === "not_configured" ? 500 : 400, { error: message });
|
|
161
|
+
console.error("[feedback] issue create failed", err);
|
|
162
|
+
send(res, 502, { error: "issue_create_failed", message });
|
|
163
|
+
}
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
exports.cookieGate = cookieGate;
|
|
168
|
+
exports.createFeedbackIssue = createFeedbackIssue;
|
|
169
|
+
exports.createNextRoute = createNextRoute;
|
|
170
|
+
exports.createNodeHandler = createNodeHandler;
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { IncomingMessage, ServerResponse } from 'node:http';
|
|
2
|
+
|
|
3
|
+
/** Rectangle in PAGE coordinates: clientX + window.scrollX, clientY + window.scrollY. */
|
|
4
|
+
type FeedbackRect = {
|
|
5
|
+
x: number;
|
|
6
|
+
y: number;
|
|
7
|
+
width: number;
|
|
8
|
+
height: number;
|
|
9
|
+
};
|
|
10
|
+
type FeedbackAnnotation = {
|
|
11
|
+
rect: FeedbackRect;
|
|
12
|
+
/** The note the user typed. */
|
|
13
|
+
note: string;
|
|
14
|
+
/** Selected type id (e.g. "bug" | "improvement"). */
|
|
15
|
+
type: string;
|
|
16
|
+
/** Human label for the selected type (e.g. "Bug") — used in the issue title. */
|
|
17
|
+
typeLabel?: string;
|
|
18
|
+
/** Optional reporter name (persisted in localStorage). */
|
|
19
|
+
name?: string;
|
|
20
|
+
};
|
|
21
|
+
type FeedbackContext = {
|
|
22
|
+
url: string;
|
|
23
|
+
pathname: string;
|
|
24
|
+
title: string;
|
|
25
|
+
viewport: {
|
|
26
|
+
width: number;
|
|
27
|
+
height: number;
|
|
28
|
+
dpr: number;
|
|
29
|
+
};
|
|
30
|
+
scroll: {
|
|
31
|
+
x: number;
|
|
32
|
+
y: number;
|
|
33
|
+
};
|
|
34
|
+
userAgent: string;
|
|
35
|
+
referrer: string;
|
|
36
|
+
/** Short CSS-ish path to the element under the annotation — a hint for triage/agents. */
|
|
37
|
+
elementHint: string | null;
|
|
38
|
+
timestamp: string;
|
|
39
|
+
};
|
|
40
|
+
/** Body POSTed to the feedback endpoint. */
|
|
41
|
+
type FeedbackPayload = {
|
|
42
|
+
annotation: FeedbackAnnotation;
|
|
43
|
+
context: FeedbackContext;
|
|
44
|
+
/** data:image/jpeg;base64,… or null if capture failed (submission still proceeds). */
|
|
45
|
+
screenshot: string | null;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
type FeedbackServerConfig = {
|
|
49
|
+
/** Linear personal API key (server-side secret). */
|
|
50
|
+
apiKey: string;
|
|
51
|
+
/** Target team UUID. */
|
|
52
|
+
teamId: string;
|
|
53
|
+
/**
|
|
54
|
+
* Map a type id (e.g. "bug") to a Linear label NAME. Defaults to the type id itself, so a type
|
|
55
|
+
* "bug" looks for a label named "bug". Labels are resolved by name at request time.
|
|
56
|
+
*/
|
|
57
|
+
labels?: Record<string, string>;
|
|
58
|
+
};
|
|
59
|
+
type CreatedIssue = {
|
|
60
|
+
id?: string;
|
|
61
|
+
identifier?: string;
|
|
62
|
+
url?: string;
|
|
63
|
+
};
|
|
64
|
+
declare function createFeedbackIssue(payload: FeedbackPayload, config: FeedbackServerConfig): Promise<CreatedIssue>;
|
|
65
|
+
|
|
66
|
+
type NextRouteConfig = FeedbackServerConfig & {
|
|
67
|
+
/** Restrict to a single site origin (cheap extra; not a hard auth boundary). */
|
|
68
|
+
allowedOrigin?: string;
|
|
69
|
+
/** Return false to reject the request (e.g. a cookie/session check). */
|
|
70
|
+
authorize?: (req: Request) => boolean | Promise<boolean>;
|
|
71
|
+
};
|
|
72
|
+
declare function createNextRoute(config: NextRouteConfig): (req: Request) => Promise<Response>;
|
|
73
|
+
/** Authorize helper: allow only requests carrying `name=value` in the Cookie header. */
|
|
74
|
+
declare function cookieGate(name: string, value?: string): (req: Request) => boolean;
|
|
75
|
+
|
|
76
|
+
type NodeHandlerConfig = FeedbackServerConfig & {
|
|
77
|
+
allowedOrigin?: string;
|
|
78
|
+
authorize?: (req: IncomingMessage) => boolean | Promise<boolean>;
|
|
79
|
+
};
|
|
80
|
+
type NodeReq = IncomingMessage & {
|
|
81
|
+
body?: unknown;
|
|
82
|
+
};
|
|
83
|
+
declare function createNodeHandler(config: NodeHandlerConfig): (req: NodeReq, res: ServerResponse) => Promise<void>;
|
|
84
|
+
|
|
85
|
+
export { type CreatedIssue, type FeedbackAnnotation, type FeedbackContext, type FeedbackPayload, type FeedbackRect, type FeedbackServerConfig, type NextRouteConfig, type NodeHandlerConfig, cookieGate, createFeedbackIssue, createNextRoute, createNodeHandler };
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { IncomingMessage, ServerResponse } from 'node:http';
|
|
2
|
+
|
|
3
|
+
/** Rectangle in PAGE coordinates: clientX + window.scrollX, clientY + window.scrollY. */
|
|
4
|
+
type FeedbackRect = {
|
|
5
|
+
x: number;
|
|
6
|
+
y: number;
|
|
7
|
+
width: number;
|
|
8
|
+
height: number;
|
|
9
|
+
};
|
|
10
|
+
type FeedbackAnnotation = {
|
|
11
|
+
rect: FeedbackRect;
|
|
12
|
+
/** The note the user typed. */
|
|
13
|
+
note: string;
|
|
14
|
+
/** Selected type id (e.g. "bug" | "improvement"). */
|
|
15
|
+
type: string;
|
|
16
|
+
/** Human label for the selected type (e.g. "Bug") — used in the issue title. */
|
|
17
|
+
typeLabel?: string;
|
|
18
|
+
/** Optional reporter name (persisted in localStorage). */
|
|
19
|
+
name?: string;
|
|
20
|
+
};
|
|
21
|
+
type FeedbackContext = {
|
|
22
|
+
url: string;
|
|
23
|
+
pathname: string;
|
|
24
|
+
title: string;
|
|
25
|
+
viewport: {
|
|
26
|
+
width: number;
|
|
27
|
+
height: number;
|
|
28
|
+
dpr: number;
|
|
29
|
+
};
|
|
30
|
+
scroll: {
|
|
31
|
+
x: number;
|
|
32
|
+
y: number;
|
|
33
|
+
};
|
|
34
|
+
userAgent: string;
|
|
35
|
+
referrer: string;
|
|
36
|
+
/** Short CSS-ish path to the element under the annotation — a hint for triage/agents. */
|
|
37
|
+
elementHint: string | null;
|
|
38
|
+
timestamp: string;
|
|
39
|
+
};
|
|
40
|
+
/** Body POSTed to the feedback endpoint. */
|
|
41
|
+
type FeedbackPayload = {
|
|
42
|
+
annotation: FeedbackAnnotation;
|
|
43
|
+
context: FeedbackContext;
|
|
44
|
+
/** data:image/jpeg;base64,… or null if capture failed (submission still proceeds). */
|
|
45
|
+
screenshot: string | null;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
type FeedbackServerConfig = {
|
|
49
|
+
/** Linear personal API key (server-side secret). */
|
|
50
|
+
apiKey: string;
|
|
51
|
+
/** Target team UUID. */
|
|
52
|
+
teamId: string;
|
|
53
|
+
/**
|
|
54
|
+
* Map a type id (e.g. "bug") to a Linear label NAME. Defaults to the type id itself, so a type
|
|
55
|
+
* "bug" looks for a label named "bug". Labels are resolved by name at request time.
|
|
56
|
+
*/
|
|
57
|
+
labels?: Record<string, string>;
|
|
58
|
+
};
|
|
59
|
+
type CreatedIssue = {
|
|
60
|
+
id?: string;
|
|
61
|
+
identifier?: string;
|
|
62
|
+
url?: string;
|
|
63
|
+
};
|
|
64
|
+
declare function createFeedbackIssue(payload: FeedbackPayload, config: FeedbackServerConfig): Promise<CreatedIssue>;
|
|
65
|
+
|
|
66
|
+
type NextRouteConfig = FeedbackServerConfig & {
|
|
67
|
+
/** Restrict to a single site origin (cheap extra; not a hard auth boundary). */
|
|
68
|
+
allowedOrigin?: string;
|
|
69
|
+
/** Return false to reject the request (e.g. a cookie/session check). */
|
|
70
|
+
authorize?: (req: Request) => boolean | Promise<boolean>;
|
|
71
|
+
};
|
|
72
|
+
declare function createNextRoute(config: NextRouteConfig): (req: Request) => Promise<Response>;
|
|
73
|
+
/** Authorize helper: allow only requests carrying `name=value` in the Cookie header. */
|
|
74
|
+
declare function cookieGate(name: string, value?: string): (req: Request) => boolean;
|
|
75
|
+
|
|
76
|
+
type NodeHandlerConfig = FeedbackServerConfig & {
|
|
77
|
+
allowedOrigin?: string;
|
|
78
|
+
authorize?: (req: IncomingMessage) => boolean | Promise<boolean>;
|
|
79
|
+
};
|
|
80
|
+
type NodeReq = IncomingMessage & {
|
|
81
|
+
body?: unknown;
|
|
82
|
+
};
|
|
83
|
+
declare function createNodeHandler(config: NodeHandlerConfig): (req: NodeReq, res: ServerResponse) => Promise<void>;
|
|
84
|
+
|
|
85
|
+
export { type CreatedIssue, type FeedbackAnnotation, type FeedbackContext, type FeedbackPayload, type FeedbackRect, type FeedbackServerConfig, type NextRouteConfig, type NodeHandlerConfig, cookieGate, createFeedbackIssue, createNextRoute, createNodeHandler };
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import { LinearClient } from '@linear/sdk';
|
|
2
|
+
|
|
3
|
+
// src/server/core.ts
|
|
4
|
+
var MAX_NOTE = 5e3;
|
|
5
|
+
async function createFeedbackIssue(payload, config) {
|
|
6
|
+
const { apiKey, teamId } = config;
|
|
7
|
+
if (!apiKey || !teamId) throw new Error("not_configured");
|
|
8
|
+
const note = payload?.annotation?.note?.trim();
|
|
9
|
+
if (!note) throw new Error("note_required");
|
|
10
|
+
if (note.length > MAX_NOTE) throw new Error("note_too_long");
|
|
11
|
+
const { annotation, context } = payload;
|
|
12
|
+
const typeLabel = annotation.typeLabel || capitalize(annotation.type || "Feedback");
|
|
13
|
+
const linear = new LinearClient({ apiKey });
|
|
14
|
+
let assetUrl = null;
|
|
15
|
+
if (payload.screenshot?.startsWith("data:image/")) {
|
|
16
|
+
try {
|
|
17
|
+
assetUrl = await uploadScreenshot(linear, payload.screenshot);
|
|
18
|
+
} catch (err) {
|
|
19
|
+
console.error("[feedback] screenshot upload failed", err);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
const description = [
|
|
23
|
+
note,
|
|
24
|
+
"",
|
|
25
|
+
"---",
|
|
26
|
+
assetUrl ? `})` : "_No screenshot captured._",
|
|
27
|
+
"",
|
|
28
|
+
"**Context**",
|
|
29
|
+
`- Type: ${typeLabel}`,
|
|
30
|
+
context?.url ? `- Page: ${context.url}` : null,
|
|
31
|
+
annotation.name ? `- Reported by: ${annotation.name}` : null,
|
|
32
|
+
context?.userAgent ? `- User agent: ${context.userAgent}` : null,
|
|
33
|
+
context?.timestamp ? `- Submitted: ${context.timestamp}` : null
|
|
34
|
+
].filter(Boolean).join("\n");
|
|
35
|
+
const title = `${typeLabel}: ${note.slice(0, 60)}${note.length > 60 ? "\u2026" : ""}`;
|
|
36
|
+
const labelName = config.labels?.[annotation.type] ?? annotation.type;
|
|
37
|
+
const labelId = labelName ? await resolveLabelId(linear, labelName, teamId) : null;
|
|
38
|
+
if (labelName && !labelId) console.warn(`[feedback] label "${labelName}" not found \u2014 creating issue without it`);
|
|
39
|
+
const create = async (ids) => {
|
|
40
|
+
const created = await linear.createIssue({ teamId, title, description, labelIds: ids.length ? ids : void 0 });
|
|
41
|
+
return await created.issue;
|
|
42
|
+
};
|
|
43
|
+
const issue = await create(labelId ? [labelId] : []).catch((err) => {
|
|
44
|
+
if (!labelId) throw err;
|
|
45
|
+
console.warn("[feedback] create failed with label, retrying without it", err);
|
|
46
|
+
return create([]);
|
|
47
|
+
});
|
|
48
|
+
return { id: issue?.id, identifier: issue?.identifier, url: issue?.url };
|
|
49
|
+
}
|
|
50
|
+
function capitalize(s) {
|
|
51
|
+
return s.charAt(0).toUpperCase() + s.slice(1);
|
|
52
|
+
}
|
|
53
|
+
async function resolveLabelId(linear, name, teamId) {
|
|
54
|
+
try {
|
|
55
|
+
const { nodes } = await linear.issueLabels({ filter: { name: { eqIgnoreCase: name } }, first: 50 });
|
|
56
|
+
if (nodes.length === 0) return null;
|
|
57
|
+
if (nodes.length === 1) return nodes[0].id;
|
|
58
|
+
const scored = await Promise.all(
|
|
59
|
+
nodes.map(async (n) => {
|
|
60
|
+
try {
|
|
61
|
+
const team = await n.team;
|
|
62
|
+
return { id: n.id, teamId: team?.id ?? null };
|
|
63
|
+
} catch {
|
|
64
|
+
return { id: n.id, teamId: null };
|
|
65
|
+
}
|
|
66
|
+
})
|
|
67
|
+
);
|
|
68
|
+
const pick = scored.find((s) => s.teamId === teamId) ?? scored.find((s) => s.teamId === null) ?? scored[0];
|
|
69
|
+
return pick.id;
|
|
70
|
+
} catch (err) {
|
|
71
|
+
console.warn("[feedback] label lookup failed", name, err);
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
async function uploadScreenshot(linear, dataUrl) {
|
|
76
|
+
const [meta, b64] = dataUrl.split(",");
|
|
77
|
+
const contentType = /data:(.*?);base64/.exec(meta)?.[1] ?? "image/jpeg";
|
|
78
|
+
const bytes = Buffer.from(b64, "base64");
|
|
79
|
+
const filename = `feedback-${Date.now()}.jpg`;
|
|
80
|
+
const upload = await linear.fileUpload(contentType, filename, bytes.length);
|
|
81
|
+
if (!upload.success || !upload.uploadFile) throw new Error("failed to request upload URL");
|
|
82
|
+
const headers = new Headers();
|
|
83
|
+
headers.set("Content-Type", contentType);
|
|
84
|
+
headers.set("Cache-Control", "public, max-age=31536000");
|
|
85
|
+
upload.uploadFile.headers.forEach(({ key, value }) => headers.set(key, value));
|
|
86
|
+
const put = await fetch(upload.uploadFile.uploadUrl, { method: "PUT", headers, body: new Uint8Array(bytes) });
|
|
87
|
+
if (!put.ok) throw new Error(`upload PUT failed: ${put.status}`);
|
|
88
|
+
return upload.uploadFile.assetUrl;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// src/server/next.ts
|
|
92
|
+
var BAD_REQUEST = /* @__PURE__ */ new Set(["note_required", "note_too_long", "bad_type", "not_configured"]);
|
|
93
|
+
function json(body, status) {
|
|
94
|
+
return new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } });
|
|
95
|
+
}
|
|
96
|
+
function createNextRoute(config) {
|
|
97
|
+
return async function POST(req) {
|
|
98
|
+
if (config.authorize && !await config.authorize(req)) return json({ error: "unauthorized" }, 404);
|
|
99
|
+
if (config.allowedOrigin) {
|
|
100
|
+
const origin = req.headers.get("origin") ?? "";
|
|
101
|
+
if (origin && origin !== config.allowedOrigin) return json({ error: "forbidden_origin" }, 403);
|
|
102
|
+
}
|
|
103
|
+
let payload;
|
|
104
|
+
try {
|
|
105
|
+
payload = await req.json();
|
|
106
|
+
} catch {
|
|
107
|
+
return json({ error: "bad_json" }, 400);
|
|
108
|
+
}
|
|
109
|
+
try {
|
|
110
|
+
const issue = await createFeedbackIssue(payload, config);
|
|
111
|
+
return json({ ok: true, ...issue }, 200);
|
|
112
|
+
} catch (err) {
|
|
113
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
114
|
+
if (BAD_REQUEST.has(message)) return json({ error: message }, message === "not_configured" ? 500 : 400);
|
|
115
|
+
console.error("[feedback] issue create failed", err);
|
|
116
|
+
return json({ error: "issue_create_failed", message }, 502);
|
|
117
|
+
}
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
function cookieGate(name, value = "1") {
|
|
121
|
+
return (req) => {
|
|
122
|
+
const cookie = req.headers.get("cookie") ?? "";
|
|
123
|
+
return cookie.split(";").map((c) => c.trim()).some((c) => c === `${name}=${value}`);
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// src/server/node.ts
|
|
128
|
+
var BAD_REQUEST2 = /* @__PURE__ */ new Set(["note_required", "note_too_long", "bad_type", "bad_json", "not_configured"]);
|
|
129
|
+
function send(res, status, body) {
|
|
130
|
+
res.statusCode = status;
|
|
131
|
+
res.setHeader("content-type", "application/json");
|
|
132
|
+
res.end(JSON.stringify(body));
|
|
133
|
+
}
|
|
134
|
+
async function readJson(req) {
|
|
135
|
+
const chunks = [];
|
|
136
|
+
for await (const chunk of req) chunks.push(Buffer.from(chunk));
|
|
137
|
+
const raw = Buffer.concat(chunks).toString("utf8");
|
|
138
|
+
if (!raw) throw new Error("bad_json");
|
|
139
|
+
try {
|
|
140
|
+
return JSON.parse(raw);
|
|
141
|
+
} catch {
|
|
142
|
+
throw new Error("bad_json");
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
function createNodeHandler(config) {
|
|
146
|
+
return async function handler(req, res) {
|
|
147
|
+
try {
|
|
148
|
+
if (config.authorize && !await config.authorize(req)) return send(res, 404, { error: "unauthorized" });
|
|
149
|
+
if (config.allowedOrigin) {
|
|
150
|
+
const origin = req.headers.origin ?? "";
|
|
151
|
+
if (origin && origin !== config.allowedOrigin) return send(res, 403, { error: "forbidden_origin" });
|
|
152
|
+
}
|
|
153
|
+
const payload = req.body ?? await readJson(req);
|
|
154
|
+
const issue = await createFeedbackIssue(payload, config);
|
|
155
|
+
send(res, 200, { ok: true, ...issue });
|
|
156
|
+
} catch (err) {
|
|
157
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
158
|
+
if (BAD_REQUEST2.has(message)) return send(res, message === "not_configured" ? 500 : 400, { error: message });
|
|
159
|
+
console.error("[feedback] issue create failed", err);
|
|
160
|
+
send(res, 502, { error: "issue_create_failed", message });
|
|
161
|
+
}
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export { cookieGate, createFeedbackIssue, createNextRoute, createNodeHandler };
|
package/package.json
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "react-linear-feedback",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Drop-in React feedback widget: draw a box, write a note, and it captures a screenshot and opens a Linear issue. Framework-agnostic, self-contained styles, zero design-system dependencies.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "Oliver Odgaard",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/oliverodgaardwastehero/react-linear-feedback.git"
|
|
10
|
+
},
|
|
11
|
+
"homepage": "https://github.com/oliverodgaardwastehero/react-linear-feedback#readme",
|
|
12
|
+
"bugs": {
|
|
13
|
+
"url": "https://github.com/oliverodgaardwastehero/react-linear-feedback/issues"
|
|
14
|
+
},
|
|
15
|
+
"publishConfig": {
|
|
16
|
+
"access": "public"
|
|
17
|
+
},
|
|
18
|
+
"type": "module",
|
|
19
|
+
"sideEffects": false,
|
|
20
|
+
"keywords": [
|
|
21
|
+
"react",
|
|
22
|
+
"feedback",
|
|
23
|
+
"linear",
|
|
24
|
+
"screenshot",
|
|
25
|
+
"bug-report",
|
|
26
|
+
"widget"
|
|
27
|
+
],
|
|
28
|
+
"files": [
|
|
29
|
+
"dist",
|
|
30
|
+
"README.md",
|
|
31
|
+
"LICENSE"
|
|
32
|
+
],
|
|
33
|
+
"exports": {
|
|
34
|
+
"./react": {
|
|
35
|
+
"types": "./dist/react/index.d.ts",
|
|
36
|
+
"import": "./dist/react/index.js",
|
|
37
|
+
"require": "./dist/react/index.cjs"
|
|
38
|
+
},
|
|
39
|
+
"./server": {
|
|
40
|
+
"types": "./dist/server/index.d.ts",
|
|
41
|
+
"import": "./dist/server/index.js",
|
|
42
|
+
"require": "./dist/server/index.cjs"
|
|
43
|
+
}
|
|
44
|
+
},
|
|
45
|
+
"scripts": {
|
|
46
|
+
"build": "tsup && node scripts/postbuild.mjs",
|
|
47
|
+
"typecheck": "tsc --noEmit",
|
|
48
|
+
"prepublishOnly": "npm run build"
|
|
49
|
+
},
|
|
50
|
+
"dependencies": {
|
|
51
|
+
"modern-screenshot": "^4.7.0"
|
|
52
|
+
},
|
|
53
|
+
"peerDependencies": {
|
|
54
|
+
"@linear/sdk": ">=40",
|
|
55
|
+
"react": ">=18",
|
|
56
|
+
"react-dom": ">=18"
|
|
57
|
+
},
|
|
58
|
+
"peerDependenciesMeta": {
|
|
59
|
+
"@linear/sdk": {
|
|
60
|
+
"optional": true
|
|
61
|
+
},
|
|
62
|
+
"react-dom": {
|
|
63
|
+
"optional": true
|
|
64
|
+
}
|
|
65
|
+
},
|
|
66
|
+
"devDependencies": {
|
|
67
|
+
"@linear/sdk": "^86.0.0",
|
|
68
|
+
"@types/node": "^22.0.0",
|
|
69
|
+
"@types/react": "^19.0.0",
|
|
70
|
+
"@types/react-dom": "^19.0.0",
|
|
71
|
+
"react": "^19.0.0",
|
|
72
|
+
"react-dom": "^19.0.0",
|
|
73
|
+
"tsup": "^8.5.0",
|
|
74
|
+
"typescript": "^5.7.0"
|
|
75
|
+
}
|
|
76
|
+
}
|