@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,366 @@
1
+ import { openExternalUrl, type PluginSurfaceProps } from "@getpaseo/plugin/client";
2
+ import { copyText, Icon, useToast } from "@getpaseo/plugin/client/react-native";
3
+ import { useCallback, useMemo, useState } from "react";
4
+ import { ScrollView, Text, View } from "react-native";
5
+ import type { IssueDetail, IssuePatch, LinearTeam, LinearUser } from "../shared/linear";
6
+ import { ACTION_LABELS } from "../shared/prompts";
7
+ import type { AgentAction } from "../shared/settings";
8
+ import { IssueBreadcrumbs } from "./breadcrumbs";
9
+ import { Markdown } from "./markdown";
10
+ import { Comments, IssueLinks, IssueRelations, LinkedAgents } from "./issue-sections";
11
+ import type { LaunchTarget } from "./launch";
12
+ import {
13
+ assigneeOptions,
14
+ PickerModal,
15
+ priorityOptions,
16
+ PropertyChip,
17
+ stateOptions,
18
+ UNASSIGNED,
19
+ } from "./pickers";
20
+ import { useIssue, useUpdateIssue } from "./queries";
21
+ import {
22
+ Avatar,
23
+ Button,
24
+ EmptyState,
25
+ errorMessage,
26
+ IconButton,
27
+ LabelPill,
28
+ priorityMeta,
29
+ PriorityIcon,
30
+ SectionLabel,
31
+ StateIcon,
32
+ type Theme,
33
+ } from "./ui";
34
+
35
+ type PickerKind = "state" | "priority" | "assignee";
36
+ type Picker = PickerKind | null;
37
+
38
+ const PICKER_META: Record<PickerKind, { title: string; icon: string }> = {
39
+ state: { title: "Status", icon: "CircleDot" },
40
+ priority: { title: "Priority", icon: "SignalHigh" },
41
+ assignee: { title: "Assignee", icon: "UserRound" },
42
+ };
43
+ const ROW_WRAP_STYLE = { flexDirection: "row", flexWrap: "wrap", gap: 8 } as const;
44
+ const LABELS_STYLE = { flexDirection: "row", flexWrap: "wrap", gap: 6, marginTop: -8 } as const;
45
+ const ROOT_STYLE = { flex: 1, minHeight: 0 } as const;
46
+ const SCROLL_STYLE = { flex: 1 } as const;
47
+
48
+ function pickerValue(picker: Picker, issue: IssueDetail): string {
49
+ if (picker === "state") return issue.state.id;
50
+ if (picker === "priority") return String(issue.priority);
51
+ return issue.assignee?.id ?? UNASSIGNED;
52
+ }
53
+
54
+ function pickerPatch(picker: Picker, value: string): IssuePatch {
55
+ if (picker === "state") return { stateId: value };
56
+ if (picker === "priority") return { priority: Number(value) };
57
+ return { assigneeId: value === UNASSIGNED ? null : value };
58
+ }
59
+
60
+ export interface IssueDetailProps {
61
+ theme: Theme;
62
+ compact: boolean;
63
+ issueId: string;
64
+ teams: readonly LinearTeam[];
65
+ users: readonly LinearUser[];
66
+ viewerId: string | null;
67
+ target: LaunchTarget;
68
+ navigation: PluginSurfaceProps["navigation"];
69
+ onBack(): void;
70
+ onOpenIssue(issueId: string): void;
71
+ onLaunch(action: AgentAction): void;
72
+ }
73
+
74
+ export function IssueDetailView(props: IssueDetailProps) {
75
+ const { theme } = props;
76
+ const issue = useIssue(props.issueId);
77
+ const { refetch } = issue;
78
+ const reload = useCallback(() => void refetch(), [refetch]);
79
+ if (issue.isPending) return <EmptyState theme={theme} icon="RefreshCw" title="Loading issue" />;
80
+ if (issue.isError) {
81
+ return (
82
+ <EmptyState
83
+ theme={theme}
84
+ icon="TriangleAlert"
85
+ tone="danger"
86
+ title="Could not load the issue"
87
+ detail={errorMessage(issue.error)}
88
+ >
89
+ <Button theme={theme} label="Back to issues" icon="ChevronLeft" onPress={props.onBack} />
90
+ </EmptyState>
91
+ );
92
+ }
93
+ return <LoadedIssue {...props} issue={issue.data} refetch={reload} />;
94
+ }
95
+
96
+ function LoadedIssue(props: IssueDetailProps & { issue: IssueDetail; refetch(): void }) {
97
+ const { theme, issue, compact } = props;
98
+ const { colors } = theme;
99
+ const [picker, setPicker] = useState<Picker>(null);
100
+ const update = useUpdateIssue(issue.id);
101
+ const styles = useMemo(() => {
102
+ const padding = compact ? 16 : 24;
103
+ return {
104
+ content: { padding, gap: 20, paddingBottom: 48 },
105
+ title: {
106
+ color: colors.foreground,
107
+ fontSize: compact ? 18 : 20,
108
+ fontWeight: "500",
109
+ lineHeight: compact ? 24 : 28,
110
+ },
111
+ description: { color: colors.foregroundMuted, fontSize: 14, lineHeight: 21 },
112
+ } as const;
113
+ }, [colors, compact]);
114
+ return (
115
+ <View style={ROOT_STYLE}>
116
+ <IssueHeader
117
+ theme={theme}
118
+ compact={compact}
119
+ issue={issue}
120
+ onBack={props.onBack}
121
+ onRefresh={props.refetch}
122
+ onOpenIssue={props.onOpenIssue}
123
+ />
124
+ <ScrollView style={SCROLL_STYLE} contentContainerStyle={styles.content}>
125
+ <Text selectable style={styles.title}>
126
+ {issue.title}
127
+ </Text>
128
+ <IssueProperties
129
+ theme={theme}
130
+ issue={issue}
131
+ disabled={update.isPending}
132
+ onPick={setPicker}
133
+ />
134
+ {issue.labels.length > 0 ? (
135
+ <View style={LABELS_STYLE}>
136
+ {issue.labels.map((label) => (
137
+ <LabelPill key={label.id} theme={theme} name={label.name} color={label.color} />
138
+ ))}
139
+ </View>
140
+ ) : null}
141
+ <LaunchButtons theme={theme} onLaunch={props.onLaunch} />
142
+ <LinkedAgents theme={theme} identifier={issue.identifier} navigation={props.navigation} />
143
+ <View>
144
+ <SectionLabel theme={theme}>Description</SectionLabel>
145
+ {issue.description?.trim() ? (
146
+ <Markdown theme={theme} compact={compact} source={issue.description} />
147
+ ) : (
148
+ <Text style={styles.description}>No description.</Text>
149
+ )}
150
+ </View>
151
+ <IssueRelations theme={theme} issue={issue} onOpenIssue={props.onOpenIssue} />
152
+ <IssueLinks theme={theme} issue={issue} />
153
+ <Comments theme={theme} issue={issue} />
154
+ </ScrollView>
155
+ <IssuePicker
156
+ theme={theme}
157
+ issue={issue}
158
+ picker={picker}
159
+ teams={props.teams}
160
+ users={props.users}
161
+ viewerId={props.viewerId}
162
+ onUpdate={update.mutate}
163
+ onClose={setPicker}
164
+ />
165
+ </View>
166
+ );
167
+ }
168
+
169
+ function IssueHeader(props: {
170
+ theme: Theme;
171
+ compact: boolean;
172
+ issue: IssueDetail;
173
+ onBack(): void;
174
+ onRefresh(): void;
175
+ onOpenIssue(issueId: string): void;
176
+ }) {
177
+ const { theme, compact, issue } = props;
178
+ const { colors } = theme;
179
+ const toast = useToast();
180
+ const { branchName, url } = issue;
181
+ const styles = useMemo(
182
+ () =>
183
+ ({
184
+ bar: {
185
+ height: 48,
186
+ paddingHorizontal: compact ? 8 : 12,
187
+ flexDirection: "row",
188
+ alignItems: "center",
189
+ gap: 4,
190
+ borderBottomWidth: 1,
191
+ borderBottomColor: colors.border,
192
+ },
193
+ }) as const,
194
+ [colors, compact],
195
+ );
196
+ const copyBranch = useCallback(async () => {
197
+ try {
198
+ await copyText(branchName);
199
+ toast.show(`Copied ${branchName}`, { variant: "success" });
200
+ } catch {
201
+ toast.error("Could not copy the branch name");
202
+ }
203
+ }, [branchName, toast]);
204
+ const pressCopy = useCallback(() => void copyBranch(), [copyBranch]);
205
+ const openInLinear = useCallback(() => void openExternalUrl(url), [url]);
206
+ return (
207
+ <View style={styles.bar}>
208
+ <IconButton
209
+ theme={theme}
210
+ icon={compact ? "ChevronLeft" : "X"}
211
+ label="Close issue"
212
+ onPress={props.onBack}
213
+ />
214
+ <IssueBreadcrumbs
215
+ theme={theme}
216
+ compact={compact}
217
+ issue={issue}
218
+ onOpenIssue={props.onOpenIssue}
219
+ />
220
+ <IconButton theme={theme} icon="RefreshCw" label="Refresh issue" onPress={props.onRefresh} />
221
+ <IconButton theme={theme} icon="GitBranch" label="Copy branch name" onPress={pressCopy} />
222
+ <IconButton theme={theme} icon="ExternalLink" label="Open in Linear" onPress={openInLinear} />
223
+ </View>
224
+ );
225
+ }
226
+
227
+ function IssueProperties(props: {
228
+ theme: Theme;
229
+ issue: IssueDetail;
230
+ disabled: boolean;
231
+ onPick(picker: PickerKind): void;
232
+ }) {
233
+ const { theme, issue, onPick } = props;
234
+ const { colors } = theme;
235
+ const assigneeName = issue.assignee?.displayName ?? null;
236
+ const stateIcon = useMemo(() => <StateIcon state={issue.state} size={14} />, [issue.state]);
237
+ const priorityIcon = useMemo(
238
+ () => <PriorityIcon theme={theme} priority={issue.priority} size={14} />,
239
+ [theme, issue.priority],
240
+ );
241
+ const avatar = useMemo(
242
+ () => <Avatar theme={theme} name={assigneeName} size={16} />,
243
+ [theme, assigneeName],
244
+ );
245
+ const projectStyles = useMemo(
246
+ () =>
247
+ ({
248
+ row: {
249
+ height: 30,
250
+ flexDirection: "row",
251
+ alignItems: "center",
252
+ gap: 6,
253
+ paddingHorizontal: 4,
254
+ },
255
+ name: { color: colors.foregroundMuted, fontSize: 13 },
256
+ }) as const,
257
+ [colors],
258
+ );
259
+ const pickState = useCallback(() => onPick("state"), [onPick]);
260
+ const pickPriority = useCallback(() => onPick("priority"), [onPick]);
261
+ const pickAssignee = useCallback(() => onPick("assignee"), [onPick]);
262
+ return (
263
+ <View style={ROW_WRAP_STYLE}>
264
+ <PropertyChip
265
+ theme={theme}
266
+ label={issue.state.name}
267
+ leading={stateIcon}
268
+ accessibilityLabel={`Status: ${issue.state.name}. Change status`}
269
+ disabled={props.disabled}
270
+ onPress={pickState}
271
+ />
272
+ <PropertyChip
273
+ theme={theme}
274
+ label={priorityMeta(issue.priority).label}
275
+ leading={priorityIcon}
276
+ accessibilityLabel={`Priority: ${issue.priorityLabel}. Change priority`}
277
+ disabled={props.disabled}
278
+ onPress={pickPriority}
279
+ />
280
+ <PropertyChip
281
+ theme={theme}
282
+ label={assigneeName ?? "Unassigned"}
283
+ leading={avatar}
284
+ accessibilityLabel={`Assignee: ${assigneeName ?? "nobody"}. Change assignee`}
285
+ disabled={props.disabled}
286
+ onPress={pickAssignee}
287
+ />
288
+ {issue.project ? (
289
+ <View style={projectStyles.row}>
290
+ <Icon name="FolderGit2" size={14} color={colors.foregroundMuted} />
291
+ <Text style={projectStyles.name}>{issue.project.name}</Text>
292
+ </View>
293
+ ) : null}
294
+ </View>
295
+ );
296
+ }
297
+
298
+ function LaunchButtons({ theme, onLaunch }: { theme: Theme; onLaunch(action: AgentAction): void }) {
299
+ const implement = useCallback(() => onLaunch("implement"), [onLaunch]);
300
+ const review = useCallback(() => onLaunch("review"), [onLaunch]);
301
+ return (
302
+ <View style={ROW_WRAP_STYLE}>
303
+ <Button
304
+ theme={theme}
305
+ variant="primary"
306
+ size="md"
307
+ icon={ACTION_LABELS.implement.icon}
308
+ label={ACTION_LABELS.implement.verb}
309
+ onPress={implement}
310
+ />
311
+ <Button
312
+ theme={theme}
313
+ variant="secondary"
314
+ size="md"
315
+ icon={ACTION_LABELS.review.icon}
316
+ label={ACTION_LABELS.review.verb}
317
+ onPress={review}
318
+ />
319
+ </View>
320
+ );
321
+ }
322
+
323
+ function IssuePicker(props: {
324
+ theme: Theme;
325
+ issue: IssueDetail;
326
+ picker: Picker;
327
+ teams: readonly LinearTeam[];
328
+ users: readonly LinearUser[];
329
+ viewerId: string | null;
330
+ onUpdate: ReturnType<typeof useUpdateIssue>["mutate"];
331
+ onClose(picker: null): void;
332
+ }) {
333
+ const { theme, issue, picker, users, viewerId, onUpdate, onClose } = props;
334
+ const toast = useToast();
335
+ const team = props.teams.find((entry) => entry.id === issue.team.id) ?? null;
336
+ const options = useMemo(() => {
337
+ if (picker === "state") return stateOptions(team?.states ?? []);
338
+ if (picker === "priority") return priorityOptions(theme);
339
+ if (picker === "assignee") return assigneeOptions(theme, users, viewerId);
340
+ return [];
341
+ }, [picker, team, theme, users, viewerId]);
342
+ const close = useCallback(() => onClose(null), [onClose]);
343
+ const choose = useCallback(
344
+ (value: string) => {
345
+ onClose(null);
346
+ onUpdate(pickerPatch(picker, value), {
347
+ onError: (error) => toast.error(errorMessage(error)),
348
+ });
349
+ },
350
+ [picker, onClose, onUpdate, toast],
351
+ );
352
+ const meta = PICKER_META[picker ?? "assignee"];
353
+ return (
354
+ <PickerModal
355
+ theme={theme}
356
+ title={meta.title}
357
+ icon={meta.icon}
358
+ open={picker !== null}
359
+ options={options}
360
+ value={pickerValue(picker, issue)}
361
+ searchable={picker === "assignee"}
362
+ onClose={close}
363
+ onSelect={choose}
364
+ />
365
+ );
366
+ }