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

Github connect invitations #2603

Merged
merged 10 commits into from
Sep 12, 2024
Merged
9 changes: 9 additions & 0 deletions backend/src/api/integration/helpers/getGithubInstallations.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import Permissions from '../../../security/permissions'
import IntegrationService from '../../../services/integrationService'
import PermissionChecker from '../../../services/user/permissionChecker'

export default async (req, res) => {
new PermissionChecker(req).validateHas(Permissions.values.tenantEdit)
const payload = await new IntegrationService(req).getGithubInstallations()
await req.responseHandler.success(req, res, payload)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import Permissions from '../../../security/permissions'
import IntegrationService from '../../../services/integrationService'
import PermissionChecker from '../../../services/user/permissionChecker'

export default async (req, res) => {
new PermissionChecker(req).validateHas(Permissions.values.tenantEdit)
const payload = await new IntegrationService(req).connectGithubInstallation(req.body.installId)
await req.responseHandler.success(req, res, payload)
}
10 changes: 10 additions & 0 deletions backend/src/api/integration/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,16 @@ export default (app) => {
safeWrap(require('./helpers/jiraConnectOrUpdate').default),
)

app.get(
'/tenant/:tenantId/github-installations',
safeWrap(require('./helpers/getGithubInstallations').default),
)

app.post(
'/tenant/:tenantId/github-connect-installation',
safeWrap(require('./helpers/githubConnectInstallation').default),
)

app.get('/gitlab/:tenantId/connect', safeWrap(require('./helpers/gitlabAuthenticate').default))

app.get(
Expand Down
Empty file.
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
CREATE TABLE IF NOT EXISTS "githubInstallations" (
"id" UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
"installationId" VARCHAR(255) NOT NULL UNIQUE,
"type" VARCHAR(255) NOT NULL,
"numRepos" INTEGER NOT NULL,
"login" VARCHAR(255) NOT NULL,
"avatarUrl" TEXT,
"createdAt" TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);
20 changes: 20 additions & 0 deletions backend/src/database/repositories/githubInstallationsRepository.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { QueryTypes } from 'sequelize'
import { IRepositoryOptions } from './IRepositoryOptions'
import SequelizeRepository from './sequelizeRepository'

export default class GithubInstallationsRepository {
static async getInstallations(options: IRepositoryOptions) {
const transaction = SequelizeRepository.getTransaction(options)
const seq = SequelizeRepository.getSequelize(options)

return seq.query(
`
select * from "githubInstallations"
`,
{
transaction,
type: QueryTypes.SELECT,
},
)
}
}
34 changes: 34 additions & 0 deletions backend/src/services/integrationService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ import SearchSyncService from './searchSyncService'
import { IRepositoryOptions } from '@/database/repositories/IRepositoryOptions'
import IntegrationProgressRepository from '@/database/repositories/integrationProgressRepository'
import { IntegrationProgress } from '@/serverless/integrations/types/regularTypes'
import GithubInstallationsRepository from '@/database/repositories/githubInstallationsRepository'
import {
fetchGitlabUserProjects,
fetchGitlabGroupProjects,
Expand Down Expand Up @@ -433,6 +434,39 @@ export default class IntegrationService {
return integration
}

async connectGithubInstallation(installId: string) {
const installToken = await IntegrationService.getInstallToken(installId)
const repos = await getInstalledRepositories(installToken)
const githubOwner = IntegrationService.extractOwner(repos, this.options)

let orgAvatar
try {
const response = await request('GET /users/{user}', {
user: githubOwner,
})
orgAvatar = response.data.avatar_url
} catch (err) {
this.options.log.warn(err, 'Error while fetching GitHub user!')
}

const integration = await this.createOrUpdateGithubIntegration(
{
platform: PlatformType.GITHUB,
token: installToken,
settings: { updateMemberAttributes: true, orgAvatar },
integrationIdentifier: installId,
status: 'mapping',
},
repos,
)

return integration
}

async getGithubInstallations() {
return GithubInstallationsRepository.getInstallations(this.options)
}

/**
* Creates or updates a GitHub integration, handling large repos data
* @param integrationData The integration data to create or update
Expand Down
221 changes: 221 additions & 0 deletions frontend/src/integrations/github/components/github-connect-modal.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,221 @@
<template>
<lf-modal v-model="isModalOpen" width="40rem">
<div class="p-6">
<div class="flex justify-between">
<!-- icon -->
<div class="bg-primary-50 h-10 w-10 flex justify-center items-center rounded-full">
<lf-icon name="git-repository-private-line" :size="24" class="text-primary-500" />
</div>

<!-- close button -->
<lf-button
type="secondary-ghost-light"
size="large"
:icon-only="true"
class="-mt-2 -mr-2"
@click="isModalOpen = false"
>
<lf-icon name="close-line" :size="24" />
</lf-button>
</div>
<section class="pt-6 pb-8">
<h5 class="pb-2">
Connect your GitHub organization
</h5>
<p class="text-medium text-gray-600">
Choose the organization and repositories you want to connect with your project.
</p>
</section>
<section class="mb-7 border border-gray-200 rounded-lg p-5">
<h6 class="pb-2">
I'm the GitHub organization admin
</h6>
<p class="text-medium text-gray-600 pb-6">
As an admin member of the organization you want to connect,
you can give access to Community Management and select the repositories you want to track.
</p>
<lf-button type="primary" class="w-full" @click="openGithubInstallation">
Choose organization to connect
</lf-button>
</section>
<section class="border border-gray-200 rounded-lg p-5">
<article class="mb-8">
<h6 class="pb-2">
I'm not the admin of the GitHub organization
</h6>
<p class="text-medium text-gray-600">
Request the installation of Community Management App from an admin member.
</p>
</article>
<article class="flex mb-8">
<div class="min-w-8 w-8 h-8 flex items-center justify-center rounded-full bg-gray-200">
<h6>1</h6>
</div>
<div class="pl-3">
<p class="text-medium text-gray-600 pb-3">
Share the <span class="font-semibold">Community Management App</span> installation link
with an admin member of your GitHub organization.
</p>
<lf-button type="primary-link" size="small" @click="copy()">
<template v-if="!copied">
<lf-icon name="file-copy-line" />
Copy app installation link to clipboard
</template>
<template v-else>
<lf-icon name="checkbox-circle-fill" class="text-green-500" />
<span class="text-green-500">Copied to clipboard!</span>
</template>
</lf-button>
</div>
</article>
<article class="flex mb-6">
<div class="min-w-8 w-8 h-8 flex items-center justify-center rounded-full bg-gray-200">
<h6>2</h6>
</div>
<div class="pl-3">
<p class="text-medium text-gray-600 pb-3">
Once the app is installed, you will be able to select the organization you want to connect.
Type the organization name in order to proceed.
</p>
</div>
</article>
<article>
<el-select
v-model="installationId"
placeholder="Enter organization name..."
class="w-full mb-4"
filterable
:filter-method="(query: string) => search = query"
no-data-text="Type to search"
clearable
:automatic-dropdown="false"
:popper-class="search.length ? '' : 'hidden'"
>
<template v-if="selectedInstallation" #prefix>
<lf-avatar
:src="selectedInstallation.avatarUrl"
:name="selectedInstallation.login"
:size="18"
class="!rounded border border-gray-200 mr-1 mt-px"
>
<template #placeholder>
<div class="w-full h-full bg-gray-50 flex items-center justify-center">
<lf-icon name="community-line" :size="14" class="text-gray-400" />
</div>
</template>
</lf-avatar>
</template>
<el-option
v-for="i of filteredInstallations"
:key="i.installationId"
:value="i.installationId"
:label="i.login"
class="!px-3"
>
<div class="flex items-center gap-2">
<lf-avatar :src="i.avatarUrl" :name="i.login" :size="18" class="!rounded border border-gray-200 mr-1 mt-px">
<template #placeholder>
<div class="w-full h-full bg-gray-50 flex items-center justify-center">
<lf-icon name="community-line" :size="14" class="text-gray-400" />
</div>
</template>
</lf-avatar>
<p>{{ i.login }}</p>
</div>
</el-option>
</el-select>

<lf-button class="w-full" :disabled="!installationId.length" @click="connectInstallation">
Connect organization
</lf-button>
</article>
</section>
</div>
</lf-modal>
</template>

<script setup lang="ts">
import { computed, onMounted, ref } from 'vue';
import LfModal from '@/ui-kit/modal/Modal.vue';
import config from '@/config';
import LfIcon from '@/ui-kit/icon/Icon.vue';
import LfButton from '@/ui-kit/button/Button.vue';
import LfAvatar from '@/ui-kit/avatar/Avatar.vue';
import { IntegrationService } from '@/modules/integration/integration-service';
import { mapActions } from '@/shared/vuex/vuex.helpers';

interface GithubInstallation {
id: string;
installationId: string;
type: string;
numRepos: number;
login: string;
avatarUrl: string;
}

const props = defineProps<{
modelValue: boolean,
integration: any,
}>();

const emit = defineEmits<{(e: 'update:modelValue', value: boolean): void }>();

const { doFetch } = mapActions('integration');
const copied = ref(false);
const installations = ref<GithubInstallation[]>([]);
const installationId = ref<string>('');
const search = ref<string>('');

const isModalOpen = computed({
get: () => props.modelValue,
set: (value: boolean) => emit('update:modelValue', value),
});

const filteredInstallations = computed(() => (search.value.length
? installations.value.filter((i) => i.login.toLowerCase().startsWith(search.value.toLowerCase()))
: []));

const selectedInstallation = computed(() => installations.value.find((i) => i.installationId === installationId.value));

const openGithubInstallation = () => window.open(config.gitHubInstallationUrl, '_self');

const copy = () => {
if (navigator.clipboard) {
const urlWithState = `${config.gitHubInstallationUrl}?state=noconnect`;
navigator.clipboard.writeText(urlWithState);
copied.value = true;
setTimeout(() => {
copied.value = false;
}, 1000);
}
};

const connectInstallation = () => {
if (!installationId.value) {
return;
}

IntegrationService.githubConnectInstallation(installationId.value)
.then(() => {
isModalOpen.value = false;
doFetch();
});
};

const getGithubInstallations = () => {
IntegrationService.getGithubInstallations()
.then((res: GithubInstallation[]) => {
installations.value = res;
});
};

onMounted(() => {
getGithubInstallations();
});
</script>

<script lang="ts">
export default {
name: 'LfGithubConnectModal',
};
</script>
42 changes: 10 additions & 32 deletions frontend/src/integrations/github/components/github-connect.vue
Original file line number Diff line number Diff line change
@@ -1,11 +1,15 @@
<template>
<slot :connect="connect" :has-settings="false" :settings-component="GithubSettings" />
<lf-github-connect-modal
v-if="isModalOpen"
v-model="isModalOpen"
:integration="integration"
/>
</template>

<script setup>
import { computed } from 'vue';
import config from '@/config';
import ConfirmDialog from '@/shared/dialog/confirm-dialog';
import { ref } from 'vue';
import LfGithubConnectModal from '@/integrations/github/components/github-connect-modal.vue';
import GithubSettings from './github-settings.vue';

defineProps({
Expand All @@ -15,36 +19,10 @@ defineProps({
},
});

// We have 3 GitHub apps: test, test-local and prod
// Getting the proper URL from config file
const githubConnectUrl = computed(() => config.gitHubInstallationUrl);
const isModalOpen = ref(false);

const connect = () => {
ConfirmDialog({
type: 'notification',
title:
'Are you the admin of your GitHub organization?',
titleClass: 'text-lg pt-2',
message:
`Only GitHub users with admin permissions are able to connect LFX's GitHub integration.
If you are an organization member, you will need an approval from the GitHub workspace admin. <a href="https://docs.crowd.dev/docs/github-integration" target="_blank">Read more</a>`,
icon: 'ri-information-line',
confirmButtonText: 'I\'m the GitHub organization admin',
cancelButtonText: 'Invite organization admin to this workspace',
verticalCancelButtonClass: 'hidden',
verticalConfirmButtonClass: 'btn btn--md btn--primary w-full !mb-2',
vertical: true,
distinguishCancelAndClose: true,
autofocus: false,
messageClass: 'text-xs !leading-5 !mt-1 text-gray-600',
}).then(() => {
window.open(githubConnectUrl.value, '_self');
}).catch((action) => {
if (action === 'cancel') {
router.push({
name: 'settings',
});
}
});
isModalOpen.value = true;
};

</script>
Expand Down
Loading
Loading