Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Self-Hosted] Allow integrating with 'github.com' without a GitHub App #9231

Merged
merged 3 commits into from
Apr 21, 2022
Merged
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
85 changes: 68 additions & 17 deletions components/dashboard/src/projects/NewProject.tsx
Original file line number Diff line number Diff line change
@@ -41,11 +41,15 @@ export default function NewProject() {
const [sourceOfConfig, setSourceOfConfig] = useState<"repo" | "db" | undefined>();

const [authProviders, setAuthProviders] = useState<AuthProviderInfo[]>([]);
const [isGitHubAppEnabled, setIsGitHubAppEnabled] = useState<boolean>();
const [isGitHubWebhooksUnauthorized, setIsGitHubWebhooksUnauthorized] = useState<boolean>();

useEffect(() => {
(async () => {
setAuthProviders(await getGitpodService().server.getAuthProviders());
})();
const { server } = getGitpodService();
Promise.all([
server.getAuthProviders().then((v) => () => setAuthProviders(v)),
server.isGitHubAppEnabled().then((v) => () => setIsGitHubAppEnabled(v)),
]).then((setters) => setters.forEach((s) => s()));
}, []);

useEffect(() => {
@@ -62,7 +66,25 @@ export default function NewProject() {
}
}
}
}, [user, authProviders]);
}, [user, authProviders, selectedProviderHost]);

useEffect(() => {
setIsGitHubWebhooksUnauthorized(false);
if (!authProviders || !selectedProviderHost || isGitHubAppEnabled) {
return;
}
const ap = authProviders.find((ap) => ap.host === selectedProviderHost);
if (!ap || ap.authProviderType !== "GitHub") {
return;
}
getGitpodService()
.server.getToken({ host: ap.host })
.then((token) => {
if (!token || !token.scopes.includes("repo")) {
setIsGitHubWebhooksUnauthorized(true);
}
});
}, [authProviders, isGitHubAppEnabled, selectedProviderHost]);

useEffect(() => {
const params = new URLSearchParams(location.search);
@@ -210,6 +232,25 @@ export default function NewProject() {
});
};

const authorize = () => {
const ap = authProviders.find((ap) => ap.host === selectedProviderHost);
if (!ap) {
return;
}
openAuthorizeWindow({
host: ap.host,
scopes: ap.authProviderType === "GitHub" ? ["repo"] : ap.requirements?.default,
onSuccess: async () => {
if (ap.authProviderType === "GitHub") {
setIsGitHubWebhooksUnauthorized(false);
}
},
onError: (payload) => {
console.error("Authorization failed", selectedProviderHost, payload);
},
});
};

