# Files changed · PR \#17 · screen/col

[View on GitCafe](https://git.cafe/screen/col/pull/17/diffs)

Repository: [screen/col](https://git.cafe/screen/col)

Visibility: public

[Handle contributor API failures with Effect](https://git.cafe/screen/col/pull/17.md)

Version: 0

Head commit: 6987b509414a5f39395a55abf789a1d1fd90baa9

Base commit: 7d2a2221496bb601a3ee86ac7209cc36f8992437

Comparison base: 7d2a2221496bb601a3ee86ac7209cc36f8992437

Showing up to 20 entries from this revision. If the pull request changes, reopen the first page before continuing.

## app/contributors/page.tsx

Status: modified; +1; −32

```diff
@@ -2,44 +2,13 @@
 import { ArrowUpRight } from "lucide-react";
 import { Header } from "@/components/Header";
 import { SiteFooter } from "@/components/SiteFooter";
+import { getContributors } from "@/lib/github-contributors";
 
 export const metadata: Metadata = {
   title: "Contributors — Col",
   description: "Meet the people contributing to Col.",
 };
 
-type GitHubContributor = {
-  id: number;
-  login: string;
-  avatar_url: string;
-  html_url: string;
-  contributions: number;
-  type: string;
-};
-
-async function getContributors(): Promise<GitHubContributor[]> {
-  try {
-    const contributors: GitHubContributor[] = [];
-    let page = 1;
-
-    while (true) {
-      const response = await fetch(`https://api.github.com/repos/screen-gd/Col/contributors?per_page=100&page=${page}`, {
-        headers: { Accept: "application/vnd.github+json", "User-Agent": "Col-directory" },
-        next: { revalidate: 3600 },
-      });
-      if (!response.ok) return [];
-      const batch = await response.json() as GitHubContributor[];
-      contributors.push(...batch);
-      if (batch.length < 100) break;
-      page += 1;
-    }
-
-    return contributors.filter((contributor) => contributor.type === "User");
-  } catch {
-    return [];
-  }
-}
-
 export default async function ContributorsPage() {
   const contributors = await getContributors();
 
```

## lib/github-contributors.test.mjs

Status: added; +29; −0

```diff
@@ -1,0 +1,29 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+import { getContributors } from "./github-contributors.ts";
+
+const person = (id, type = "User") => ({
+  id,
+  login: `person-${id}`,
+  avatar_url: "https://example.com/avatar.png",
+  html_url: "https://example.com/profile",
+  contributions: 1,
+  type,
+});
+
+test("loads all pages and filters non-user accounts", async () => {
+  let calls = 0;
+  const contributors = await getContributors(async () => {
+    calls += 1;
+    return Response.json(calls === 1 ? Array.from({ length: 100 }, (_, id) => person(id)) : [person(100), person(101, "Bot")]);
+  });
+
+  assert.equal(calls, 2);
+  assert.equal(contributors.length, 101);
+  assert.equal(contributors.at(-1)?.login, "person-100");
+});
+
+test("rejects invalid API data and failed requests", async () => {
+  assert.deepEqual(await getContributors(async () => Response.json([{ id: "wrong" }])), []);
+  assert.deepEqual(await getContributors(async () => new Response(null, { status: 503 })), []);
+});
```

## lib/github-contributors.ts

Status: added; +44; −0

```diff
@@ -1,0 +1,44 @@
+import { Effect, Schema } from "effect";
+
+const Contributor = Schema.Struct({
+  id: Schema.Number,
+  login: Schema.String,
+  avatar_url: Schema.String,
+  html_url: Schema.String,
+  contributions: Schema.Number,
+  type: Schema.String,
+});
+
+const Contributors = Schema.Array(Contributor);
+
+export type GitHubContributor = Schema.Schema.Type<typeof Contributor>;
+
+/** Fetches every contributor page and returns an empty list when GitHub data is unavailable or invalid. */
+export function getContributors(request: typeof fetch = fetch): Promise<GitHubContributor[]> {
+  const load = Effect.gen(function* () {
+    const contributors: GitHubContributor[] = [];
+
+    for (let page = 1; ; page += 1) {
+      const response = yield* Effect.tryPromise(() =>
+        request(`https://api.github.com/repos/screen-gd/Col/contributors?per_page=100&page=${page}`, {
+          headers: { Accept: "application/vnd.github+json", "User-Agent": "Col-directory" },
+          next: { revalidate: 3600 },
+        }),
+      );
+
+      if (!response.ok) return yield* Effect.fail(new Error(`GitHub contributors returned ${response.status}`));
+
+      const payload: unknown = yield* Effect.tryPromise(() => response.json());
+      const batch = yield* Schema.decodeUnknown(Contributors)(payload);
+      contributors.push(...batch);
+      if (batch.length < 100) break;
+    }
+
+    return contributors.filter((contributor) => contributor.type === "User");
+  });
+
+  return Effect.runPromise(load.pipe(Effect.catchAll((error) => Effect.sync(() => {
+    console.warn("Unable to load GitHub contributors", error);
+    return [] as GitHubContributor[];
+  }))));
+}
```

## package-lock.json

Status: modified; +55; −0

```diff
@@ -16,6 +16,7 @@
         "class-variance-authority": "^0.7.1",
         "clsx": "^2.1.1",
         "cn": "^0.4.0",
+        "effect": "^3.22.2",
         "gsap": "^3.15.0",
         "lucide-react": "^1.47.0",
         "motion": "^13.4.0",
@@ -2354,6 +2355,12 @@
       "version": "1.1.3",
       "resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.3.tgz",
       "integrity": "sha512-JtyZR+mqgBibTo8xea3B6ZRmzZiM/YeVBtUkas6zMuXjAlfIFIW2FgqeM9eLyvEaYX66vr6DJMK+4U6LV0KhNw==",
+      "license": "MIT"
+    },
+    "node_modules/@standard-schema/spec": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
+      "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
       "license": "MIT"
     },
     "node_modules/@swc/helpers": {
@@ -2867,6 +2874,16 @@
       "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==",
       "license": "MIT"
     },
