Skip to content

Commit 7676b4b

Browse files
authored
Feat: GitHub Workflow status directly in OpenPlanner (#219)
* Base working * Finish ups
1 parent 9513390 commit 7676b4b

File tree

8 files changed

+519
-38
lines changed

8 files changed

+519
-38
lines changed
Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
1+
import React, { useMemo } from 'react'
2+
import { Box, Chip, LinearProgress, Typography, Alert, AlertTitle } from '@mui/material'
3+
import { CheckCircle, Error as ErrorIcon, Pause, Schedule, Build } from '@mui/icons-material'
4+
import GitHubIcon from '@mui/icons-material/GitHub'
5+
import { useGitHubActions } from '../hooks/useGitHubActions'
6+
7+
export type BuildStatus = 'queued' | 'in_progress' | 'completed' | 'failed' | 'cancelled' | 'skipped'
8+
9+
export type BuildStep = {
10+
id: string
11+
name: string
12+
status: BuildStatus
13+
conclusion?: string
14+
started_at?: string
15+
completed_at?: string
16+
duration?: number
17+
}
18+
19+
export type BuildJob = {
20+
id: number
21+
name: string
22+
status: BuildStatus
23+
conclusion?: string
24+
started_at?: string
25+
completed_at?: string
26+
duration?: number
27+
steps: BuildStep[]
28+
}
29+
30+
export type WatchBuildProgressProps = {
31+
repoUrl: string
32+
branch?: string
33+
token?: string
34+
refreshInterval?: number
35+
timeout?: number
36+
}
37+
38+
const parseGitHubUrl = (url: string): { owner: string; repo: string } | null => {
39+
try {
40+
const urlObj = new URL(url)
41+
if (urlObj.hostname !== 'github.com') return null
42+
const pathParts = urlObj.pathname.split('/').filter(Boolean)
43+
if (pathParts.length < 2) return null
44+
return {
45+
owner: pathParts[0],
46+
repo: pathParts[1],
47+
}
48+
} catch {
49+
return null
50+
}
51+
}
52+
53+
const getStatusColor = (status: BuildStatus, conclusion?: string) => {
54+
switch (status) {
55+
case 'completed':
56+
return conclusion === 'success' ? 'success' : 'error'
57+
case 'in_progress':
58+
return 'primary'
59+
case 'queued':
60+
return 'warning'
61+
case 'failed':
62+
return 'error'
63+
case 'cancelled':
64+
return 'default'
65+
case 'skipped':
66+
return 'default'
67+
default:
68+
return 'default'
69+
}
70+
}
71+
72+
const getStatusIcon = (status: BuildStatus, conclusion?: string) => {
73+
switch (status) {
74+
case 'completed':
75+
return conclusion === 'success' ? <CheckCircle /> : <ErrorIcon />
76+
case 'in_progress':
77+
return <Build />
78+
case 'queued':
79+
return <Schedule />
80+
case 'failed':
81+
return <ErrorIcon />
82+
case 'cancelled':
83+
return <Pause />
84+
case 'skipped':
85+
return <Schedule />
86+
default:
87+
return <Schedule />
88+
}
89+
}
90+
91+
const formatDuration = (seconds: number): string => {
92+
const minutes = Math.floor(seconds / 60)
93+
const remainingSeconds = seconds % 60
94+
if (minutes > 0) {
95+
return `${minutes}m ${remainingSeconds}s`
96+
}
97+
return `${remainingSeconds}s`
98+
}
99+
100+
const formatTimestamp = (timestamp: string): string => {
101+
const date = new Date(Date.parse(timestamp))
102+
return date.toLocaleString()
103+
}
104+
105+
const calculateCurrentDuration = (startedAt?: string): number | undefined => {
106+
if (!startedAt) return undefined
107+
return Math.floor((Date.now() - new Date(startedAt).getTime()) / 1000)
108+
}
109+
110+
export const WatchBuildProgress: React.FC<WatchBuildProgressProps> = ({
111+
repoUrl,
112+
branch,
113+
token,
114+
refreshInterval = 5000,
115+
timeout = 180000,
116+
}) => {
117+
const parsedRepo = useMemo(() => parseGitHubUrl(repoUrl), [repoUrl])
118+
const owner = parsedRepo?.owner
119+
const repository = parsedRepo?.repo
120+
121+
const { workflowRun, loading, isWatching } = useGitHubActions({
122+
owner: owner || '',
123+
repo: repository || '',
124+
branch,
125+
token,
126+
autoRefresh: true,
127+
refreshInterval,
128+
timeout,
129+
})
130+
131+
const status = workflowRun?.status
132+
const conclusion = workflowRun?.conclusion
133+
134+
// Calculate current workflow duration
135+
const currentWorkflowDuration = workflowRun?.run_started_at
136+
? calculateCurrentDuration(workflowRun.run_started_at)
137+
: undefined
138+
139+
const isWorkflowRunningOrPending = status === 'in_progress' || status === 'queued'
140+
141+
if ((loading && !isWorkflowRunningOrPending) || !status) {
142+
return <Box sx={{ width: 16, height: 16 }} />
143+
}
144+
145+
return (
146+
<Box>
147+
<Box display="flex" alignItems="center" mb={2} mx={1}>
148+
<Chip
149+
icon={<GitHubIcon />}
150+
deleteIcon={getStatusIcon(status, conclusion)}
151+
label={
152+
status.replace('_', ' ') +
153+
(currentWorkflowDuration && isWorkflowRunningOrPending
154+
? ` (${formatDuration(currentWorkflowDuration)})`
155+
: '')
156+
}
157+
color={getStatusColor(status, conclusion) as any}
158+
variant="outlined"
159+
onClick={() => {
160+
if (workflowRun && owner && repository) {
161+
const githubUrl = `https://github.com/${owner}/${repository}/actions/runs/${workflowRun.id}`
162+
window.open(githubUrl, '_blank')
163+
}
164+
}}
165+
sx={{ cursor: 'pointer' }}
166+
/>
167+
{isWatching && (
168+
<Box
169+
sx={{
170+
marginLeft: 1,
171+
width: 8,
172+
height: 8,
173+
borderRadius: '50%',
174+
backgroundColor: 'success.main',
175+
animation: 'pulse 2s infinite',
176+
'@keyframes pulse': {
177+
'0%': { opacity: 1 },
178+
'50%': { opacity: 0.2 },
179+
'100%': { opacity: 1 },
180+
},
181+
}}
182+
/>
183+
)}
184+
</Box>
185+
</Box>
186+
)
187+
}

src/events/page/api/Api.tsx

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { Card, Container, Grid, Typography, Box, Button } from '@mui/material'
99
import { yupResolver } from '@hookform/resolvers/yup'
1010
import { mapEventDevSettingsFormToMutateObject } from '../settings/mapEventSettingsFormToMutateObject'
1111
import { WebhooksFields } from '../settings/components/WebhooksFields'
12+
import { RepoFields } from '../settings/components/RepoFields'
1213
import { EventStaticApiFilePaths } from '../settings/components/EventStaticApiFilePaths'
1314
import LoadingButton from '@mui/lab/LoadingButton'
1415
import { SaveShortcut } from '../../../components/form/SaveShortcut'
@@ -33,6 +34,9 @@ const schema = yup
3334
lastAnswer: yup.string().nullable(),
3435
})
3536
),
37+
repoUrl: yup.string().nullable(),
38+
workflowRunId: yup.string().nullable(),
39+
token: yup.string().nullable(),
3640
})
3741
.required()
3842

