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
1 change: 1 addition & 0 deletions GitWeb/README.en.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ src/
- Stash: create, apply, pop, drop — wired into the main navigation.
- Sync: fetch/pull/push/publish branch that adapts to the ahead/behind/upstream state.
- Pull Requests: sign in with OAuth Device Flow (GitHub.com) or PAT (GitHub.com or Enterprise), list, create, view files/comments, merge, close/reopen.
- Collaborators: see who has access, invite by username, cancel invitations, remove people and change permissions (on organization repositories), right in the app.
- AI chat with tools (create/switch/merge branch, commit, push/pull/fetch, stash, list/create PR) with a human-approval step before each action.
- Interface, main-process messages and AI answers in Portuguese (Brazil), English or Spanish — following the system language by default, switchable live in Settings.
- Automatic light/dark themes (from the system preference) and high-contrast support (Windows High Contrast Mode / `prefers-contrast`).
Expand Down
1 change: 1 addition & 0 deletions GitWeb/README.es.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ src/
- Stash: crear, aplicar, pop, descartar — conectado a la navegación principal.
- Sincronización: fetch/pull/push/publicar rama que se adapta al estado ahead/behind/upstream.
- Pull Requests: inicio de sesión con OAuth Device Flow (GitHub.com) o PAT (GitHub.com o Enterprise), listar, crear, ver archivos/comentarios, fusionar, cerrar/reabrir.
- Colaboradores: ver quién tiene acceso, invitar por usuario, cancelar invitaciones, quitar personas y cambiar permisos (en repositorios de organización), desde la app.
- Chat de IA con herramientas (crear/cambiar/fusionar rama, commit, push/pull/fetch, stash, listar/crear PR) con aprobación humana antes de cada acción.
- Interfaz, mensajes del proceso principal y respuestas de la IA en portugués (Brasil), inglés o español — sigue el idioma del sistema por defecto y se puede cambiar al instante en Configuración.
- Temas claro/oscuro automáticos (según la preferencia del sistema) y soporte de alto contraste (Windows High Contrast Mode / `prefers-contrast`).
Expand Down
1 change: 1 addition & 0 deletions GitWeb/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ src/
- Stash: criar, aplicar, pop, descartar — conectado à navegação principal.
- Sync: fetch/pull/push/publicar branch adaptativo conforme o estado ahead/behind/upstream.
- Pull Requests: login com OAuth Device Flow (GitHub.com) ou PAT (GitHub.com ou Enterprise), listar, criar, ver arquivos/comentários, mesclar, fechar/reabrir.
- Colaboradores: ver quem tem acesso, convidar por username, cancelar convites, remover pessoas e mudar permissões (em repositórios de organização), direto no app.
- Chat de IA com ferramentas (criar/trocar/mesclar branch, commit, push/pull/fetch, stash, listar/criar PR) com fluxo de aprovação humana antes de cada ação.
- Interface, mensagens do processo principal e respostas da IA em português (Brasil), inglês ou espanhol — segue o idioma do sistema por padrão e pode ser trocado na hora em Configurações.
- Temas claro/escuro automáticos (via preferência do sistema) e suporte a alto contraste (Windows High Contrast Mode / `prefers-contrast`).
Expand Down
140 changes: 140 additions & 0 deletions GitWeb/src/main/githubApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { getSecret, saveSecret, deleteSecret } from './credentials'
import { httpFetch } from './httpFetch'
import { tMain } from './i18n'
import type {
AddCollaboratorResult,
BranchRules,
CreatePullRequestInput,
GitHubRepoSummary,
Expand All @@ -15,7 +16,10 @@ import type {
PullRequestReviewSummary,
PullRequestState,
PullRequestStatus,
RepositoryCollaborators,
RepositoryInvitation,
RepositoryOwner,
RepositoryRole,
ReviewDecision
} from '../shared/github'

Expand Down Expand Up @@ -711,3 +715,139 @@ export async function createRepository(
})
return { fullName: repo.full_name, htmlUrl: repo.html_url ?? `https://${host}/${repo.full_name}`, cloneUrl: repo.clone_url }
}

// --- Collaborators ------------------------------------------------------------

interface ApiCollaborator {
login: string
avatar_url: string
role_name: string
}

interface ApiInvitation {
id: number
invitee: { login: string; avatar_url?: string } | null
permissions: string
created_at: string
expired?: boolean
}

// The write endpoint still takes the role names GitHub used before read/write.
const API_PERMISSION: Record<RepositoryRole, string> = {
read: 'pull',
triage: 'triage',
write: 'push',
maintain: 'maintain',
admin: 'admin'
}

function repositoryPath(owner: string, repo: string): string {
return `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}`
}

