@sentry/junior-dashboard 0.234.0 → 0.236.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 (53) hide show
  1. package/dist/app.js +100 -18
  2. package/dist/client.js +47 -46
  3. package/dist/index.d.ts +3 -1
  4. package/dist/index.js +100 -18
  5. package/dist/tailwind.css +1 -1
  6. package/package.json +3 -3
  7. package/src/app.ts +7 -1
  8. package/src/assets/junior-avatar.png +0 -0
  9. package/src/assets/junior-favicon.png +0 -0
  10. package/src/assets.ts +1 -0
  11. package/src/client/App.tsx +35 -0
  12. package/src/client/api.ts +63 -0
  13. package/src/client/components/README.md +8 -4
  14. package/src/client/components/SegmentedNav.tsx +167 -0
  15. package/src/client/components/SegmentedTabs.tsx +24 -5
  16. package/src/client/components/layout/SecondaryNavigation.tsx +40 -83
  17. package/src/client/conversations/ConversationEventLog.tsx +45 -5
  18. package/src/client/conversations/ConversationForkLinks.tsx +51 -0
  19. package/src/client/conversations/ConversationHeader.tsx +12 -1
  20. package/src/client/conversations/ConversationPage.tsx +72 -2
  21. package/src/client/conversations/ForkConversationButton.tsx +125 -0
  22. package/src/client/conversations/TranscriptMessageView.tsx +28 -7
  23. package/src/client/conversations/TranscriptView.tsx +4 -0
  24. package/src/client/conversations/conversationRoutes.ts +10 -0
  25. package/src/client/conversations/queries.ts +45 -0
  26. package/src/client/pages/automations/AutomationDetailsDrawer.tsx +28 -27
  27. package/src/client/pages/automations/AutomationEditPage.tsx +23 -24
  28. package/src/client/pages/automations/AutomationEditor.tsx +66 -13
  29. package/src/client/pages/automations/AutomationFilters.tsx +22 -14
  30. package/src/client/pages/automations/AutomationLifecycle.tsx +6 -4
  31. package/src/client/pages/automations/AutomationOutcomeFields.tsx +18 -0
  32. package/src/client/pages/automations/AutomationRow.tsx +172 -187
  33. package/src/client/pages/automations/AutomationVersionsPage.tsx +356 -0
  34. package/src/client/pages/automations/AutomationsPage.tsx +40 -42
  35. package/src/client/pages/automations/AutomationsPageLayout.tsx +6 -5
  36. package/src/client/pages/automations/README.md +14 -3
  37. package/src/client/pages/automations/automationFormat.ts +43 -0
  38. package/src/client/pages/code/CodeChangeRow.tsx +41 -0
  39. package/src/client/pages/code/CodePage.tsx +18 -93
  40. package/src/client/pages/code/CodeSummaryCards.tsx +53 -0
  41. package/src/client/pages/code/RepositoryPage.tsx +227 -0
  42. package/src/client/pages/code/RepositorySections.tsx +108 -0
  43. package/src/client/pages/code/RepositoryTabs.tsx +146 -0
  44. package/src/client/pages/code/codeRepositoryRoutes.ts +18 -0
  45. package/src/client/pages/dev/ComponentsPage.tsx +5 -22
  46. package/src/client/pages/dev/SegmentedFixtures.tsx +58 -0
  47. package/src/client/pages/memory/MemoryPageLayout.tsx +12 -13
  48. package/src/client/pages/system/SystemNavigation.tsx +6 -2
  49. package/src/client/pages/system/SystemPageLayout.tsx +4 -6
  50. package/src/mock-reporting/code-repository.ts +77 -0
  51. package/src/mock-reporting/fixtures.ts +8 -2
  52. package/src/mock-reporting/routes.ts +25 -8
  53. package/src/shell.ts +21 -5
@@ -1,11 +1,9 @@
1
1
  import { useState } from "react";
2
2
  import type { CodeOverviewReport } from "@sentry/junior/api/schema";
