@yannelli/paseo-linear-plugin 0.1.0 → 0.3.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.
Files changed (49) hide show
  1. package/NOTICE +8 -4
  2. package/OVERVIEW.md +22 -13
  3. package/README.md +134 -29
  4. package/client/agent-options.ts +200 -0
  5. package/client/breadcrumbs.tsx +110 -0
  6. package/client/browser.tsx +275 -0
  7. package/client/compat.ts +107 -0
  8. package/client/connect.tsx +136 -0
  9. package/client/create-issue.tsx +291 -0
  10. package/client/glyphs.tsx +117 -0
  11. package/client/issue-detail.tsx +366 -0
  12. package/client/issue-list.tsx +373 -0
  13. package/client/issue-row.tsx +152 -0
  14. package/client/issue-sections.tsx +325 -0
  15. package/client/issue-tree.ts +132 -0
  16. package/client/key-scope.tsx +23 -0
  17. package/client/launch-choices.ts +125 -0
  18. package/client/launch-fields.tsx +152 -0
  19. package/client/launch-plan.ts +227 -0
  20. package/client/launch.tsx +484 -0
  21. package/client/markdown.tsx +240 -0
  22. package/client/model-browser.tsx +296 -0
  23. package/client/pickers.tsx +238 -0
  24. package/client/provider-icon.tsx +78 -0
  25. package/client/queries.ts +211 -0
  26. package/client/settings-connection.tsx +109 -0
  27. package/client/settings-fields.tsx +173 -0
  28. package/client/settings-keys.tsx +117 -0
  29. package/client/settings-projects.tsx +281 -0
  30. package/client/settings-prompts.tsx +198 -0
  31. package/client/store.ts +71 -0
  32. package/client/timeline-card.tsx +63 -0
  33. package/client/ui.tsx +352 -0
  34. package/index.client.tsx +111 -0
  35. package/index.server.ts +7 -3
  36. package/package.json +4 -3
  37. package/paseo-plugin.json +2 -2
  38. package/server/cache.ts +68 -0
  39. package/server/credentials.ts +101 -0
  40. package/server/graphql.ts +68 -0
  41. package/server/handlers.ts +172 -0
  42. package/server/linear.ts +11 -64
  43. package/server/queries.ts +280 -0
  44. package/shared/linear.ts +268 -0
  45. package/shared/markdown.ts +416 -0
  46. package/shared/prompts.ts +150 -0
  47. package/shared/settings.ts +68 -0
  48. package/index.client.ts +0 -7
  49. package/server/issues.ts +0 -10
