swadhinbiswas

[email protected]
2026-08-15 11:56:23 +0600 committed NaN years ago
960d787
commit 960d787
3 files changed
+197 -2
▼src/components/repo/RepoSettings.tsx
+85-0
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
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
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
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
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
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
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