+    "node_modules/effect": {
+      "version": "3.22.2",
+      "resolved": "https://registry.npmjs.org/effect/-/effect-3.22.2.tgz",
+      "integrity": "sha512-fm1CQXCs7GTMwpadZxwU5rnAb2bN6mbQpknq/pXBGCzSKkosNYb9k0wsoL0TfB10rSa5xOEZJjo77u/KLXSANg==",
+      "license": "MIT",
+      "dependencies": {
+        "@standard-schema/spec": "^1.0.0",
+        "fast-check": "^3.23.1"
+      }
+    },
     "node_modules/enhanced-resolve": {
       "version": "5.25.1",
       "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.25.1.tgz",
@@ -2881,6 +2898,28 @@
         "node": ">=10.13.0"
       }
     },
+    "node_modules/fast-check": {
+      "version": "3.23.2",
+      "resolved": "https://registry.npmjs.org/fast-check/-/fast-check-3.23.2.tgz",
+      "integrity": "sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A==",
+      "funding": [
+        {
+          "type": "individual",
+          "url": "https://github.com/sponsors/dubzzz"
+        },
+        {
+          "type": "opencollective",
+          "url": "https://opencollective.com/fast-check"
+        }
+      ],
+      "license": "MIT",
+      "dependencies": {
+        "pure-rand": "^6.1.0"
+      },
+      "engines": {
+        "node": ">=8.0.0"
+      }
+    },
     "node_modules/framer-motion": {
       "version": "13.4.0",
       "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-13.4.0.tgz",
@@ -3399,6 +3438,22 @@
         "node": "^10 || ^12 || >=14"
       }
     },
