Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions .github/workflows/storage-integration.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
name: Storage Integration

on:
pull_request:
push:
branches: [main]

permissions:
contents: read

jobs:
test:
runs-on: ubuntu-latest
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial | ⚡ Quick win

Add a job timeout for this network integration workflow.

Line 13 defines a network-dependent job without timeout-minutes; a stuck call can tie up runners unnecessarily.

Proposed patch
   test:
     runs-on: ubuntu-latest
+    timeout-minutes: 15
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
runs-on: ubuntu-latest
test:
runs-on: ubuntu-latest
timeout-minutes: 15
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.github/workflows/storage-integration.yml at line 13, The job currently
declares "runs-on: ubuntu-latest" without a timeout; add a job-level timeout by
inserting a "timeout-minutes: <N>" entry (e.g. 20–60 minutes) alongside the
"runs-on" key in the same job definition so the network integration workflow
cannot hang indefinitely; update the job that contains runs-on: ubuntu-latest
(the network integration job) to include the timeout-minutes setting.

# Secrets aren't available on PRs from forks, so this workflow only runs
# on same-repo branches and pushes to main. The test itself requires the
# env vars to be set — no skipIf fallback.
if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository }}
steps:
- uses: actions/checkout@v6.0.2
- uses: actions/setup-node@v6.4.0
with:
node-version: 22
cache: yarn
- run: yarn install --frozen-lockfile
- run: yarn workspace @gitpulse/cli test:integration
env:
BLOB_READ_WRITE_TOKEN: ${{ secrets.BLOB_READ_WRITE_TOKEN }}
# Store ID is public (appears in every blob URL) — not a secret.
GITPULSE_TEST_STORE_ID: store_ryXhp9N7MsNEbMh1
4 changes: 3 additions & 1 deletion cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,8 @@
"prepublishOnly": "yarn build",
"lint": "echo 'no lint yet'",
"typecheck": "tsc --noEmit",
"test": "vitest run"
"test": "vitest run",
"test:integration": "vitest run --config vitest.integration.config.ts"
},
"devDependencies": {
"@types/node": "25.6.0",
Expand All @@ -53,6 +54,7 @@
"@langchain/core": "1.1.42",
"@langchain/openai": "1.4.5",
"@octokit/graphql": "9.0.3",
"@vercel/blob": "2.3.3",
"langchain": "1.3.5",
"zod": "4.4.1"
},
Expand Down
22 changes: 22 additions & 0 deletions cli/src/image/storage/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import type { ImageStorage, StorageConfig } from './types.ts';
import { VercelBlobStorage } from './vercel-blob.ts';

export type { ImageStorage, StorageConfig, StorageProvider } from './types.ts';
export { VercelBlobStorage } from './vercel-blob.ts';

export function createStorage(config: StorageConfig): ImageStorage {
switch (config.provider) {
case 'vercel-blob':
return new VercelBlobStorage({ storeId: config.storeId });
case 'r2':
case 's3':
case 'supabase':
throw new Error(
`Storage provider "${config.provider}" is not yet implemented`,
);
default: {
const _exhaustive: never = config;
throw new Error(`Unknown storage provider: ${JSON.stringify(_exhaustive)}`);
}
}
}
22 changes: 22 additions & 0 deletions cli/src/image/storage/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
// Pluggable storage backend for binary assets (today: AI-generated images, later:
// any other artifact gitpulse needs to persist). The interface is deliberately
// thin — upload, resolve to a public URL, list, batch-delete. All four methods
// have direct equivalents on Vercel Blob, Cloudflare R2, AWS S3, and Supabase
// Storage, so adding providers later is purely additive.
export interface ImageStorage {
upload(key: string, body: Buffer, contentType: string): Promise<void>;
urlFor(key: string): string;
list(prefix: string): Promise<string[]>;
delete(keys: string[]): Promise<void>;
}

// Discriminated union so future providers can land without breaking
// .gitpulse.json schema. Only 'vercel-blob' is implemented in PR 1; the others
// are reserved here so the zod schema in project-config.ts stays stable.
export type StorageConfig =
| { provider: 'vercel-blob'; storeId: string }
| { provider: 'r2'; accountId: string; bucket: string; publicBaseUrl: string }
| { provider: 's3'; region: string; bucket: string; publicBaseUrl?: string }
| { provider: 'supabase'; projectUrl: string; bucket: string };

export type StorageProvider = StorageConfig['provider'];
67 changes: 67 additions & 0 deletions cli/src/image/storage/vercel-blob.integration.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { describe, it, expect, afterEach } from 'vitest';
import { randomUUID } from 'node:crypto';
import { VercelBlobStorage } from './vercel-blob.ts';

