a love letter to tangled (android, iOS, and a search API)
19
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: issue and pull request handling

+365 -115
+1
.gitignore
··· 16 16 *.log 17 17 log.txt 18 18 npm-debug.log* 19 + *.tsbuildinfo 19 20 20 21 /.idea 21 22 /.ionic
+6 -6
docs/tasks/phase-2.md
··· 41 41 42 42 ## Issues (read-only) 43 43 44 - - [ ] Fetch issues for a repo from PDS records 45 - - [ ] Display issue list with state filter (open/closed) 44 + - [x] Fetch issues for a repo from PDS records (`listIssueRecords` + `listIssueStateRecords` from owner's PDS) 45 + - [x] Display issue list with state filter (open/closed) 46 46 - [ ] Issue detail view: title, body, author, state 47 47 - [ ] Issue comments: fetch `sh.tangled.repo.issue.comment` records, render threaded 48 48 49 49 ## Pull Requests (read-only) 50 50 51 - - [ ] Fetch PRs for a repo from PDS records 52 - - [ ] Display PR list with status filter (open/closed/merged) 51 + - [x] Fetch PRs for a repo from PDS records (`listPullRecords` + `listPullStatusRecords` from owner's PDS) 52 + - [x] Display PR list with status filter (open/closed/merged) 53 53 - [ ] PR detail view: title, body, author, source/target branches 54 54 - [ ] PR comments: fetch `sh.tangled.repo.pull.comment` records 55 55 56 56 ## Caching 57 57 58 - - [ ] Configure TanStack Query stale/gc times per data type (see spec) 59 - - [ ] Set up IndexedDB query persister for offline reads 58 + - [x] Configure TanStack Query stale/gc times per data type (see spec) 59 + - [x] Set up IndexedDB query persister for offline reads 60 60 - [ ] Verify stale-while-revalidate behavior: cached data shows immediately, refreshes in background 61 61 62 62 ## Quality
+4 -1
package.json
··· 9 9 "preview": "vite preview", 10 10 "test:e2e": "cypress run", 11 11 "test:unit": "vitest", 12 - "lint": "eslint ." 12 + "lint": "eslint .", 13 + "check": "vue-tsc --noEmit" 13 14 }, 14 15 "dependencies": { 15 16 "@atcute/bluesky": "^3.3.0", ··· 24 25 "@capacitor/status-bar": "8.0.1", 25 26 "@ionic/vue": "^8.0.0", 26 27 "@ionic/vue-router": "^8.0.0", 28 + "@tanstack/query-persist-client-core": "^5.95.0", 27 29 "@tanstack/vue-query": "^5.94.5", 30 + "idb-keyval": "^6.2.2", 28 31 "ionicons": "^7.0.0", 29 32 "pinia": "^3.0.4", 30 33 "vue": "^3.3.0",
+23
pnpm-lock.yaml
··· 44 44 '@ionic/vue-router': 45 45 specifier: ^8.0.0 46 46 version: 8.8.1(@stencil/core@4.43.3)(vue-router@4.6.4(vue@3.5.30(typescript@5.9.3)))(vue@3.5.30(typescript@5.9.3)) 47 + '@tanstack/query-persist-client-core': 48 + specifier: ^5.95.0 49 + version: 5.95.0 47 50 '@tanstack/vue-query': 48 51 specifier: ^5.94.5 49 52 version: 5.94.5(vue@3.5.30(typescript@5.9.3)) 53 + idb-keyval: 54 + specifier: ^6.2.2 55 + version: 6.2.2 50 56 ionicons: 51 57 specifier: ^7.0.0 52 58 version: 7.4.0 ··· 1176 1182 1177 1183 '@tanstack/query-core@5.94.5': 1178 1184 resolution: {integrity: sha512-Vx1JJiBURW/wdNGP45afjrqn0LfxYwL7K/bSrQvNRtyLGF1bxQPgUXCpzscG29e+UeFOh9hz1KOVala0N+bZiA==} 1185 + 1186 + '@tanstack/query-core@5.95.0': 1187 + resolution: {integrity: sha512-H1/CWCe8tGL3YIVeo770Z6kPbt0B3M1d/iQXIIK1qlFiFt6G2neYdkHgLapOC8uMYNt9DmHjmGukEKgdMk1P+A==} 1188 + 1189 + '@tanstack/query-persist-client-core@5.95.0': 1190 + resolution: {integrity: sha512-83qvMi11P+8kPqgnvgB8qz37UDUnw7htrBkoc8r2SMso+ZfxwUIi0rIrJljrQuS8rW99QweoP3ACfG5SeBORkw==} 1179 1191 1180 1192 '@tanstack/vue-query@5.94.5': 1181 1193 resolution: {integrity: sha512-xmnOj1fP0JvUqGrkHmdIY/3FyO4L0IjJBCqOFxnnMIJjrsvCvlpjp/XpI1Zv4eLuV0e8l1LIOOuEvN40ckVuOA==} ··· 2132 2144 resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} 2133 2145 engines: {node: '>=0.10.0'} 2134 2146 2147 + idb-keyval@6.2.2: 2148 + resolution: {integrity: sha512-yjD9nARJ/jb1g+CvD0tlhUHOrJ9Sy0P8T9MF3YaLlHnSRpwPfpTX0XIvpmw3gAJUmEu3FiICLBDPXVwyEvrleg==} 2149 + 2135 2150 ieee754@1.2.1: 2136 2151 resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} 2137 2152 ··· 4456 4471 4457 4472 '@tanstack/query-core@5.94.5': {} 4458 4473 4474 + '@tanstack/query-core@5.95.0': {} 4475 + 4476 + '@tanstack/query-persist-client-core@5.95.0': 4477 + dependencies: 4478 + '@tanstack/query-core': 5.95.0 4479 + 4459 4480 '@tanstack/vue-query@5.94.5(vue@3.5.30(typescript@5.9.3))': 4460 4481 dependencies: 4461 4482 '@tanstack/match-sorter-utils': 8.19.4 ··· 5569 5590 iconv-lite@0.6.3: 5570 5591 dependencies: 5571 5592 safer-buffer: 2.1.2 5593 + 5594 + idb-keyval@6.2.2: {} 5572 5595 5573 5596 ieee754@1.2.1: {} 5574 5597
+1 -1
src/App.vue
··· 5 5 </template> 6 6 7 7 <script setup lang="ts"> 8 - import { IonApp, IonRouterOutlet } from '@ionic/vue'; 8 + import { IonApp, IonRouterOutlet } from "@ionic/vue"; 9 9 </script>
+2 -2
src/core/query/client.ts
··· 1 - import { QueryClient } from '@tanstack/vue-query'; 1 + import { QueryClient } from "@tanstack/vue-query"; 2 2 3 3 export const queryClient = new QueryClient({ 4 4 defaultOptions: { 5 5 queries: { 6 6 staleTime: 5 * 60 * 1000, // 5 minutes 7 - gcTime: 10 * 60 * 1000, // 10 minutes 7 + gcTime: 10 * 60 * 1000, // 10 minutes 8 8 retry: 2, 9 9 }, 10 10 },
+12
src/core/query/persister.ts
··· 1 + import { get, set, del } from "idb-keyval"; 2 + import type { Persister } from "@tanstack/query-persist-client-core"; 3 + 4 + const CACHE_KEY = "twisted-query-cache"; 5 + 6 + export function createIdbPersister(): Persister { 7 + return { 8 + persistClient: (client) => set(CACHE_KEY, client), 9 + restoreClient: () => get(CACHE_KEY), 10 + removeClient: () => del(CACHE_KEY), 11 + }; 12 + }
+12 -2
src/features/repo/RepoDetailPage.vue
··· 45 45 :knot-host="knotHost" 46 46 :knot-repo="knotRepo" 47 47 :branch="defaultBranch" /> 48 - <RepoIssues v-else-if="segment === 'issues'" :issues="[]" /> 49 - <RepoPRs v-else-if="segment === 'prs'" :prs="[]" /> 48 + <RepoIssues v-else-if="segment === 'issues'" :issues="issues" :is-loading="issuesQuery.isPending.value" /> 49 + <RepoPRs v-else-if="segment === 'prs'" :prs="prs" :is-loading="prsQuery.isPending.value" /> 50 50 </template> 51 51 </ion-content> 52 52 </ion-page> ··· 81 81 useRepoBlob, 82 82 useRepoLanguages, 83 83 useRepoLog, 84 + useRepoIssues, 85 + useRepoPRs, 84 86 } from "@/services/tangled/queries.js"; 85 87 import type { RepoDetail } from "@/domain/models/repo.js"; 86 88 ··· 120 122 }; 121 123 }); 122 124 125 + const repoAtUri = computed(() => recordQuery.data.value?.atUri ?? ""); 126 + const hasAtUri = computed(() => !!repoAtUri.value); 127 + 128 + const issuesQuery = useRepoIssues(pds, did, owner, repoAtUri, { enabled: hasAtUri }); 129 + const prsQuery = useRepoPRs(pds, did, owner, repoAtUri, { enabled: hasAtUri }); 130 + 123 131 const files = computed(() => treeQuery.data.value ?? []); 124 132 const commits = computed(() => logQuery.data.value ?? []); 133 + const issues = computed(() => issuesQuery.data.value ?? []); 134 + const prs = computed(() => prsQuery.data.value ?? []); 125 135 126 136 const isLoading = computed(() => identity.isPending.value || recordQuery.isPending.value); 127 137 const isError = computed(() => identity.isError.value || recordQuery.isError.value);
+2 -11
src/features/repo/RepoFiles.vue
··· 38 38 <!-- File tree --> 39 39 <template v-else> 40 40 <ion-list lines="inset" class="file-list"> 41 - <FileTreeItem 42 - v-for="file in sortedFiles" 43 - :key="file.name" 44 - :file="file" 45 - @click="handleFileClick(file)" /> 41 + <FileTreeItem v-for="file in sortedFiles" :key="file.name" :file="file" @click="handleFileClick(file)" /> 46 42 </ion-list> 47 43 <EmptyState 48 44 v-if="!files.length" ··· 63 59 import { useRepoBlob } from "@/services/tangled/queries.js"; 64 60 import type { RepoFile } from "@/domain/models/repo.js"; 65 61 66 - const props = defineProps<{ 67 - files: RepoFile[]; 68 - knotHost: string; 69 - knotRepo: string; 70 - branch: string; 71 - }>(); 62 + const props = defineProps<{ files: RepoFile[]; knotHost: string; knotRepo: string; branch: string }>(); 72 63 73 64 const selectedFile = ref<RepoFile | null>(null); 74 65
+50 -38
src/features/repo/RepoIssues.vue
··· 1 1 <template> 2 2 <div class="issues-view"> 3 - <!-- Filter --> 4 - <div class="filters-row"> 5 - <ion-chip 6 - v-for="f in FILTERS" 7 - :key="f.value" 8 - class="filter-chip" 9 - :class="{ active: filter === f.value }" 10 - @click="filter = f.value"> 11 - {{ f.label }} 12 - </ion-chip> 3 + <div v-if="isLoading" class="loading-center"> 4 + <ion-spinner name="crescent" /> 13 5 </div> 14 6 15 - <ion-list lines="inset" class="issue-list"> 16 - <ion-item v-for="issue in filtered" :key="issue.atUri" class="issue-item" button lines="inset"> 17 - <div slot="start" class="state-dot" :class="issue.state" /> 18 - <ion-label class="issue-label"> 19 - <span class="issue-title">{{ issue.title }}</span> 20 - <div class="issue-meta"> 21 - <span class="mono">{{ issue.authorHandle }}</span> 22 - <span class="sep">·</span> 23 - <span>{{ relativeTime(issue.createdAt) }}</span> 24 - <template v-if="issue.commentCount"> 7 + <template v-else> 8 + <!-- Filter --> 9 + <div class="filters-row"> 10 + <ion-chip 11 + v-for="f in FILTERS" 12 + :key="f.value" 13 + class="filter-chip" 14 + :class="{ active: filter === f.value }" 15 + @click="filter = f.value"> 16 + {{ f.label }} 17 + </ion-chip> 18 + </div> 19 + 20 + <ion-list lines="inset" class="issue-list"> 21 + <ion-item v-for="issue in filtered" :key="issue.atUri" class="issue-item" button lines="inset"> 22 + <div slot="start" class="state-dot" :class="issue.state" /> 23 + <ion-label class="issue-label"> 24 + <span class="issue-title">{{ issue.title }}</span> 25 + <div class="issue-meta"> 26 + <span class="mono">{{ issue.authorHandle }}</span> 25 27 <span class="sep">·</span> 26 - <ion-icon :icon="chatbubbleOutline" class="meta-icon" /> 27 - <span>{{ issue.commentCount }}</span> 28 - </template> 29 - </div> 30 - </ion-label> 31 - <ion-badge slot="end" class="state-badge" :class="issue.state"> 32 - {{ issue.state }} 33 - </ion-badge> 34 - </ion-item> 35 - </ion-list> 28 + <span>{{ relativeTime(issue.createdAt) }}</span> 29 + <template v-if="issue.commentCount"> 30 + <span class="sep">·</span> 31 + <ion-icon :icon="chatbubbleOutline" class="meta-icon" /> 32 + <span>{{ issue.commentCount }}</span> 33 + </template> 34 + </div> 35 + </ion-label> 36 + <ion-badge slot="end" class="state-badge" :class="issue.state"> 37 + {{ issue.state }} 38 + </ion-badge> 39 + </ion-item> 40 + </ion-list> 36 41 37 - <EmptyState 38 - v-if="!filtered.length" 39 - :icon="alertCircleOutline" 40 - title="No issues" 41 - :message="filter === 'all' ? 'No issues filed yet.' : `No ${filter} issues.`" /> 42 + <EmptyState 43 + v-if="!filtered.length" 44 + :icon="alertCircleOutline" 45 + title="No issues" 46 + :message="filter === 'all' ? 'No issues filed yet.' : `No ${filter} issues.`" /> 47 + </template> 42 48 </div> 43 49 </template> 44 50 45 51 <script setup lang="ts"> 46 52 import { ref, computed } from "vue"; 47 - import { IonList, IonItem, IonLabel, IonBadge, IonIcon, IonChip } from "@ionic/vue"; 53 + import { IonList, IonItem, IonLabel, IonBadge, IonIcon, IonChip, IonSpinner } from "@ionic/vue"; 48 54 import { chatbubbleOutline, alertCircleOutline } from "ionicons/icons"; 49 55 import EmptyState from "@/components/common/EmptyState.vue"; 50 - import type { IssueSummary } from "@/domain/models/issue"; 56 + import type { IssueSummary } from "@/domain/models/issue.js"; 51 57 52 - const props = defineProps<{ issues: IssueSummary[] }>(); 58 + const props = defineProps<{ issues: IssueSummary[]; isLoading?: boolean }>(); 53 59 54 60 const filter = ref<"all" | "open" | "closed">("open"); 55 61 ··· 79 85 <style scoped> 80 86 .issues-view { 81 87 padding-bottom: 32px; 88 + } 89 + 90 + .loading-center { 91 + display: flex; 92 + justify-content: center; 93 + padding: 48px 0; 82 94 } 83 95 84 96 .filters-row {
+51 -39
src/features/repo/RepoPRs.vue
··· 1 1 <template> 2 2 <div class="prs-view"> 3 - <!-- Filter --> 4 - <div class="filters-row"> 5 - <ion-chip 6 - v-for="f in FILTERS" 7 - :key="f.value" 8 - class="filter-chip" 9 - :class="{ active: filter === f.value }" 10 - @click="filter = f.value"> 11 - {{ f.label }} 12 - </ion-chip> 3 + <div v-if="isLoading" class="loading-center"> 4 + <ion-spinner name="crescent" /> 13 5 </div> 14 6 15 - <ion-list lines="inset" class="pr-list"> 16 - <ion-item v-for="pr in filtered" :key="pr.atUri" class="pr-item" button lines="inset"> 17 - <div slot="start" class="status-icon" :class="pr.status"> 18 - <ion-icon :icon="gitMergeOutline" /> 19 - </div> 20 - <ion-label class="pr-label"> 21 - <span class="pr-title">{{ pr.title }}</span> 22 - <div class="pr-meta"> 23 - <span class="mono">{{ pr.authorHandle }}</span> 24 - <span class="sep">·</span> 25 - <span class="branch mono">{{ pr.sourceBranch }}</span> 26 - <span class="sep">→</span> 27 - <span class="branch mono">{{ pr.targetBranch }}</span> 28 - <span class="sep">·</span> 29 - <span>{{ relativeTime(pr.createdAt) }}</span> 7 + <template v-else> 8 + <!-- Filter --> 9 + <div class="filters-row"> 10 + <ion-chip 11 + v-for="f in FILTERS" 12 + :key="f.value" 13 + class="filter-chip" 14 + :class="{ active: filter === f.value }" 15 + @click="filter = f.value"> 16 + {{ f.label }} 17 + </ion-chip> 18 + </div> 19 + 20 + <ion-list lines="inset" class="pr-list"> 21 + <ion-item v-for="pr in filtered" :key="pr.atUri" class="pr-item" button lines="inset"> 22 + <div slot="start" class="status-icon" :class="pr.status"> 23 + <ion-icon :icon="gitMergeOutline" /> 30 24 </div> 31 - </ion-label> 32 - <ion-badge slot="end" class="status-badge" :class="pr.status"> 33 - {{ pr.status }} 34 - </ion-badge> 35 - </ion-item> 36 - </ion-list> 25 + <ion-label class="pr-label"> 26 + <span class="pr-title">{{ pr.title }}</span> 27 + <div class="pr-meta"> 28 + <span class="mono">{{ pr.authorHandle }}</span> 29 + <span class="sep">·</span> 30 + <span class="branch mono">{{ pr.sourceBranch }}</span> 31 + <span class="sep">→</span> 32 + <span class="branch mono">{{ pr.targetBranch }}</span> 33 + <span class="sep">·</span> 34 + <span>{{ relativeTime(pr.createdAt) }}</span> 35 + </div> 36 + </ion-label> 37 + <ion-badge slot="end" class="status-badge" :class="pr.status"> 38 + {{ pr.status }} 39 + </ion-badge> 40 + </ion-item> 41 + </ion-list> 37 42 38 - <EmptyState 39 - v-if="!filtered.length" 40 - :icon="gitMergeOutline" 41 - title="No pull requests" 42 - :message="filter === 'all' ? 'No PRs yet.' : `No ${filter} PRs.`" /> 43 + <EmptyState 44 + v-if="!filtered.length" 45 + :icon="gitMergeOutline" 46 + title="No pull requests" 47 + :message="filter === 'all' ? 'No PRs yet.' : `No ${filter} PRs.`" /> 48 + </template> 43 49 </div> 44 50 </template> 45 51 46 52 <script setup lang="ts"> 47 53 import { ref, computed } from "vue"; 48 - import { IonList, IonItem, IonLabel, IonBadge, IonIcon, IonChip } from "@ionic/vue"; 54 + import { IonList, IonItem, IonLabel, IonBadge, IonIcon, IonChip, IonSpinner } from "@ionic/vue"; 49 55 import { gitMergeOutline } from "ionicons/icons"; 50 56 import EmptyState from "@/components/common/EmptyState.vue"; 51 - import type { PullRequestSummary } from "@/domain/models/pull-request"; 57 + import type { PullRequestSummary } from "@/domain/models/pull-request.js"; 52 58 53 - const props = defineProps<{ prs: PullRequestSummary[] }>(); 59 + const props = defineProps<{ prs: PullRequestSummary[]; isLoading?: boolean }>(); 54 60 55 61 const filter = ref<"all" | "open" | "merged" | "closed">("open"); 56 62 ··· 81 87 <style scoped> 82 88 .prs-view { 83 89 padding-bottom: 32px; 90 + } 91 + 92 + .loading-center { 93 + display: flex; 94 + justify-content: center; 95 + padding: 48px 0; 84 96 } 85 97 86 98 .filters-row {
+5
src/main.ts
··· 6 6 import { createPinia } from "pinia"; 7 7 import { VueQueryPlugin } from "@tanstack/vue-query"; 8 8 import { queryClient } from "./core/query/client.js"; 9 + import { persistQueryClient } from "@tanstack/query-persist-client-core"; 10 + import { createIdbPersister } from "./core/query/persister.js"; 9 11 10 12 import "@ionic/vue/css/core.css"; 11 13 import "@ionic/vue/css/normalize.css"; ··· 31 33 32 34 /* Theme variables */ 33 35 import "./theme/variables.css"; 36 + 37 + // eslint-disable-next-line @typescript-eslint/no-explicit-any 38 + persistQueryClient({ queryClient: queryClient as any, persister: createIdbPersister(), maxAge: 30 * 60 * 1000 }); 34 39 35 40 const app = createApp(App).use(IonicVue).use(router).use(createPinia()).use(VueQueryPlugin, { queryClient }); 36 41
+66
src/services/tangled/endpoints.ts
··· 28 28 ShTangledRepoCompare, 29 29 ShTangledRepo, 30 30 ShTangledActorProfile, 31 + ShTangledRepoIssue, 32 + ShTangledRepoIssueState, 33 + ShTangledRepoPull, 34 + ShTangledRepoPullStatus, 31 35 } from "@atcute/tangled"; 32 36 import { throwOnXrpcError } from "@/services/atproto/client.js"; 33 37 import { MalformedResponseError } from "@/core/errors/tangled.js"; ··· 197 201 throw new MalformedResponseError("resolvePds", `No PDS endpoint in DID document: ${did}`); 198 202 } 199 203 return new URL(svc.serviceEndpoint).hostname; 204 + } 205 + 206 + type ListRecordsResponse<T> = { records: Array<{ uri: string; cid: string; value: T }>; cursor?: string }; 207 + 208 + async function listRecords<T>( 209 + pds: string, 210 + did: string, 211 + collection: string, 212 + limit = 50, 213 + cursor?: string, 214 + ): Promise<ListRecordsResponse<T>> { 215 + const url = new URL(`https://${pds}/xrpc/com.atproto.repo.listRecords`); 216 + url.searchParams.set("repo", did); 217 + url.searchParams.set("collection", collection); 218 + url.searchParams.set("limit", String(limit)); 219 + if (cursor) url.searchParams.set("cursor", cursor); 220 + const res = await fetch(url.toString()); 221 + if (!res.ok) { 222 + const body = (await res.json().catch(() => ({}))) as { error?: string; message?: string }; 223 + throwOnXrpcError(res.status, body.error ?? "Unknown", body.message); 224 + } 225 + return res.json() as Promise<ListRecordsResponse<T>>; 226 + } 227 + 228 + /** List sh.tangled.repo.issue records from a user's PDS. */ 229 + export async function listIssueRecords( 230 + pds: string, 231 + did: string, 232 + limit = 50, 233 + cursor?: string, 234 + ): Promise<ListRecordsResponse<ShTangledRepoIssue.Main>> { 235 + return listRecords<ShTangledRepoIssue.Main>(pds, did, "sh.tangled.repo.issue", limit, cursor); 236 + } 237 + 238 + /** List sh.tangled.repo.issue.state records from a user's PDS. */ 239 + export async function listIssueStateRecords( 240 + pds: string, 241 + did: string, 242 + limit = 100, 243 + cursor?: string, 244 + ): Promise<ListRecordsResponse<ShTangledRepoIssueState.Main>> { 245 + return listRecords<ShTangledRepoIssueState.Main>(pds, did, "sh.tangled.repo.issue.state", limit, cursor); 246 + } 247 + 248 + /** List sh.tangled.repo.pull records from a user's PDS. */ 249 + export async function listPullRecords( 250 + pds: string, 251 + did: string, 252 + limit = 50, 253 + cursor?: string, 254 + ): Promise<ListRecordsResponse<ShTangledRepoPull.Main>> { 255 + return listRecords<ShTangledRepoPull.Main>(pds, did, "sh.tangled.repo.pull", limit, cursor); 256 + } 257 + 258 + /** List sh.tangled.repo.pull.status records from a user's PDS. */ 259 + export async function listPullStatusRecords( 260 + pds: string, 261 + did: string, 262 + limit = 100, 263 + cursor?: string, 264 + ): Promise<ListRecordsResponse<ShTangledRepoPullStatus.Main>> { 265 + return listRecords<ShTangledRepoPullStatus.Main>(pds, did, "sh.tangled.repo.pull.status", limit, cursor); 200 266 } 201 267 202 268 /**
+33
src/services/tangled/normalizers.ts
··· 10 10 ShTangledRepoLanguages, 11 11 ShTangledRepo, 12 12 ShTangledActorProfile, 13 + ShTangledRepoIssue, 14 + ShTangledRepoPull, 13 15 } from "@atcute/tangled"; 14 16 import type { RepoFile, RepoSummary, RepoDetail } from "@/domain/models/repo.js"; 15 17 import type { UserSummary } from "@/domain/models/user.js"; 18 + import type { IssueSummary } from "@/domain/models/issue.js"; 19 + import type { PullRequestSummary } from "@/domain/models/pull-request.js"; 16 20 17 21 function modeToFileKind(mode: string): RepoFile["type"] { 18 22 if (mode.startsWith("04")) return "dir"; ··· 175 179 extras: Partial<Pick<RepoDetail, "readme" | "defaultBranch" | "languages">> = {}, 176 180 ): RepoDetail { 177 181 return { ...normalizeRepoRecord(record, ownerDid, ownerHandle, atUri), topics: record.topics, ...extras }; 182 + } 183 + 184 + export function normalizeIssueRecord( 185 + record: ShTangledRepoIssue.Main, 186 + atUri: string, 187 + authorDid: string, 188 + authorHandle: string, 189 + state: "open" | "closed" = "open", 190 + ): IssueSummary { 191 + return { atUri, title: record.title, authorDid, authorHandle, state, createdAt: record.createdAt }; 192 + } 193 + 194 + export function normalizePullRecord( 195 + record: ShTangledRepoPull.Main, 196 + atUri: string, 197 + authorDid: string, 198 + authorHandle: string, 199 + status: "open" | "merged" | "closed" = "open", 200 + ): PullRequestSummary { 201 + return { 202 + atUri, 203 + title: record.title, 204 + authorDid, 205 + authorHandle, 206 + status, 207 + createdAt: record.createdAt, 208 + sourceBranch: record.source?.branch ?? "", 209 + targetBranch: record.target.branch, 210 + }; 178 211 } 179 212 180 213 export function normalizeActorProfile(
+84
src/services/tangled/queries.ts
··· 30 30 fetchActorProfile, 31 31 fetchRepoRecord, 32 32 listRepoRecords, 33 + listIssueRecords, 34 + listIssueStateRecords, 35 + listPullRecords, 36 + listPullStatusRecords, 33 37 resolveHandle, 34 38 resolvePds, 35 39 } from "./endpoints.js"; ··· 43 47 normalizeRepoRecordToDetail, 44 48 normalizeActorProfile, 45 49 normalizeRepoRecord, 50 + normalizeIssueRecord, 51 + normalizePullRecord, 46 52 } from "./normalizers.js"; 47 53 48 54 export type { CommitEntry, BranchEntry, BlobContent, DefaultBranchInfo } from "./normalizers.js"; ··· 320 326 enabled: options.enabled, 321 327 staleTime: 5 * MIN, 322 328 gcTime: 30 * MIN, 329 + }); 330 + } 331 + 332 + /** 333 + * Issues for a repo. Lists sh.tangled.repo.issue records from the owner's PDS, 334 + * filtered by repo AT URI, joined with state from sh.tangled.repo.issue.state. 335 + */ 336 + export function useRepoIssues( 337 + pds: MaybeRef<string>, 338 + did: MaybeRef<string>, 339 + handle: MaybeRef<string>, 340 + repoAtUri: MaybeRef<string>, 341 + options: { enabled?: MaybeRef<boolean> } = {}, 342 + ) { 343 + return useQuery({ 344 + queryKey: computed(() => ["issues", toValue(pds), toValue(did), toValue(repoAtUri)]), 345 + queryFn: async () => { 346 + const [issuesRes, statesRes] = await Promise.all([ 347 + listIssueRecords(toValue(pds), toValue(did)), 348 + listIssueStateRecords(toValue(pds), toValue(did)), 349 + ]); 350 + 351 + const stateMap = new Map<string, "open" | "closed">(); 352 + for (const s of statesRes.records) { 353 + const closed = s.value.state === "sh.tangled.repo.issue.state.closed"; 354 + stateMap.set(s.value.issue, closed ? "closed" : "open"); 355 + } 356 + 357 + const targetRepo = toValue(repoAtUri); 358 + return issuesRes.records 359 + .filter((r) => !targetRepo || r.value.repo === targetRepo) 360 + .map((r) => normalizeIssueRecord(r.value, r.uri, toValue(did), toValue(handle), stateMap.get(r.uri) ?? "open")); 361 + }, 362 + enabled: options.enabled, 363 + staleTime: 2 * MIN, 364 + gcTime: 10 * MIN, 365 + }); 366 + } 367 + 368 + /** 369 + * Pull requests for a repo. Lists sh.tangled.repo.pull records from the owner's 370 + * PDS filtered by target repo AT URI, joined with status records. 371 + */ 372 + export function useRepoPRs( 373 + pds: MaybeRef<string>, 374 + did: MaybeRef<string>, 375 + handle: MaybeRef<string>, 376 + repoAtUri: MaybeRef<string>, 377 + options: { enabled?: MaybeRef<boolean> } = {}, 378 + ) { 379 + return useQuery({ 380 + queryKey: computed(() => ["prs", toValue(pds), toValue(did), toValue(repoAtUri)]), 381 + queryFn: async () => { 382 + const [pullsRes, statusesRes] = await Promise.all([ 383 + listPullRecords(toValue(pds), toValue(did)), 384 + listPullStatusRecords(toValue(pds), toValue(did)), 385 + ]); 386 + 387 + const statusMap = new Map<string, "open" | "merged" | "closed">(); 388 + for (const s of statusesRes.records) { 389 + const raw = s.value.status ?? "sh.tangled.repo.pull.status.open"; 390 + const status = 391 + raw === "sh.tangled.repo.pull.status.merged" 392 + ? "merged" 393 + : raw === "sh.tangled.repo.pull.status.closed" 394 + ? "closed" 395 + : "open"; 396 + statusMap.set(s.value.pull, status); 397 + } 398 + 399 + const targetRepo = toValue(repoAtUri); 400 + return pullsRes.records 401 + .filter((r) => !targetRepo || r.value.target.repo === targetRepo) 402 + .map((r) => normalizePullRecord(r.value, r.uri, toValue(did), toValue(handle), statusMap.get(r.uri) ?? "open")); 403 + }, 404 + enabled: options.enabled, 405 + staleTime: 2 * MIN, 406 + gcTime: 10 * MIN, 323 407 }); 324 408 } 325 409
+10 -5
src/views/HomePage.vue
··· 15 15 16 16 <div id="container"> 17 17 <strong>Ready to create an app?</strong> 18 - <p>Start with Ionic <a target="_blank" rel="noopener noreferrer" href="https://ionicframework.com/docs/components">UI Components</a></p> 18 + <p> 19 + Start with Ionic 20 + <a target="_blank" rel="noopener noreferrer" href="https://ionicframework.com/docs/components" 21 + >UI Components</a 22 + > 23 + </p> 19 24 </div> 20 25 </ion-content> 21 26 </ion-page> 22 27 </template> 23 28 24 29 <script setup lang="ts"> 25 - import { IonContent, IonHeader, IonPage, IonTitle, IonToolbar } from '@ionic/vue'; 30 + import { IonContent, IonHeader, IonPage, IonTitle, IonToolbar } from "@ionic/vue"; 26 31 </script> 27 32 28 33 <style scoped> 29 34 #container { 30 35 text-align: center; 31 - 36 + 32 37 position: absolute; 33 38 left: 0; 34 39 right: 0; ··· 44 49 #container p { 45 50 font-size: 16px; 46 51 line-height: 22px; 47 - 52 + 48 53 color: #8c8c8c; 49 - 54 + 50 55 margin: 0; 51 56 } 52 57
+2 -10
src/views/TabsPage.vue
··· 25 25 </template> 26 26 27 27 <script setup lang="ts"> 28 - import { 29 - IonPage, 30 - IonTabs, 31 - IonRouterOutlet, 32 - IonTabBar, 33 - IonTabButton, 34 - IonIcon, 35 - IonLabel, 36 - } from '@ionic/vue'; 37 - import { homeOutline, searchOutline, pulseOutline, personOutline } from 'ionicons/icons'; 28 + import { IonPage, IonTabs, IonRouterOutlet, IonTabBar, IonTabButton, IonIcon, IonLabel } from "@ionic/vue"; 29 + import { homeOutline, searchOutline, pulseOutline, personOutline } from "ionicons/icons"; 38 30 </script>
+1
tsconfig.node.json
··· 4 4 "module": "nodenext", 5 5 "moduleResolution": "nodenext", 6 6 "allowSyntheticDefaultImports": true, 7 + "skipLibCheck": true, 7 8 "types": ["@atcute/bluesky", "@atcute/tangled"] 8 9 }, 9 10 "include": ["vite.config.ts"]