Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesPull RequestsActionsSecurityInsightsSettings
✨ AI
More
Blame · Line-by-line history

PullDetailScreen.tsx

Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.

PullDetailScreen.tsxBlame639 lines · 1 contributor
9a8fbedClaude1import React, { useCallback, useEffect, useState } from 'react';
2import {
3 FlatList,
4 KeyboardAvoidingView,
5 Platform,
6 ScrollView,
7 StyleSheet,
8 Text,
9 TextInput,
10 TouchableOpacity,
11 View,
12 ActivityIndicator,
13} from 'react-native';
14import { SafeAreaView } from 'react-native-safe-area-context';
15import type { NativeStackScreenProps } from '@react-navigation/native-stack';
16import {
17 getPullRequest,
18 getPrComments,
19 getAiReview,
20 createPrComment,
21 mergePullRequest,
22 closePullRequest,
23} from '../api/pulls';
24import type { PullRequest, PrComment, AiReviewSummary } from '../api/pulls';
25import { AiReviewCard } from '../components/AiReviewCard';
26import { GateStatusBadge } from '../components/GateStatusBadge';
27import { LoadingSpinner } from '../components/LoadingSpinner';
28import { ErrorBanner } from '../components/ErrorBanner';
29import { colors } from '../theme/colors';
30import type { RepoStackParamList } from '../navigation/AppNavigator';
31import { useAuthStore } from '../store/authStore';
32
33type Props = NativeStackScreenProps<RepoStackParamList, 'PullDetail'>;
34
35function formatRelative(dateStr: string): string {
36 const diff = Date.now() - new Date(dateStr).getTime();
37 const mins = Math.floor(diff / 60000);
38 if (mins < 1) return 'just now';
39 if (mins < 60) return `${mins}m ago`;
40 const hrs = Math.floor(mins / 60);
41 if (hrs < 24) return `${hrs}h ago`;
42 const days = Math.floor(hrs / 24);
43 if (days < 30) return `${days}d ago`;
44 const months = Math.floor(days / 30);
45 if (months < 12) return `${months}mo ago`;
46 return `${Math.floor(months / 12)}y ago`;
47}
48
49function prStateColor(state: PullRequest['state']): string {
50 switch (state) {
51 case 'open': return colors.accent;
52 case 'merged': return colors.accentPurple;
53 case 'closed': return colors.accentRed;
54 }
55}
56
57function HumanComment({ comment }: { comment: PrComment }): React.ReactElement {
58 return (
59 <View style={styles.commentCard}>
60 <View style={styles.commentHeader}>
61 <Text style={styles.commentAuthor}>{comment.authorUsername}</Text>
62 {comment.isAiReview && (
63 <View style={styles.aiTag}>
64 <Text style={styles.aiTagText}>✦ AI</Text>
65 </View>
66 )}
67 <Text style={styles.commentTime}>{formatRelative(comment.createdAt)}</Text>
68 </View>
69 {comment.filePath !== null && (
70 <View style={styles.fileRef}>
71 <Text style={styles.fileRefText} numberOfLines={1}>
72 {comment.filePath}{comment.lineNumber !== null ? `:${comment.lineNumber}` : ''}
73 </Text>
74 </View>
75 )}
76 <Text style={styles.commentBody}>{comment.body}</Text>
77 </View>
78 );
79}
80
81export function PullDetailScreen({ route }: Props): React.ReactElement {
82 const { owner, repo, number } = route.params;
83 const currentUser = useAuthStore((s) => s.user);
84
85 const [pr, setPr] = useState<PullRequest | null>(null);
86 const [comments, setComments] = useState<PrComment[]>([]);
87 const [aiReview, setAiReview] = useState<AiReviewSummary | null>(null);
88 const [isLoading, setIsLoading] = useState(true);
89 const [error, setError] = useState<string | null>(null);
90 const [commentText, setCommentText] = useState('');
91 const [isSubmitting, setIsSubmitting] = useState(false);
92 const [mergeSuccess, setMergeSuccess] = useState(false);
93 const [tick, setTick] = useState(0);
94
95 useEffect(() => {
96 let cancelled = false;
97 setIsLoading(true);
98 setError(null);
99
100 Promise.all([
101 getPullRequest(owner, repo, number),
102 getPrComments(owner, repo, number),
103 getAiReview(owner, repo, number),
104 ])
105 .then(([prData, commentsData, reviewData]) => {
106 if (!cancelled) {
107 setPr(prData);
108 // Human comments: exclude inline AI comments (those are shown in AiReviewCard)
109 setComments(commentsData.filter((c) => !c.isAiReview));
110 setAiReview(reviewData);
111 }
112 })
113 .catch((err) => {
114 if (!cancelled) {
115 setError(err instanceof Error ? err.message : 'Failed to load pull request');
116 }
117 })
118 .finally(() => {
119 if (!cancelled) setIsLoading(false);
120 });
121
122 return () => {
123 cancelled = true;
124 };
125 }, [owner, repo, number, tick]);
126
127 const handleMerge = useCallback(async () => {
128 if (!pr || isSubmitting) return;
129 setIsSubmitting(true);
130 setError(null);
131 try {
132 await mergePullRequest(owner, repo, number);
133 setMergeSuccess(true);
134 setTick((t) => t + 1);
135 } catch (err) {
136 setError(err instanceof Error ? err.message : 'Merge failed');
137 } finally {
138 setIsSubmitting(false);
139 }
140 }, [pr, owner, repo, number, isSubmitting]);
141
142 const handleClose = useCallback(async () => {
143 if (!pr || isSubmitting) return;
144 setIsSubmitting(true);
145 setError(null);
146 try {
147 const updated = await closePullRequest(owner, repo, number);
148 setPr(updated);
149 } catch (err) {
150 setError(err instanceof Error ? err.message : 'Failed to close PR');
151 } finally {
152 setIsSubmitting(false);
153 }
154 }, [pr, owner, repo, number, isSubmitting]);
155
156 const handleSubmitComment = useCallback(async () => {
157 const body = commentText.trim();
158 if (!body || isSubmitting) return;
159 setIsSubmitting(true);
160 try {
161 const newComment = await createPrComment(owner, repo, number, { body });
162 setComments((prev) => [...prev, newComment]);
163 setCommentText('');
164 } catch (err) {
165 setError(err instanceof Error ? err.message : 'Failed to post comment');
166 } finally {
167 setIsSubmitting(false);
168 }
169 }, [owner, repo, number, commentText, isSubmitting]);
170
171 const renderComment = useCallback(
172 ({ item }: { item: PrComment }) => <HumanComment comment={item} />,
173 [],
174 );
175
176 const keyExtractor = useCallback((item: PrComment) => String(item.id), []);
177
178 const canMerge =
179 pr?.state === 'open' &&
180 currentUser !== null &&
181 pr.gateStatus !== 'failed' &&
182 !mergeSuccess;
183
184 if (isLoading) return <LoadingSpinner fullScreen />;
185
186 if (pr === null) {
187 return (
188 <SafeAreaView style={styles.safe} edges={['bottom']}>
189 <ErrorBanner
190 message={error ?? 'Pull request not found'}
191 onRetry={() => setTick((t) => t + 1)}
192 />
193 </SafeAreaView>
194 );
195 }
196
197 const stateColor = prStateColor(pr.state);
198
199 return (
200 <SafeAreaView style={styles.safe} edges={['bottom']}>
201 <KeyboardAvoidingView
202 style={styles.flex}
203 behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
204 keyboardVerticalOffset={Platform.OS === 'ios' ? 90 : 0}
205 >
206 <FlatList
207 data={comments}
208 keyExtractor={keyExtractor}
209 renderItem={renderComment}
210 style={styles.list}
211 contentContainerStyle={styles.listContent}
212 ListHeaderComponent={
213 <View>
214 {error !== null && <ErrorBanner message={error} />}
215
216 {/* PR header */}
217 <View style={styles.prHeader}>
218 <View style={styles.titleRow}>
219 <View style={[styles.stateBadge, { backgroundColor: stateColor }]}>
220 <Text style={styles.stateText}>{pr.state}</Text>
221 </View>
222 {pr.isDraft && (
223 <View style={styles.draftBadge}>
224 <Text style={styles.draftText}>Draft</Text>
225 </View>
226 )}
227 </View>
228
229 <Text style={styles.prTitle}>{pr.title}</Text>
230
231 <Text style={styles.prMeta}>
232 #{pr.number} by {pr.authorUsername} · {formatRelative(pr.createdAt)}
233 </Text>
234
235 {/* Branch info */}
236 <View style={styles.branchInfo}>
237 <Text style={styles.branchText}>{pr.headBranch}</Text>
238 <Text style={styles.branchArrow}> → </Text>
239 <Text style={styles.branchText}>{pr.baseBranch}</Text>
240 </View>
241
242 {/* Gate status */}
243 <View style={styles.gateRow}>
244 <Text style={styles.gateLabel}>Gate:</Text>
245 <GateStatusBadge
246 status={pr.gateStatus === 'none' ? 'pending' : pr.gateStatus}
247 size="small"
248 />
249 </View>
250
251 {/* Stats */}
252 <View style={styles.statsRow}>
253 <Text style={styles.statItem}>
254 <Text style={{ color: colors.accent }}>+{pr.additions}</Text>
255 {' '}
256 <Text style={{ color: colors.accentRed }}>-{pr.deletions}</Text>
257 </Text>
258 <Text style={styles.statDot}>·</Text>
259 <Text style={styles.statItem}>{pr.changedFiles} files</Text>
260 <Text style={styles.statDot}>·</Text>
261 <Text style={styles.statItem}>{pr.commitCount} commits</Text>
262 </View>
263 </View>
264
265 {/* PR body */}
266 {pr.body !== null && pr.body.length > 0 && (
267 <View style={styles.prBody}>
268 <Text style={styles.bodyText}>{pr.body}</Text>
269 </View>
270 )}
271
272 {/* AI Review */}
273 {aiReview !== null && (
274 <View style={styles.aiSection}>
275 <AiReviewCard review={aiReview} />
276 </View>
277 )}
278
279 {/* Merge / Close buttons */}
280 {pr.state === 'open' && currentUser !== null && (
281 <View style={styles.actionsRow}>
282 <TouchableOpacity
283 style={[styles.mergeButton, !canMerge && styles.buttonDisabled]}
284 onPress={handleMerge}
285 disabled={!canMerge || isSubmitting}
286 activeOpacity={0.8}
287 >
288 {isSubmitting ? (
289 <ActivityIndicator size="small" color={colors.text} />
290 ) : (
291 <Text style={styles.mergeText}>
292 {mergeSuccess ? 'Merged!' : 'Merge Pull Request'}
293 </Text>
294 )}
295 </TouchableOpacity>
296 <TouchableOpacity
297 style={[styles.closeButton, isSubmitting && styles.buttonDisabled]}
298 onPress={handleClose}
299 disabled={isSubmitting}
300 activeOpacity={0.8}
301 >
302 <Text style={styles.closeText}>Close PR</Text>
303 </TouchableOpacity>
304 </View>
305 )}
306
307 {pr.state === 'merged' && (
308 <View style={styles.mergedBanner}>
309 <Text style={styles.mergedBannerText}>
310 ✓ Merged by {pr.mergedByUsername ?? 'unknown'} · {pr.mergedAt !== null ? formatRelative(pr.mergedAt) : ''}
311 </Text>
312 </View>
313 )}
314
315 {/* Comments header */}
316 {comments.length > 0 && (
317 <View style={styles.commentsHeader}>
318 <Text style={styles.commentsHeaderText}>
319 {comments.length} {comments.length === 1 ? 'review comment' : 'review comments'}
320 </Text>
321 </View>
322 )}
323 </View>
324 }
325 ListEmptyComponent={
326 <View style={styles.noComments}>
327 <Text style={styles.noCommentsText}>No review comments yet</Text>
328 </View>
329 }
330 />
331
332 {/* Comment input */}
333 <View style={styles.inputArea}>
334 <View style={styles.inputRow}>
335 <TextInput
336 style={styles.commentInput}
337 value={commentText}
338 onChangeText={setCommentText}
339 placeholder="Leave a review comment..."
340 placeholderTextColor={colors.textMuted}
341 multiline
342 maxLength={65536}
343 />
344 <TouchableOpacity
345 style={[styles.sendButton, (!commentText.trim() || isSubmitting) && styles.sendButtonDisabled]}
346 onPress={handleSubmitComment}
347 disabled={!commentText.trim() || isSubmitting}
348 activeOpacity={0.8}
349 >
350 <Text style={styles.sendIcon}>↑</Text>
351 </TouchableOpacity>
352 </View>
353 </View>
354 </KeyboardAvoidingView>
355 </SafeAreaView>
356 );
357}
358
359const styles = StyleSheet.create({
360 safe: {
361 flex: 1,
362 backgroundColor: colors.bg,
363 },
364 flex: {
365 flex: 1,
366 },
367 list: {
368 flex: 1,
369 },
370 listContent: {
371 paddingBottom: 8,
372 },
373 prHeader: {
374 padding: 16,
375 backgroundColor: colors.bgSecondary,
376 borderBottomWidth: 1,
377 borderBottomColor: colors.border,
378 gap: 10,
379 },
380 titleRow: {
381 flexDirection: 'row',
382 gap: 8,
383 flexWrap: 'wrap',
384 },
385 stateBadge: {
386 paddingHorizontal: 10,
387 paddingVertical: 4,
388 borderRadius: 20,
389 },
390 stateText: {
391 fontSize: 12,
392 fontWeight: '600',
393 color: colors.text,
394 textTransform: 'capitalize',
395 },
396 draftBadge: {
397 paddingHorizontal: 8,
398 paddingVertical: 4,
399 borderRadius: 20,
400 backgroundColor: colors.bgTertiary,
401 borderWidth: 1,
402 borderColor: colors.border,
403 },
404 draftText: {
405 fontSize: 12,
406 color: colors.textMuted,
407 },
408 prTitle: {
409 fontSize: 18,
410 fontWeight: '700',
411 color: colors.text,
412 lineHeight: 26,
413 },
414 prMeta: {
415 fontSize: 13,
416 color: colors.textMuted,
417 },
418 branchInfo: {
419 flexDirection: 'row',
420 alignItems: 'center',
421 backgroundColor: colors.bg,
422 borderRadius: 6,
423 paddingHorizontal: 10,
424 paddingVertical: 6,
425 borderWidth: 1,
426 borderColor: colors.border,
427 alignSelf: 'flex-start',
428 },
429 branchText: {
430 fontSize: 12,
431 fontFamily: 'monospace',
432 color: colors.textLink,
433 },
434 branchArrow: {
435 fontSize: 12,
436 color: colors.textMuted,
437 },
438 gateRow: {
439 flexDirection: 'row',
440 alignItems: 'center',
441 gap: 8,
442 },
443 gateLabel: {
444 fontSize: 13,
445 color: colors.textMuted,
446 },
447 statsRow: {
448 flexDirection: 'row',
449 alignItems: 'center',
450 gap: 6,
451 },
452 statItem: {
453 fontSize: 13,
454 color: colors.textMuted,
455 },
456 statDot: {
457 fontSize: 13,
458 color: colors.textMuted,
459 },
460 prBody: {
461 padding: 16,
462 borderBottomWidth: 1,
463 borderBottomColor: colors.border,
464 },
465 bodyText: {
466 fontSize: 14,
467 color: colors.text,
468 lineHeight: 22,
469 },
470 aiSection: {
471 paddingTop: 16,
472 },
473 actionsRow: {
474 flexDirection: 'row',
475 padding: 16,
476 gap: 12,
477 borderBottomWidth: 1,
478 borderBottomColor: colors.border,
479 },
480 mergeButton: {
481 flex: 2,
482 backgroundColor: colors.accent,
483 borderRadius: 8,
484 paddingVertical: 12,
485 alignItems: 'center',
486 justifyContent: 'center',
487 minHeight: 44,
488 },
489 mergeText: {
490 fontSize: 14,
491 fontWeight: '600',
492 color: colors.text,
493 },
494 closeButton: {
495 flex: 1,
496 borderRadius: 8,
497 paddingVertical: 12,
498 alignItems: 'center',
499 borderWidth: 1,
500 borderColor: colors.accentRed,
501 minHeight: 44,
502 },
503 closeText: {
504 fontSize: 14,
505 fontWeight: '600',
506 color: colors.accentRed,
507 },
508 buttonDisabled: {
509 opacity: 0.5,
510 },
511 mergedBanner: {
512 margin: 16,
513 padding: 12,
514 borderRadius: 8,
515 backgroundColor: colors.bgSecondary,
516 borderWidth: 1,
517 borderColor: colors.accentPurple,
518 },
519 mergedBannerText: {
520 fontSize: 13,
521 color: colors.accentPurple,
522 textAlign: 'center',
523 },
524 commentsHeader: {
525 paddingHorizontal: 16,
526 paddingVertical: 10,
527 borderBottomWidth: 1,
528 borderBottomColor: colors.border,
529 backgroundColor: colors.bgTertiary,
530 },
531 commentsHeaderText: {
532 fontSize: 12,
533 fontWeight: '600',
534 color: colors.textMuted,
535 textTransform: 'uppercase',
536 letterSpacing: 0.5,
537 },
538 commentCard: {
539 padding: 16,
540 borderBottomWidth: 1,
541 borderBottomColor: colors.border,
542 backgroundColor: colors.bgSecondary,
543 gap: 8,
544 },
545 commentHeader: {
546 flexDirection: 'row',
547 alignItems: 'center',
548 gap: 8,
549 },
550 commentAuthor: {
551 fontSize: 13,
552 fontWeight: '600',
553 color: colors.text,
554 },
555 aiTag: {
556 paddingHorizontal: 6,
557 paddingVertical: 2,
558 borderRadius: 10,
559 backgroundColor: colors.bgTertiary,
560 borderWidth: 1,
561 borderColor: colors.accentPurple,
562 },
563 aiTagText: {
564 fontSize: 10,
565 color: colors.accentPurple,
566 fontWeight: '600',
567 },
568 commentTime: {
569 fontSize: 12,
570 color: colors.textMuted,
571 marginLeft: 'auto',
572 },
573 fileRef: {
574 paddingHorizontal: 8,
575 paddingVertical: 4,
576 borderRadius: 4,
577 backgroundColor: colors.bgTertiary,
578 borderWidth: 1,
579 borderColor: colors.border,
580 alignSelf: 'flex-start',
581 },
582 fileRefText: {
583 fontSize: 11,
584 fontFamily: 'monospace',
585 color: colors.textMuted,
586 },
587 commentBody: {
588 fontSize: 14,
589 color: colors.text,
590 lineHeight: 20,
591 },
592 noComments: {
593 padding: 32,
594 alignItems: 'center',
595 },
596 noCommentsText: {
597 fontSize: 14,
598 color: colors.textMuted,
599 },
600 inputArea: {
601 padding: 12,
602 backgroundColor: colors.bgSecondary,
603 borderTopWidth: 1,
604 borderTopColor: colors.border,
605 },
606 inputRow: {
607 flexDirection: 'row',
608 alignItems: 'flex-end',
609 gap: 10,
610 },
611 commentInput: {
612 flex: 1,
613 backgroundColor: colors.bg,
614 borderWidth: 1,
615 borderColor: colors.border,
616 borderRadius: 8,
617 padding: 10,
618 fontSize: 14,
619 color: colors.text,
620 maxHeight: 100,
621 minHeight: 44,
622 },
623 sendButton: {
624 width: 40,
625 height: 40,
626 borderRadius: 20,
627 backgroundColor: colors.accentBlue,
628 alignItems: 'center',
629 justifyContent: 'center',
630 },
631 sendButtonDisabled: {
632 opacity: 0.4,
633 },
634 sendIcon: {
635 fontSize: 18,
636 color: colors.bg,
637 fontWeight: '700',
638 },
639});