@chesapeake/stack 0.1.3 → 0.1.5
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.
- package/CHANGELOG.md +18 -0
- package/README.md +4 -0
- package/components/ux/connectivity/ConnectivityBadge.tsx +75 -0
- package/components/ux/connectivity/index.ts +5 -0
- package/components/ux/index.ts +4 -0
- package/components/ux/runner/RunnerPairingCard.tsx +39 -0
- package/components/ux/runner/RunnerStatusCard.tsx +57 -0
- package/components/ux/runner/index.ts +4 -0
- package/components/ux/task/TaskCard.tsx +85 -0
- package/components/ux/task/TaskInput.tsx +65 -0
- package/components/ux/task/TaskList.tsx +32 -0
- package/components/ux/task/index.ts +6 -0
- package/index.ts +1 -0
- package/lib/githubIssue.ts +186 -0
- package/package.json +11 -1
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,23 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
|
6
6
|
and this project adheres to
|
|
7
7
|
[Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
8
8
|
|
|
9
|
+
## [0.1.5] - 2026-08-03
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- `lib/githubIssue`: product-neutral GitHub issue URL parsing, PR rejection,
|
|
14
|
+
snapshot normalization, and import keys
|
|
15
|
+
|
|
16
|
+
## [0.1.4] - 2026-08-03
|
|
17
|
+
|
|
18
|
+
### Added
|
|
19
|
+
|
|
20
|
+
- UX task components: `TaskInput`, `TaskCard`, and `TaskList` with
|
|
21
|
+
application-neutral props and public subpath exports
|
|
22
|
+
- UX connectivity component: `ConnectivityBadge` with tone/status presentation
|
|
23
|
+
- UX runner presentation: `RunnerPairingCard` and `RunnerStatusCard` for pairing
|
|
24
|
+
command display and runner status chrome
|
|
25
|
+
|
|
9
26
|
## [0.1.3] - 2026-03-09
|
|
10
27
|
|
|
11
28
|
### Changed
|
|
@@ -65,6 +82,7 @@ and this project adheres to
|
|
|
65
82
|
- Full dark mode support via CSS custom properties
|
|
66
83
|
- Apache 2.0 license
|
|
67
84
|
|
|
85
|
+
[0.1.4]: https://github.com/chesapeakedev/stack/releases/tag/v0.1.4
|
|
68
86
|
[0.1.3]: https://github.com/chesapeakedev/stack/releases/tag/v0.1.3
|
|
69
87
|
[0.1.2]: https://github.com/chesapeakedev/stack/releases/tag/v0.1.2
|
|
70
88
|
[0.1.1]: https://github.com/chesapeakedev/stack/releases/tag/v0.1.1
|
package/README.md
CHANGED
|
@@ -105,6 +105,10 @@ import type {
|
|
|
105
105
|
|
|
106
106
|
### Available Components
|
|
107
107
|
|
|
108
|
+
The UX task, connectivity, and runner components are application-neutral.
|
|
109
|
+
They accept display values and callbacks; persistence, networking, runner APIs,
|
|
110
|
+
and product-specific task metadata remain in the consuming application.
|
|
111
|
+
|
|
108
112
|
#### UI Components (`components/ui/`)
|
|
109
113
|
|
|
110
114
|
- **Button** - Various button variants and sizes
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import type { KeyboardEvent } from "react";
|
|
2
|
+
import {
|
|
3
|
+
AlertCircle,
|
|
4
|
+
AlertTriangle,
|
|
5
|
+
Check,
|
|
6
|
+
CloudOff,
|
|
7
|
+
Loader2,
|
|
8
|
+
} from "lucide-react";
|
|
9
|
+
|
|
10
|
+
export type ConnectivityTone =
|
|
11
|
+
| "offline"
|
|
12
|
+
| "syncing"
|
|
13
|
+
| "online"
|
|
14
|
+
| "degraded"
|
|
15
|
+
| "conflict";
|
|
16
|
+
|
|
17
|
+
export interface ConnectivityBadgeProps {
|
|
18
|
+
label: string;
|
|
19
|
+
tone: ConnectivityTone;
|
|
20
|
+
tooltip?: string;
|
|
21
|
+
onAction?: () => void;
|
|
22
|
+
actionLabel?: string;
|
|
23
|
+
locked?: boolean;
|
|
24
|
+
className?: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function ConnectivityBadge({
|
|
28
|
+
label,
|
|
29
|
+
tone,
|
|
30
|
+
tooltip,
|
|
31
|
+
onAction,
|
|
32
|
+
actionLabel,
|
|
33
|
+
locked = false,
|
|
34
|
+
className,
|
|
35
|
+
}: ConnectivityBadgeProps) {
|
|
36
|
+
const interactive = onAction != null && !locked;
|
|
37
|
+
const title = tooltip ?? label;
|
|
38
|
+
const icon =
|
|
39
|
+
tone === "syncing" ? (
|
|
40
|
+
<Loader2 className="h-4 w-4 animate-spin" />
|
|
41
|
+
) : tone === "degraded" ? (
|
|
42
|
+
<AlertTriangle className="h-4 w-4" />
|
|
43
|
+
) : tone === "conflict" ? (
|
|
44
|
+
<AlertCircle className="h-4 w-4" />
|
|
45
|
+
) : tone === "online" ? (
|
|
46
|
+
<Check className="h-4 w-4" />
|
|
47
|
+
) : (
|
|
48
|
+
<CloudOff className="h-4 w-4" />
|
|
49
|
+
);
|
|
50
|
+
|
|
51
|
+
return (
|
|
52
|
+
<div
|
|
53
|
+
role={interactive ? "button" : "status"}
|
|
54
|
+
tabIndex={interactive ? 0 : undefined}
|
|
55
|
+
aria-label={actionLabel ?? label}
|
|
56
|
+
aria-live="polite"
|
|
57
|
+
title={title}
|
|
58
|
+
className={`inline-flex h-9 items-center justify-center gap-2 rounded-md border px-3 text-sm font-medium ${tone === "online" ? "border-primary/60 text-primary bg-primary/5" : tone === "degraded" || tone === "conflict" ? "border-destructive/50 text-destructive bg-destructive/5" : "border-muted-foreground/40 text-muted-foreground bg-muted/40"} ${interactive ? "cursor-pointer" : ""} ${className ?? ""}`}
|
|
59
|
+
onClick={interactive ? onAction : undefined}
|
|
60
|
+
onKeyDown={
|
|
61
|
+
interactive
|
|
62
|
+
? (event: KeyboardEvent) => {
|
|
63
|
+
if (event.key === "Enter" || event.key === " ") {
|
|
64
|
+
event.preventDefault();
|
|
65
|
+
onAction();
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
: undefined
|
|
69
|
+
}
|
|
70
|
+
>
|
|
71
|
+
{icon}
|
|
72
|
+
<span>{label}</span>
|
|
73
|
+
</div>
|
|
74
|
+
);
|
|
75
|
+
}
|
package/components/ux/index.ts
CHANGED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { Copy } from "lucide-react";
|
|
2
|
+
import { Button } from "../../ui/button.tsx";
|
|
3
|
+
|
|
4
|
+
export interface RunnerPairingCardProps {
|
|
5
|
+
command: string;
|
|
6
|
+
code?: string;
|
|
7
|
+
expiresAt?: string;
|
|
8
|
+
onCopy: () => void;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function RunnerPairingCard({
|
|
12
|
+
command,
|
|
13
|
+
code,
|
|
14
|
+
expiresAt,
|
|
15
|
+
onCopy,
|
|
16
|
+
}: RunnerPairingCardProps) {
|
|
17
|
+
return (
|
|
18
|
+
<div className="bg-muted mt-4 space-y-2 rounded-md border p-3">
|
|
19
|
+
<p className="text-sm font-medium">Run this command on your device</p>
|
|
20
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
21
|
+
<code className="bg-background min-w-0 flex-1 overflow-x-auto rounded border px-3 py-2 text-xs">
|
|
22
|
+
{command}
|
|
23
|
+
</code>
|
|
24
|
+
<Button type="button" size="sm" variant="outline" onClick={onCopy}>
|
|
25
|
+
<Copy className="mr-2 h-4 w-4" />
|
|
26
|
+
Copy
|
|
27
|
+
</Button>
|
|
28
|
+
</div>
|
|
29
|
+
{code && (
|
|
30
|
+
<p className="text-muted-foreground text-xs">
|
|
31
|
+
Code {code}
|
|
32
|
+
{expiresAt
|
|
33
|
+
? ` expires ${new Date(expiresAt).toLocaleTimeString()}.`
|
|
34
|
+
: "."}
|
|
35
|
+
</p>
|
|
36
|
+
)}
|
|
37
|
+
</div>
|
|
38
|
+
);
|
|
39
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
import { Card } from "../../ui/card.tsx";
|
|
3
|
+
|
|
4
|
+
export interface RunnerStatusCardProps {
|
|
5
|
+
name: string;
|
|
6
|
+
state: string;
|
|
7
|
+
details?: string;
|
|
8
|
+
repositories?: string[];
|
|
9
|
+
children?: ReactNode;
|
|
10
|
+
jobs?: ReactNode;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function RunnerStatusCard({
|
|
14
|
+
name,
|
|
15
|
+
state,
|
|
16
|
+
details,
|
|
17
|
+
repositories = [],
|
|
18
|
+
children,
|
|
19
|
+
jobs,
|
|
20
|
+
}: RunnerStatusCardProps) {
|
|
21
|
+
return (
|
|
22
|
+
<Card
|
|
23
|
+
className="space-y-3 rounded-md border p-4"
|
|
24
|
+
aria-label={`${name} runner`}
|
|
25
|
+
>
|
|
26
|
+
<div className="flex flex-wrap items-start justify-between gap-3">
|
|
27
|
+
<div>
|
|
28
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
29
|
+
<h4 className="font-medium">{name}</h4>
|
|
30
|
+
<span className="bg-muted rounded-full px-2 py-0.5 text-xs">
|
|
31
|
+
{state}
|
|
32
|
+
</span>
|
|
33
|
+
</div>
|
|
34
|
+
{details && (
|
|
35
|
+
<p className="text-muted-foreground mt-1 text-xs">{details}</p>
|
|
36
|
+
)}
|
|
37
|
+
</div>
|
|
38
|
+
<div className="flex flex-wrap gap-2">{children}</div>
|
|
39
|
+
</div>
|
|
40
|
+
<div>
|
|
41
|
+
<p className="text-sm font-medium">Repository access</p>
|
|
42
|
+
{repositories.length ? (
|
|
43
|
+
<ul className="text-muted-foreground mt-1 list-inside list-disc text-sm">
|
|
44
|
+
{repositories.map((repository) => (
|
|
45
|
+
<li key={repository}>{repository}</li>
|
|
46
|
+
))}
|
|
47
|
+
</ul>
|
|
48
|
+
) : (
|
|
49
|
+
<p className="text-muted-foreground mt-1 text-sm">
|
|
50
|
+
No repositories registered.
|
|
51
|
+
</p>
|
|
52
|
+
)}
|
|
53
|
+
</div>
|
|
54
|
+
{jobs}
|
|
55
|
+
</Card>
|
|
56
|
+
);
|
|
57
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { useState } from "react";
|
|
2
|
+
import type { ReactNode } from "react";
|
|
3
|
+
import { Card } from "../../ui/card.tsx";
|
|
4
|
+
import { Input } from "../../ui/input.tsx";
|
|
5
|
+
|
|
6
|
+
export interface TaskCardProps {
|
|
7
|
+
title: string;
|
|
8
|
+
completed: boolean;
|
|
9
|
+
onToggle: () => void;
|
|
10
|
+
onTitleChange?: (title: string) => void;
|
|
11
|
+
leading?: ReactNode;
|
|
12
|
+
content?: ReactNode;
|
|
13
|
+
actions?: ReactNode;
|
|
14
|
+
className?: string;
|
|
15
|
+
newlyAdded?: boolean;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function TaskCard({
|
|
19
|
+
title,
|
|
20
|
+
completed,
|
|
21
|
+
onToggle,
|
|
22
|
+
onTitleChange,
|
|
23
|
+
leading,
|
|
24
|
+
content,
|
|
25
|
+
actions,
|
|
26
|
+
className,
|
|
27
|
+
newlyAdded = false,
|
|
28
|
+
}: TaskCardProps) {
|
|
29
|
+
const [editing, setEditing] = useState(false);
|
|
30
|
+
const [draft, setDraft] = useState(title);
|
|
31
|
+
const save = () => {
|
|
32
|
+
const next = draft.trim();
|
|
33
|
+
if (next && next !== title) onTitleChange?.(next);
|
|
34
|
+
setEditing(false);
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
return (
|
|
38
|
+
<Card
|
|
39
|
+
role="listitem"
|
|
40
|
+
className={`bg-background min-w-0 p-4 transition-all hover:shadow-md ${
|
|
41
|
+
completed ? "bg-muted/50 opacity-75" : ""
|
|
42
|
+
} ${newlyAdded ? "animate-in slide-in-from-top-2" : ""} ${className ?? ""}`}
|
|
43
|
+
>
|
|
44
|
+
<div className="flex min-w-0 items-start gap-2 md:gap-3">
|
|
45
|
+
{leading}
|
|
46
|
+
<input
|
|
47
|
+
type="checkbox"
|
|
48
|
+
checked={completed}
|
|
49
|
+
onChange={onToggle}
|
|
50
|
+
className="mt-1 h-5 w-5 shrink-0 cursor-pointer rounded text-primary focus:ring-2 focus:ring-primary"
|
|
51
|
+
aria-label={completed ? "Mark incomplete" : "Mark complete"}
|
|
52
|
+
/>
|
|
53
|
+
<div className="min-w-0 flex-1 space-y-2">
|
|
54
|
+
{editing ? (
|
|
55
|
+
<Input
|
|
56
|
+
autoFocus
|
|
57
|
+
value={draft}
|
|
58
|
+
onChange={(event) => {
|
|
59
|
+
setDraft(event.target.value);
|
|
60
|
+
}}
|
|
61
|
+
onBlur={save}
|
|
62
|
+
onKeyDown={(event) => {
|
|
63
|
+
if (event.key === "Enter") save();
|
|
64
|
+
if (event.key === "Escape") setEditing(false);
|
|
65
|
+
}}
|
|
66
|
+
aria-label="Edit task title"
|
|
67
|
+
/>
|
|
68
|
+
) : (
|
|
69
|
+
<h3
|
|
70
|
+
className={`cursor-text transition-colors hover:text-primary ${completed ? "text-muted-foreground line-through" : ""}`}
|
|
71
|
+
onClick={() => {
|
|
72
|
+
setDraft(title);
|
|
73
|
+
if (onTitleChange) setEditing(true);
|
|
74
|
+
}}
|
|
75
|
+
>
|
|
76
|
+
{title}
|
|
77
|
+
</h3>
|
|
78
|
+
)}
|
|
79
|
+
{content}
|
|
80
|
+
</div>
|
|
81
|
+
{actions}
|
|
82
|
+
</div>
|
|
83
|
+
</Card>
|
|
84
|
+
);
|
|
85
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { useRef } from "react";
|
|
2
|
+
import type { RefObject } from "react";
|
|
3
|
+
import { Button } from "../../ui/button.tsx";
|
|
4
|
+
|
|
5
|
+
export interface TaskInputProps {
|
|
6
|
+
value: string;
|
|
7
|
+
onChange: (value: string) => void;
|
|
8
|
+
onAdd: () => void;
|
|
9
|
+
placeholder?: string;
|
|
10
|
+
inputRef?: RefObject<HTMLInputElement | null>;
|
|
11
|
+
className?: string;
|
|
12
|
+
inputClassName?: string;
|
|
13
|
+
addButtonLabel?: string;
|
|
14
|
+
/** Optional attribute for consumer product tours / E2E hooks. */
|
|
15
|
+
inputDataTour?: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function TaskInput({
|
|
19
|
+
value,
|
|
20
|
+
onChange,
|
|
21
|
+
onAdd,
|
|
22
|
+
placeholder = "Add a task...",
|
|
23
|
+
inputRef,
|
|
24
|
+
className,
|
|
25
|
+
inputClassName,
|
|
26
|
+
addButtonLabel = "Add task",
|
|
27
|
+
inputDataTour,
|
|
28
|
+
}: TaskInputProps) {
|
|
29
|
+
const internalRef = useRef<HTMLInputElement>(null);
|
|
30
|
+
const ref = inputRef ?? internalRef;
|
|
31
|
+
|
|
32
|
+
return (
|
|
33
|
+
<div
|
|
34
|
+
className={`border-border bg-background border-b p-4 ${className ?? ""}`}
|
|
35
|
+
>
|
|
36
|
+
<div className="flex gap-2">
|
|
37
|
+
<input
|
|
38
|
+
ref={ref}
|
|
39
|
+
data-tour={inputDataTour}
|
|
40
|
+
className={`border-border bg-background text-foreground placeholder:text-muted-foreground flex-1 rounded border px-2 py-1 ${inputClassName ?? ""}`}
|
|
41
|
+
value={value}
|
|
42
|
+
onChange={(event) => {
|
|
43
|
+
onChange(event.target.value);
|
|
44
|
+
}}
|
|
45
|
+
onKeyDown={(event) => {
|
|
46
|
+
if (event.key === "Enter") onAdd();
|
|
47
|
+
}}
|
|
48
|
+
placeholder={placeholder}
|
|
49
|
+
aria-label={placeholder}
|
|
50
|
+
/>
|
|
51
|
+
<Button
|
|
52
|
+
type="button"
|
|
53
|
+
variant="default"
|
|
54
|
+
size="icon"
|
|
55
|
+
onClick={onAdd}
|
|
56
|
+
aria-label={addButtonLabel}
|
|
57
|
+
>
|
|
58
|
+
<span aria-hidden="true" className="text-xl leading-none">
|
|
59
|
+
+
|
|
60
|
+
</span>
|
|
61
|
+
</Button>
|
|
62
|
+
</div>
|
|
63
|
+
</div>
|
|
64
|
+
);
|
|
65
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
|
|
3
|
+
export interface TaskListProps {
|
|
4
|
+
children: ReactNode;
|
|
5
|
+
loading?: boolean;
|
|
6
|
+
empty?: ReactNode;
|
|
7
|
+
className?: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function TaskList({
|
|
11
|
+
children,
|
|
12
|
+
loading = false,
|
|
13
|
+
empty,
|
|
14
|
+
className,
|
|
15
|
+
}: TaskListProps) {
|
|
16
|
+
const content = loading ? (
|
|
17
|
+
<div className="text-muted-foreground p-8 text-center" role="status">
|
|
18
|
+
Loading tasks...
|
|
19
|
+
</div>
|
|
20
|
+
) : (
|
|
21
|
+
(empty ?? children)
|
|
22
|
+
);
|
|
23
|
+
|
|
24
|
+
return (
|
|
25
|
+
<div
|
|
26
|
+
className={`flex min-h-0 flex-1 flex-col gap-2 overflow-y-auto p-4 ${className ?? ""}`}
|
|
27
|
+
role="list"
|
|
28
|
+
>
|
|
29
|
+
{content}
|
|
30
|
+
</div>
|
|
31
|
+
);
|
|
32
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export { TaskCard } from "./TaskCard.tsx";
|
|
2
|
+
export type { TaskCardProps } from "./TaskCard.tsx";
|
|
3
|
+
export { TaskInput } from "./TaskInput.tsx";
|
|
4
|
+
export type { TaskInputProps } from "./TaskInput.tsx";
|
|
5
|
+
export { TaskList } from "./TaskList.tsx";
|
|
6
|
+
export type { TaskListProps } from "./TaskList.tsx";
|
package/index.ts
CHANGED
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
// Copyright 2026 Chesapeake Computing
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Product-neutral GitHub issue helpers shared by browser apps (denoise-ui, void-ui).
|
|
6
|
+
* Does not call GitHub or any product API — parse, validate, and normalize only.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export interface ParsedGitHubIssueRef {
|
|
10
|
+
owner: string;
|
|
11
|
+
repo: string;
|
|
12
|
+
number: number;
|
|
13
|
+
/** Canonical https://github.com/{owner}/{repo}/issues/{number} URL. */
|
|
14
|
+
url: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface GitHubIssueLabel {
|
|
18
|
+
id?: number;
|
|
19
|
+
name: string;
|
|
20
|
+
color?: string;
|
|
21
|
+
description?: string | null;
|
|
22
|
+
default?: boolean;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Minimal issue snapshot suitable for local task persistence. */
|
|
26
|
+
export interface GitHubIssueSnapshot {
|
|
27
|
+
number: number;
|
|
28
|
+
title: string;
|
|
29
|
+
body: string | null;
|
|
30
|
+
state: "open" | "closed";
|
|
31
|
+
html_url: string;
|
|
32
|
+
labels: GitHubIssueLabel[];
|
|
33
|
+
created_at: string;
|
|
34
|
+
updated_at: string;
|
|
35
|
+
closed_at: string | null;
|
|
36
|
+
owner: string;
|
|
37
|
+
repo: string;
|
|
38
|
+
repository: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const ISSUE_PATH =
|
|
42
|
+
/^\/([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+)\/issues\/([1-9]\d*)\/?$/;
|
|
43
|
+
|
|
44
|
+
const PULL_PATH =
|
|
45
|
+
/^\/([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+)\/pull\/([1-9]\d*)\/?$/;
|
|
46
|
+
|
|
47
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
48
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Parse a GitHub issue URL into owner/repo/number.
|
|
53
|
+
* Rejects pull-request URLs (`/pull/N`) and non-github.com hosts.
|
|
54
|
+
*/
|
|
55
|
+
export function parseGitHubIssueUrl(
|
|
56
|
+
value: string
|
|
57
|
+
): ParsedGitHubIssueRef | null {
|
|
58
|
+
const trimmed = value.trim();
|
|
59
|
+
if (trimmed === "") return null;
|
|
60
|
+
|
|
61
|
+
let url: URL;
|
|
62
|
+
try {
|
|
63
|
+
url = new URL(trimmed);
|
|
64
|
+
} catch {
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (url.protocol !== "https:" || url.hostname !== "github.com") {
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (PULL_PATH.test(url.pathname)) {
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const match = ISSUE_PATH.exec(url.pathname);
|
|
77
|
+
if (!match) return null;
|
|
78
|
+
|
|
79
|
+
const owner = match[1];
|
|
80
|
+
const repo = match[2];
|
|
81
|
+
const number = Number(match[3]);
|
|
82
|
+
if (!Number.isSafeInteger(number) || number < 1) return null;
|
|
83
|
+
|
|
84
|
+
return {
|
|
85
|
+
owner,
|
|
86
|
+
repo,
|
|
87
|
+
number,
|
|
88
|
+
url: canonicalGitHubIssueUrl(owner, repo, number),
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function canonicalGitHubIssueUrl(
|
|
93
|
+
owner: string,
|
|
94
|
+
repo: string,
|
|
95
|
+
number: number
|
|
96
|
+
): string {
|
|
97
|
+
return `https://github.com/${owner}/${repo}/issues/${String(number)}`;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function githubIssueImportKey(
|
|
101
|
+
owner: string,
|
|
102
|
+
repo: string,
|
|
103
|
+
number: number
|
|
104
|
+
): string {
|
|
105
|
+
return `${owner}/${repo}#${String(number)}`.toLowerCase();
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** True when a GitHub Issues API payload represents a pull request. */
|
|
109
|
+
export function isGitHubPullRequestPayload(value: unknown): boolean {
|
|
110
|
+
if (!isRecord(value)) return false;
|
|
111
|
+
return value.pull_request != null;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function isValidRepoName(value: string): boolean {
|
|
115
|
+
return /^[A-Za-z0-9_.-]+$/.test(value) && value.length > 0 && value.length <= 100;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function isValidIssueNumber(value: unknown): value is number {
|
|
119
|
+
return typeof value === "number" && Number.isSafeInteger(value) && value >= 1;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function parseLabels(value: unknown): GitHubIssueLabel[] {
|
|
123
|
+
if (!Array.isArray(value)) return [];
|
|
124
|
+
const labels: GitHubIssueLabel[] = [];
|
|
125
|
+
for (const item of value) {
|
|
126
|
+
if (!isRecord(item) || typeof item.name !== "string") continue;
|
|
127
|
+
labels.push({
|
|
128
|
+
...(typeof item.id === "number" ? { id: item.id } : {}),
|
|
129
|
+
name: item.name,
|
|
130
|
+
...(typeof item.color === "string" ? { color: item.color } : {}),
|
|
131
|
+
...(item.description === null || typeof item.description === "string"
|
|
132
|
+
? { description: item.description }
|
|
133
|
+
: {}),
|
|
134
|
+
...(typeof item.default === "boolean" ? { default: item.default } : {}),
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
return labels;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Normalize a GitHub Issues API payload (or a Void API response) into a
|
|
142
|
+
* snapshot. Returns null for pull requests or malformed payloads.
|
|
143
|
+
*/
|
|
144
|
+
export function normalizeGitHubIssue(
|
|
145
|
+
value: unknown,
|
|
146
|
+
owner: string,
|
|
147
|
+
repo: string
|
|
148
|
+
): GitHubIssueSnapshot | null {
|
|
149
|
+
if (!isRecord(value)) return null;
|
|
150
|
+
if (isGitHubPullRequestPayload(value)) return null;
|
|
151
|
+
|
|
152
|
+
if (
|
|
153
|
+
typeof value.number !== "number" ||
|
|
154
|
+
!isValidIssueNumber(value.number) ||
|
|
155
|
+
typeof value.title !== "string" ||
|
|
156
|
+
(value.state !== "open" && value.state !== "closed") ||
|
|
157
|
+
typeof value.html_url !== "string" ||
|
|
158
|
+
value.html_url.trim() === "" ||
|
|
159
|
+
typeof value.created_at !== "string" ||
|
|
160
|
+
typeof value.updated_at !== "string"
|
|
161
|
+
) {
|
|
162
|
+
return null;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const body =
|
|
166
|
+
value.body === null || typeof value.body === "string" ? value.body : null;
|
|
167
|
+
const closedAt =
|
|
168
|
+
value.closed_at === null || typeof value.closed_at === "string"
|
|
169
|
+
? value.closed_at
|
|
170
|
+
: null;
|
|
171
|
+
|
|
172
|
+
return {
|
|
173
|
+
number: value.number,
|
|
174
|
+
title: value.title,
|
|
175
|
+
body,
|
|
176
|
+
state: value.state,
|
|
177
|
+
html_url: value.html_url,
|
|
178
|
+
labels: parseLabels(value.labels),
|
|
179
|
+
created_at: value.created_at,
|
|
180
|
+
updated_at: value.updated_at,
|
|
181
|
+
closed_at: closedAt,
|
|
182
|
+
owner,
|
|
183
|
+
repo,
|
|
184
|
+
repository: `${owner}/${repo}`,
|
|
185
|
+
};
|
|
186
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@chesapeake/stack",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.5",
|
|
4
4
|
"description": "A simple, reusable React component library and design system built on shadcn/ui with Tailwind CSS",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"packageManager": "npm@10.2.0",
|
|
@@ -107,6 +107,15 @@
|
|
|
107
107
|
"./components/ux/auth/google/AuthProvider": "./components/ux/auth/google/AuthProvider.tsx",
|
|
108
108
|
"./components/ux/auth/google/AuthCallback": "./components/ux/auth/google/AuthCallback.tsx",
|
|
109
109
|
"./components/ux/auth/google/types": "./components/ux/auth/google/types.ts",
|
|
110
|
+
"./components/ux/task": "./components/ux/task/index.ts",
|
|
111
|
+
"./components/ux/task/TaskInput": "./components/ux/task/TaskInput.tsx",
|
|
112
|
+
"./components/ux/task/TaskCard": "./components/ux/task/TaskCard.tsx",
|
|
113
|
+
"./components/ux/task/TaskList": "./components/ux/task/TaskList.tsx",
|
|
114
|
+
"./components/ux/connectivity": "./components/ux/connectivity/index.ts",
|
|
115
|
+
"./components/ux/connectivity/ConnectivityBadge": "./components/ux/connectivity/ConnectivityBadge.tsx",
|
|
116
|
+
"./components/ux/runner": "./components/ux/runner/index.ts",
|
|
117
|
+
"./components/ux/runner/RunnerPairingCard": "./components/ux/runner/RunnerPairingCard.tsx",
|
|
118
|
+
"./components/ux/runner/RunnerStatusCard": "./components/ux/runner/RunnerStatusCard.tsx",
|
|
110
119
|
"./components/ux/auth/github": "./components/ux/auth/github/index.ts",
|
|
111
120
|
"./components/ux/auth/github/client": "./components/ux/auth/github/client.ts",
|
|
112
121
|
"./components/ux/auth/github/default-client": "./components/ux/auth/github/default-client.ts",
|
|
@@ -114,6 +123,7 @@
|
|
|
114
123
|
"./components/ux/auth/github/AuthCallback": "./components/ux/auth/github/AuthCallback.tsx",
|
|
115
124
|
"./components/ux/auth/github/types": "./components/ux/auth/github/types.ts",
|
|
116
125
|
"./lib/utils": "./lib/utils.ts",
|
|
126
|
+
"./lib/githubIssue": "./lib/githubIssue.ts",
|
|
117
127
|
"./types/user": "./types/user.ts",
|
|
118
128
|
"./styles": "./styles/index.css",
|
|
119
129
|
"./tailwind.preset": "./tailwind.preset.ts",
|