Skip to content

Commit 07546eb

Browse files
authored
Merge pull request #25 from Multiplier-Labs/fix/code-review-security-fixes
Improve workflow schedule UI with 15-min time picker and bi-weekly option
2 parents 1031f66 + 212b418 commit 07546eb

File tree

5 files changed

+177
-56
lines changed

5 files changed

+177
-56
lines changed

server/config.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88

99
import { homedir } from 'os'
1010
import { join } from 'path'
11-
import { readFileSync, existsSync } from 'fs'
11+
import { readFileSync, existsSync, realpathSync } from 'fs'
1212

1313
// ---------------------------------------------------------------------------
1414
// Network
@@ -45,8 +45,11 @@ export const AUTH_TOKEN = loadAuthToken()
4545
// Paths
4646
// ---------------------------------------------------------------------------
4747

48-
/** Root directory for cloned repositories. */
49-
export const REPOS_ROOT = process.env.REPOS_ROOT || join(homedir(), 'repos')
48+
/** Root directory for cloned repositories. Resolved via realpathSync to follow symlinks. */
49+
const rawReposRoot = process.env.REPOS_ROOT || join(homedir(), 'repos')
50+
export const REPOS_ROOT = existsSync(rawReposRoot)
51+
? realpathSync(rawReposRoot)
52+
: rawReposRoot
5053

5154
/** Codekin data directory (sessions, approvals, workflows, etc.). */
5255
export const DATA_DIR = process.env.DATA_DIR || join(homedir(), '.codekin')

src/components/AddWorkflowModal.tsx