3
- import { Coins, GitPullRequest, LibraryBig, Timer } from "lucide-react";
3
+ import { Link } from "react-router";
4
4
  import { useCodeOverviewData } from "../../api";
5
- import { formatDuration } from "../../components/Duration";
6
5
  import { EmptyTelemetry } from "../../components/EmptyTelemetry";
7
6
  import { PageRouteLoading } from "../../components/PageRouteLoading";
8
- import { StatusChip } from "../../components/StatusChip";
9
7
  import {
10
8
  selectTimeSeries,
11
9
  timeRangeBucketUnit,
@@ -14,29 +12,14 @@ import {
14
12
  import { Card } from "../../components/layout/Card";
15
13
  import { PageHeader } from "../../components/layout/PageHeader";
16
14
  import { PageLayout } from "../../components/layout/PageLayout";
17
- import { StatCard } from "../../components/metrics/StatCard";
18
- import { formatCompactNumber, formatCostSummary } from "../../format";
19
15
  import { CodeActivityChart } from "./CodeActivityChart";
20
-
21
- function mergeRate(value: number | undefined): string {
22
- return value === undefined ? "—" : `${Math.round(value * 100)}%`;
23
- }
24
-
25
- function medianMergeTime(value: number | undefined): string {
26
- return formatDuration(value) || "—";
27
- }
28
-
29
- function costUsd(value: number | undefined): string {
30
- return (
31
- formatCostSummary(value === undefined ? undefined : { total: value }) || "—"
32
- );
33
- }
34
-
35
- function stateTone(state: "closed" | "merged" | "open") {
36
- if (state === "merged") return "success" as const;
37
- if (state === "open") return "info" as const;
38
- return "neutral" as const;
39
- }
16
+ import { CodeChangeRow } from "./CodeChangeRow";
17
+ import {
18
+ CodeSummaryCards,
19
+ formatCodeCost,
20
+ formatMergeRate,
21
+ } from "./CodeSummaryCards";
22
+ import { codeRepositoryPath } from "./codeRepositoryRoutes";
40
23
 
41
24
  /** Render code analytics and recent code changes. */
42
25
  export function CodePage() {
@@ -77,32 +60,7 @@ function CodeOverview(props: {
77
60
  const data = props.data;
78
61
  return (
79
62
  <>
80
- <div className="grid grid-cols-2 gap-3 lg:grid-cols-4">
81
- <StatCard
82
- detail="In the last 30 days"
83
- icon={GitPullRequest}
84
- label="Created"
85
- value={formatCompactNumber(data.summary.created)}
86
- />
87
- <StatCard
88
- detail="Share of completed changes that merged"
89
- icon={LibraryBig}
90
- label="Merge rate"
91
- value={mergeRate(data.summary.mergeRate)}
92
- />
93
- <StatCard
94
- detail="Median time from open to merge in the last 30 days"
95
- icon={Timer}
96
- label="Median merge time"
97
- value={medianMergeTime(data.summary.medianMergeTimeMs)}
98
- />
99
- <StatCard
100
- detail="Conversation cost for changes opened in the last 30 days"
101
- icon={Coins}
102
- label="Cost"
103
- value={costUsd(data.summary.costUsd)}
104
- />
105
- </div>
63
+ <CodeSummaryCards summary={data.summary} />
106
64
  <CodeActivityChart
107
65
  bucketUnit={timeRangeBucketUnit(props.range)}
108
66
  days={selectTimeSeries({
@@ -160,18 +118,12 @@ function CodeOverview(props: {
160
118
  >
161
119
  <td className="min-w-0 px-4 py-3">
162
120
  <div className="truncate font-display text-sm text-dashboard-text">
163
- {repository.url ? (
164
- <a
165
- className="text-inherit no-underline hover:text-cyan-100"
166
- href={repository.url}
167
- rel="noreferrer"
168
- target="_blank"
169
- >
170
- {repository.name}
171
- </a>
172
- ) : (
173
- repository.name
174
- )}
121
+ <Link
122
+ className="text-inherit no-underline hover:text-cyan-100"
123
+ to={codeRepositoryPath(repository.id)}
124
+ >
125
+ {repository.name}
126
+ </Link>
175
127
  </div>
176
128
  <div className="mt-1 truncate font-mono text-xs text-dashboard-text-muted">
177
129
  {repository.provider}
@@ -181,10 +133,10 @@ function CodeOverview(props: {
181
133
  {repository.created}
182
134
  </td>
183
135
  <td className="px-4 py-3 text-right font-mono text-sm whitespace-nowrap text-dashboard-text">
184
- {mergeRate(repository.mergeRate)}
136
+ {formatMergeRate(repository.mergeRate)}
185
137
  </td>
186
138
  <td className="px-4 py-3 text-right font-mono text-sm whitespace-nowrap text-dashboard-text">
187
- {costUsd(repository.medianCostUsd)}
139
+ {formatCodeCost(repository.medianCostUsd)}
188
140
  </td>
189
141
  </tr>
190
142
  ))}
@@ -200,34 +152,7 @@ function CodeOverview(props: {
200
152
  </div>
201
153
  <div>
202
154
  {data.changes.map((change) => (
203
- <div
204
- className="flex min-w-0 items-center justify-between gap-4 border-b border-dashboard-border-subtle px-4 py-3 last:border-b-0"
205
- key={change.id}
206
- >
207
- <div className="min-w-0">
208
- <div className="truncate font-display text-sm text-dashboard-text">
209
- {change.url ? (
210
- <a
211
- className="text-inherit no-underline hover:text-cyan-100"
212
- href={change.url}
213
- rel="noreferrer"
214
- target="_blank"
215
- >
216
- {change.title ??
217
- `${change.repository} #${change.number}`}
218
- </a>
219
- ) : (
220
- (change.title ?? `${change.repository} #${change.number}`)
221
- )}
222
- </div>
223
- <div className="mt-1 truncate font-mono text-xs text-dashboard-text-muted">
224
- {change.repository} #{change.number} · {change.provider}
225
- </div>
226
- </div>
227
- <StatusChip size="compact" tone={stateTone(change.state)}>
228
- {change.state}
229
- </StatusChip>
230
- </div>
155
+ <CodeChangeRow change={change} key={change.id} />
231
156
  ))}
232
157
  </div>
233
158
  </Card>
@@ -0,0 +1,53 @@
1
+ import type { CodeOverviewReport } from "@sentry/junior/api/schema";
2
+ import { Coins, GitPullRequest, LibraryBig, Timer } from "lucide-react";
3
+
4
+ import { formatDuration } from "../../components/Duration";
5
+ import { StatCard } from "../../components/metrics/StatCard";
6
+ import { formatCompactNumber, formatCostSummary } from "../../format";
7
+
8
+ /** Format a merge rate, or a dash when no change has finished. */
9
+ export function formatMergeRate(value: number | undefined): string {
10
+ return value === undefined ? "—" : `${Math.round(value * 100)}%`;
11
+ }
12
+
13
+ /** Format a code cost, or a dash when no cost is recorded. */
14
+ export function formatCodeCost(value: number | undefined): string {
15
+ return (
16
+ formatCostSummary(value === undefined ? undefined : { total: value }) || "—"
17
+ );
18
+ }
19
+
20
+ /** Show the 30-day code change summary cards. */
21
+ export function CodeSummaryCards(props: {
22
+ summary: CodeOverviewReport["summary"];
23
+ }) {
24
+ const summary = props.summary;
25
+ return (
26
+ <div className="grid grid-cols-2 gap-3 lg:grid-cols-4">
27
+ <StatCard
28
+ detail="In the last 30 days"
29
+ icon={GitPullRequest}
30
+ label="Created"
31
+ value={formatCompactNumber(summary.created)}
32
+ />
33
+ <StatCard
34
+ detail="Share of completed changes that merged"
35
+ icon={LibraryBig}
36
+ label="Merge rate"
37
+ value={formatMergeRate(summary.mergeRate)}
38
+ />
39
+ <StatCard
40
+ detail="Median time from open to merge in the last 30 days"
41
+ icon={Timer}
42
+ label="Median merge time"
43
+ value={formatDuration(summary.medianMergeTimeMs) || "—"}
44
+ />
45
+ <StatCard
46
+ detail="Conversation cost for changes opened in the last 30 days"
47
+ icon={Coins}
48
+ label="Cost"
49
+ value={formatCodeCost(summary.costUsd)}
50
+ />
51
+ </div>
52
+ );
53
+ }
@@ -0,0 +1,227 @@
1
+ import { useState } from "react";
2
+ import type { CodeRepositoryReport } from "@sentry/junior/api/schema";
3
+ import { ArrowLeft, ExternalLink } from "lucide-react";
4
+ import { Link, useParams } from "react-router";
5
+
6
+ import {
7
+ useCodeRepositoryConversationsData,
8
+ useCodeRepositoryData,
9
+ } from "../../api";
10
+ import { ButtonLink } from "../../components/Button";
11
+ import { EmptyTelemetry } from "../../components/EmptyTelemetry";
12
+ import { PageRouteLoading } from "../../components/PageRouteLoading";
13
+ import {
14
+ timeRangeBucketUnit,
15
+ TimeRangeSelector,
16
+ type TimeRangeDays,
17
+ } from "../../components/controls/TimeRangeSelector";
18
+ import { PageHeader } from "../../components/layout/PageHeader";
19
+ import { PageLayout } from "../../components/layout/PageLayout";
20
+ import { SecondaryNavigation } from "../../components/layout/SecondaryNavigation";
21
+ import { buildConversations } from "../../format";
22
+ import { CodeActivityChart } from "./CodeActivityChart";
23
+ import { CodeChangeRow } from "./CodeChangeRow";
24
+ import { CodeSummaryCards } from "./CodeSummaryCards";
25
+ import {
26
+ codeRepositoryPath,
27
+ type CodeRepositoryTab,
28
+ } from "./codeRepositoryRoutes";
29
+ import {
30
+ RecentConversationRow,
31
+ RepositorySection,
32
+ repositoryActivity,
33
+ } from "./RepositorySections";
34
+ import {
35
+ RepositoryChangesTab,
36
+ RepositoryConversationsTab,
37
+ } from "./RepositoryTabs";
38
+
39
+ const RECENT_ROWS = 5;
40
+ const DESCRIPTION = "Conversations and code changes from Junior.";
41
+
42
+ /** Inline link style used by `ConversationSummary` location links. */
43
+ const inlineLinkClass =
44
+ "font-semibold text-dashboard-text underline decoration-white/20 underline-offset-2 transition-colors hover:decoration-white/60";
45
+
46
+ /** Render one repository tab under the shared header and tab bar. */
47
+ export function RepositoryPage(props: { tab: CodeRepositoryTab }) {
48
+ const params = useParams();
49
+ const repositoryId = params.repositoryId;
50
+ const [range, setRange] = useState<TimeRangeDays>(30);
51
+ const query = useCodeRepositoryData(repositoryId);
52
+ if (!query.data && !query.error) {
53
+ return (
54
+ <PageRouteLoading
55
+ description={DESCRIPTION}
56
+ label="Loading repository"
57
+ title="Repository"
58
+ variant="stats"
59
+ />
60
+ );
61
+ }
62
+ const data = query.data;
63
+ if (!data) {
64
+ return (
65
+ <PageLayout>
66
+ <BackToCode />
67
+ <EmptyTelemetry>
68
+ This repository is unavailable. It may not have any code changes from
69
+ Junior.
70
+ </EmptyTelemetry>
71
+ </PageLayout>
72
+ );
73
+ }
74
+ const repository = data.repository;
75
+ return (
76
+ <SecondaryNavigation
77
+ ariaLabel="Repository navigation"
78
+ items={[
79
+ {
80
+ end: true,
81
+ label: "Overview",
82
+ to: codeRepositoryPath(repository.id),
83
+ },
84
+ {
85
+ label: "Conversations",
86
+ to: codeRepositoryPath(repository.id, "conversations"),
87
+ },
88
+ {
89
+ label: "Changes",
90
+ to: codeRepositoryPath(repository.id, "changes"),
91
+ },
92
+ ]}
93
+ >
94
+ <PageLayout>
95
+ <BackToCode />
96
+ <PageHeader
97
+ actions={
98
+ <div className="flex flex-wrap items-center gap-2">
99
+ <TimeRangeSelector onChange={setRange} value={range} />
100
+ {repository.url ? (
101
+ <ButtonLink
102
+ rel="noopener noreferrer"
103
+ target="_blank"
104
+ to={repository.url}
105
+ >
106
+ Open repository
107
+ <ExternalLink
108
+ aria-hidden="true"
109
+ className="size-3.5 shrink-0"
110
+ />
111
+ </ButtonLink>
112
+ ) : null}
113
+ </div>
114
+ }
115
+ description={<RepositoryDescription data={data} />}
116
+ title={repository.name}
117
+ />
118
+ {props.tab === "overview" ? (
119
+ <RepositoryOverview data={data} range={range} />
120
+ ) : null}
121
+ {props.tab === "conversations" ? (
122
+ <RepositoryConversationsTab repositoryId={repository.id} />
123
+ ) : null}
124
+ {props.tab === "changes" ? (
125
+ <RepositoryChangesTab data={data} range={range} />
126
+ ) : null}
127
+ </PageLayout>
128
+ </SecondaryNavigation>
129
+ );
130
+ }
131
+
132
+ function BackToCode() {
133
+ return (
134
+ <Link
135
+ className="flex w-fit items-center gap-2 font-display text-sm font-medium text-dashboard-text-muted no-underline transition-colors hover:text-dashboard-text"
136
+ to="/code"
137
+ >
138
+ <ArrowLeft aria-hidden="true" size={15} strokeWidth={1.8} />
139
+ Back to code
140
+ </Link>
141
+ );
142
+ }
143
+
144
+ /** Page description, then each Workspace that includes the repository. */
145
+ function RepositoryDescription(props: { data: CodeRepositoryReport }) {
146
+ const workspaces = props.data.workspaces;
147
+ if (workspaces.length === 0) return DESCRIPTION;
148
+ return (
149
+ <>
150
+ {DESCRIPTION} / Workspaces{" "}
151
+ {workspaces.map((workspace, index) => (
152
+ <span key={workspace.id}>
153
+ {index > 0 ? ", " : null}
154
+ <Link
155
+ className={inlineLinkClass}
156
+ to={`/system/workspaces/${encodeURIComponent(workspace.id)}`}
157
+ >
158
+ {workspace.name}
159
+ </Link>
160
+ </span>
161
+ ))}
162
+ </>
163
+ );
164
+ }
165
+
166
+ function RepositoryOverview(props: {
167
+ data: CodeRepositoryReport;
168
+ range: TimeRangeDays;
169
+ }) {
170
+ const data = props.data;
171
+ const conversationsQuery = useCodeRepositoryConversationsData(
172
+ data.repository.id,
173
+ );
174
+ const conversations = buildConversations(
175
+ conversationsQuery.data?.conversations ?? [],
176
+ ).slice(0, RECENT_ROWS);
177
+ const changes = data.changes.slice(0, RECENT_ROWS);
178
+ return (
179
+ <>
180
+ <CodeSummaryCards summary={data.summary} />
181
+ <div className="grid gap-3 lg:grid-cols-2">
182
+ <RepositorySection
183
+ title="Recent conversations"
184
+ viewAllTo={codeRepositoryPath(data.repository.id, "conversations")}
185
+ >
186
+ {conversations.length > 0 ? (
187
+ conversations.map((conversation) => (
188
+ <RecentConversationRow
189
+ conversation={conversation}
190
+ key={conversation.id}
191
+ />
192
+ ))
193
+ ) : (
194
+ <div className="p-4">
195
+ <EmptyTelemetry>
196
+ {conversationsQuery.error
197
+ ? "Conversations are unavailable. Try refreshing the dashboard."
198
+ : conversationsQuery.data
199
+ ? "No conversations are linked to this repository yet."
200
+ : "Loading conversations…"}
201
+ </EmptyTelemetry>
202
+ </div>
203
+ )}
204
+ </RepositorySection>
205
+ <RepositorySection
206
+ title="Recent changes"
207
+ viewAllTo={codeRepositoryPath(data.repository.id, "changes")}
208
+ >
209
+ {changes.length > 0 ? (
210
+ changes.map((change) => (
211
+ <CodeChangeRow change={change} key={change.id} />
212
+ ))
213
+ ) : (
214
+ <div className="p-4">
215
+ <EmptyTelemetry>No code changes are recorded yet.</EmptyTelemetry>
216
+ </div>
217
+ )}
218
+ </RepositorySection>
219
+ </div>
220
+ <CodeActivityChart
221
+ bucketUnit={timeRangeBucketUnit(props.range)}
222
+ days={repositoryActivity(data, props.range)}
223
+ range={props.range}
224
+ />
225
+ </>
226
+ );
227
+ }
@@ -0,0 +1,108 @@
1
+ import type { CodeRepositoryReport } from "@sentry/junior/api/schema";
2
+ import type { ReactNode } from "react";
3
+ import { Link } from "react-router";
4
+
5
+ import {
6
+ selectTimeSeries,
7
+ type TimeRangeDays,
8
+ } from "../../components/controls/TimeRangeSelector";
9
+ import { Card } from "../../components/layout/Card";
10
+ import { SectionHeader } from "../../components/layout/SectionHeader";
11
+ import { SectionTitle } from "../../components/layout/SectionTitle";
12
+ import { ConversationSidebarAnnotations } from "../../conversations/ConversationMeta";
13
+ import { ConversationListStatusIcon } from "../../conversations/ConversationListStatusIcon";
14
+ import { conversationPath } from "../../conversations/conversationRoutes";
15
+ import {
16
+ conversationDisplayTitle,
17
+ formatRelativeTime,
18
+ slackLocationLabel,
19
+ visualStatusForConversation,
20
+ } from "../../format";
21
+ import type { Conversation } from "../../types";
22
+
23
+ /** Titled repository card with an optional "View all" link to its tab. */
24
+ export function RepositorySection(props: {
25
+ children: ReactNode;
26
+ title: string;
27
+ viewAllTo?: string;
28
+ }) {
29
+ return (
30
+ <Card as="section" className="mb-0" variant="section">
31
+ <SectionHeader
32
+ actions={
33
+ props.viewAllTo ? (
34
+ <Link
35
+ className="text-sm text-dashboard-text underline underline-offset-2"
36
+ to={props.viewAllTo}
37
+ >
38
+ View all
39
+ </Link>
40
+ ) : undefined
41
+ }
42
+ >
43
+ <SectionTitle>{props.title}</SectionTitle>
44
+ </SectionHeader>
45
+ {props.children}
46
+ </Card>
47
+ );
48
+ }
49
+
50
+ /**
51
+ * Two-line conversation row built from the sidebar row parts: status icon and
52
+ * title, then plugin annotations or the location.
53
+ */
54
+ export function RecentConversationRow(props: { conversation: Conversation }) {
55
+ const conversation = props.conversation;
56
+ const title = conversationDisplayTitle(conversation);
57
+ const location = slackLocationLabel(conversation, { includeId: false });
58
+ const hasAnnotations = Boolean(conversation.sidebarAnnotations?.length);
59
+ return (
60
+ <div className="relative min-w-0 border-b border-dashboard-border-subtle px-4 py-3 transition-colors last:border-b-0 hover:bg-dashboard-fill-soft">
61
+ <Link
62
+ aria-label={`Open ${title}`}
63
+ className="absolute inset-0 z-0 focus:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-dashboard-focus"
64
+ to={conversationPath(conversation.id)}
65
+ />
66
+ <div className="pointer-events-none relative z-[1] grid min-w-0 grid-cols-[auto_minmax(0,1fr)_auto] items-start gap-x-2">
67
+ <span className="mt-1.5 grid size-3 shrink-0 place-items-center">
68
+ <ConversationListStatusIcon
69
+ finishedSinceSeen={false}
70
+ isPrivate={conversation.visibility === "private"}
71
+ status={visualStatusForConversation(conversation)}
72
+ />
73
+ </span>
74
+ <div className="min-w-0 truncate font-display text-sm text-dashboard-text">
75
+ {title}
76
+ </div>
77
+ <span className="whitespace-nowrap font-mono text-xs text-dashboard-text-muted">
78
+ {formatRelativeTime(conversation.lastSeenAt)}
79
+ </span>
80
+ {/* Keep the meta line at text height so annotation chips do not make
81
+ this row taller than a code change row beside it. */}
82
+ <div className="col-start-2 col-end-4 mt-1 flex h-4 min-w-0 items-center gap-1.5 font-mono text-xs text-dashboard-text-muted">
83
+ {hasAnnotations ? (
84
+ <ConversationSidebarAnnotations
85
+ annotations={conversation.sidebarAnnotations}
86
+ />
87
+ ) : location ? (
88
+ <span className="truncate">{location}</span>
89
+ ) : null}
90
+ </div>
91
+ </div>
92
+ </div>
93
+ );
94
+ }
95
+
96
+ /** Chart buckets for the selected range from a repository report. */
97
+ export function repositoryActivity(
98
+ data: CodeRepositoryReport,
99
+ range: TimeRangeDays,
100
+ ) {
101
+ return selectTimeSeries({
102
+ days: data.activityDays,
103
+ hours: data.activityHours,
104
+ sixHours: data.activitySixHours,
105
+ range,
106
+ emptySixHour: (date) => ({ closed: 0, created: 0, date, merged: 0 }),
107
+ });
108
+ }
@@ -0,0 +1,146 @@
1
+ import { useDeferredValue, useEffect, useState } from "react";
2
+ import type { CodeRepositoryReport } from "@sentry/junior/api/schema";
3
+
4
+ import { useCodeRepositoryConversationsData } from "../../api";
5
+ import { EmptyTelemetry } from "../../components/EmptyTelemetry";
6
+ import { FilterTabList } from "../../components/FilterBar";
7
+ import {
8
+ PagePagination,
9
+ pageCount,
10
+ pageItems,
11
+ } from "../../components/Pagination";
12
+ import { SearchInput } from "../../components/SearchInput";
13
+ import {
14
+ timeRangeBucketUnit,
15
+ type TimeRangeDays,
16
+ } from "../../components/controls/TimeRangeSelector";
17
+ import { Card } from "../../components/layout/Card";
18
+ import { ConversationHomeList } from "../../conversations/ConversationHomeList";
19
+ import { buildConversations, getDashboardTimeZone } from "../../format";
20
+ import { CodeActivityChart } from "./CodeActivityChart";
21
+ import { CodeChangeRow } from "./CodeChangeRow";
22
+ import { repositoryActivity } from "./RepositorySections";
23
+
24
+ const PAGE_SIZE = 25;
25
+ const noFinishedConversations = new Set<string>();
26
+
27
+ /** Conversations linked to the repository, in the home list layout. */
28
+ export function RepositoryConversationsTab(props: { repositoryId: string }) {
29
+ const [query, setQuery] = useState("");
30
+ const [page, setPage] = useState(1);
31
+ const search = useDeferredValue(query.trim());
32
+ const feed = useCodeRepositoryConversationsData(props.repositoryId, search);
33
+ const conversations = buildConversations(feed.data?.conversations ?? []);
34
+ useEffect(() => {
35
+ setPage(1);
36
+ }, [search]);
37
+ return (
38
+ <section aria-label="Conversations" className="grid gap-3">
39
+ <SearchInput
40
+ className="w-full sm:ml-auto sm:w-72"
41
+ label="Search conversations in this repository"
42
+ onChange={setQuery}
43
+ placeholder="Search conversations…"
44
+ value={query}
45
+ />
46
+ {feed.error ? (
47
+ <EmptyTelemetry>
48
+ Conversations are unavailable. Try refreshing the dashboard.
49
+ </EmptyTelemetry>
50
+ ) : (
51
+ <ConversationHomeList
52
+ conversations={pageItems(conversations, page, PAGE_SIZE)}
53
+ emptyLabel={
54
+ search
55
+ ? "No conversations match this search."
56
+ : "No conversations are linked to this repository yet."
57
+ }
58
+ finishedConversationIds={noFinishedConversations}
59
+ loading={!feed.data}
60
+ timeZone={getDashboardTimeZone()}
61
+ />
62
+ )}
63
+ <PagePagination
64
+ className="pt-1"
65
+ onPageChange={setPage}
66
+ page={page}
67
+ pageCount={pageCount(conversations.length, PAGE_SIZE)}
68
+ pageSize={PAGE_SIZE}
69
+ total={conversations.length}
70
+ />
71
+ </section>
72
+ );
73
+ }
74
+
75
+ /** Recent code changes in the repository, filtered by state and search. */
76
+ export function RepositoryChangesTab(props: {
77
+ data: CodeRepositoryReport;
78
+ range: TimeRangeDays;
79
+ }) {
80
+ const [state, setState] = useState("");
81
+ const [query, setQuery] = useState("");
82
+ const [page, setPage] = useState(1);
83
+ const all = props.data.changes;
84
+ const needle = query.trim().toLowerCase();
85
+ const changes = all.filter(
86
+ (change) =>
87
+ (!state || change.state === state) &&
88
+ (!needle ||
89
+ change.title?.toLowerCase().includes(needle) ||
90
+ `#${change.number}`.includes(needle)),
91
+ );
92
+ const count = (value: string) =>
93
+ all.filter((change) => change.state === value).length;
94
+ useEffect(() => {
95
+ setPage(1);
96
+ }, [state, needle]);
97
+ return (
98
+ <>
99
+ <CodeActivityChart
100
+ bucketUnit={timeRangeBucketUnit(props.range)}
101
+ days={repositoryActivity(props.data, props.range)}
102
+ range={props.range}
103
+ />
104
+ <section aria-label="Changes" className="grid gap-4">
105
+ <div className="flex flex-wrap items-end justify-between gap-4">
106
+ <FilterTabList
107
+ ariaLabel="Change state"
108
+ items={[
109
+ { count: all.length, label: "All", value: "" },
110
+ { count: count("open"), label: "Open", value: "open" },
111
+ { count: count("merged"), label: "Merged", value: "merged" },
112
+ { count: count("closed"), label: "Closed", value: "closed" },
113
+ ]}
114
+ onChange={setState}
115
+ value={state}
116
+ />
117
+ <SearchInput
118
+ className="w-full max-w-md sm:w-auto"
119
+ label="Search changes"
120
+ onChange={setQuery}
121
+ placeholder="Title or number"
122
+ value={query}
123
+ />
124
+ </div>
125
+ <Card>
126
+ {changes.length === 0 ? (
127
+ <div className="p-4">
128
+ <EmptyTelemetry>No changes match these filters.</EmptyTelemetry>
129
+ </div>
130
+ ) : (
131
+ pageItems(changes, page, PAGE_SIZE).map((change) => (
132
+ <CodeChangeRow change={change} key={change.id} />
133
+ ))
134
+ )}
135
+ </Card>
136
+ <PagePagination
137
+ onPageChange={setPage}
138
+ page={page}
139
+ pageCount={pageCount(changes.length, PAGE_SIZE)}
140
+ pageSize={PAGE_SIZE}
141
+ total={changes.length}
142
+ />
143
+ </section>
144
+ </>
145
+ );
146
+ }