+    "node_modules/pure-rand": {
+      "version": "6.1.0",
+      "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz",
+      "integrity": "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==",
+      "funding": [
+        {
+          "type": "individual",
+          "url": "https://github.com/sponsors/dubzzz"
+        },
+        {
+          "type": "opencollective",
+          "url": "https://opencollective.com/fast-check"
+        }
+      ],
+      "license": "MIT"
+    },
     "node_modules/radix-ui": {
       "version": "1.6.7",
       "resolved": "https://registry.npmjs.org/radix-ui/-/radix-ui-1.6.7.tgz",
```

## package.json

Status: modified; +1; −0

```diff
@@ -17,6 +17,7 @@
     "class-variance-authority": "^0.7.1",
     "clsx": "^2.1.1",
     "cn": "^0.4.0",
+    "effect": "^3.22.2",
     "gsap": "^3.15.0",
     "lucide-react": "^1.47.0",
     "motion": "^13.4.0",
```

## pnpm-lock.yaml

Status: modified; +29; −0

```diff
@@ -29,6 +29,9 @@
       cn:
         specifier: ^0.4.0
         version: 0.4.0
+      effect:
+        specifier: ^3.22.2
+        version: 3.22.2
       gsap:
         specifier: ^3.15.0
         version: 3.15.0
@@ -1034,6 +1037,9 @@
   '@radix-ui/rect@1.1.3':
     resolution: {integrity: sha512-JtyZR+mqgBibTo8xea3B6ZRmzZiM/YeVBtUkas6zMuXjAlfIFIW2FgqeM9eLyvEaYX66vr6DJMK+4U6LV0KhNw==}
 
+  '@standard-schema/spec@1.1.0':
+    resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
+
   '@swc/helpers@0.5.23':
     resolution: {integrity: sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==}
 
@@ -1186,10 +1192,17 @@
   detect-node-es@1.1.0:
     resolution: {integrity: sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==}
 
+  effect@3.22.2:
+    resolution: {integrity: sha512-fm1CQXCs7GTMwpadZxwU5rnAb2bN6mbQpknq/pXBGCzSKkosNYb9k0wsoL0TfB10rSa5xOEZJjo77u/KLXSANg==}
+
   enhanced-resolve@5.25.1:
     resolution: {integrity: sha512-nGXts5znJzmWPu+mIE9izCOzdg63oJca2mDzGWWTth7sr4aCToKcoyFVBQwN75Ij5Pf6p510EwkTqViTRzDV+w==}
     engines: {node: '>=10.13.0'}
 
+  fast-check@3.23.2:
+    resolution: {integrity: sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A==}
+    engines: {node: '>=8.0.0'}
+
   framer-motion@13.4.0:
     resolution: {integrity: sha512-HCpqKM9BTu6UYKtj0u6J1QNxojnnirNcghcSDZ+SkpiL3myxvtsgXPS3ZGEELC2eSma7YiA937rAEXtRyydSXQ==}
     peerDependencies:
@@ -1351,6 +1364,9 @@
     resolution: {integrity: sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==}
     engines: {node: ^10 || ^12 || >=14}
 
+  pure-rand@6.1.0:
+    resolution: {integrity: sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==}
+
   radix-ui@1.6.7:
     resolution: {integrity: sha512-QBdhh1arIEUvPC0dQ5+nwWAxt7+N+oP/9jPwjJkGFoSk/sqxg32gJtSXGtFh8frAIcS6oC9cx2Q+7KYCQLOAeA==}
     peerDependencies:
@@ -2412,6 +2428,8 @@
 
   '@radix-ui/rect@1.1.3': {}
 
+  '@standard-schema/spec@1.1.0': {}
+
   '@swc/helpers@0.5.23':
     dependencies:
       tslib: 2.8.1
@@ -2528,11 +2546,20 @@
 
   detect-node-es@1.1.0: {}
 
+  effect@3.22.2:
+    dependencies:
+      '@standard-schema/spec': 1.1.0
+      fast-check: 3.23.2
+
   enhanced-resolve@5.25.1:
     dependencies:
       graceful-fs: 4.2.11
       tapable: 2.3.3
 
+  fast-check@3.23.2:
+    dependencies:
+      pure-rand: 6.1.0
+
   framer-motion@13.4.0(react-dom@19.3.0(react@19.3.0))(react@19.3.0):
     dependencies:
       motion-dom: 13.3.0
@@ -2662,6 +2689,8 @@
       picocolors: 1.1.1
       source-map-js: 1.2.1
 
+  pure-rand@6.1.0: {}
+
   radix-ui@1.6.7(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0):
     dependencies:
       '@radix-ui/primitive': 1.1.7
```