const createProject = async (teamOrUser: Team | User, repo: ProviderRepository) => {
if (!selectedProviderHost) {
return;
@@ -275,7 +316,7 @@ export default function NewProject() {
onClick: () => setSelectedAccount(account),
});
}
if (isGitHub()) {
if (isGitHub() && isGitHubAppEnabled) {
result.push({
title: "Add another GitHub account",
customContent: renderItemContent("Add GitHub Orgs or Account", Plus),
@@ -293,10 +334,15 @@ export default function NewProject() {
};

const renderSelectRepository = () => {
const noReposAvailable = reposInAccounts.length === 0;
const filteredRepos = Array.from(reposInAccounts).filter(
(r) => r.account === selectedAccount && `${r.name}`.toLowerCase().includes(repoSearchFilter.toLowerCase()),
);
// Don't list GitHub projects if we cannot install webhooks on them (project creation would eventually fail)
const noReposAvailable = reposInAccounts.length === 0 || isGitHubWebhooksUnauthorized;
const filteredRepos = isGitHubWebhooksUnauthorized
? []
: Array.from(reposInAccounts).filter(
(r) =>
r.account === selectedAccount &&
`${r.name}`.toLowerCase().includes(repoSearchFilter.toLowerCase()),
);
const icon = selectedAccount && accounts.get(selectedAccount)?.avatarUrl;

const showSearchInput = !!repoSearchFilter || filteredRepos.length > 0;
@@ -425,19 +471,25 @@ export default function NewProject() {
<div>
<div className="px-12 py-20 text-center text-gray-500 bg-gray-50 dark:bg-gray-800 rounded-xl">
<span className="dark:text-gray-400">
Additional authorization is required for our GitHub App to watch your
Additional authorization is required for Gitpod to watch your GitHub
repositories and trigger prebuilds.
</span>
<br />
<button className="mt-6" onClick={() => reconfigure()}>
Configure Gitpod App
</button>
{isGitHubWebhooksUnauthorized ? (
<button className="mt-6" onClick={() => authorize()}>
Authorize GitHub
</button>
) : (
<button className="mt-6" onClick={() => reconfigure()}>
Configure Gitpod App
</button>
)}
</div>
</div>
)}
</div>
</div>
{reposInAccounts.length > 0 && isGitHub() && (
{reposInAccounts.length > 0 && isGitHub() && isGitHubAppEnabled && (
<div>
<div className="text-gray-500 text-center w-96 mx-8">
Repository not found?{" "}
@@ -634,14 +686,13 @@ function GitProviders(props: {
setErrorMessage(undefined);

const token = await getGitpodService().server.getToken({ host: ap.host });
const isGitHubEnterprise = AuthProviderInfo.isGitHubEnterprise(ap);
if (token && !(isGitHubEnterprise && !token.scopes.includes("repo"))) {
if (token && !(ap.authProviderType === "GitHub" && !token.scopes.includes("repo"))) {
props.onHostSelected(ap.host);
return;
}
await openAuthorizeWindow({
host: ap.host,
scopes: isGitHubEnterprise ? ["repo"] : ap.requirements?.default,
scopes: ap.authProviderType === "GitHub" ? ["repo"] : ap.requirements?.default,
onSuccess: async () => {
props.onHostSelected(ap.host, true);
},
1 change: 1 addition & 0 deletions components/gitpod-protocol/src/gitpod-service.ts
Original file line number Diff line number Diff line change
@@ -189,6 +189,7 @@ export interface GitpodServer extends JsonRpcServer<GitpodClient>, AdminServer,

// misc
sendFeedback(feedback: string): Promise<string | undefined>;
isGitHubAppEnabled(): Promise<boolean>;
registerGithubApp(installationId: string): Promise<void>;

/**
6 changes: 0 additions & 6 deletions components/gitpod-protocol/src/protocol.ts
Original file line number Diff line number Diff line change
@@ -1159,12 +1159,6 @@ export interface AuthProviderInfo {
};
}

export namespace AuthProviderInfo {
export function isGitHubEnterprise(info?: AuthProviderInfo): boolean {
return !!info && info.authProviderType === "GitHub" && info.host !== "github.com";
}
}

export interface AuthProviderEntry {
readonly id: string;
readonly type: AuthProviderEntry.Type;
8 changes: 6 additions & 2 deletions components/server/ee/src/prebuilds/github-service.ts
Original file line number Diff line number Diff line change
@@ -26,8 +26,12 @@ export class GitHubService extends RepositoryService {
@inject(GithubContextParser) protected githubContextParser: GithubContextParser;

async getRepositoriesForAutomatedPrebuilds(user: User): Promise<ProviderRepository[]> {
const repositories = (await this.githubApi.run(user, (gh) => gh.repos.listForAuthenticatedUser({}))).data;
const adminRepositories = repositories.filter((r) => !!r.permissions?.admin);
const octokit = await this.githubApi.create(user);
const adminRepositories = await octokit.paginate(
octokit.repos.listForAuthenticatedUser,
{ per_page: 100 },
(response) => response.data.filter((r) => !!r.permissions?.admin),
);
return adminRepositories.map((r) => {
return <ProviderRepository>{
name: r.name,
2 changes: 1 addition & 1 deletion components/server/ee/src/workspace/gitpod-server-impl.ts
Original file line number Diff line number Diff line change
@@ -1781,7 +1781,7 @@ export class GitpodServerEEImpl extends GitpodServerImpl {
const providerHost = params.provider;
const provider = (await this.getAuthProviders(ctx)).find((ap) => ap.host === providerHost);

if (providerHost === "github.com") {
if (providerHost === "github.com" && this.config.githubApp?.enabled) {
repositories.push(...(await this.githubAppSupport.getProviderRepositoriesForUser({ user, ...params })));
} else if (provider?.authProviderType === "GitHub") {
const hostContext = this.hostContextProvider.get(providerHost);
1 change: 1 addition & 0 deletions components/server/src/auth/rate-limiter.ts
Original file line number Diff line number Diff line change
@@ -121,6 +121,7 @@ function getConfig(config: RateLimiterConfig): RateLimiterConfig {
generateNewGitpodToken: { group: "default", points: 1 },
deleteGitpodToken: { group: "default", points: 1 },
sendFeedback: { group: "default", points: 1 },
isGitHubAppEnabled: { group: "default", points: 1 },
registerGithubApp: { group: "default", points: 1 },
takeSnapshot: { group: "default", points: 1 },
waitForSnapshot: { group: "default", points: 1 },
2 changes: 1 addition & 1 deletion components/server/src/github/api.ts
Original file line number Diff line number Diff line change
@@ -140,7 +140,7 @@ export interface QueryLocation {
export class GitHubRestApi {
@inject(AuthProviderParams) readonly config: AuthProviderParams;
@inject(GitHubTokenHelper) protected readonly tokenHelper: GitHubTokenHelper;
protected async create(userOrToken: User | string) {
public async create(userOrToken: User | string) {
let token: string | undefined;
if (typeof userOrToken === "string") {
token = userOrToken;
5 changes: 3 additions & 2 deletions components/server/src/projects/projects-service.ts
Original file line number Diff line number Diff line change
@@ -7,7 +7,6 @@
import { inject, injectable } from "inversify";
import { DBWithTracing, ProjectDB, TeamDB, TracedWorkspaceDB, UserDB, WorkspaceDB } from "@gitpod/gitpod-db/lib";
import {
AuthProviderInfo,
Branch,
PrebuildWithStatus,
CreateProjectParams,
@@ -20,6 +19,7 @@ import { HostContextProvider } from "../auth/host-context-provider";
import { RepoURL } from "../repohost";
import { log } from "@gitpod/gitpod-protocol/lib/util/logging";
import { PartialProject, ProjectUsage } from "@gitpod/gitpod-protocol/src/teams-projects-protocol";
import { Config } from "../config";

@injectable()
export class ProjectsService {
@@ -28,6 +28,7 @@ export class ProjectsService {
@inject(UserDB) protected readonly userDB: UserDB;
@inject(TracedWorkspaceDB) protected readonly workspaceDb: DBWithTracing<WorkspaceDB>;
@inject(HostContextProvider) protected readonly hostContextProvider: HostContextProvider;
@inject(Config) protected readonly config: Config;

async getProject(projectId: string): Promise<Project | undefined> {
return this.projectDB.findProjectById(projectId);
@@ -154,7 +155,7 @@ export class ProjectsService {
type === "GitLab" ||
type === "Bitbucket" ||
type === "BitbucketServer" ||
AuthProviderInfo.isGitHubEnterprise(authProvider)
(type === "GitHub" && (authProvider?.host !== "github.com" || !this.config.githubApp?.enabled))
) {
const repositoryService = hostContext?.services?.repositoryService;
if (repositoryService) {
5 changes: 5 additions & 0 deletions components/server/src/workspace/gitpod-server-impl.ts
Original file line number Diff line number Diff line change
@@ -1710,6 +1710,11 @@ export class GitpodServerImpl implements GitpodServerWithTracing, Disposable {
throw new ResponseError(ErrorCodes.EE_FEATURE, "Sending feedback is not implemented");
}

async isGitHubAppEnabled(ctx: TraceContext): Promise<boolean> {
this.checkAndBlockUser();
return !!this.config.githubApp?.enabled;
}

async registerGithubApp(ctx: TraceContext, installationId: string): Promise<void> {
traceAPIParams(ctx, { installationId });