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