// Runs a real round-trip against the Vercel Blob store identified by
// GITPULSE_TEST_STORE_ID. Both env vars are required — the test fails loudly
// rather than silently skipping if they're missing. Workflow gates ensure
// this only runs in environments that have access to the secret.
const STORE_ID = requireEnv('GITPULSE_TEST_STORE_ID');
requireEnv('BLOB_READ_WRITE_TOKEN');

describe('VercelBlobStorage integration', () => {
const createdKeys: string[] = [];

afterEach(async () => {
if (createdKeys.length === 0) return;
const storage = new VercelBlobStorage({ storeId: STORE_ID });
await storage.delete(createdKeys.splice(0));
});

it('uploads, fetches, lists, and deletes a blob', async () => {
const storage = new VercelBlobStorage({ storeId: STORE_ID });
const key = `__integration-test__/${Date.now()}-${randomUUID()}.txt`;
const bodyText = `hello from gitpulse integration test ${randomUUID()}\n`;
createdKeys.push(key);

await storage.upload(key, Buffer.from(bodyText), 'text/plain');

const url = storage.urlFor(key);
const fetched = await fetch(url);
expect(fetched.status).toBe(200);
expect(await fetched.text()).toBe(bodyText);

const keys = await storage.list('__integration-test__/');
expect(keys).toContain(key);

await storage.delete([key]);
createdKeys.length = 0;

// Vercel Blob is eventually consistent at the CDN (up to ~60s propagation
// per docs). Retry briefly so CI doesn't flake on the post-delete check.
const status = await waitFor404(url, 10_000);
expect(status).toBe(404);
});
});

async function waitFor404(url: string, timeoutMs: number): Promise<number> {
const deadline = Date.now() + timeoutMs;
let status = 0;
while (Date.now() < deadline) {
const res = await fetch(url, { cache: 'no-store' });
status = res.status;
if (status === 404) return status;
await new Promise((r) => setTimeout(r, 250));
}
return status;
}

function requireEnv(name: string): string {
const value = process.env[name];
if (!value) {
throw new Error(
`${name} is required to run the storage integration test`,
);
}
return value;
}
153 changes: 153 additions & 0 deletions cli/src/image/storage/vercel-blob.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { put, list as blobList, del } from '@vercel/blob';
import { VercelBlobStorage, storeIdToHost } from './vercel-blob.ts';

vi.mock('@vercel/blob', () => ({
put: vi.fn(async () => ({ url: 'mocked' })),
list: vi.fn(),
del: vi.fn(async () => undefined),
}));

const TOKEN = 'vercel_blob_rw_FAKE_TOKEN_FOR_TESTS';
const STORE_ID = 'store_AbCdEf123';

describe('storeIdToHost', () => {
it('strips store_ prefix and lowercases', () => {
expect(storeIdToHost('store_AbCdEf123')).toBe(
'abcdef123.public.blob.vercel-storage.com',
);
});

it('handles already-lowercase ids', () => {
expect(storeIdToHost('store_abc')).toBe('abc.public.blob.vercel-storage.com');
});

it('handles ids without store_ prefix (defensive)', () => {
expect(storeIdToHost('XyZ')).toBe('xyz.public.blob.vercel-storage.com');
});

it('trims whitespace', () => {
expect(storeIdToHost(' store_abc ')).toBe(
'abc.public.blob.vercel-storage.com',
);
});

it('throws on empty or whitespace-only input', () => {
expect(() => storeIdToHost('')).toThrow(/storeId is required/);
expect(() => storeIdToHost(' ')).toThrow(/storeId is required/);
});

it('throws when the prefix is the entire value', () => {
expect(() => storeIdToHost('store_')).toThrow(/Invalid storeId/);
});
});