function toInvitation(invitation: ApiInvitation): RepositoryInvitation {
return {
id: invitation.id,
login: invitation.invitee?.login ?? null,
avatarUrl: invitation.invitee?.avatar_url ?? null,
role: invitation.permissions,
createdAt: invitation.created_at,
expired: Boolean(invitation.expired)
}
}

/**
* Who has access and what the signed-in account may do about it. Invitations
* are allowed to fail on their own: a list of collaborators without them is
* still worth showing.
*/
export async function getRepositoryCollaborators(host: string, owner: string, repo: string): Promise<RepositoryCollaborators> {
const token = await requireToken(host)
const base = repositoryPath(owner, repo)
const info = await request<{ owner: { login: string; type: string }; permissions?: { admin?: boolean; push?: boolean } }>(host, base, token)
const ownerLogin = info.owner.login
const ownerKind = info.owner.type === 'Organization' ? 'org' : 'user'
const canAdminister = Boolean(info.permissions?.admin)
const canView = canAdminister || Boolean(info.permissions?.push)
if (!canView) return { ownerLogin, ownerKind, canView, canAdminister, collaborators: [], invitations: [], invitationsError: null }

const list = (affiliation: 'all' | 'direct'): Promise<ApiCollaborator[]> =>
requestAll<ApiCollaborator>(host, `${base}/collaborators?affiliation=${affiliation}&per_page=100`, token)

let everyone: ApiCollaborator[]
let direct: Set<string> | null = null
try {
// On a personal repository everyone is direct, so asking twice would only cost a request.
const [all, directOnly] = await Promise.all([list('all'), ownerKind === 'org' ? list('direct') : null])
everyone = all
if (directOnly) direct = new Set(directOnly.map((c) => c.login))
} catch (err) {
// An organization can refuse this list to a sign-in that works everywhere
// else, so a bare "Forbidden" would not say where to go next.
if (ownerKind === 'org' && err instanceof GitHubApiError && (err.status === 403 || err.status === 404)) {
throw new GitHubApiError(tMain().github.collaboratorsForbidden(err.message), err.status)
}
throw err
}

let invitations: RepositoryInvitation[] = []
let invitationsError: string | null = null
if (canAdminister) {
try {
invitations = (await requestAll<ApiInvitation>(host, `${base}/invitations?per_page=100`, token)).map(toInvitation)
} catch (err) {
invitationsError = err instanceof Error ? err.message : String(err)
}
}

return {
ownerLogin,
ownerKind,
canView,
canAdminister,
collaborators: everyone
.map((c) => ({ login: c.login, avatarUrl: c.avatar_url, role: c.role_name, direct: direct ? direct.has(c.login) : true }))
.sort((a, b) => a.login.localeCompare(b.login, undefined, { sensitivity: 'base' })),
invitations,
invitationsError
}
}

/**
* Adds a person, or changes the role of one already there — GitHub has a single
* endpoint for both. Without a role GitHub applies its default, and on personal
* repositories a role is not accepted at all.
*/
export async function addCollaborator(
host: string,
owner: string,
repo: string,
username: string,
role?: RepositoryRole
): Promise<AddCollaboratorResult> {
const token = await requireToken(host)
try {
const invitation = await request<ApiInvitation | undefined>(
host,
`${repositoryPath(owner, repo)}/collaborators/${encodeURIComponent(username)}`,
token,
{ method: 'PUT', body: JSON.stringify(role ? { permission: API_PERMISSION[role] } : {}) }
)
return invitation ? { kind: 'invited', invitation: toInvitation(invitation) } : { kind: 'added' }
} catch (err) {
if (err instanceof GitHubApiError && err.status === 404) {
throw new GitHubApiError(tMain().github.collaboratorNotFound(username), err.status)
}
throw err
}
}

export async function removeCollaborator(host: string, owner: string, repo: string, username: string): Promise<void> {
const token = await requireToken(host)
await request(host, `${repositoryPath(owner, repo)}/collaborators/${encodeURIComponent(username)}`, token, { method: 'DELETE' })
}

