Skip to content
Open
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
129 changes: 129 additions & 0 deletions services/githubService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -465,3 +465,132 @@ export const deleteRepositorySecret = async (
throw new Error(data?.message || 'Failed to delete repository secret');
}
};

// ============ File Operations API ============

interface FileContent {
content: string;
sha: string;
}

/**
* Fetch the content of a file from a repository
*/
export const fetchFileContent = async (
token: string,
owner: string,
repo: string,
path: string
): Promise<FileContent> => {
const response = await fetch(
`${GITHUB_API_BASE}/repos/${owner}/${repo}/contents/${encodeURIComponent(path)}`,
{
headers: {
Authorization: `token ${token}`,
Accept: 'application/vnd.github.v3+json',
},
cache: 'no-cache',
}
);

if (!response.ok) {
throw new Error('Failed to fetch file content');
}

const data = await response.json();

// Decode base64 content
const content = atob(data.content.replace(/\n/g, ''));

return {
content,
sha: data.sha,
};
};

/**
* Create or update a file in a repository
*/
export const createOrUpdateFile = async (
token: string,
owner: string,
repo: string,
path: string,
content: string,
message: string,
sha?: string
): Promise<void> => {
const body: any = {
message,
content: btoa(content), // Encode to base64
};

if (sha) {
body.sha = sha;
}

const response = await fetch(
`${GITHUB_API_BASE}/repos/${owner}/${repo}/contents/${encodeURIComponent(path)}`,
{
method: 'PUT',
headers: {
Authorization: `token ${token}`,
Accept: 'application/vnd.github.v3+json',
'Content-Type': 'application/json',
},
body: JSON.stringify(body),
}
);

if (!response.ok) {
const data = await response.json().catch(() => ({}));
throw new Error(data?.message || 'Failed to create or update file');
}
};

/**
* Copy workflows from friuns/VibeGithub to a target repository
*/
export const copyVibeGithubWorkflows = async (
token: string,
targetOwner: string,
targetRepo: string
): Promise<void> => {
// Fetch workflow files from friuns/VibeGithub
const sourceWorkflows = await fetchRepoWorkflowFiles(token, 'friuns', 'VibeGithub');

// Copy each workflow file
for (const workflow of sourceWorkflows) {
try {
// Fetch the content of the source workflow
const { content } = await fetchFileContent(token, 'friuns', 'VibeGithub', workflow.path);

// Try to get the current file in target repo to check if it exists
let existingSha: string | undefined;
try {
const existing = await fetchFileContent(token, targetOwner, targetRepo, workflow.path);
existingSha = existing.sha;
} catch (err) {
// File doesn't exist, that's fine
}

// Create or update the file in target repo
const message = existingSha
? `Update workflow: ${workflow.name}`
: `Add workflow: ${workflow.name}`;

await createOrUpdateFile(
token,
targetOwner,
targetRepo,
workflow.path,
content,
message,
existingSha
);
} catch (err) {
console.error(`Failed to copy workflow ${workflow.name}:`, err);
throw new Error(`Failed to copy workflow ${workflow.name}: ${err.message}`);
}
}
};
28 changes: 24 additions & 4 deletions views/RepoDetail.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import React, { useEffect, useState, useRef } from 'react';
import { Repository, Issue, WorkflowFile, RepoSecret } from '../types';
import { fetchIssues, createIssue, fetchAllWorkflowFiles, fetchRepositorySecrets, setRepositorySecret, deleteRepositorySecret } from '../services/githubService';
import { fetchIssues, createIssue, fetchAllWorkflowFiles, fetchRepositorySecrets, setRepositorySecret, deleteRepositorySecret, copyVibeGithubWorkflows } from '../services/githubService';
import { Button } from '../components/Button';
import { ToastContainer, useToast } from '../components/Toast';
import { ArrowLeft, Plus, MessageCircle, AlertCircle, CheckCircle2, X, RefreshCw, FileCode, ChevronDown, ChevronUp, Key, Trash2, Eye, EyeOff, Shield } from 'lucide-react';
Expand Down Expand Up @@ -49,6 +49,9 @@ export const RepoDetail: React.FC<RepoDetailProps> = ({ token, repo, onBack, onI
const [newSecretValue, setNewSecretValue] = useState('');
const [showSecretValue, setShowSecretValue] = useState(false);
const [savingSecret, setSavingSecret] = useState(false);

// Copy Workflows State
const [copyingWorkflows, setCopyingWorkflows] = useState(false);
const [deletingSecret, setDeletingSecret] = useState<string | null>(null);
const [autoSetOAuthChecked, setAutoSetOAuthChecked] = useState(false);

Expand Down Expand Up @@ -225,6 +228,20 @@ export const RepoDetail: React.FC<RepoDetailProps> = ({ token, repo, onBack, onI
}
};

const handleCopyWorkflows = async () => {
setCopyingWorkflows(true);
try {
await copyVibeGithubWorkflows(token, repo.owner.login, repo.name);
// Reload workflow files to include the new ones
await loadWorkflowFiles();
// Show success message - since we don't have a success toast, we'll just not show error
} catch (err) {
showError('Failed to copy workflows');
} finally {
setCopyingWorkflows(false);
}
};



return (
Expand All @@ -248,9 +265,12 @@ export const RepoDetail: React.FC<RepoDetailProps> = ({ token, repo, onBack, onI
<Button variant="secondary" onClick={() => loadIssues(true)} icon={<RefreshCw size={16} className={isRefreshing ? 'animate-spin' : ''} />} disabled={isRefreshing}>
Refresh
</Button>
<Button variant="secondary" onClick={() => setIsSecretsModalOpen(true)} icon={<Key size={16} />}>
Secrets
</Button>
<Button variant="secondary" onClick={() => setIsSecretsModalOpen(true)} icon={<Key size={16} />}>
Secrets
</Button>
<Button variant="secondary" onClick={handleCopyWorkflows} icon={<FileCode size={16} />} isLoading={copyingWorkflows}>
Copy Workflows
</Button>
<Button variant="primary" icon={<Plus size={18} />} onClick={() => setIsModalOpen(true)}>
New Issue
</Button>
Expand Down