describe('VercelBlobStorage', () => {
beforeEach(() => {
vi.mocked(put).mockClear();
vi.mocked(blobList).mockReset();
vi.mocked(del).mockClear();
});

it('throws if BLOB_READ_WRITE_TOKEN is missing', () => {
expect(() => new VercelBlobStorage({ storeId: STORE_ID }, {})).toThrow(
/BLOB_READ_WRITE_TOKEN/,
);
});

it('upload calls put with public access, exact options, and the token', async () => {
const storage = new VercelBlobStorage(
{ storeId: STORE_ID },
{ BLOB_READ_WRITE_TOKEN: TOKEN },
);
const body = Buffer.from('hello');
await storage.upload('a/b/c.webp', body, 'image/webp');
expect(put).toHaveBeenCalledExactlyOnceWith('a/b/c.webp', body, {
access: 'public',
contentType: 'image/webp',
allowOverwrite: true,
addRandomSuffix: false,
token: TOKEN,
});
});

it('urlFor produces the deterministic public URL', () => {
const storage = new VercelBlobStorage(
{ storeId: STORE_ID },
{ BLOB_READ_WRITE_TOKEN: TOKEN },
);
expect(storage.urlFor('foo/bar.webp')).toBe(
'https://abcdef123.public.blob.vercel-storage.com/foo/bar.webp',
);
});

it('urlFor encodes special characters in path segments but preserves slashes', () => {
const storage = new VercelBlobStorage(
{ storeId: STORE_ID },
{ BLOB_READ_WRITE_TOKEN: TOKEN },
);
// Branch names in PR 2 keys can include spaces, ?, #, etc. Each segment
// gets percent-encoded; slashes stay as path separators.
expect(storage.urlFor('feature/foo bar/#hash?.webp')).toBe(
'https://abcdef123.public.blob.vercel-storage.com/feature/foo%20bar/%23hash%3F.webp',
);
});

it('list returns pathnames and follows cursors across pages', async () => {
vi.mocked(blobList)
.mockResolvedValueOnce({
blobs: [
{ pathname: 'pfx/one.txt' },
{ pathname: 'pfx/two.txt' },
],
cursor: 'next-page',
} as never)
.mockResolvedValueOnce({
blobs: [{ pathname: 'pfx/three.txt' }],
cursor: undefined,
} as never);

const storage = new VercelBlobStorage(
{ storeId: STORE_ID },
{ BLOB_READ_WRITE_TOKEN: TOKEN },
);
const keys = await storage.list('pfx/');

expect(keys).toEqual(['pfx/one.txt', 'pfx/two.txt', 'pfx/three.txt']);
expect(blobList).toHaveBeenCalledTimes(2);
expect(blobList).toHaveBeenNthCalledWith(1, {
prefix: 'pfx/',
cursor: undefined,
token: TOKEN,
});
expect(blobList).toHaveBeenNthCalledWith(2, {
prefix: 'pfx/',
cursor: 'next-page',
token: TOKEN,
});
});

it('delete maps keys through urlFor before calling del', async () => {
const storage = new VercelBlobStorage(
{ storeId: STORE_ID },
{ BLOB_READ_WRITE_TOKEN: TOKEN },
);
await storage.delete(['a/b.txt', 'c/d.webp']);
expect(del).toHaveBeenCalledExactlyOnceWith(
[
'https://abcdef123.public.blob.vercel-storage.com/a/b.txt',
'https://abcdef123.public.blob.vercel-storage.com/c/d.webp',
],
{ token: TOKEN },
);
});

it('delete is a no-op when given an empty array', async () => {
const storage = new VercelBlobStorage(
{ storeId: STORE_ID },
{ BLOB_READ_WRITE_TOKEN: TOKEN },
);
await storage.delete([]);
expect(del).not.toHaveBeenCalled();
});
});
71 changes: 71 additions & 0 deletions cli/src/image/storage/vercel-blob.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { put, list as blobList, del } from '@vercel/blob';
import type { ImageStorage } from './types.ts';

export interface VercelBlobStorageOptions {
storeId: string;
}

export class VercelBlobStorage implements ImageStorage {
private readonly token: string;
private readonly host: string;

constructor(opts: VercelBlobStorageOptions, env: NodeJS.ProcessEnv = process.env) {
const token = env.BLOB_READ_WRITE_TOKEN;
if (!token) {
throw new Error(
'BLOB_READ_WRITE_TOKEN env var is required for vercel-blob storage',
);
}
this.token = token;
this.host = storeIdToHost(opts.storeId);
Comment thread
greptile-apps[bot] marked this conversation as resolved.
}

async upload(key: string, body: Buffer, contentType: string): Promise<void> {
await put(key, body, {
access: 'public',
contentType,
allowOverwrite: true,
addRandomSuffix: false,
token: this.token,
});
}

urlFor(key: string): string {
return `https://${this.host}/${encodePath(key)}`;
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

async list(prefix: string): Promise<string[]> {
const keys: string[] = [];
let cursor: string | undefined;
do {
const page = await blobList({ prefix, cursor, token: this.token });
for (const blob of page.blobs) keys.push(blob.pathname);
cursor = page.cursor;
} while (cursor);
return keys;
}

async delete(keys: string[]): Promise<void> {
if (keys.length === 0) return;
const urls = keys.map((k) => this.urlFor(k));
await del(urls, { token: this.token });
}
}

// Vercel Blob public URLs follow the pattern
// https://<lowercase-storeId-without-prefix>.public.blob.vercel-storage.com/<key>
// confirmed by derisking against the real API.
export function storeIdToHost(storeId: string): string {
const trimmed = storeId.trim();
if (!trimmed) throw new Error('storeId is required');
const slug = trimmed.replace(/^store_/, '').toLowerCase();
if (!slug) throw new Error(`Invalid storeId: ${storeId}`);
return `${slug}.public.blob.vercel-storage.com`;
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

// Encode a slash-delimited key as URL path segments, preserving the slashes
// as path separators. Keys can include branch names or other arbitrary
// strings in PR 2/3, so we must handle spaces, ?, #, and unicode safely.
function encodePath(key: string): string {
return key.split('/').map(encodeURIComponent).join('/');
}
Loading