Lines changed: 57 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,9 @@ import { useRepos } from '../hooks/useRepos'
1313
import { listKinds } from '../lib/workflowApi'
1414
import type { ReviewRepoConfig, WorkflowKindInfo } from '../lib/workflowApi'
1515
import {
16-
WORKFLOW_KINDS, DAY_PATTERNS,
17-
buildCron, formatHour, describeCron, slugify, kindCategory,
16+
WORKFLOW_KINDS, DAY_PRESETS, DAY_INDIVIDUAL,
17+
buildCron, describeCron, slugify, kindCategory,
18+
toTimeValue, fromTimeValue,
1819
} from '../lib/workflowHelpers'
1920
import { CategoryBadge } from './WorkflowBadges'
2021
import { RepoList } from './RepoList'
@@ -26,6 +27,7 @@ interface FormState {
2627
repoPath: string
2728
repoName: string
2829
cronHour: number
30+
cronMinute: number
2931
cronDow: string
3032
customPrompt: string
3133
}
@@ -227,13 +229,38 @@ function StepKind({
227229
// Step 3: Schedule + custom prompt
228230
// ---------------------------------------------------------------------------
229231

232+
function FrequencyButton({
233+
label, dow, selected, onSelect,
234+
}: {
235+
label: string; dow: string; selected: boolean; onSelect: (dow: string) => void
236+
}) {
237+
return (
238+
<button
239+
type="button"
240+
onClick={() => onSelect(dow)}
241+
className={`rounded-md border px-3 py-1.5 text-[13px] font-medium transition-colors ${
242+
selected
243+
? 'border-accent-6 bg-accent-9/40 text-accent-2'
244+
: 'border-neutral-7 bg-neutral-10 text-neutral-3 hover:border-neutral-6 hover:text-neutral-2'
245+
}`}
246+
>
247+
{label}
248+
</button>
249+
)
250+
}
251+
230252
function StepSchedule({
231253
form,
232254
onChange,
233255
}: {
234256
form: FormState
235257
onChange: (patch: Partial<FormState>) => void
236258
}) {
259+
const handleTimeChange = (e: React.ChangeEvent<HTMLInputElement>) => {
260+
const { hour, minute } = fromTimeValue(e.target.value)
261+
onChange({ cronHour: hour, cronMinute: minute })
262+
}
263+
237264
return (
238265
<div className="space-y-5">
239266
<div>
@@ -243,37 +270,41 @@ function StepSchedule({
243270

244271
{/* Time picker */}
245272
<label className="block text-[13px] font-medium text-neutral-3 mb-2">Time</label>
246-
<select
247-
value={form.cronHour}
248-
onChange={e => onChange({ cronHour: parseInt(e.target.value) })}
273+
<input
274+
type="time"
275+
step={900}
276+
value={toTimeValue(form.cronHour, form.cronMinute)}
277+
onChange={handleTimeChange}
249278
className="rounded-md border border-neutral-7 bg-neutral-10 px-3 py-2 text-[15px] text-neutral-1 focus:border-accent-6 focus:outline-none w-full"
250-
>
251-
{Array.from({ length: 24 }, (_, h) => (
252-
<option key={h} value={h}>{formatHour(h)}</option>
253-
))}
254-
</select>
279+
/>
255280
</div>
256281

257282
<div>
258283
<label className="block text-[13px] font-medium text-neutral-3 mb-2">Frequency</label>
259-
<div className="flex flex-wrap gap-1.5">
260-
{DAY_PATTERNS.map(p => (
261-
<button
284+
<div className="flex flex-wrap gap-1.5 mb-2">
285+
{DAY_PRESETS.map(p => (
286+
<FrequencyButton
262287
key={p.dow}
263-
type="button"
264-
onClick={() => onChange({ cronDow: p.dow })}
265-
className={`rounded-md border px-3 py-1.5 text-[13px] font-medium transition-colors ${
266-
form.cronDow === p.dow
267-
? 'border-accent-6 bg-accent-9/40 text-accent-2'
268-
: 'border-neutral-7 bg-neutral-10 text-neutral-3 hover:border-neutral-6 hover:text-neutral-2'
269-
}`}
270-
>
271-
{p.label}
272-
</button>
288+
label={p.label}
289+
dow={p.dow}
290+
selected={form.cronDow === p.dow}
291+
onSelect={dow => onChange({ cronDow: dow })}
292+
/>
293+
))}
294+
</div>
295+
<div className="flex gap-1.5">
296+
{DAY_INDIVIDUAL.map(p => (
297+
<FrequencyButton
298+
key={p.dow}
299+
label={p.label}
300+
dow={p.dow}
301+
selected={form.cronDow === p.dow}
302+
onSelect={dow => onChange({ cronDow: dow })}
303+
/>
273304
))}
274305
</div>
275306
<div className="mt-2 text-[13px] text-neutral-5">
276-
{describeCron(buildCron(form.cronHour, form.cronDow))}
307+
{describeCron(buildCron(form.cronHour, form.cronDow, form.cronMinute))}
277308
</div>
278309
</div>
279310

@@ -306,6 +337,7 @@ export function AddWorkflowModal({ token, onClose, onAdd }: Props) {
306337
repoPath: '',
307338
repoName: '',
308339
cronHour: 6,
340+
cronMinute: 0,
309341
cronDow: '*',
310342
customPrompt: '',
311343
})
@@ -347,7 +379,7 @@ export function AddWorkflowModal({ token, onClose, onAdd }: Props) {
347379
id: `${slugify(name)}-${slugify(form.kind)}`,
348380
name,
349381
repoPath: form.repoPath.trim(),
350-
cronExpression: buildCron(form.cronHour, form.cronDow),
382+
cronExpression: buildCron(form.cronHour, form.cronDow, form.cronMinute),
351383
kind: form.kind,
352384
enabled: true,
353385
customPrompt: form.customPrompt.trim() || undefined,

src/components/EditWorkflowModal.tsx

Lines changed: 34 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,9 @@ import { useState } from 'react'
77
import { IconX, IconLoader2 } from '@tabler/icons-react'
88
import type { ReviewRepoConfig } from '../lib/workflowApi'
99
import {
10-
WORKFLOW_KINDS, DAY_PATTERNS,
11-
buildCron, parseCron, formatHour, describeCron, kindLabel,
10+
WORKFLOW_KINDS, DAY_PRESETS, DAY_INDIVIDUAL,
11+
buildCron, parseCron, describeCron, kindLabel,
12+
toTimeValue, fromTimeValue,
1213
} from '../lib/workflowHelpers'
1314
import { CategoryBadge } from './WorkflowBadges'
1415

@@ -25,6 +26,7 @@ export function EditWorkflowModal({ repo, onClose, onSave }: Props) {
2526
const [form, setForm] = useState({
2627
kind: repo.kind ?? 'coverage.daily',
2728
cronHour: parsed.hour,
29+
cronMinute: parsed.minute,
2830
cronDow: parsed.dow,
2931
customPrompt: repo.customPrompt ?? '',
3032
})
@@ -38,7 +40,7 @@ export function EditWorkflowModal({ repo, onClose, onSave }: Props) {
3840
try {
3941
await onSave(repo.id, {
4042
kind: form.kind,
41-
cronExpression: buildCron(form.cronHour, form.cronDow),
43+
cronExpression: buildCron(form.cronHour, form.cronDow, form.cronMinute),
4244
customPrompt: form.customPrompt.trim() || undefined,
4345
})
4446
onClose()
@@ -103,18 +105,36 @@ export function EditWorkflowModal({ repo, onClose, onSave }: Props) {
103105

104106
{/* Schedule */}
105107
<div>
106-
<label className="block text-[13px] font-medium text-neutral-3 mb-2">Schedule</label>
107-
<select
108-
value={form.cronHour}
109-
onChange={e => setForm(f => ({ ...f, cronHour: parseInt(e.target.value) }))}
108+
<label className="block text-[13px] font-medium text-neutral-3 mb-2">Time</label>
109+
<input
110+
type="time"
111+
step={900}
112+
value={toTimeValue(form.cronHour, form.cronMinute)}
113+
onChange={e => {
114+
const { hour, minute } = fromTimeValue(e.target.value)
115+
setForm(f => ({ ...f, cronHour: hour, cronMinute: minute }))
116+
}}
110117
className="rounded-md border border-neutral-7 bg-neutral-10 px-3 py-2 text-[15px] text-neutral-1 focus:border-accent-6 focus:outline-none w-full mb-3"
111-
>
112-
{Array.from({ length: 24 }, (_, h) => (
113-
<option key={h} value={h}>{formatHour(h)}</option>
118+
/>
119+
<label className="block text-[13px] font-medium text-neutral-3 mb-2">Frequency</label>
120+
<div className="flex flex-wrap gap-1.5 mb-2">
121+
{DAY_PRESETS.map(p => (
122+
<button
123+
key={p.dow}
124+
type="button"
125+
onClick={() => setForm(f => ({ ...f, cronDow: p.dow }))}
126+
className={`rounded-md border px-3 py-1.5 text-[13px] font-medium transition-colors ${
127+
form.cronDow === p.dow
128+
? 'border-accent-6 bg-accent-9/40 text-accent-2'
129+
: 'border-neutral-7 bg-neutral-10 text-neutral-3 hover:border-neutral-6 hover:text-neutral-2'
130+
}`}
131+
>
132+
{p.label}
133+
</button>
114134
))}
115-
</select>
116-
<div className="flex flex-wrap gap-1.5">
117-
{DAY_PATTERNS.map(p => (
135+
</div>
136+
<div className="flex gap-1.5">
137+
{DAY_INDIVIDUAL.map(p => (
118138
<button
119139
key={p.dow}
120140
type="button"
@@ -130,7 +150,7 @@ export function EditWorkflowModal({ repo, onClose, onSave }: Props) {
130150
))}
131151
</div>
132152
<div className="mt-2 text-[13px] text-neutral-5">
133-
{describeCron(buildCron(form.cronHour, form.cronDow))}
153+
{describeCron(buildCron(form.cronHour, form.cronDow, form.cronMinute))}
134154
</div>
135155
</div>
136156

src/lib/workflowHelpers.test.ts

Lines changed: 47 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ import {
1111
formatTime,
1212
repoNameFromRun,
1313
statusBadge,
14+
toTimeValue,
15+
fromTimeValue,
1416
} from './workflowHelpers.js'
1517
import type { WorkflowRun } from './workflowApi'
1618

@@ -21,21 +23,41 @@ describe('workflowHelpers', () => {
2123
expect(buildCron(14, '1-5')).toBe('0 14 * * 1-5')
2224
expect(buildCron(0, '0')).toBe('0 0 * * 0')
2325
})
26+
27+
it('builds a cron expression with minute', () => {
28+
expect(buildCron(6, '*', 30)).toBe('30 6 * * *')
29+
expect(buildCron(14, '1-5', 15)).toBe('15 14 * * 1-5')
30+
})
31+
32+
it('builds a biweekly cron expression', () => {
33+
expect(buildCron(6, 'biweekly')).toBe('0 6 */14 * *')
34+
expect(buildCron(9, 'biweekly', 45)).toBe('45 9 */14 * *')
35+
})
2436
})
2537

2638
describe('parseCron', () => {
2739
it('parses a valid 5-field cron expression', () => {
28-
expect(parseCron('0 6 * * *')).toEqual({ hour: 6, dow: '*' })
29-
expect(parseCron('0 14 * * 1-5')).toEqual({ hour: 14, dow: '1-5' })
40+
expect(parseCron('0 6 * * *')).toEqual({ hour: 6, minute: 0, dow: '*' })
41+
expect(parseCron('0 14 * * 1-5')).toEqual({ hour: 14, minute: 0, dow: '1-5' })
42+
})
43+
44+
it('parses minute field', () => {
45+
expect(parseCron('30 6 * * *')).toEqual({ hour: 6, minute: 30, dow: '*' })
46+
expect(parseCron('15 14 * * 1-5')).toEqual({ hour: 14, minute: 15, dow: '1-5' })
47+
})
48+
49+
it('parses biweekly expression', () => {
50+
expect(parseCron('0 6 */14 * *')).toEqual({ hour: 6, minute: 0, dow: 'biweekly' })
51+
expect(parseCron('45 9 */14 * *')).toEqual({ hour: 9, minute: 45, dow: 'biweekly' })
3052
})
3153

3254
it('returns defaults for invalid expression', () => {
33-
expect(parseCron('invalid')).toEqual({ hour: 6, dow: '*' })
34-
expect(parseCron('* *')).toEqual({ hour: 6, dow: '*' })
55+
expect(parseCron('invalid')).toEqual({ hour: 6, minute: 0, dow: '*' })
56+
expect(parseCron('* *')).toEqual({ hour: 6, minute: 0, dow: '*' })
3557
})
3658

3759
it('handles non-numeric hour', () => {
38-
expect(parseCron('0 abc * * *')).toEqual({ hour: 0, dow: '*' })
60+
expect(parseCron('0 abc * * *')).toEqual({ hour: 0, minute: 0, dow: '*' })
3961
})
4062
})
4163

@@ -59,6 +81,21 @@ describe('workflowHelpers', () => {
5981
})
6082
})
6183

84+
describe('toTimeValue', () => {
85+
it('pads hour and minute', () => {
86+
expect(toTimeValue(6, 0)).toBe('06:00')
87+
expect(toTimeValue(14, 30)).toBe('14:30')
88+
expect(toTimeValue(0, 15)).toBe('00:15')
89+
})
90+
})
91+
92+
describe('fromTimeValue', () => {
93+
it('parses time string', () => {
94+
expect(fromTimeValue('06:00')).toEqual({ hour: 6, minute: 0 })
95+
expect(fromTimeValue('14:30')).toEqual({ hour: 14, minute: 30 })
96+
})
97+
})
98+
6299
describe('describeCron', () => {
63100
it('describes daily cron', () => {
64101
expect(describeCron('0 6 * * *')).toBe('Daily at 06:00')
@@ -73,6 +110,11 @@ describe('workflowHelpers', () => {
73110
expect(describeCron('0 9 * * 0')).toBe('Weekly Sun at 09:00')
74111
})
75112

113+
it('describes biweekly cron', () => {
114+
expect(describeCron('0 6 */14 * *')).toBe('Bi-weekly at 06:00')
115+
expect(describeCron('30 9 */14 * *')).toBe('Bi-weekly at 09:30')
116+
})
117+
76118
it('returns raw expression for invalid format', () => {
77119
expect(describeCron('invalid')).toBe('invalid')
78120
})

0 commit comments

Comments
 (0)