@@ -0,0 +1,291 @@
1
+ import { useRpc } from "@getpaseo/plugin/client";
2
+ import { Icon, Modal, TextInput, useToast } from "@getpaseo/plugin/client/react-native";
3
+ import { useMutation, useQueryClient } from "@tanstack/react-query";
4
+ import { useCallback, useMemo, useState } from "react";
5
+ import { Text, View } from "react-native";
6
+ import { createIssueRpc, type LinearTeam, type LinearUser } from "../shared/linear";
7
+ import {
8
+ assigneeOptions,
9
+ type PickerOption,
10
+ PickerPage,
11
+ priorityOptions,
12
+ PropertyChip,
13
+ stateOptions,
14
+ UNASSIGNED,
15
+ } from "./pickers";
16
+ import { useKeyScope } from "./key-scope";
17
+ import { queryKeys } from "./queries";
18
+ import {
19
+ Avatar,
20
+ Button,
21
+ errorMessage,
22
+ priorityMeta,
23
+ PriorityIcon,
24
+ StateIcon,
25
+ type Theme,
26
+ } from "./ui";
27
+
28
+ type Picker = "team" | "state" | "priority" | "assignee";
29
+
30
+ interface CreateIssueProps {
31
+ theme: Theme;
32
+ teams: readonly LinearTeam[];
33
+ users: readonly LinearUser[];
34
+ viewerId: string | null;
35
+ defaultTeamId: string | null;
36
+ onClose(): void;
37
+ onCreated(issueId: string): void;
38
+ }
39
+
40
+ interface Draft {
41
+ teamId: string | null;
42
+ stateId: string | null;
43
+ priority: number;
44
+ assigneeId: string | null;
45
+ }
46
+
47
+ const PICKER_TITLES: Record<Picker, string> = {
48
+ team: "Team",
49
+ state: "Status",
50
+ priority: "Priority",
51
+ assignee: "Assignee",
52
+ };
53
+
54
+ function pickerOptions(kind: Picker, props: CreateIssueProps, team: LinearTeam | null) {
55
+ if (kind === "team") {
56
+ return props.teams.map((entry) => ({ value: entry.id, label: entry.name, detail: entry.key }));
57
+ }
58
+ if (kind === "state") return stateOptions(team?.states ?? []);
59
+ if (kind === "priority") return priorityOptions(props.theme);
60
+ return assigneeOptions(props.theme, props.users, props.viewerId);
61
+ }
62
+
63
+ function pickerValue(kind: Picker, draft: Draft): string | null {
64
+ if (kind === "team") return draft.teamId;
65
+ if (kind === "state") return draft.stateId;
66
+ if (kind === "priority") return String(draft.priority);
67
+ return draft.assigneeId ?? UNASSIGNED;
68
+ }
69
+
70
+ function applyPick(kind: Picker, value: string, draft: Draft): Draft {
71
+ if (kind === "team") return { ...draft, teamId: value, stateId: null };
72
+ if (kind === "state") return { ...draft, stateId: value };
73
+ if (kind === "priority") return { ...draft, priority: Number(value) };
74
+ return { ...draft, assigneeId: value === UNASSIGNED ? null : value };
75
+ }
76
+
77
+ export function CreateIssueModal(props: CreateIssueProps) {
78
+ const { theme, onClose, onCreated } = props;
79
+ const { colors } = theme;
80
+ const toast = useToast();
81
+ const queries = useQueryClient();
82
+ const createIssue = useRpc(createIssueRpc);
83
+ const scope = useKeyScope();
84
+ const [draft, setDraft] = useState<Draft>(() => ({
85
+ teamId: props.defaultTeamId ?? props.teams[0]?.id ?? null,
86
+ stateId: null,
87
+ priority: 0,
88
+ assigneeId: props.viewerId,
89
+ }));
90
+ const [title, setTitle] = useState("");
91
+ const [description, setDescription] = useState("");
92
+ const [picker, setPicker] = useState<Picker | null>(null);
93
+ const team = props.teams.find((entry) => entry.id === draft.teamId) ?? null;
94
+
95
+ const create = useMutation({
96
+ mutationFn: () => {
97
+ if (!draft.teamId) throw new Error("Choose a team");
98
+ return createIssue({
99
+ teamId: draft.teamId,
100
+ title,
101
+ description: description.trim() || undefined,
102
+ priority: draft.priority,
103
+ assigneeId: draft.assigneeId,
104
+ ...(draft.stateId ? { stateId: draft.stateId } : {}),
105
+ projectId: scope,
106
+ });
107
+ },
108
+ onSuccess: ({ issue }) => {
109
+ void queries.invalidateQueries({ queryKey: queryKeys.issues(scope) });
110
+ toast.show(`Created ${issue.identifier}`, { variant: "success" });
111
+ onCreated(issue.id);
112
+ },
113
+ });
114
+ const { mutate } = create;
115
+ const submit = useCallback(() => mutate(), [mutate]);
116
+ const icon = useMemo(() => <Icon name="Plus" size={18} color={colors.foreground} />, [colors]);
117
+ const openChange = useCallback(
118
+ (open: boolean) => {
119
+ if (!open) onClose();
120
+ },
121
+ [onClose],
122
+ );
123
+ const closePicker = useCallback(() => setPicker(null), []);
124
+ const select = useCallback(
125
+ (value: string) => {
126
+ if (!picker) return;
127
+ setDraft((current) => applyPick(picker, value, current));
128
+ setPicker(null);
129
+ },
130
+ [picker],
131
+ );
132
+ const options: PickerOption[] = useMemo(
133
+ () => (picker ? pickerOptions(picker, props, team) : []),
134
+ [picker, props, team],
135
+ );
136
+ const styles = useMemo(() => {
137
+ const input = {
138
+ paddingHorizontal: 12,
139
+ borderRadius: 8,
140
+ borderWidth: 1,
141
+ borderColor: colors.border,
142
+ backgroundColor: colors.surface2,
143
+ color: colors.foreground,
144
+ fontSize: 14,
145
+ };
146
+ return {
147
+ title: { ...input, height: 42, fontSize: 15 },
148
+ description: {
149
+ ...input,
150
+ minHeight: 120,
151
+ paddingVertical: 10,
152
+ textAlignVertical: "top" as const,
153
+ },
154
+ error: { color: colors.statusDanger, fontSize: 13 },
155
+ };
156
+ }, [colors]);
157
+
158
+ return (
159
+ <Modal title="New issue" icon={icon} open onOpenChange={openChange}>
160
+ <Modal.Content>
161
+ {picker ? (
162
+ <PickerPage
163
+ theme={theme}
164
+ title={PICKER_TITLES[picker]}
165
+ options={options}
166
+ value={pickerValue(picker, draft)}
167
+ searchable={picker === "assignee" || picker === "team"}
168
+ onBack={closePicker}
169
+ onSelect={select}
170
+ />
171
+ ) : (
172
+ <>
173
+ <DraftChips
174
+ theme={theme}
175
+ draft={draft}
176
+ team={team}
177
+ users={props.users}
178
+ onPick={setPicker}
179
+ />
180
+ <TextInput
181
+ value={title}
182
+ onChangeText={setTitle}
183
+ placeholder="Issue title"
184
+ placeholderTextColor={colors.foregroundMuted}
185
+ accessibilityLabel="Issue title"
186
+ autoFocus
187
+ style={styles.title}
188
+ />
189
+ <TextInput
190
+ value={description}
191
+ onChangeText={setDescription}
192
+ multiline
193
+ placeholder="Add a description (Markdown)"
194
+ placeholderTextColor={colors.foregroundMuted}
195
+ accessibilityLabel="Issue description"
196
+ style={styles.description}
197
+ />
198
+ {create.error ? (
199
+ <Text accessibilityRole="alert" style={styles.error}>
200
+ {errorMessage(create.error)}
201
+ </Text>
202
+ ) : null}
203
+ <Button
204
+ theme={theme}
205
+ variant="primary"
206
+ size="md"
207
+ icon="Plus"
208
+ label="Create issue"
209
+ busy={create.isPending}
210
+ disabled={!title.trim() || !draft.teamId}
211
+ onPress={submit}
212
+ />
213
+ </>
214
+ )}
215
+ </Modal.Content>
216
+ </Modal>
217
+ );
218
+ }
219
+
220
+ const CHIPS_STYLE = { flexDirection: "row", flexWrap: "wrap", gap: 8 } as const;
221
+
222
+ function DraftChips(props: {
223
+ theme: Theme;
224
+ draft: Draft;
225
+ team: LinearTeam | null;
226
+ users: readonly LinearUser[];
227
+ onPick(kind: Picker): void;
228
+ }) {
229
+ const { theme, draft, team, onPick } = props;
230
+ const { colors } = theme;
231
+ const state = team?.states.find((entry) => entry.id === draft.stateId) ?? null;
232
+ const assignee = props.users.find((user) => user.id === draft.assigneeId) ?? null;
233
+ const assigneeName = assignee?.displayName ?? null;
234
+ const teamIcon = useMemo(
235
+ () => <Icon name="Users" size={14} color={colors.foregroundMuted} />,
236
+ [colors],
237
+ );
238
+ const stateIcon = useMemo(
239
+ () =>
240
+ state ? (
241
+ <StateIcon state={state} size={14} />
242
+ ) : (
243
+ <Icon name="Circle" size={14} color={colors.foregroundMuted} />
244
+ ),
245
+ [state, colors],
246
+ );
247
+ const priorityIcon = useMemo(
248
+ () => <PriorityIcon theme={theme} priority={draft.priority} size={14} />,
249
+ [theme, draft.priority],
250
+ );
251
+ const avatar = useMemo(
252
+ () => <Avatar theme={theme} name={assigneeName} size={16} />,
253
+ [theme, assigneeName],
254
+ );
255
+ const pickTeam = useCallback(() => onPick("team"), [onPick]);
256
+ const pickState = useCallback(() => onPick("state"), [onPick]);
257
+ const pickPriority = useCallback(() => onPick("priority"), [onPick]);
258
+ const pickAssignee = useCallback(() => onPick("assignee"), [onPick]);
259
+ return (
260
+ <View style={CHIPS_STYLE}>
261
+ <PropertyChip
262
+ theme={theme}
263
+ label={team?.name ?? "Team"}
264
+ leading={teamIcon}
265
+ accessibilityLabel="Team"
266
+ onPress={pickTeam}
267
+ />
268
+ <PropertyChip
269
+ theme={theme}
270
+ label={state?.name ?? "Default status"}
271
+ leading={stateIcon}
272
+ accessibilityLabel="Status"
273
+ onPress={pickState}
274
+ />
275
+ <PropertyChip
276
+ theme={theme}
277
+ label={priorityMeta(draft.priority).label}
278
+ leading={priorityIcon}
279
+ accessibilityLabel="Priority"
280
+ onPress={pickPriority}
281
+ />
282
+ <PropertyChip
283
+ theme={theme}
284
+ label={assigneeName ?? "Unassigned"}
285
+ leading={avatar}
286
+ accessibilityLabel="Assignee"
287
+ onPress={pickAssignee}
288
+ />
289
+ </View>
290
+ );
291
+ }
@@ -0,0 +1,117 @@
1
+ import type { PluginSurfaceProps } from "@getpaseo/plugin/client";
2
+ import { Icon } from "@getpaseo/plugin/client/react-native";
3
+ import { useMemo } from "react";
4
+ import { Text, View } from "react-native";
5
+ import { PRIORITIES } from "../shared/linear";
6
+
7
+ type Theme = PluginSurfaceProps["theme"];
8
+
9
+ const STATE_ICONS: Record<string, string> = {
10
+ triage: "CircleDot",
11
+ backlog: "CircleDashed",
12
+ unstarted: "Circle",
13
+ started: "CircleDot",
14
+ completed: "CircleCheck",
15
+ canceled: "CircleX",
16
+ duplicate: "CircleX",
17
+ };
18
+
19
+ export function StateIcon({
20
+ state,
21
+ size = 16,
22
+ }: {
23
+ state: { type: string; color: string };
24
+ size?: number;
25
+ }) {
26
+ return <Icon name={STATE_ICONS[state.type] ?? "Circle"} size={size} color={state.color} />;
27
+ }
28
+
29
+ export function priorityMeta(priority: number) {
30
+ return PRIORITIES.find((entry) => entry.value === priority) ?? PRIORITIES[0];
31
+ }
32
+
33
+ export function PriorityIcon({
34
+ theme,
35
+ priority,
36
+ size = 16,
37
+ }: {
38
+ theme: Theme;
39
+ priority: number;
40
+ size?: number;
41
+ }) {
42
+ const meta = priorityMeta(priority);
43
+ const color = priority === 1 ? theme.colors.statusDanger : theme.colors.foregroundMuted;
44
+ return <Icon name={meta.icon} size={size} color={color} />;
45
+ }
46
+
47
+ function initials(name: string): string {
48
+ const parts = name.trim().split(/\s+/).filter(Boolean);
49
+ const letters =
50
+ parts.length > 1 ? `${parts[0]?.[0] ?? ""}${parts[1]?.[0] ?? ""}` : name.slice(0, 2);
51
+ return letters.toUpperCase() || "?";
52
+ }
53
+
54
+ export function Avatar({
55
+ theme,
56
+ name,
57
+ size = 20,
58
+ }: {
59
+ theme: Theme;
60
+ name: string | null;
61
+ size?: number;
62
+ }) {
63
+ const { colors } = theme;
64
+ const styles = useMemo(
65
+ () =>
66
+ ({
67
+ circle: {
68
+ width: size,
69
+ height: size,
70
+ borderRadius: size / 2,
71
+ alignItems: "center",
72
+ justifyContent: "center",
73
+ backgroundColor: colors.surface2,
74
+ borderWidth: 1,
75
+ borderColor: colors.border,
76
+ },
77
+ letters: { fontSize: Math.max(8, size * 0.42), color: colors.foreground },
78
+ }) as const,
79
+ [colors, size],
80
+ );
81
+ if (!name) return <Icon name="CircleUser" size={size} color={colors.foregroundMuted} />;
82
+ return (
83
+ <View accessibilityLabel={name} style={styles.circle}>
84
+ <Text style={styles.letters}>{initials(name)}</Text>
85
+ </View>
86
+ );
87
+ }
88
+
89
+ export function LabelPill({ theme, name, color }: { theme: Theme; name: string; color: string }) {
90
+ const { colors } = theme;
91
+ const styles = useMemo(
92
+ () =>
93
+ ({
94
+ pill: {
95
+ flexDirection: "row",
96
+ alignItems: "center",
97
+ gap: 5,
98
+ height: 22,
99
+ paddingHorizontal: 8,
100
+ borderRadius: 11,
101
+ borderWidth: 1,
102
+ borderColor: colors.border,
103
+ },
104
+ dot: { width: 7, height: 7, borderRadius: 4, backgroundColor: color },
105
+ name: { fontSize: 12, color: colors.foregroundMuted },
106
+ }) as const,
107
+ [colors, color],
108
+ );
109
+ return (
110
+ <View style={styles.pill}>
111
+ <View style={styles.dot} />
112
+ <Text numberOfLines={1} style={styles.name}>
113
+ {name}
114
+ </Text>
115
+ </View>
116
+ );
117
+ }