@bdsqqq/lnr-cli 1.1.2 → 1.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/package.json +7 -3
- package/src/cli.ts +22 -24
- package/src/lib/output.test.ts +181 -0
- package/src/lib/output.ts +317 -1
- package/src/{commands → router}/auth.ts +22 -15
- package/src/router/config.ts +71 -0
- package/src/router/cycles.ts +131 -0
- package/src/router/docs.ts +153 -0
- package/src/router/index.ts +28 -0
- package/src/router/issues.ts +558 -0
- package/src/router/labels.ts +192 -0
- package/src/{commands → router}/me.ts +47 -29
- package/src/router/projects.ts +220 -0
- package/src/{commands → router}/search.ts +20 -19
- package/src/{commands → router}/teams.ts +33 -46
- package/src/router/trpc.ts +7 -0
- package/src/commands/config.ts +0 -64
- package/src/commands/cycles.ts +0 -134
- package/src/commands/issues.ts +0 -390
- package/src/commands/projects.ts +0 -214
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bdsqqq/lnr-cli",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.2.0",
|
|
4
4
|
"description": "cli for linear issue tracking",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"private": false,
|
|
@@ -21,12 +21,16 @@
|
|
|
21
21
|
"scripts": {
|
|
22
22
|
"dev": "bun run src/cli.ts",
|
|
23
23
|
"check": "tsc --noEmit",
|
|
24
|
-
"build": "bun build ./src/cli.ts --compile --outfile lnr"
|
|
24
|
+
"build": "bun build ./src/cli.ts --compile --outfile lnr",
|
|
25
|
+
"test": "bun test"
|
|
25
26
|
},
|
|
26
27
|
"dependencies": {
|
|
27
28
|
"@bdsqqq/lnr-core": "workspace:*",
|
|
29
|
+
"@trpc/server": "^11.8.1",
|
|
28
30
|
"chalk": "^5.6.2",
|
|
29
|
-
"commander": "^14.0.2"
|
|
31
|
+
"commander": "^14.0.2",
|
|
32
|
+
"trpc-cli": "^0.12.2",
|
|
33
|
+
"zod": "^4.3.5"
|
|
30
34
|
},
|
|
31
35
|
"peerDependencies": {
|
|
32
36
|
"typescript": "^5"
|
package/src/cli.ts
CHANGED
|
@@ -1,30 +1,28 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
2
|
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import { registerIssuesCommand } from "./commands/issues";
|
|
6
|
-
import { registerTeamsCommand } from "./commands/teams";
|
|
7
|
-
import { registerProjectsCommand } from "./commands/projects";
|
|
8
|
-
import { registerCyclesCommand } from "./commands/cycles";
|
|
9
|
-
import { registerMeCommand } from "./commands/me";
|
|
10
|
-
import { registerSearchCommand } from "./commands/search";
|
|
11
|
-
import { registerConfigCommand } from "./commands/config";
|
|
3
|
+
import { createCli } from "trpc-cli";
|
|
4
|
+
import { appRouter } from "./router";
|
|
12
5
|
import pkg from "../package.json";
|
|
13
6
|
|
|
14
|
-
|
|
7
|
+
// parse global --api-key flag before trpc-cli
|
|
8
|
+
// precedence: --api-key > LINEAR_API_KEY env > config file
|
|
9
|
+
const apiKeyIndex = process.argv.findIndex((arg) => arg === "--api-key");
|
|
10
|
+
if (apiKeyIndex !== -1 && process.argv[apiKeyIndex + 1]) {
|
|
11
|
+
process.env.LINEAR_API_KEY = process.argv[apiKeyIndex + 1];
|
|
12
|
+
process.argv.splice(apiKeyIndex, 2);
|
|
13
|
+
} else {
|
|
14
|
+
const apiKeyEqMatch = process.argv.find((arg) => arg.startsWith("--api-key="));
|
|
15
|
+
if (apiKeyEqMatch) {
|
|
16
|
+
process.env.LINEAR_API_KEY = apiKeyEqMatch.slice("--api-key=".length);
|
|
17
|
+
process.argv = process.argv.filter((arg) => !arg.startsWith("--api-key="));
|
|
18
|
+
}
|
|
19
|
+
}
|
|
15
20
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
21
|
+
const cli = createCli({
|
|
22
|
+
router: appRouter,
|
|
23
|
+
name: "lnr",
|
|
24
|
+
version: pkg.version,
|
|
25
|
+
description: "command-line interface for Linear",
|
|
26
|
+
});
|
|
20
27
|
|
|
21
|
-
|
|
22
|
-
registerIssuesCommand(program);
|
|
23
|
-
registerTeamsCommand(program);
|
|
24
|
-
registerProjectsCommand(program);
|
|
25
|
-
registerCyclesCommand(program);
|
|
26
|
-
registerMeCommand(program);
|
|
27
|
-
registerSearchCommand(program);
|
|
28
|
-
registerConfigCommand(program);
|
|
29
|
-
|
|
30
|
-
program.parse();
|
|
28
|
+
void cli.run();
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
truncate,
|
|
4
|
+
formatDate,
|
|
5
|
+
formatPriority,
|
|
6
|
+
formatRelativeTime,
|
|
7
|
+
shortcodeToEmoji,
|
|
8
|
+
formatReactions,
|
|
9
|
+
wrapText,
|
|
10
|
+
buildChildMap,
|
|
11
|
+
} from "./output";
|
|
12
|
+
import type { Comment } from "@bdsqqq/lnr-core";
|
|
13
|
+
|
|
14
|
+
describe("truncate", () => {
|
|
15
|
+
test("shorter than max → unchanged", () => {
|
|
16
|
+
expect(truncate("hello", 10)).toBe("hello");
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test("at exact max → unchanged", () => {
|
|
20
|
+
expect(truncate("hello", 5)).toBe("hello");
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test("longer → truncated with …", () => {
|
|
24
|
+
expect(truncate("hello world", 6)).toBe("hello…");
|
|
25
|
+
});
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
describe("formatDate", () => {
|
|
29
|
+
test("null → -", () => {
|
|
30
|
+
expect(formatDate(null)).toBe("-");
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("undefined → -", () => {
|
|
34
|
+
expect(formatDate(undefined)).toBe("-");
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("Date → YYYY-MM-DD", () => {
|
|
38
|
+
expect(formatDate(new Date("2024-03-15T12:00:00Z"))).toBe("2024-03-15");
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("ISO string → YYYY-MM-DD", () => {
|
|
42
|
+
expect(formatDate("2024-03-15T12:00:00Z")).toBe("2024-03-15");
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
describe("formatPriority", () => {
|
|
47
|
+
test("0 → -", () => expect(formatPriority(0)).toBe("-"));
|
|
48
|
+
test("1 → urgent", () => expect(formatPriority(1)).toBe("urgent"));
|
|
49
|
+
test("2 → high", () => expect(formatPriority(2)).toBe("high"));
|
|
50
|
+
test("3 → medium", () => expect(formatPriority(3)).toBe("medium"));
|
|
51
|
+
test("4 → low", () => expect(formatPriority(4)).toBe("low"));
|
|
52
|
+
test("undefined → -", () => expect(formatPriority(undefined)).toBe("-"));
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
describe("formatRelativeTime", () => {
|
|
56
|
+
test("< 1 min → just now", () => {
|
|
57
|
+
const now = new Date();
|
|
58
|
+
expect(formatRelativeTime(now)).toBe("just now");
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("minutes ago", () => {
|
|
62
|
+
const fiveMinAgo = new Date(Date.now() - 5 * 60 * 1000);
|
|
63
|
+
expect(formatRelativeTime(fiveMinAgo)).toBe("5m ago");
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("hours ago", () => {
|
|
67
|
+
const threeHoursAgo = new Date(Date.now() - 3 * 60 * 60 * 1000);
|
|
68
|
+
expect(formatRelativeTime(threeHoursAgo)).toBe("3h ago");
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("days ago", () => {
|
|
72
|
+
const twoDaysAgo = new Date(Date.now() - 2 * 24 * 60 * 60 * 1000);
|
|
73
|
+
expect(formatRelativeTime(twoDaysAgo)).toBe("2d ago");
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("weeks ago", () => {
|
|
77
|
+
const twoWeeksAgo = new Date(Date.now() - 14 * 24 * 60 * 60 * 1000);
|
|
78
|
+
expect(formatRelativeTime(twoWeeksAgo)).toBe("2w ago");
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
describe("shortcodeToEmoji", () => {
|
|
83
|
+
test("known → emoji", () => {
|
|
84
|
+
expect(shortcodeToEmoji("+1")).toBe("👍");
|
|
85
|
+
expect(shortcodeToEmoji("fire")).toBe("🔥");
|
|
86
|
+
expect(shortcodeToEmoji("heart")).toBe("❤️");
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test("unknown → :shortcode:", () => {
|
|
90
|
+
expect(shortcodeToEmoji("not_a_real_emoji")).toBe(":not_a_real_emoji:");
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
describe("formatReactions", () => {
|
|
95
|
+
test("empty → empty string", () => {
|
|
96
|
+
expect(formatReactions([])).toBe("");
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test("count 1 → just emoji", () => {
|
|
100
|
+
expect(formatReactions([{ emoji: "+1", count: 1 }])).toBe("👍");
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("count > 1 → emoji + count", () => {
|
|
104
|
+
expect(formatReactions([{ emoji: "+1", count: 3 }])).toBe("👍3");
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test("multiple reactions", () => {
|
|
108
|
+
expect(
|
|
109
|
+
formatReactions([
|
|
110
|
+
{ emoji: "+1", count: 2 },
|
|
111
|
+
{ emoji: "fire", count: 1 },
|
|
112
|
+
])
|
|
113
|
+
).toBe("👍2 🔥");
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
describe("wrapText", () => {
|
|
118
|
+
test("short text → single line", () => {
|
|
119
|
+
expect(wrapText("hello", 20, "")).toEqual(["hello"]);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test("long text wraps", () => {
|
|
123
|
+
const result = wrapText("hello world foo bar", 12, "");
|
|
124
|
+
expect(result).toEqual(["hello world", "foo bar"]);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test("preserves paragraph breaks", () => {
|
|
128
|
+
const result = wrapText("hello\n\nworld", 20, "");
|
|
129
|
+
expect(result).toEqual(["hello", "", "world"]);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test("applies indent", () => {
|
|
133
|
+
const result = wrapText("hello", 20, " ");
|
|
134
|
+
expect(result).toEqual([" hello"]);
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
describe("buildChildMap", () => {
|
|
139
|
+
const makeComment = (
|
|
140
|
+
id: string,
|
|
141
|
+
parentId: string | null,
|
|
142
|
+
createdAt: Date
|
|
143
|
+
): Comment => ({
|
|
144
|
+
id,
|
|
145
|
+
body: "test",
|
|
146
|
+
user: "user",
|
|
147
|
+
externalUser: null,
|
|
148
|
+
botActor: null,
|
|
149
|
+
url: "https://example.com",
|
|
150
|
+
createdAt,
|
|
151
|
+
updatedAt: createdAt,
|
|
152
|
+
parentId,
|
|
153
|
+
reactions: [],
|
|
154
|
+
syncedWith: [],
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
test("roots go under null key", () => {
|
|
158
|
+
const comments = [makeComment("a", null, new Date("2024-01-01"))];
|
|
159
|
+
const map = buildChildMap(comments);
|
|
160
|
+
expect(map.get(null)?.map((c) => c.id)).toEqual(["a"]);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
test("children under parent id", () => {
|
|
164
|
+
const comments = [
|
|
165
|
+
makeComment("a", null, new Date("2024-01-01")),
|
|
166
|
+
makeComment("b", "a", new Date("2024-01-02")),
|
|
167
|
+
];
|
|
168
|
+
const map = buildChildMap(comments);
|
|
169
|
+
expect(map.get("a")?.map((c) => c.id)).toEqual(["b"]);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
test("sorted by createdAt", () => {
|
|
173
|
+
const comments = [
|
|
174
|
+
makeComment("c", null, new Date("2024-01-03")),
|
|
175
|
+
makeComment("a", null, new Date("2024-01-01")),
|
|
176
|
+
makeComment("b", null, new Date("2024-01-02")),
|
|
177
|
+
];
|
|
178
|
+
const map = buildChildMap(comments);
|
|
179
|
+
expect(map.get(null)?.map((c) => c.id)).toEqual(["a", "b", "c"]);
|
|
180
|
+
});
|
|
181
|
+
});
|
package/src/lib/output.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import chalk from "chalk";
|
|
2
|
-
import { getConfigValue } from "@bdsqqq/lnr-core";
|
|
2
|
+
import { getConfigValue, type Comment } from "@bdsqqq/lnr-core";
|
|
3
3
|
|
|
4
4
|
export type OutputFormat = "table" | "json" | "quiet";
|
|
5
5
|
|
|
@@ -97,3 +97,319 @@ export function formatPriority(priority: number | undefined): string {
|
|
|
97
97
|
return "-";
|
|
98
98
|
}
|
|
99
99
|
}
|
|
100
|
+
|
|
101
|
+
export function formatRelativeTime(date: Date): string {
|
|
102
|
+
const now = new Date();
|
|
103
|
+
const diffMs = now.getTime() - date.getTime();
|
|
104
|
+
const diffMins = Math.floor(diffMs / (1000 * 60));
|
|
105
|
+
const diffHours = Math.floor(diffMs / (1000 * 60 * 60));
|
|
106
|
+
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
|
|
107
|
+
const diffWeeks = Math.floor(diffDays / 7);
|
|
108
|
+
const diffMonths = Math.floor(diffDays / 30);
|
|
109
|
+
const diffYears = Math.floor(diffDays / 365);
|
|
110
|
+
|
|
111
|
+
if (diffMins < 1) return "just now";
|
|
112
|
+
if (diffMins < 60) return `${diffMins}m ago`;
|
|
113
|
+
if (diffHours < 24) return `${diffHours}h ago`;
|
|
114
|
+
if (diffDays < 7) return `${diffDays}d ago`;
|
|
115
|
+
if (diffWeeks < 4) return `${diffWeeks}w ago`;
|
|
116
|
+
if (diffMonths < 12) return `${diffMonths}mo ago`;
|
|
117
|
+
return `${diffYears}y ago`;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function buildChildMap(comments: Comment[]): Map<string | null, Comment[]> {
|
|
121
|
+
const childMap = new Map<string | null, Comment[]>();
|
|
122
|
+
|
|
123
|
+
for (const c of comments) {
|
|
124
|
+
const parentKey = c.parentId ?? null;
|
|
125
|
+
const existing = childMap.get(parentKey) ?? [];
|
|
126
|
+
existing.push(c);
|
|
127
|
+
childMap.set(parentKey, existing);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
for (const children of Array.from(childMap.values())) {
|
|
131
|
+
children.sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime());
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return childMap;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const EMOJI_MAP: Record<string, string> = {
|
|
138
|
+
// gestures
|
|
139
|
+
"+1": "👍", thumbsup: "👍", "-1": "👎", thumbsdown: "👎",
|
|
140
|
+
wave: "👋", raised_back_of_hand: "🤚", raised_hand: "✋", hand: "✋",
|
|
141
|
+
vulcan_salute: "🖖", ok_hand: "👌", pinched_fingers: "🤌", pinching_hand: "🤏",
|
|
142
|
+
v: "✌️", crossed_fingers: "🤞", love_you_gesture: "🤟", metal: "🤘",
|
|
143
|
+
call_me_hand: "🤙", point_left: "👈", point_right: "👉", point_up_2: "👆",
|
|
144
|
+
point_down: "👇", point_up: "☝️", fist: "✊", fist_raised: "✊",
|
|
145
|
+
facepunch: "👊", punch: "👊", fist_oncoming: "👊", fist_left: "🤛", fist_right: "🤜",
|
|
146
|
+
clap: "👏", raised_hands: "🙌", open_hands: "👐", palms_up_together: "🤲",
|
|
147
|
+
handshake: "🤝", pray: "🙏", writing_hand: "✍️", nail_care: "💅",
|
|
148
|
+
selfie: "🤳", muscle: "💪", mechanical_arm: "🦾",
|
|
149
|
+
eyes: "👀", eye: "👁️", tongue: "👅", lips: "👄",
|
|
150
|
+
|
|
151
|
+
// smileys
|
|
152
|
+
grinning: "😀", smile: "😊", grin: "😁", joy: "😂", rofl: "🤣",
|
|
153
|
+
smiley: "😃", sweat_smile: "😅", laughing: "😆", laugh: "😄",
|
|
154
|
+
wink: "😉", blush: "😊", yum: "😋", sunglasses: "😎", heart_eyes: "😍",
|
|
155
|
+
kissing_heart: "💋", kissing: "😗", relaxed: "☺️",
|
|
156
|
+
stuck_out_tongue: "😛", stuck_out_tongue_winking_eye: "😜", stuck_out_tongue_closed_eyes: "😝",
|
|
157
|
+
disappointed: "😞", worried: "😟", angry: "😠", rage: "😡", pout: "😡",
|
|
158
|
+
cry: "😢", persevere: "😣", triumph: "😤", disappointed_relieved: "😥",
|
|
159
|
+
frowning: "😦", anguished: "😧", fearful: "😨", weary: "😩",
|
|
160
|
+
sleepy: "😪", tired_face: "😫", grimacing: "😬", sob: "😭",
|
|
161
|
+
open_mouth: "😮", hushed: "😯", cold_sweat: "😰", scream: "😱",
|
|
162
|
+
astonished: "😲", flushed: "😳", sleeping: "😴", dizzy_face: "😵",
|
|
163
|
+
no_mouth: "😶", mask: "😷", neutral_face: "😐", expressionless: "😑",
|
|
164
|
+
unamused: "😒", sweat: "😓", pensive: "😔", confused: "😕", confounded: "😖",
|
|
165
|
+
upside_down_face: "🙃", money_mouth_face: "🤑", thinking_face: "🤔", thinking: "🤔",
|
|
166
|
+
zipper_mouth_face: "🤐", nerd_face: "🤓", hugs: "🤗", rolling_eyes: "🙄",
|
|
167
|
+
smirk: "😏", drooling_face: "🤤", lying_face: "🤥",
|
|
168
|
+
face_with_raised_eyebrow: "🤨", shushing_face: "🤫", face_with_hand_over_mouth: "🤭",
|
|
169
|
+
face_vomiting: "🤮", exploding_head: "🤯", cowboy_hat_face: "🤠",
|
|
170
|
+
partying_face: "🥳", disguised_face: "🥸", pleading_face: "🥺",
|
|
171
|
+
skull: "💀", skull_and_crossbones: "☠️", ghost: "👻", alien: "👽",
|
|
172
|
+
robot: "🤖", poop: "💩", hankey: "💩", clown_face: "🤡",
|
|
173
|
+
|
|
174
|
+
// hearts
|
|
175
|
+
heart: "❤️", red_heart: "❤️", orange_heart: "🧡", yellow_heart: "💛",
|
|
176
|
+
green_heart: "💚", blue_heart: "💙", purple_heart: "💜", black_heart: "🖤",
|
|
177
|
+
white_heart: "🤍", brown_heart: "🤎", broken_heart: "💔", heart_exclamation: "❣️",
|
|
178
|
+
two_hearts: "💕", revolving_hearts: "💞", heartbeat: "💓", heartpulse: "💗",
|
|
179
|
+
sparkling_heart: "💖", cupid: "💘", gift_heart: "💝", heart_decoration: "💟",
|
|
180
|
+
|
|
181
|
+
// celebration
|
|
182
|
+
tada: "🎉", confetti_ball: "🎊", balloon: "🎈", birthday: "🎂", gift: "🎁",
|
|
183
|
+
trophy: "🏆", medal_military: "🎖️", medal_sports: "🏅",
|
|
184
|
+
first_place_medal: "🥇", second_place_medal: "🥈", third_place_medal: "🥉",
|
|
185
|
+
|
|
186
|
+
// symbols
|
|
187
|
+
fire: "🔥", sparkles: "✨", star: "⭐", star2: "🌟", dizzy: "💫",
|
|
188
|
+
boom: "💥", collision: "💥", zap: "⚡", lightning: "⚡",
|
|
189
|
+
snowflake: "❄️", cloud: "☁️", sunny: "☀️", rainbow: "🌈",
|
|
190
|
+
rocket: "🚀", airplane: "✈️", 100: "💯",
|
|
191
|
+
check: "✅", white_check_mark: "✅", ballot_box_with_check: "☑️", heavy_check_mark: "✔️",
|
|
192
|
+
x: "❌", cross_mark: "❌", negative_squared_cross_mark: "❎",
|
|
193
|
+
warning: "⚠️", exclamation: "❗", question: "❓",
|
|
194
|
+
grey_exclamation: "❕", grey_question: "❔", bangbang: "‼️", interrobang: "⁉️",
|
|
195
|
+
bulb: "💡", memo: "📝", pencil: "✏️", pencil2: "✏️", pen: "🖊️",
|
|
196
|
+
lock: "🔒", unlock: "🔓", key: "🔑", bell: "🔔", no_bell: "🔕",
|
|
197
|
+
bookmark: "🔖", link: "🔗", paperclip: "📎", pushpin: "📌", scissors: "✂️",
|
|
198
|
+
file_folder: "📁", open_file_folder: "📂", page_facing_up: "📄", page_with_curl: "📃",
|
|
199
|
+
calendar: "📅", date: "📅", clipboard: "📋",
|
|
200
|
+
chart_with_upwards_trend: "📈", chart_with_downwards_trend: "📉", bar_chart: "📊",
|
|
201
|
+
email: "📧", envelope: "✉️", inbox_tray: "📥", outbox_tray: "📤",
|
|
202
|
+
package: "📦", mailbox: "📫", speech_balloon: "💬", thought_balloon: "💭",
|
|
203
|
+
mag: "🔍", mag_right: "🔎", gear: "⚙️", wrench: "🔧", hammer: "🔨",
|
|
204
|
+
hammer_and_wrench: "🛠️", tools: "🛠️", nut_and_bolt: "🔩", shield: "🛡️",
|
|
205
|
+
hourglass: "⌛", hourglass_flowing_sand: "⏳", watch: "⌚", alarm_clock: "⏰",
|
|
206
|
+
stopwatch: "⏱️", timer_clock: "⏲️",
|
|
207
|
+
|
|
208
|
+
// animals
|
|
209
|
+
see_no_evil: "🙈", hear_no_evil: "🙉", speak_no_evil: "🙊",
|
|
210
|
+
monkey: "🐒", monkey_face: "🐵", dog: "🐕", dog2: "🐕", cat: "🐈", cat2: "🐈",
|
|
211
|
+
tiger: "🐅", tiger2: "🐅", lion: "🦁", horse: "🐴", unicorn: "🦄",
|
|
212
|
+
cow: "🐄", cow2: "🐄", pig: "🐷", pig2: "🐷", chicken: "🐔", penguin: "🐧",
|
|
213
|
+
bird: "🐦", eagle: "🦅", duck: "🦆", owl: "🦉", bat: "🦇", wolf: "🐺",
|
|
214
|
+
fox_face: "🦊", bear: "🐻", panda_face: "🐼", koala: "🐨",
|
|
215
|
+
rabbit: "🐰", rabbit2: "🐇", mouse: "🐭", mouse2: "🐁", rat: "🐀", hamster: "🐹",
|
|
216
|
+
frog: "🐸", snake: "🐍", turtle: "🐢", lizard: "🦎", dragon: "🐉", dragon_face: "🐲",
|
|
217
|
+
whale: "🐋", whale2: "🐋", dolphin: "🐬", fish: "🐟", tropical_fish: "🐠",
|
|
218
|
+
blowfish: "🐡", shark: "🦈", octopus: "🐙", crab: "🦀", lobster: "🦞",
|
|
219
|
+
shrimp: "🦐", squid: "🦑", snail: "🐌", butterfly: "🦋", bug: "🐛",
|
|
220
|
+
ant: "🐜", bee: "🐝", honeybee: "🐝", spider: "🕷️", spider_web: "🕸️",
|
|
221
|
+
|
|
222
|
+
// food & drink
|
|
223
|
+
apple: "🍎", green_apple: "🍏", pear: "🍐", tangerine: "🍊", orange: "🍊",
|
|
224
|
+
lemon: "🍋", banana: "🍌", watermelon: "🍉", grapes: "🍇", strawberry: "🍓",
|
|
225
|
+
peach: "🍑", cherries: "🍒", pizza: "🍕", hamburger: "🍔", fries: "🍟",
|
|
226
|
+
hotdog: "🌭", sandwich: "🥪", taco: "🌮", burrito: "🌯", egg: "🥚", cooking: "🍳",
|
|
227
|
+
cake: "🍰", cookie: "🍪", chocolate_bar: "🍫", candy: "🍬", lollipop: "🍭",
|
|
228
|
+
ice_cream: "🍨", icecream: "🍦", doughnut: "🍩",
|
|
229
|
+
coffee: "☕", tea: "🍵", beer: "🍺", beers: "🍻", wine_glass: "🍷",
|
|
230
|
+
cocktail: "🍸", tropical_drink: "🍹", champagne: "🍾",
|
|
231
|
+
|
|
232
|
+
// objects
|
|
233
|
+
computer: "💻", keyboard: "⌨️", desktop_computer: "🖥️", printer: "🖨️",
|
|
234
|
+
mouse_three_button: "🖱️", trackball: "🖲️", joystick: "🕹️", video_game: "🎮",
|
|
235
|
+
phone: "📱", iphone: "📱", telephone: "☎️", telephone_receiver: "📞",
|
|
236
|
+
battery: "🔋", electric_plug: "🔌", camera: "📷", camera_flash: "📸",
|
|
237
|
+
video_camera: "📹", movie_camera: "🎥", film_projector: "📽️", tv: "📺",
|
|
238
|
+
radio: "📻", microphone: "🎤", headphones: "🎧", musical_note: "🎵", notes: "🎶",
|
|
239
|
+
art: "🎨", performing_arts: "🎭", tickets: "🎟️", clapper: "🎬",
|
|
240
|
+
books: "📚", book: "📖", notebook: "📓", newspaper: "📰", scroll: "📜",
|
|
241
|
+
moneybag: "💰", dollar: "💵", credit_card: "💳", gem: "💎", ring: "💍",
|
|
242
|
+
crown: "👑", tophat: "🎩", necktie: "👔", shirt: "👕", jeans: "👖",
|
|
243
|
+
dress: "👗", lipstick: "💄", kiss: "💋", footprints: "👣",
|
|
244
|
+
|
|
245
|
+
// arrows
|
|
246
|
+
arrow_up: "⬆️", arrow_down: "⬇️", arrow_left: "⬅️", arrow_right: "➡️",
|
|
247
|
+
arrow_upper_left: "↖️", arrow_upper_right: "↗️", arrow_lower_left: "↙️", arrow_lower_right: "↘️",
|
|
248
|
+
left_right_arrow: "↔️", arrow_up_down: "↕️",
|
|
249
|
+
arrows_counterclockwise: "🔄", arrows_clockwise: "🔃",
|
|
250
|
+
rewind: "⏪", fast_forward: "⏩", play_or_pause_button: "⏯️",
|
|
251
|
+
pause_button: "⏸️", stop_button: "⏹️", record_button: "⏺️",
|
|
252
|
+
|
|
253
|
+
// numbers
|
|
254
|
+
zero: "0️⃣", one: "1️⃣", two: "2️⃣", three: "3️⃣", four: "4️⃣",
|
|
255
|
+
five: "5️⃣", six: "6️⃣", seven: "7️⃣", eight: "8️⃣", nine: "9️⃣", keycap_ten: "🔟",
|
|
256
|
+
};
|
|
257
|
+
|
|
258
|
+
export function shortcodeToEmoji(shortcode: string): string {
|
|
259
|
+
return EMOJI_MAP[shortcode] ?? `:${shortcode}:`;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
export function formatReactions(reactions: { emoji: string; count: number }[]): string {
|
|
263
|
+
if (reactions.length === 0) return "";
|
|
264
|
+
return reactions.map((r) => {
|
|
265
|
+
const emoji = shortcodeToEmoji(r.emoji);
|
|
266
|
+
return `${emoji}${r.count > 1 ? r.count : ""}`;
|
|
267
|
+
}).join(" ");
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
export function wrapText(text: string, width: number, indent: string): string[] {
|
|
271
|
+
const lines: string[] = [];
|
|
272
|
+
const paragraphs = text.split(/\n/);
|
|
273
|
+
|
|
274
|
+
for (const paragraph of paragraphs) {
|
|
275
|
+
if (paragraph.trim() === "") {
|
|
276
|
+
lines.push("");
|
|
277
|
+
continue;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
const words = paragraph.split(/\s+/);
|
|
281
|
+
let currentLine = "";
|
|
282
|
+
|
|
283
|
+
for (const word of words) {
|
|
284
|
+
if (currentLine.length + word.length + 1 <= width) {
|
|
285
|
+
currentLine += (currentLine ? " " : "") + word;
|
|
286
|
+
} else {
|
|
287
|
+
if (currentLine) lines.push(indent + currentLine);
|
|
288
|
+
currentLine = word;
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
if (currentLine) lines.push(indent + currentLine);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
return lines;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function getActorName(comment: Comment): string {
|
|
298
|
+
return comment.externalUser ?? comment.user ?? comment.botActor ?? "unknown";
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function getSourceLabel(comment: Comment): string {
|
|
302
|
+
const sync = comment.syncedWith[0];
|
|
303
|
+
if (!sync) return "";
|
|
304
|
+
const serviceName = sync.service.charAt(0).toUpperCase() + sync.service.slice(1).toLowerCase();
|
|
305
|
+
return ` via ${serviceName}`;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function getSyncChannelName(comment: Comment): string | undefined {
|
|
309
|
+
const sync = comment.syncedWith[0];
|
|
310
|
+
if (!sync) return undefined;
|
|
311
|
+
|
|
312
|
+
if (sync.meta.type === "slack") {
|
|
313
|
+
return sync.meta.channelName;
|
|
314
|
+
}
|
|
315
|
+
if (sync.meta.type === "github" && sync.meta.repo) {
|
|
316
|
+
return sync.meta.owner ? `${sync.meta.owner}/${sync.meta.repo}` : sync.meta.repo;
|
|
317
|
+
}
|
|
318
|
+
if (sync.meta.type === "jira" && sync.meta.issueKey) {
|
|
319
|
+
return sync.meta.issueKey;
|
|
320
|
+
}
|
|
321
|
+
return undefined;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
function formatCommentHeader(
|
|
325
|
+
comment: Comment,
|
|
326
|
+
isThreadRoot: boolean,
|
|
327
|
+
replyCount?: number,
|
|
328
|
+
threadUrl?: string
|
|
329
|
+
): string {
|
|
330
|
+
const sync = comment.syncedWith[0];
|
|
331
|
+
const time = formatRelativeTime(comment.createdAt);
|
|
332
|
+
|
|
333
|
+
if (isThreadRoot && sync) {
|
|
334
|
+
const channelName = getSyncChannelName(comment);
|
|
335
|
+
const channelPart = channelName ? ` in #${chalk.white(channelName)}` : "";
|
|
336
|
+
const serviceName = sync.service.charAt(0).toUpperCase() + sync.service.slice(1).toLowerCase();
|
|
337
|
+
let header = `${chalk.white(serviceName)} thread connected${channelPart} ${chalk.dim(time)}`;
|
|
338
|
+
|
|
339
|
+
if (replyCount && replyCount > 3 && threadUrl) {
|
|
340
|
+
header += `\n└ ${chalk.dim(`${replyCount - 3} previous replies,`)} [view all](${threadUrl})`;
|
|
341
|
+
}
|
|
342
|
+
return header;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
const actor = chalk.white(`@${getActorName(comment)}`);
|
|
346
|
+
const source = chalk.dim(getSourceLabel(comment));
|
|
347
|
+
return `${actor} ${chalk.dim(time)}${source}`;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
function outputSingleComment(comment: Comment, indent: string): void {
|
|
351
|
+
const bodyLines = wrapText(comment.body.trim(), 60, indent + "└ ");
|
|
352
|
+
for (const line of bodyLines) {
|
|
353
|
+
console.log(line);
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
const reactions = formatReactions(comment.reactions);
|
|
357
|
+
if (reactions) {
|
|
358
|
+
console.log(`${indent}└ ${chalk.dim(`[${reactions}]`)}`);
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
function renderCommentRecursive(
|
|
363
|
+
comment: Comment,
|
|
364
|
+
childMap: Map<string | null, Comment[]>,
|
|
365
|
+
depth: number,
|
|
366
|
+
maxRepliesPerLevel: number
|
|
367
|
+
): void {
|
|
368
|
+
const indent = " ".repeat(depth);
|
|
369
|
+
const header = formatCommentHeader(comment, false);
|
|
370
|
+
console.log(`${indent}└ ${header}`);
|
|
371
|
+
outputSingleComment(comment, indent + " ");
|
|
372
|
+
|
|
373
|
+
const children = childMap.get(comment.id) ?? [];
|
|
374
|
+
const recentChildren = children.slice(-maxRepliesPerLevel);
|
|
375
|
+
|
|
376
|
+
for (const child of recentChildren) {
|
|
377
|
+
renderCommentRecursive(child, childMap, depth + 1, maxRepliesPerLevel);
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
export function outputCommentThreads(comments: Comment[], maxThreads = 3): void {
|
|
382
|
+
if (comments.length === 0) {
|
|
383
|
+
console.log(chalk.dim("no comments"));
|
|
384
|
+
return;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
const childMap = buildChildMap(comments);
|
|
388
|
+
const rootComments = childMap.get(null) ?? [];
|
|
389
|
+
const recentRoots = rootComments.slice(-maxThreads);
|
|
390
|
+
|
|
391
|
+
for (let i = 0; i < recentRoots.length; i++) {
|
|
392
|
+
const root = recentRoots[i];
|
|
393
|
+
if (!root) continue;
|
|
394
|
+
|
|
395
|
+
const children = childMap.get(root.id) ?? [];
|
|
396
|
+
const totalReplies = children.length;
|
|
397
|
+
const threadUrl = root.url;
|
|
398
|
+
const hasSync = root.syncedWith.length > 0;
|
|
399
|
+
|
|
400
|
+
console.log(formatCommentHeader(root, true, totalReplies, threadUrl));
|
|
401
|
+
|
|
402
|
+
if (!hasSync) {
|
|
403
|
+
outputSingleComment(root, "");
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
const recentChildren = children.slice(-3);
|
|
407
|
+
for (const child of recentChildren) {
|
|
408
|
+
renderCommentRecursive(child, childMap, 0, 3);
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
if (i < recentRoots.length - 1) {
|
|
412
|
+
console.log();
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import
|
|
1
|
+
import { z } from "zod";
|
|
2
2
|
import {
|
|
3
3
|
setApiKey,
|
|
4
4
|
clearApiKey,
|
|
@@ -6,22 +6,29 @@ import {
|
|
|
6
6
|
createClientWithKey,
|
|
7
7
|
getViewer,
|
|
8
8
|
} from "@bdsqqq/lnr-core";
|
|
9
|
+
import { router, procedure } from "./trpc";
|
|
9
10
|
import { exitWithError, EXIT_CODES } from "../lib/error";
|
|
10
11
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
12
|
+
const authInput = z.object({
|
|
13
|
+
apiKey: z.string().optional().meta({ positional: true }).describe("Linear API key"),
|
|
14
|
+
whoami: z.boolean().optional().describe("show current authenticated user"),
|
|
15
|
+
logout: z.boolean().optional().describe("clear stored credentials"),
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
export const authRouter = router({
|
|
19
|
+
auth: procedure
|
|
20
|
+
.meta({
|
|
21
|
+
description: "authenticate with Linear API",
|
|
22
|
+
})
|
|
23
|
+
.input(authInput)
|
|
24
|
+
.mutation(async ({ input }) => {
|
|
25
|
+
if (input.logout) {
|
|
19
26
|
clearApiKey();
|
|
20
27
|
console.log("logged out");
|
|
21
28
|
return;
|
|
22
29
|
}
|
|
23
30
|
|
|
24
|
-
if (
|
|
31
|
+
if (input.whoami) {
|
|
25
32
|
const storedKey = getApiKey();
|
|
26
33
|
if (!storedKey) {
|
|
27
34
|
exitWithError("not authenticated", "run: lnr auth <api-key>", EXIT_CODES.AUTH_ERROR);
|
|
@@ -37,17 +44,17 @@ export function registerAuthCommand(program: Command): void {
|
|
|
37
44
|
return;
|
|
38
45
|
}
|
|
39
46
|
|
|
40
|
-
if (!apiKey) {
|
|
47
|
+
if (!input.apiKey) {
|
|
41
48
|
exitWithError("api key required", "usage: lnr auth <api-key>");
|
|
42
49
|
}
|
|
43
50
|
|
|
44
51
|
try {
|
|
45
|
-
const client = createClientWithKey(apiKey);
|
|
52
|
+
const client = createClientWithKey(input.apiKey);
|
|
46
53
|
const viewer = await getViewer(client);
|
|
47
|
-
setApiKey(apiKey);
|
|
54
|
+
setApiKey(input.apiKey);
|
|
48
55
|
console.log(`authenticated as ${viewer.name}`);
|
|
49
56
|
} catch {
|
|
50
57
|
exitWithError("invalid api key", "get one from: https://linear.app/settings/account/security", EXIT_CODES.AUTH_ERROR);
|
|
51
58
|
}
|
|
52
|
-
})
|
|
53
|
-
}
|
|
59
|
+
}),
|
|
60
|
+
});
|