export async function cancelRepositoryInvitation(host: string, owner: string, repo: string, invitationId: number): Promise<void> {
const token = await requireToken(host)
await request(host, `${repositoryPath(owner, repo)}/invitations/${invitationId}`, token, { method: 'DELETE' })
}
15 changes: 14 additions & 1 deletion GitWeb/src/main/ipc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ import { saveSecret, getSecret, deleteSecret } from './credentials'
import { startDeviceFlow, pollDeviceFlow, cancelDeviceFlow, getOAuthAppInfo } from './githubDeviceFlow'
import * as githubApi from './githubApi'
import { isAppOrigin } from './security'
import type { CreatePullRequestInput, MergeMethod, PublishRepositoryInput, PullRequestState } from '../shared/github'
import type { CreatePullRequestInput, MergeMethod, PublishRepositoryInput, PullRequestState, RepositoryRole } from '../shared/github'
import { openDirectoryDialog } from './dialogs'
import {
addRecentRepositories,
Expand Down Expand Up @@ -350,6 +350,19 @@ export function registerIpcHandlers(): void {
githubApi.listUserReposWithToken(host, token)
)

handle('github:getRepositoryCollaborators', (_e, host: string, owner: string, repo: string) =>
githubApi.getRepositoryCollaborators(host, owner, repo)
)
handle('github:addCollaborator', (_e, host: string, owner: string, repo: string, username: string, role?: RepositoryRole) =>
githubApi.addCollaborator(host, owner, repo, username, role)
)
handle('github:removeCollaborator', (_e, host: string, owner: string, repo: string, username: string) =>
githubApi.removeCollaborator(host, owner, repo, username)
)
handle('github:cancelRepositoryInvitation', (_e, host: string, owner: string, repo: string, invitationId: number) =>
githubApi.cancelRepositoryInvitation(host, owner, repo, invitationId)
)

handle('github:listPullRequests', (_e, host: string, owner: string, repo: string, state: PullRequestState) =>
githubApi.listPullRequests(host, owner, repo, state)
)
Expand Down
11 changes: 10 additions & 1 deletion GitWeb/src/preload/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,10 @@ import type {
PullRequestStatus,
PublishRepositoryInput,
PublishRepositoryResult,
RepositoryOwner
AddCollaboratorResult,
RepositoryCollaborators,
RepositoryOwner,
RepositoryRole
} from '../shared/github'
import type { UpdateStatus } from '../shared/updates'

Expand Down Expand Up @@ -194,6 +197,12 @@ export interface Api {
publishRepository: (cwd: string, input: PublishRepositoryInput) => Promise<PublishRepositoryResult>
listUserReposWithToken: (host: string, token: string) => Promise<GitHubRepoSummary[]>

getRepositoryCollaborators: (host: string, owner: string, repo: string) => Promise<RepositoryCollaborators>
/** Invites a person, or changes the role of one already there. */
addCollaborator: (host: string, owner: string, repo: string, username: string, role?: RepositoryRole) => Promise<AddCollaboratorResult>
removeCollaborator: (host: string, owner: string, repo: string, username: string) => Promise<void>
cancelRepositoryInvitation: (host: string, owner: string, repo: string, invitationId: number) => Promise<void>

listPullRequests: (host: string, owner: string, repo: string, state: PullRequestState) => Promise<PullRequest[]>
getPullRequest: (host: string, owner: string, repo: string, number: number) => Promise<PullRequest>
getBranchRules: (host: string, owner: string, repo: string, branch: string) => Promise<BranchRules>
Expand Down
14 changes: 13 additions & 1 deletion GitWeb/src/preload/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,10 @@ import type {
PullRequestStatus,
PublishRepositoryInput,
PublishRepositoryResult,
RepositoryOwner
AddCollaboratorResult,
RepositoryCollaborators,
RepositoryOwner,
RepositoryRole
} from '../shared/github'
import type { UpdateStatus } from '../shared/updates'

Expand Down Expand Up @@ -276,6 +279,15 @@ const api = {
listUserReposWithToken: (host: string, token: string): Promise<GitHubRepoSummary[]> =>
invoke('github:listUserReposWithToken', host, token),

getRepositoryCollaborators: (host: string, owner: string, repo: string): Promise<RepositoryCollaborators> =>
invoke('github:getRepositoryCollaborators', host, owner, repo),
addCollaborator: (host: string, owner: string, repo: string, username: string, role?: RepositoryRole): Promise<AddCollaboratorResult> =>
invoke('github:addCollaborator', host, owner, repo, username, role),
removeCollaborator: (host: string, owner: string, repo: string, username: string): Promise<void> =>
invoke('github:removeCollaborator', host, owner, repo, username),
cancelRepositoryInvitation: (host: string, owner: string, repo: string, invitationId: number): Promise<void> =>
invoke('github:cancelRepositoryInvitation', host, owner, repo, invitationId),

listPullRequests: (host: string, owner: string, repo: string, state: PullRequestState): Promise<PullRequest[]> =>
invoke('github:listPullRequests', host, owner, repo, state),
getPullRequest: (host: string, owner: string, repo: string, number: number): Promise<PullRequest> =>
Expand Down
2 changes: 2 additions & 0 deletions GitWeb/src/renderer/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import FileInsightPanel from './components/FileInsightPanel'
import RebasePlannerDialog from './components/RebasePlannerDialog'
import StashesView from './views/StashesView'
import PullRequestsView from './views/PullRequestsView'
import CollaboratorsView from './views/CollaboratorsView'
import DashboardView from './views/DashboardView'
import AiChatView from './views/AiChatView'
import SettingsView from './views/SettingsView'
Expand Down Expand Up @@ -310,6 +311,7 @@ function App(): JSX.Element {
{view === 'history' && <HistoryView />}
{view === 'stash' && <StashesView />}
{view === 'pullRequests' && <PullRequestsView />}
{view === 'collaborators' && <CollaboratorsView />}
{view === 'dashboard' && <DashboardView />}
{view === 'settings' && <SettingsView />}
</main>
Expand Down
66 changes: 66 additions & 0 deletions GitWeb/src/renderer/src/browserApiStub.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,31 @@ function demoGithub(): boolean {

const DEMO_USER = { login: 'demo-dev', name: 'Demo Dev', avatarUrl: 'https://github.com/github.png' }

// localStorage.demoCollaborators picks which situation the Collaborators screen
// is designed against: 'org' | 'readOnly' | 'noAccess' | 'denied' (default: a
// personal repository administered by the demo user).
function demoCollaboratorVariant(): string | null {
try {
return localStorage.getItem('demoCollaborators')
} catch {
return null
}
}

let demoCollaborators = [
{ login: 'demo', avatarUrl: '', role: 'admin', direct: true },
{ login: 'demo-dev', avatarUrl: '', role: 'admin', direct: true },
{ login: 'dependabot', avatarUrl: '', role: 'triage', direct: false },
{ login: 'hubot', avatarUrl: '', role: 'maintain', direct: false },
{ login: 'monalisa', avatarUrl: '', role: 'read', direct: true },
{ login: 'octocat', avatarUrl: '', role: 'write', direct: true }
]

let demoInvitations = [
{ id: 2, login: 'torvalds', avatarUrl: null, role: 'write', createdAt: new Date(Date.now() - 3 * 3_600_000).toISOString(), expired: false },
{ id: 1, login: 'defunkt', avatarUrl: null, role: 'read', createdAt: new Date(Date.now() - 9 * 86_400_000).toISOString(), expired: true }
]

const hoursAgo = (h: number): string => new Date(Date.now() - h * 3600e3).toISOString()

const DEMO_PRS = [
Expand Down Expand Up @@ -517,6 +542,47 @@ function installBrowserApiStub(): void {
throw new Error(GITHUB_UNAVAILABLE)
},

getRepositoryCollaborators: async () => {
if (!demoGithub()) throw new Error(GITHUB_UNAVAILABLE)
const variant = demoCollaboratorVariant()
if (variant === 'denied') throw new Error('GitHub recusou a solicitação (403).')
if (variant === 'noAccess') {
return { ownerLogin: 'demo', ownerKind: 'user' as const, canView: false, canAdminister: false, collaborators: [], invitations: [], invitationsError: null }
}
const collaborators =
variant === 'org'
? demoCollaborators
: demoCollaborators.filter((c) => c.direct).map((c) => (c.role === 'admin' ? c : { ...c, role: 'write' }))
return {
ownerLogin: 'demo',
ownerKind: variant === 'org' ? ('org' as const) : ('user' as const),
canView: true,
canAdminister: variant !== 'readOnly',
collaborators,
invitations: variant === 'readOnly' ? [] : demoInvitations,
invitationsError: null
}
},
addCollaborator: async (_host, _owner, _repo, username, role) => {
if (!demoGithub()) throw new Error(GITHUB_UNAVAILABLE)
if (username.toLowerCase() === 'ghost-user') throw new Error(`O GitHub não encontrou ${username}, ou sua conta não pode gerenciar este repositório.`)
const existing = demoCollaborators.find((c) => c.login.toLowerCase() === username.toLowerCase())
if (existing) {
if (role) existing.role = role
existing.direct = true
return { kind: 'added' as const }
}
const invitation = { id: Date.now(), login: username, avatarUrl: null, role: role ?? 'write', createdAt: new Date().toISOString(), expired: false }
demoInvitations = [invitation, ...demoInvitations]
return { kind: 'invited' as const, invitation }
},
removeCollaborator: async (_host, _owner, _repo, username) => {
demoCollaborators = demoCollaborators.filter((c) => c.login !== username)
},
cancelRepositoryInvitation: async (_host, _owner, _repo, invitationId) => {
demoInvitations = demoInvitations.filter((i) => i.id !== invitationId)
},

listPullRequests: async (_host, _owner, _repo, state) =>
demoGithub() ? DEMO_PRS.filter((pr) => state === 'all' || pr.state === state) : [],
getPullRequest: async (_host, _owner, _repo, number) => {
Expand Down
Loading
Loading