Skip to content

Commit

Permalink
Github connect invitations (#2603)
Browse files Browse the repository at this point in the history
Co-authored-by: garrrikkotua <kotuaigor@gmail.com>
  • Loading branch information
gaspergrom and garrrikkotua authored Sep 12, 2024
1 parent 35ccbf1 commit f82a8f2
Show file tree
Hide file tree
Showing 13 changed files with 410 additions and 33 deletions.
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

0 comments on commit f82a8f2

Please sign in to comment.