@@ -42,6 +46,8 @@ const convertInputEvent = (event: Event): EventSettingForForm => {
4246
webhooks: event.webhooks || [],
4347
apiKey: event.apiKey,
4448
publicEnabled: event.publicEnabled || false,
49+
repoUrl: event.repoUrl || null,
50+
repoToken: event.repoToken || null,
4551
}
4652
}
4753

@@ -166,6 +172,11 @@ export const API = ({ event }: APIProps) => {
166172
Deployments
167173
</Typography>
168174
<WebhooksFields control={control} isSubmitting={formState.isSubmitting} event={event} />
175+
176+
<Typography component="h2" variant="h5" mt={4}>
177+
Repository Settings
178+
</Typography>
179+
<RepoFields control={control} isSubmitting={formState.isSubmitting} event={event} />
169180
</Grid>
170181

171182
<Grid item xs={12}>

src/events/page/layouts/EventDrawerContent.tsx

Lines changed: 53 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { EventSelector } from '../../../components/EventSelector'
99
import confetti from 'canvas-confetti'
1010
import { DateTime } from 'luxon'
1111
import { OSSSponsor } from '../../../components/OSSSponsor'
12+
import { WatchBuildProgress } from '../../../components/WatchBuildProgress'
1213

1314
export type EventDrawerContentProps = {
1415
event: Event
@@ -17,6 +18,7 @@ export type EventDrawerContentProps = {
1718
export const EventDrawerContent = ({ event }: EventDrawerContentProps) => {
1819
const [loading, setLoading] = useState(false)
1920
const { createNotification } = useNotification()
21+
const [githubWatchKey, setGithubWatchKey] = useState(0)
2022
const buttonRef = useRef<HTMLButtonElement>(null)
2123

2224
const getRelativeTime = () => {
@@ -39,45 +41,59 @@ export const EventDrawerContent = ({ event }: EventDrawerContentProps) => {
3941
<List component="nav">
4042
<EventScreenMenuItems />
4143
<Tooltip title={getRelativeTime()} arrow placement="top">
42-
<LoadingButton
43-
ref={buttonRef}
44-
variant="contained"
45-
loading={loading}
46-
disabled={loading}
47-
onClick={async () => {
48-
setLoading(true)
49-
await updateWebsiteTriggerWebhooksAction(event, createNotification)
50-
setLoading(false)
44+
<Box>
45+
<LoadingButton
46+
ref={buttonRef}
47+
variant="contained"
48+
loading={loading}
49+
disabled={loading}
50+
onClick={async () => {
51+
setLoading(true)
52+
await updateWebsiteTriggerWebhooksAction(event, createNotification)
53+
setLoading(false)
5154

52-
// Trigger confetti effect from button position
53-
if (buttonRef.current) {
54-
const rect = buttonRef.current.getBoundingClientRect()
55-
const x = rect.left + rect.width / 2
56-
const y = rect.top + rect.height / 2
55+
// Trigger confetti effect from button position
56+
if (buttonRef.current) {
57+
const rect = buttonRef.current.getBoundingClientRect()
58+
const x = rect.left + rect.width / 2
59+
const y = rect.top + rect.height / 2
5760

58-
confetti({
59-
origin: {
60-
x: x / window.innerWidth,
61-
y: y / window.innerHeight,
62-
},
63-
spread: 70,
64-
startVelocity: 30,
65-
particleCount: 100,
66-
angle: 20,
67-
gravity: -0.4,
68-
zIndex: 2000,
69-
drift: 0.5,
70-
})
71-
}
72-
}}
73-
sx={{
74-
margin: 1,
75-
whiteSpace: 'break-spaces',
76-
width: 'calc(100% - 16px)',
77-
}}
78-
loadingIndicator={<CircularProgress color="secondary" size={16} />}>
79-
<ListItemText primary={'Update website'} />
80-
</LoadingButton>
61+
confetti({
62+
origin: {
63+
x: x / window.innerWidth,
64+
y: y / window.innerHeight,
65+
},
66+
spread: 70,
67+
startVelocity: 30,
68+
particleCount: 100,
69+
angle: 20,
70+
gravity: -0.4,
71+
zIndex: 2000,
72+
drift: 0.5,
73+
})
74+
}
75+
setTimeout(() => {
76+
setGithubWatchKey(githubWatchKey + 1)
77+
}, 1000)
78+
}}
79+
sx={{
80+
margin: 1,
81+
whiteSpace: 'break-spaces',
82+
width: 'calc(100% - 16px)',
83+
}}
84+
loadingIndicator={<CircularProgress color="secondary" size={16} />}>
85+
<ListItemText primary={'Update website'} />
86+
</LoadingButton>
87+
{event.repoUrl && (
88+
<WatchBuildProgress
89+
key={githubWatchKey} // it trigger the refresh of the component after a deploy on the button above
90+
repoUrl={event.repoUrl}
91+
token={event.repoToken || undefined}
92+
refreshInterval={5000}
93+
timeout={180000}
94+
/>
95+
)}
96+
</Box>
8197
</Tooltip>
8298
</List>
8399

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import { Typography } from '@mui/material'
2+
import { Control, TextFieldElement } from 'react-hook-form-mui'
3+
import { Event, EventSettingForForm } from '../../../../types'
4+
5+
export type RepoFieldsProps = {
6+
control: Control<EventSettingForForm, any>
7+
isSubmitting: boolean
8+
event: Event
9+
}
10+
11+
export const RepoFields = ({ control, isSubmitting, event }: RepoFieldsProps) => {
12+
return (
13+
<>
14+
<Typography variant="body2">for CI progress monitoring, only GitHub is supported.</Typography>
15+
16+
<TextFieldElement
17+
label="Repository URL"
18+
name="repoUrl"
19+
control={control}
20+
variant="filled"
21+
size="small"
22+
margin="dense"
23+
fullWidth
24+
disabled={isSubmitting}
25+
type="url"
26+
helperText="GitHub repository URL (e.g., https://github.com/owner/repo)"
27+
/>
28+
29+
<TextFieldElement
30+
label="GitHub Token (optional)"
31+
name="repoToken"
32+
control={control}
33+
variant="filled"
34+
size="small"
35+
margin="dense"
36+
type="password"
37+
fullWidth
38+
disabled={isSubmitting}
39+
helperText="GitHub personal access token for API access, recommended to prevent rate limiting. Generate a token at https://github.com/settings/personal-access-tokens with actions read permission"
40+
/>
41+
</>
42+
)
43+
}

src/events/page/settings/components/WebhooksFields.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ export const WebhooksFields = ({ control, isSubmitting, event }: WebhooksFieldsP
3434
Webhooks
3535
</Typography>
3636

37-
<Box paddingLeft={2}>
37+
<Box>
3838
{fields.map((webhook: WebhooksWithKey, index) => {
3939
const eventWebhook = event.webhooks.find((w) => w.url === webhook.url)
4040
const lastAnswerRelativeToNow = eventWebhook?.lastAnswerDate

0 commit comments

Comments
 (0)