960d787
commit 960d787
3 files changed
+197 -2
Showing 3 changed files+197-2
▼src/components/repo/RepoSettings.tsx
+85-0
| @@ -42,6 +42,23 @@ interface RepoSettingsProps { | ||
42 | 42 | export default function RepoSettings({ repo }: RepoSettingsProps) { |
43 | 43 | const [loading, setLoading] = useState(false); |
44 | 44 | const [branches, setBranches] = useState<string[]>([]); |
45 | + const [orgs, setOrgs] = useState<Array<{ id: string; name: string }>>([]); | |
46 | + const [transferTarget, setTransferTarget] = useState(""); | |
47 | + | |
48 | + useEffect(() => { | |
49 | + // Load organizations the user can transfer to (WS3-05) | |
50 | + fetch("/api/orgs") | |
51 | + .then((res) => (res.ok ? res.json() : null)) | |
52 | + .then((data) => { | |
53 | + const orgs = data?.data?.organizations || []; | |
54 | + // only orgs where the user is owner/admin | |
55 | + const adminOrgs = orgs.filter( | |
56 | + (o: any) => o.memberRole === "owner" || o.memberRole === "admin", | |
57 | + ); | |
58 | + setOrgs(adminOrgs); | |
59 | + }) | |
60 | + .catch(() => {}); | |
61 | + }, [repo.owner, repo.name]); | |
45 | 62 | |
46 | 63 | useEffect(() => { |
47 | 64 | fetch(`/api/repos/${repo.owner}/${repo.name}/branches`) |
| @@ -189,6 +206,23 @@ export default function RepoSettings({ repo }: RepoSettingsProps) { | ||
189 | 206 | } |
190 | 207 | } |
191 | 208 | |
209 | + async function handleTransfer() { | |
210 | + if (!transferTarget) return; | |
211 | + try { | |
212 | + const res = await fetch(`/api/repos/${repo.owner}/${repo.name}/transfer`, { | |
213 | + method: "POST", | |
214 | + headers: { "Content-Type": "application/json" }, | |
215 | + body: JSON.stringify({ orgName: transferTarget }), | |
216 | + }); | |
217 | + const data = await res.json(); | |
218 | + if (!res.ok) throw new Error(data?.error?.message || "Failed to transfer repository"); | |
219 | + toast.success("Repository transferred"); | |
220 | + window.location.href = data?.data?.url || `/${transferTarget}/${repo.name}`; | |
221 | + } catch (err: any) { | |
222 | + toast.error(err.message || "Failed to transfer repository"); | |
223 | + } | |
224 | + } | |
225 | + | |
192 | 226 | return ( |
193 | 227 | <div className="space-y-8"> |
194 | 228 | <form onSubmit={handleUpdate}> |
| @@ -394,6 +428,57 @@ export default function RepoSettings({ repo }: RepoSettingsProps) { | ||
394 | 428 | </AlertDialog> |
395 | 429 | </div> |
396 | 430 | |
431 | + {/* Transfer (WS3-05) */} | |
432 | + <div className="flex items-center justify-between rounded-lg border border-red-200 p-4"> | |
433 | + <div className="flex-1 min-w-0 pr-4"> | |
434 | + <h4 className="font-medium">Transfer ownership</h4> | |
435 | + <p className="text-sm text-muted-foreground"> | |
436 | + Move this repository to an organization you own or administer. | |
437 | + {orgs.length === 0 && " No organizations available — create one first."} | |
438 | + </p> | |
439 | + <select | |
440 | + value={transferTarget} | |
441 | + onChange={(e) => setTransferTarget(e.target.value)} | |
442 | + className="mt-2 flex h-9 w-full max-w-xs rounded-md border border-input bg-background px-2 py-1 text-sm" | |
443 | + disabled={orgs.length === 0} | |
444 | + > | |
445 | + <option value="">Select organization…</option> | |
446 | + {orgs.map((o) => ( | |
447 | + <option key={o.id} value={o.name}> | |
448 | + {o.name} | |
449 | + </option> | |
450 | + ))} | |
451 | + </select> | |
452 | + </div> | |
453 | + <AlertDialog> | |
454 | + <AlertDialogTrigger asChild> | |
455 | + <Button | |
456 | + variant="outline" | |
457 | + className="text-red-600 border-red-200 hover:bg-red-50" | |
458 | + disabled={!transferTarget} | |
459 | + > | |
460 | + Transfer | |
461 | + </Button> | |
462 | + </AlertDialogTrigger> | |
463 | + <AlertDialogContent> | |
464 | + <AlertDialogHeader> | |
465 | + <AlertDialogTitle>Transfer repository?</AlertDialogTitle> | |
466 | + <AlertDialogDescription> | |
467 | + This will move <strong>{repo.owner}/{repo.name}</strong> to{" "} | |
468 | + <strong>{transferTarget || "…"}</strong>. Existing collaborators | |
469 | + stay; you will retain admin access. | |
470 | + </AlertDialogDescription> | |
471 | + </AlertDialogHeader> | |
472 | + <AlertDialogFooter> | |
473 | + <AlertDialogCancel>Cancel</AlertDialogCancel> | |
474 | + <AlertDialogAction onClick={handleTransfer} className="bg-red-600 hover:bg-red-700"> | |
475 | + Transfer | |
476 | + </AlertDialogAction> | |
477 | + </AlertDialogFooter> | |
478 | + </AlertDialogContent> | |
479 | + </AlertDialog> | |
480 | + </div> | |
481 | + | |
397 | 482 | {/* Delete */} |
398 | 483 | <div className="flex items-center justify-between rounded-lg border border-red-200 p-4"> |
399 | 484 | <div> |
400 | 485 | |
▼src/lib/action-resolver.ts
+55-2
| @@ -27,7 +27,7 @@ export interface ResolveActionInput { | ||
27 | 27 | |
28 | 28 | export interface ResolvedAction { |
29 | 29 | run: string; |
30 | - kind: "composite" | "checkout" | "unsupported"; | |
30 | + kind: "composite" | "checkout" | "docker" | "unsupported"; | |
31 | 31 | } |
32 | 32 | |
33 | 33 | const CACHE_TTL_MS = 24 * 60 * 60 * 1000; |
| @@ -94,6 +94,34 @@ export async function resolveActionStep( | ||
94 | 94 | ): Promise<ResolvedAction> { |
95 | 95 | const uses = input.uses; |
96 | 96 | |
97 | + // ── uses: docker://image — no download needed, run the image directly ─ | |
98 | + if (uses.startsWith("docker://")) { | |
99 | + const image = uses.slice("docker://".length); | |
100 | + if (!image) { | |
101 | + return { | |
102 | + kind: "unsupported", | |
103 | + run: "echo '::error::docker:// action has no image' && exit 1", | |
104 | + }; | |
105 | + } | |
106 | + const inputEnv = Object.entries(input.withInputs || {}) | |
107 | + .map(([key, value]) => { | |
108 | + const envKey = `INPUT_${key.toUpperCase().replace(/-/g, "_")}`; | |
109 | + const escaped = String(value).replace(/'/g, `'\\''`); | |
110 | + return `export ${envKey}='${escaped}'`; | |
111 | + }) | |
112 | + .join("\n"); | |
113 | + return { | |
114 | + kind: "docker", | |
115 | + run: [ | |
116 | + "set -e", | |
117 | + `docker pull "${image}"`, | |
118 | + "cd \"$GITHUB_WORKSPACE\" 2>/dev/null || true", | |
119 | + inputEnv, | |
120 | + `docker run --rm -v "$(pwd):/workspace" -w /workspace -e "GITHUB_WORKSPACE=/workspace" "${image}"`, | |
121 | + ].join("\n"), | |
122 | + }; | |
123 | + } | |
124 | + | |
97 | 125 | // ── actions/checkout: native clone ──────────────────────────────────── |
98 | 126 | if (uses.startsWith("actions/checkout@")) { |
99 | 127 | if (!input.repositoryUrl) { |
| @@ -172,7 +200,32 @@ export async function resolveActionStep( | ||
172 | 200 | return { kind: "composite", run: lines.join("\n") }; |
173 | 201 | } |
174 | 202 | |
175 | - // ── Node / Docker actions: fail fast with a clear message ───────────── | |
203 | + // ── Docker actions: run the image with INPUT_* env + workspace mount ── | |
204 | + if (using === "docker" || uses.startsWith("docker://")) { | |
205 | + const image = | |
206 | + using === "docker" ? config.runs?.image : uses.slice("docker://".length); | |
207 | + const args = (config.runs?.args || []) | |
208 | + .map((a: string) => `"${a.replace(/"/g, '\\"')}"`) | |
209 | + .join(" "); | |
210 | + if (!image) { | |
211 | + return { | |
212 | + kind: "unsupported", | |
213 | + run: `echo '::error::Docker action "${uses}" has no image' && exit 1`, | |
214 | + }; | |
215 | + } | |
216 | + return { | |
217 | + kind: "docker", | |
218 | + run: [ | |
219 | + "set -e", | |
220 | + `docker pull "${image}"`, | |
221 | + "cd \"$GITHUB_WORKSPACE\" 2>/dev/null || true", | |
222 | + inputEnv, | |
223 | + `docker run --rm -v "$(pwd):/workspace" -w /workspace -e "GITHUB_WORKSPACE=/workspace" ${args} "${image}"`, | |
224 | + ].join("\n"), | |
225 | + }; | |
226 | + } | |
227 | + | |
228 | + // ── Node actions: fail fast with a clear message ────────────────────── | |
176 | 229 | const kindLabel = isLocal ? "local" : "remote"; |
177 | 230 | return { |
178 | 231 | kind: "unsupported", |
179 | 232 | |
▼tests/contract/action-resolver.test.ts
+57-0
| @@ -116,3 +116,60 @@ runs: | ||
116 | 116 | expect(a.run).toBe(b.run); |
117 | 117 | }); |
118 | 118 | }); |
119 | + | |
120 | +describe("docker action resolution", () => { | |
121 | + it("resolves uses: docker://image to a docker run script", async () => { | |
122 | + const result = await resolveActionStep({ | |
123 | + uses: "docker://alpine:3.18", | |
124 | + withInputs: { who: "world" }, | |
125 | + repositoryPath: "/tmp", | |
126 | + cacheDir: "/tmp/och-cache", | |
127 | + }); | |
128 | + expect(result.kind).toBe("docker"); | |
129 | + expect(result.run).toContain("docker pull \"alpine:3.18\""); | |
130 | + expect(result.run).toContain("docker run --rm"); | |
131 | + expect(result.run).toContain("export INPUT_WHO='world'"); | |
132 | + }); | |
133 | + | |
134 | + it("resolves local docker-type actions with image + args", async () => { | |
135 | + const dir = mkdtempSync(join(tmpdir(), "och-docker-action-")); | |
136 | + makeLocalAction( | |
137 | + dir, | |
138 | + `name: Docker | |
139 | +description: Test | |
140 | +runs: | |
141 | + using: docker | |
142 | + image: busybox:1.36 | |
143 | + args: [sh, -c, "echo hi"] | |
144 | +`, | |
145 | + ); | |
146 | + const result = await resolveActionStep({ | |
147 | + uses: "./my-action", | |
148 | + repositoryPath: dir, | |
149 | + cacheDir: "/tmp/och-cache", | |
150 | + }); | |
151 | + expect(result.kind).toBe("docker"); | |
152 | + expect(result.run).toContain("docker pull \"busybox:1.36\""); | |
153 | + expect(result.run).toContain('"sh"'); | |
154 | + }); | |
155 | + | |
156 | + it("still fail-fasts for node actions", async () => { | |
157 | + const dir = mkdtempSync(join(tmpdir(), "och-node-action-")); | |
158 | + makeLocalAction( | |
159 | + dir, | |
160 | + `name: Node | |
161 | +description: Test | |
162 | +runs: | |
163 | + using: node20 | |
164 | + main: index.js | |
165 | +`, | |
166 | + ); | |
167 | + const result = await resolveActionStep({ | |
168 | + uses: "./my-action", | |
169 | + repositoryPath: dir, | |
170 | + cacheDir: "/tmp/och-cache", | |
171 | + }); | |
172 | + expect(result.kind).toBe("unsupported"); | |
173 | + expect(result.run).toContain("::error::"); | |
174 | + }); | |
175 | +}); | |
119 | 176 | |