diff --git a/docs/docs/integrations/overview.md b/docs/docs/integrations/overview.md index 2678f4351595..5992999eea60 100644 --- a/docs/docs/integrations/overview.md +++ b/docs/docs/integrations/overview.md @@ -120,6 +120,14 @@ Manage your Flagsmith Change Requests inside ServiceNow. [Learn more](/integrati You can integrate Flagsmith with Slack. Send flag change events from Flagsmith into your Slack channels. [Learn more](/integrations/project-management/slack). +--- + + + +View your Flagsmith flags inside GitHub Issues and Pull Request. [Learn more](/integrations/project-management/github). + +--- + ## Authentication Providers / IDPs ### OAuth diff --git a/docs/docs/integrations/project-management/github.md b/docs/docs/integrations/project-management/github.md new file mode 100644 index 000000000000..e02ce920bae0 --- /dev/null +++ b/docs/docs/integrations/project-management/github.md @@ -0,0 +1,50 @@ +--- +title: GitHub +description: View your Flagsmith flags inside GitHub +sidebar_position: 10 +hide_title: true +--- + +GitHub Logo + +View your Flagsmith Flags inside your GitHub Issues and Pull Request + +:::tip + +- The GitHub integration is currently only supported with our hosted Flagsmith SaaS service. + +::: + +## Integration Setup + +### From Flagsmith + +1. In the Integrations Option in the side bar, find the GitHub integration and click on 'Add Integration'. +2. A window will open asking you to select an organization you belong to. +3. Select the repositories and save. +4. In the Flagsmith application, the button will now say "Manage Integration", click on it. +5. Finally, select the repository you wish to link. + +### From github + +1. In GitHub, add the app from the [GitHub Marketplace](https://github.com/apps/flagsmith-github-integration). +2. Select your organisation. +3. Select your repositories where you want install the app. +4. You will be redirected back to the Flagsmith app to finish the integration setup. +5. Select your Flagsmith Organisation. +6. Select the Flagmsith Project you want to associate with the repository where the app was installed to create the + Integration. + +## Adding a Flagsmith Flag to a GitHub issue or pull request + +1. Create or select a Feature Flag. +2. Go to settings section. +3. Select your integration. +4. Select GitHub Issue or GitHub PR. +5. Select your external resource and save. + +## Delete GitHub Integration + +1. In the Integrations Option in the side bar, find the GitHub integration and click on 'Manage Integration'. +2. Click on 'Delete Integracion' button, and confirm. +3. In your Github organisation, uninstall the Flagsmith GitHub App. diff --git a/docs/static/img/integrations/github/github-logo.svg b/docs/static/img/integrations/github/github-logo.svg new file mode 100644 index 000000000000..1d6fadd60f44 --- /dev/null +++ b/docs/static/img/integrations/github/github-logo.svg @@ -0,0 +1,48 @@ + + + + + + + + + + + + + + diff --git a/frontend/common/services/useExternalResource.ts b/frontend/common/services/useExternalResource.ts new file mode 100644 index 000000000000..2189f40b2134 --- /dev/null +++ b/frontend/common/services/useExternalResource.ts @@ -0,0 +1,98 @@ +import { Res } from 'common/types/responses' +import { Req } from 'common/types/requests' +import { service } from 'common/service' + +export const externalResourceService = service + .enhanceEndpoints({ addTagTypes: ['ExternalResource'] }) + .injectEndpoints({ + endpoints: (builder) => ({ + createExternalResource: builder.mutation< + Res['externalResource'], + Req['createExternalResource'] + >({ + invalidatesTags: [{ id: 'LIST', type: 'ExternalResource' }], + query: (query: Req['createExternalResource']) => ({ + body: query.body, + method: 'POST', + url: `projects/${query.project_id}/features/${query.feature_id}/feature-external-resources/`, + }), + }), + deleteExternalResource: builder.mutation< + Res['externalResource'], + Req['deleteExternalResource'] + >({ + invalidatesTags: [{ id: 'LIST', type: 'ExternalResource' }], + query: (query: Req['deleteExternalResource']) => ({ + method: 'DELETE', + url: `projects/${query.project_id}/features/${query.feature_id}/feature-external-resources/${query.external_resource_id}/`, + }), + }), + getExternalResources: builder.query< + Res['externalResource'], + Req['getExternalResources'] + >({ + providesTags: [{ id: 'LIST', type: 'ExternalResource' }], + query: (query: Req['getExternalResources']) => ({ + url: `projects/${query.project_id}/features/${query.feature_id}/feature-external-resources/`, + }), + }), + // END OF ENDPOINTS + }), + }) + +export async function createExternalResource( + store: any, + data: Req['createExternalResource'], + options?: Parameters< + typeof externalResourceService.endpoints.createExternalResource.initiate + >[1], +) { + return store.dispatch( + externalResourceService.endpoints.createExternalResource.initiate( + data, + options, + ), + ) +} +export async function deleteExternalResource( + store: any, + data: Req['deleteExternalResource'], + options?: Parameters< + typeof externalResourceService.endpoints.deleteExternalResource.initiate + >[1], +) { + return store.dispatch( + externalResourceService.endpoints.deleteExternalResource.initiate( + data, + options, + ), + ) +} +export async function getExternalResources( + store: any, + data: Req['getExternalResources'], + options?: Parameters< + typeof externalResourceService.endpoints.getExternalResources.initiate + >[1], +) { + return store.dispatch( + externalResourceService.endpoints.getExternalResources.initiate( + data, + options, + ), + ) +} +// END OF FUNCTION_EXPORTS + +export const { + useCreateExternalResourceMutation, + useDeleteExternalResourceMutation, + useGetExternalResourcesQuery, + // END OF EXPORTS +} = externalResourceService + +/* Usage examples: +const { data, isLoading } = useGetExternalResourceQuery({ id: 2 }, {}) //get hook +const [createExternalResource, { isLoading, data, isSuccess }] = useCreateExternalResourceMutation() //create hook +externalResourceService.endpoints.getExternalResources.select({id: 2})(store.getState()) //access data from any function +*/ diff --git a/frontend/common/services/useGithub.ts b/frontend/common/services/useGithub.ts new file mode 100644 index 000000000000..600b01bca300 --- /dev/null +++ b/frontend/common/services/useGithub.ts @@ -0,0 +1,85 @@ +import { Res } from 'common/types/responses' +import { Req } from 'common/types/requests' +import { service } from 'common/service' +import Utils from 'common/utils/utils' + +export const githubService = service + .enhanceEndpoints({ addTagTypes: ['Github'] }) + .injectEndpoints({ + endpoints: (builder) => ({ + getGithubIssues: builder.query< + Res['githubIssues'], + Req['getGithubIssues'] + >({ + providesTags: [{ id: 'LIST', type: 'Github' }], + query: (query: Req['getGithubIssues']) => ({ + url: `organisations/${query.organisation_id}/github/issues/?repo_name=${query.repo_name}&repo_owner=${query.repo_owner}`, + }), + }), + getGithubPulls: builder.query({ + providesTags: [{ id: 'LIST', type: 'Github' }], + query: (query: Req['getGithubPulls']) => ({ + url: `organisations/${query.organisation_id}/github/pulls/?repo_name=${query.repo_name}&repo_owner=${query.repo_owner}`, + }), + }), + getGithubRepos: builder.query({ + providesTags: [{ id: 'LIST', type: 'Github' }], + query: (query: Req['getGithubRepos']) => ({ + url: `organisations/${ + query.organisation_id + }/github/repositories/?${Utils.toParam({ + installation_id: query.installation_id, + })}`, + }), + }), + // END OF ENDPOINTS + }), + }) + +export async function getGithubIssues( + store: any, + data: Req['getGithubIssues'], + options?: Parameters< + typeof githubService.endpoints.getGithubIssues.initiate + >[1], +) { + return store.dispatch( + githubService.endpoints.getGithubIssues.initiate(data, options), + ) +} +export async function getGithubPulls( + store: any, + data: Req['getGithubPulls'], + options?: Parameters< + typeof githubService.endpoints.getGithubPulls.initiate + >[1], +) { + return store.dispatch( + githubService.endpoints.getGithubPulls.initiate(data, options), + ) +} +export async function getGithubRepos( + store: any, + data: Req['getGithubRepos'], + options?: Parameters< + typeof githubService.endpoints.getGithubRepos.initiate + >[1], +) { + return store.dispatch( + githubService.endpoints.getGithubRepos.initiate(data, options), + ) +} +// END OF FUNCTION_EXPORTS + +export const { + useGetGithubIssuesQuery, + useGetGithubPullsQuery, + useGetGithubReposQuery, + // END OF EXPORTS +} = githubService + +/* Usage examples: +const { data, isLoading } = useGetGithubIssuesQuery({ id: 2 }, {}) //get hook +const [createGithub, { isLoading, data, isSuccess }] = useCreateGithubMutation() //create hook +githubService.endpoints.getGithub.select({id: 2})(store.getState()) //access data from any function +*/ diff --git a/frontend/common/services/useGithubIntegration.ts b/frontend/common/services/useGithubIntegration.ts new file mode 100644 index 000000000000..6017eb81b5a0 --- /dev/null +++ b/frontend/common/services/useGithubIntegration.ts @@ -0,0 +1,124 @@ +import { Res } from 'common/types/responses' +import { Req } from 'common/types/requests' +import { service } from 'common/service' + +export const githubIntegrationService = service + .enhanceEndpoints({ addTagTypes: ['GithubIntegration'] }) + .injectEndpoints({ + endpoints: (builder) => ({ + createGithubIntegration: builder.mutation< + Res['githubIntegrations'], + Req['createGithubIntegration'] + >({ + invalidatesTags: [{ id: 'LIST', type: 'GithubIntegration' }], + query: (query: Req['createGithubIntegration']) => ({ + body: query.body, + method: 'POST', + url: `organisations/${query.organisation_id}/integrations/github/`, + }), + }), + deleteGithubIntegration: builder.mutation< + Res['githubIntegrations'], + Req['deleteGithubIntegration'] + >({ + invalidatesTags: [{ id: 'LIST', type: 'GithubIntegration' }], + query: (query: Req['deleteGithubIntegration']) => ({ + method: 'DELETE', + url: `organisations/${query.organisation_id}/integrations/github/${query.github_integration_id}/`, + }), + }), + getGithubIntegration: builder.query< + Res['githubIntegrations'], + Req['getGithubIntegration'] + >({ + providesTags: [{ id: 'LIST', type: 'GithubIntegration' }], + query: (query: Req['getGithubIntegration']) => ({ + url: `organisations/${query.organisation_id}/integrations/github/`, + }), + }), + updateGithubIntegration: builder.mutation< + Res['githubIntegrations'], + Req['updateGithubIntegration'] + >({ + invalidatesTags: [{ id: 'LIST', type: 'GithubIntegration' }], + query: (query: Req['updateGithubIntegration']) => ({ + body: query, + method: 'PUT', + url: `organisations/${query.organisation_id}/integrations/github/${query.github_integration_id}/`, + }), + }), + // END OF ENDPOINTS + }), + }) + +export async function createGithubIntegration( + store: any, + data: Req['createGithubIntegration'], + options?: Parameters< + typeof githubIntegrationService.endpoints.createGithubIntegration.initiate + >[1], +) { + return store.dispatch( + githubIntegrationService.endpoints.createGithubIntegration.initiate( + data, + options, + ), + ) +} +export async function deleteGithubIntegration( + store: any, + data: Req['deleteGithubIntegration'], + options?: Parameters< + typeof githubIntegrationService.endpoints.deleteGithubIntegration.initiate + >[1], +) { + return store.dispatch( + githubIntegrationService.endpoints.deleteGithubIntegration.initiate( + data, + options, + ), + ) +} +export async function getGithubIntegration( + store: any, + data: Req['getGithubIntegration'], + options?: Parameters< + typeof githubIntegrationService.endpoints.getGithubIntegration.initiate + >[1], +) { + return store.dispatch( + githubIntegrationService.endpoints.getGithubIntegration.initiate( + data, + options, + ), + ) +} +export async function updateGithubIntegration( + store: any, + data: Req['updateGithubIntegration'], + options?: Parameters< + typeof githubIntegrationService.endpoints.updateGithubIntegration.initiate + >[1], +) { + return store.dispatch( + githubIntegrationService.endpoints.updateGithubIntegration.initiate( + data, + options, + ), + ) +} +// END OF FUNCTION_EXPORTS + +export const { + useCreateGithubIntegrationMutation, + useDeleteGithubIntegrationMutation, + useGetGithubIntegrationQuery, + useUpdateGithubIntegrationMutation, + // END OF EXPORTS +} = githubIntegrationService + +/* Usage examples: +const { data, isLoading } = useGetGithubIntegrationQuery({ id: 2 }, {}) //get hook +const [createGithubIntegration, { isLoading, data, isSuccess }] = useCreateGithubIntegrationMutation() //create hook +githubIntegrationService.endpoints.getGithubIntegration.select({id: 2})(store.getState()) //access data from any function +*/ diff --git a/frontend/common/services/useGithubRepository.ts b/frontend/common/services/useGithubRepository.ts new file mode 100644 index 000000000000..40589d4c6af1 --- /dev/null +++ b/frontend/common/services/useGithubRepository.ts @@ -0,0 +1,125 @@ +import { Res } from 'common/types/responses' +import { Req } from 'common/types/requests' +import { service } from 'common/service' + +export const githubRepositoryService = service + .enhanceEndpoints({ addTagTypes: ['GithubRepository'] }) + .injectEndpoints({ + endpoints: (builder) => ({ + createGithubRepository: builder.mutation< + Res['githubRepository'], + Req['createGithubRepository'] + >({ + invalidatesTags: [{ id: 'LIST', type: 'GithubRepository' }], + query: (query: Req['createGithubRepository']) => ({ + body: query.body, + method: 'POST', + url: `organisations/${query.organisation_id}/integrations/github/${query.github_id}/repositories/`, + }), + }), + deleteGithubRepository: builder.mutation< + Res['githubRepository'], + Req['deleteGithubRepository'] + >({ + invalidatesTags: [{ id: 'LIST', type: 'GithubRepository' }], + query: (query: Req['deleteGithubRepository']) => ({ + body: query, + method: 'DELETE', + url: `organisations/${query.organisation_id}/integrations/github/${query.github_id}/repositories/${query.id}/`, + }), + }), + getGithubRepositories: builder.query< + Res['githubRepository'], + Req['getGithubRepositories'] + >({ + providesTags: [{ id: 'LIST', type: 'GithubRepository' }], + query: (query: Req['getGithubRepositories']) => ({ + url: `organisations/${query.organisation_id}/integrations/github/${query.github_id}/repositories/`, + }), + }), + updateGithubRepository: builder.mutation< + Res['githubRepository'], + Req['updateGithubRepository'] + >({ + invalidatesTags: [{ id: 'LIST', type: 'GithubRepository' }], + query: (query: Req['updateGithubRepository']) => ({ + body: query, + method: 'PUT', + url: `organisations/${query.organisation_id}/integrations/github/${query.github_id}/repositories/${query.id}/`, + }), + }), + // END OF ENDPOINTS + }), + }) + +export async function createGithubRepository( + store: any, + data: Req['createGithubRepository'], + options?: Parameters< + typeof githubRepositoryService.endpoints.createGithubRepository.initiate + >[1], +) { + return store.dispatch( + githubRepositoryService.endpoints.createGithubRepository.initiate( + data, + options, + ), + ) +} +export async function deleteGithubRepository( + store: any, + data: Req['deleteGithubRepository'], + options?: Parameters< + typeof githubRepositoryService.endpoints.deleteGithubRepository.initiate + >[1], +) { + return store.dispatch( + githubRepositoryService.endpoints.deleteGithubRepository.initiate( + data, + options, + ), + ) +} +export async function getGithubRepositories( + store: any, + data: Req['getGithubRepositories'], + options?: Parameters< + typeof githubRepositoryService.endpoints.getGithubRepositories.initiate + >[1], +) { + return store.dispatch( + githubRepositoryService.endpoints.getGithubRepositories.initiate( + data, + options, + ), + ) +} +export async function updateGithubRepository( + store: any, + data: Req['updateGithubRepository'], + options?: Parameters< + typeof githubRepositoryService.endpoints.updateGithubRepository.initiate + >[1], +) { + return store.dispatch( + githubRepositoryService.endpoints.updateGithubRepository.initiate( + data, + options, + ), + ) +} +// END OF FUNCTION_EXPORTS + +export const { + useCreateGithubRepositoryMutation, + useDeleteGithubRepositoryMutation, + useGetGithubRepositoriesQuery, + useUpdateGithubRepositoryMutation, + // END OF EXPORTS +} = githubRepositoryService + +/* Usage examples: +const { data, isLoading } = useGetGithubRepositoryQuery({ id: 2 }, {}) //get hook +const [createGithubRepository, { isLoading, data, isSuccess }] = useCreateGithubRepositoryMutation() //create hook +githubRepositoryService.endpoints.getGithubRepository.select({id: 2})(store.getState()) //access data from any function +*/ diff --git a/frontend/common/types/requests.ts b/frontend/common/types/requests.ts index 11eb47e377f5..0b9d1e11bd4c 100644 --- a/frontend/common/types/requests.ts +++ b/frontend/common/types/requests.ts @@ -4,9 +4,8 @@ import { Tag, FeatureStateValue, FeatureState, - Role, + ExternalResource, ImportStrategy, - APIKey, } from './responses' export type PagedRequest = T & { @@ -298,6 +297,70 @@ export type Req = { getGroupSummaries: { orgId: string } + getExternalResources: { project_id: string; feature_id: string } + deleteExternalResource: { + project_id: string + feature_id: string + external_resource_id: string + } + createExternalResource: { + project_id: string + feature_id: string + body: ExternalResource + } + + getGithubIntegration: { + organisation_id: string + id?: string + } + updateGithubIntegration: { + organisation_id: string + github_integration_id: string + } + deleteGithubIntegration: { + organisation_id: string + github_integration_id: string + } + createGithubIntegration: { + organisation_id: string + body: { + installation_id: string + } + } + getGithubRepositories: { + organisation_id: string + github_id: string + } + updateGithubRepository: { + organisation_id: string + github_id: string + id: string + } + deleteGithubRepository: { + organisation_id: string + github_id: string + id: string + } + createGithubRepository: { + organisation_id: string + github_id: string + body: { + project: string + repository_name: string + repository_owner: string + } + } + getGithubIssues: { + organisation_id: string + repo_name: string + repo_owner: string + } + getGithubPulls: { + organisation_id: string + repo_name: string + repo_owner: string + } + getGithubRepos: { installation_id: string; organisation_id: string } getServersideEnvironmentKeys: { environmentId: string } deleteServersideEnvironmentKeys: { environmentId: string; id: string } createServersideEnvironmentKeys: { diff --git a/frontend/common/types/responses.ts b/frontend/common/types/responses.ts index 3b5a5696d90f..aa977394fcb8 100644 --- a/frontend/common/types/responses.ts +++ b/frontend/common/types/responses.ts @@ -98,6 +98,15 @@ export type Project = { } export type ImportStrategy = 'SKIP' | 'OVERWRITE_DESTRUCTIVE' +export type ExternalResource = { + id?: number + url: string + type: string + project: number + status: null | string + feature: number +} + export type ImportExportStatus = 'SUCCESS' | 'PROCESSING' | 'FAILED' export type FeatureImport = { @@ -139,6 +148,171 @@ export type LaunchDarklyProjectImport = { project: number } +export type Issue = { + url: string + repository_url: string + labels_url: string + comments_url: string + events_url: string + html_url: string + id: number + number: number + title: string + state: string + created_at: string + updated_at: string + closed_at: null | string + body: string + timeline_url: string +} + +export type PullRequest = { + url: string + id: number + html_url: string + issue_url: string + number: number + state: string + locked: boolean + title: string + body: string | null + created_at: string + updated_at: string + closed_at: string | null + merged_at: string | null + draft: boolean + comments_url: string + statuses_url: string +} + +export type GithubPaginatedRepos = { + total_count: number + repository_selection: string + repositories: T[] +} + +export type Repository = { + id: number + node_id: string + name: string + full_name: string + private: boolean + owner: { + login: string + id: number + node_id: string + avatar_url: string + gravatar_id: string + url: string + html_url: string + followers_url: string + following_url: string + gists_url: string + starred_url: string + subscriptions_url: string + organizations_url: string + repos_url: string + events_url: string + received_events_url: string + type: string + site_admin: boolean + } + html_url: string + description: string | null + fork: boolean + url: string + forks_url: string + keys_url: string + collaborators_url: string + teams_url: string + hooks_url: string + issue_events_url: string + events_url: string + assignees_url: string + branches_url: string + tags_url: string + blobs_url: string + git_tags_url: string + git_refs_url: string + trees_url: string + statuses_url: string + languages_url: string + stargazers_url: string + contributors_url: string + subscribers_url: string + subscription_url: string + commits_url: string + git_commits_url: string + comments_url: string + issue_comment_url: string + contents_url: string + compare_url: string + merges_url: string + archive_url: string + downloads_url: string + issues_url: string + pulls_url: string + milestones_url: string + notifications_url: string + labels_url: string + releases_url: string + deployments_url: string + created_at: string + updated_at: string + pushed_at: string + git_url: string + ssh_url: string + clone_url: string + svn_url: string + homepage: string | null + size: number + stargazers_count: number + watchers_count: number + language: string + has_issues: boolean + has_projects: boolean + has_downloads: boolean + has_wiki: boolean + has_pages: boolean + has_discussions: boolean + forks_count: number + mirror_url: string | null + archived: boolean + disabled: boolean + open_issues_count: number + license: string | null + allow_forking: boolean + is_template: boolean + web_commit_signoff_required: boolean + topics: string[] + visibility: string + forks: number + open_issues: number + watchers: number + default_branch: string + permissions: { + admin: boolean + maintain: boolean + push: boolean + triage: boolean + pull: boolean + } +} + +export type GithubRepository = { + id: number + github_configuration: number + project: number + repository_owner: string + repository_name: string +} + +export type githubIntegration = { + id: string + installation_id: string + organisation: string +} + export type User = { id: number email: string @@ -491,6 +665,12 @@ export type Res = { groupWithRole: PagedResponse changeRequests: PagedResponse groupSummaries: UserGroupSummary[] + externalResource: PagedResponse + githubIntegrations: PagedResponse + githubRepository: PagedResponse | { data: { id: string } } + githubIssues: Issue[] + githubPulls: PullRequest[] + githubRepos: GithubPaginatedRepos segmentPriorities: {} featureSegment: { id: string } featureVersions: PagedResponse diff --git a/frontend/web/components/App.js b/frontend/web/components/App.js index 091499f1b19d..423830e29043 100644 --- a/frontend/web/components/App.js +++ b/frontend/web/components/App.js @@ -161,6 +161,15 @@ const App = class extends Component { this.context.router.history.replace(redirect) } else { AsyncStorage.getItem('lastEnv').then((res) => { + if ( + this.props.location.search.includes('github-redirect') && + Utils.getFlagsmithHasFeature('github_integration') + ) { + this.context.router.history.replace( + `/github-setup${this.props.location.search}`, + ) + return + } if (res) { const lastEnv = JSON.parse(res) const lastOrg = _.find(AccountStore.getUser().organisations, { @@ -247,6 +256,7 @@ const App = class extends Component { pathname === '/' || pathname === '/login' || pathname === '/signup' || + pathname === '/github-setup' || pathname.includes('/invite') if (Project.amplitude) { amplitude.getInstance().init(Project.amplitude) diff --git a/frontend/web/components/DeleteGithubIntegracion.tsx b/frontend/web/components/DeleteGithubIntegracion.tsx new file mode 100644 index 000000000000..1131ed375ef8 --- /dev/null +++ b/frontend/web/components/DeleteGithubIntegracion.tsx @@ -0,0 +1,54 @@ +import React, { FC } from 'react' +import Button from './base/forms/Button' +import { useDeleteGithubIntegrationMutation } from 'common/services/useGithubIntegration' + +type DeleteGithubIntegracionType = { + organisationId: string + githubId: string + onConfirm: () => void +} + +const DeleteGithubIntegracion: FC = ({ + githubId, + onConfirm, + organisationId, +}) => { + const [deleteGithubIntegration] = useDeleteGithubIntegrationMutation() + + return ( + + ) +} + +export default DeleteGithubIntegracion diff --git a/frontend/web/components/ExternalResourcesTable.tsx b/frontend/web/components/ExternalResourcesTable.tsx new file mode 100644 index 000000000000..6c3dfeaa2c6f --- /dev/null +++ b/frontend/web/components/ExternalResourcesTable.tsx @@ -0,0 +1,91 @@ +import React, { FC, useEffect, useState } from 'react' +import PanelSearch from './PanelSearch' +import Button from './base/forms/Button' +import Icon from './Icon' +import { + useGetExternalResourcesQuery, + useDeleteExternalResourceMutation, +} from 'common/services/useExternalResource' +import { ExternalResource } from 'common/types/responses' + +export type ExternalResourcesTableType = { + featureId: string + projectId: string +} + +const ExternalResourcesTable: FC = ({ + featureId, + projectId, +}) => { + const { data } = useGetExternalResourcesQuery({ + feature_id: featureId, + project_id: projectId, + }) + + const [deleteExternalResource, { isSuccess: isDeleted }] = + useDeleteExternalResourceMutation() + + useEffect(() => { + if (isDeleted) { + toast('External resources was deleted') + } + }, [isDeleted]) + + return ( + + + URL + + Type +
+ Status +
+
+ Remove +
+ + } + renderRow={(v: ExternalResource) => ( + + + + + +
{v.type}
+
+
+
{v.status}
+
+
+ +
+
+ )} + /> + ) +} + +export default ExternalResourcesTable diff --git a/frontend/web/components/GitHubRepositoriesSelect.tsx b/frontend/web/components/GitHubRepositoriesSelect.tsx new file mode 100644 index 000000000000..2c541d8e184b --- /dev/null +++ b/frontend/web/components/GitHubRepositoriesSelect.tsx @@ -0,0 +1,80 @@ +import React, { FC, useEffect, useState } from 'react' +import { Repository } from 'common/types/responses' +import Button from './base/forms/Button' +import { useCreateGithubRepositoryMutation } from 'common/services/useGithubRepository' + +export type GitHubRepositoriesSelectType = { + disabled?: boolean + repositories: Repository[] | undefined + organisationId: string + projectId: string + githubId: string +} + +type repoSelectValue = { + value: string +} + +const GitHubRepositoriesSelect: FC = ({ + disabled, + githubId, + organisationId, + projectId, + repositories, +}) => { + const [ + createGithubRepository, + { isSuccess: isSuccessCreatedGithubRepository }, + ] = useCreateGithubRepositoryMutation() + + const [repositoryName, setRepositoryName] = useState('') + const [repositoryOwner, setRepositoryOwner] = useState('') + + useEffect(() => { + if (isSuccessCreatedGithubRepository) { + toast('Repository linked with the Project correctly') + } + }, [isSuccessCreatedGithubRepository]) + + return ( +
+ onChange(v?.value)} + disabled={disabled} + options={issues?.map((i: Issue) => { + return { + label: `${i.title} #${i.number}`, + status: i.state, + value: i.html_url, + } + })} + /> +
+ ) +} + +export default IssueSelect diff --git a/frontend/web/components/MyGitHubRepositoriesSelect.tsx b/frontend/web/components/MyGitHubRepositoriesSelect.tsx new file mode 100644 index 000000000000..0bfe00a593e1 --- /dev/null +++ b/frontend/web/components/MyGitHubRepositoriesSelect.tsx @@ -0,0 +1,32 @@ +import { FC } from 'react' +import { useGetGithubReposQuery } from 'common/services/useGithub' +import GitHubRepositoriesSelect from './GitHubRepositoriesSelect' + +type MyGitHubRepositoriesSelectType = { + installationId: string + organisationId: string + projectId: string + githubId: string +} + +const MyGitHubRepositoriesSelect: FC = ({ + githubId, + installationId, + organisationId, + projectId, +}) => { + const { data } = useGetGithubReposQuery({ + installation_id: installationId, + organisation_id: organisationId, + }) + return ( + + ) +} + +export default MyGitHubRepositoriesSelect diff --git a/frontend/web/components/MyIssuesSelect.tsx b/frontend/web/components/MyIssuesSelect.tsx new file mode 100644 index 000000000000..3755486cf01b --- /dev/null +++ b/frontend/web/components/MyIssuesSelect.tsx @@ -0,0 +1,26 @@ +import { FC } from 'react' +import { useGetGithubIssuesQuery } from 'common/services/useGithub' +import IssueSelect from './IssueSelect' + +type MyIssuesSelectType = { + orgId: string + repoOwner: string + repoName: string + onChange: () => void +} + +const MyIssuesSelect: FC = ({ + onChange, + orgId, + repoName, + repoOwner, +}) => { + const { data } = useGetGithubIssuesQuery({ + organisation_id: orgId, + repo_name: repoName, + repo_owner: repoOwner, + }) + return +} + +export default MyIssuesSelect diff --git a/frontend/web/components/MyPullRequestsSelect.tsx b/frontend/web/components/MyPullRequestsSelect.tsx new file mode 100644 index 000000000000..ba927a1f0602 --- /dev/null +++ b/frontend/web/components/MyPullRequestsSelect.tsx @@ -0,0 +1,26 @@ +import { FC } from 'react' +import { useGetGithubPullsQuery } from 'common/services/useGithub' +import PullRequestSelect from './PullRequestSelect' + +type MyGithubPullRequestSelectType = { + orgId: string + repoOwner: string + repoName: string + onChange: (value: string) => void +} + +const MyGithubPullRequests: FC = ({ + onChange, + orgId, + repoName, + repoOwner, +}) => { + const { data } = useGetGithubPullsQuery({ + organisation_id: orgId, + repo_name: repoName, + repo_owner: repoOwner, + }) + return +} + +export default MyGithubPullRequests diff --git a/frontend/web/components/MyRepositoriesSelect.tsx b/frontend/web/components/MyRepositoriesSelect.tsx new file mode 100644 index 000000000000..16f12c29371b --- /dev/null +++ b/frontend/web/components/MyRepositoriesSelect.tsx @@ -0,0 +1,23 @@ +import { FC } from 'react' +import { useGetGithubRepositoriesQuery } from 'common/services/useGithubRepository' +import RepositoriesSelect from './RepositoriesSelect' + +type MyRepositoriesSelectType = { + githubId: string + orgId: string + onChange: () => void +} + +const MyRepositoriesSelect: FC = ({ + githubId, + onChange, + orgId, +}) => { + const { data } = useGetGithubRepositoriesQuery({ + github_id: githubId, + organisation_id: orgId, + }) + return +} + +export default MyRepositoriesSelect diff --git a/frontend/web/components/OrganisationSelect.js b/frontend/web/components/OrganisationSelect.js index 6c015447d6fb..1e23fbbd1c05 100644 --- a/frontend/web/components/OrganisationSelect.js +++ b/frontend/web/components/OrganisationSelect.js @@ -8,6 +8,15 @@ const OrganisationSelect = class extends Component { this.state = {} } + componentDidMount() { + if (localStorage.lastEnv) { + const orgId = JSON.parse(localStorage.lastEnv).orgId + if (this.props.firstOrganisation && orgId) { + this.props.onChange(orgId) + } + } + } + render() { return ( @@ -33,7 +42,6 @@ const OrganisationSelect = class extends Component { className='select-lg react-select' /> - {user && user.organisations && user.organisations.map((organisation) => ( diff --git a/frontend/web/components/ProjectFilter.tsx b/frontend/web/components/ProjectFilter.tsx index 9b608784df37..b5d13d3f627a 100644 --- a/frontend/web/components/ProjectFilter.tsx +++ b/frontend/web/components/ProjectFilter.tsx @@ -4,7 +4,7 @@ import { useGetProjectsQuery } from 'common/services/useProject' export type ProjectFilterType = { organisationId: number value?: string - onChange: (value: string) => void + onChange: (id: string, name: string) => void showAll?: boolean } @@ -33,7 +33,7 @@ const ProjectFilter: FC = ({ (data || [])?.map((v) => ({ label: v.name, value: `${v.id}` })), )} onChange={(value: { value: string; label: string }) => - onChange(value?.value || '') + onChange(value.value || '', value.label || '') } /> ) diff --git a/frontend/web/components/PullRequestSelect.tsx b/frontend/web/components/PullRequestSelect.tsx new file mode 100644 index 000000000000..944af2cf527f --- /dev/null +++ b/frontend/web/components/PullRequestSelect.tsx @@ -0,0 +1,37 @@ +import React, { FC } from 'react' +import { PullRequest } from 'common/types/responses' + +export type PullRequestsSelectType = { + disabled?: boolean + pullRequest: PullRequest[] | undefined + onChange: (value: string) => void +} + +type PullRequestValueType = { + value: string +} + +const PullRequestsSelect: FC = ({ + disabled, + onChange, + pullRequest, +}) => { + return ( +
+ onChange(v?.value)} + options={repositories?.map((i: GithubRepository) => { + return { + label: `${i.repository_owner} - ${i.repository_name}`, + value: `${i.repository_owner}/${i.repository_name}`, + } + })} + /> +
+ ) +} + +export default RepositoriesSelect diff --git a/frontend/web/components/TestWebhook.tsx b/frontend/web/components/TestWebhook.tsx index 176afeead339..775caef34d6d 100644 --- a/frontend/web/components/TestWebhook.tsx +++ b/frontend/web/components/TestWebhook.tsx @@ -23,7 +23,7 @@ const stringifyWithSpaces = (str: string) => { } const signPayload = async (body: string, secret: string): Promise => { - if(!secret) { + if (!secret) { return '' } const enc = new TextEncoder() diff --git a/frontend/web/components/modals/CreateEditIntegrationModal.js b/frontend/web/components/modals/CreateEditIntegrationModal.js index ee6f2cae15ed..d5b7586b6b9b 100644 --- a/frontend/web/components/modals/CreateEditIntegrationModal.js +++ b/frontend/web/components/modals/CreateEditIntegrationModal.js @@ -1,10 +1,18 @@ import React, { Component } from 'react' import EnvironmentSelect from 'components/EnvironmentSelect' +import MyGitHubRepositoriesSelect from 'components/MyGitHubRepositoriesSelect' import _data from 'common/data/base/_data' import ErrorMessage from 'components/ErrorMessage' import ModalHR from './ModalHR' import Button from 'components/base/forms/Button' +import GithubRepositoriesTable from 'components/GithubRepositoriesTable' import classNames from 'classnames' +import { getStore } from 'common/store' +import { getGithubRepos } from 'common/services/useGithub' +import DeleteGithubIntegracion from 'components/DeleteGithubIntegracion' + +const GITHUB_INSTALLATION_UPDATE = 'update' + const CreateEditIntegration = class extends Component { static displayName = 'CreateEditIntegration' @@ -62,6 +70,9 @@ const CreateEditIntegration = class extends Component { const isOauth = this.props.integration.isOauth && !this.state.authorised const isEdit = this.props.data && this.props.data.id Utils.preventDefault(e) + if (this.props.integration.isExternalInstallation) { + closeModal() + } if (this.state.isLoading) { return } @@ -122,7 +133,7 @@ const CreateEditIntegration = class extends Component { ) .then(this.onComplete) .catch(this.onError) - } else { + } else if (this.props.id !== 'github') { _data .post( `${Project.api}projects/${this.props.projectId}/integrations/${this.props.id}/`, @@ -156,6 +167,36 @@ const CreateEditIntegration = class extends Component { }) } + openGitHubWinInstallations = () => { + const childWindow = window.open( + `https://github.com/settings/installations/${this.props.githubMeta.installationId}`, + '_blank', + 'height=600,width=600,status=yes,toolbar=no,menubar=no,addressbar=no', + ) + + childWindow.localStorage.setItem( + 'githubIntegrationSetupFromFlagsmith', + GITHUB_INSTALLATION_UPDATE, + ) + window.addEventListener('message', (event) => { + if ( + event.source === childWindow && + !event.data?.hasOwnProperty('installationId') + ) { + getGithubRepos( + getStore(), + { + installation_id: this.props.githubMeta.installationId, + }, + { forceRefetch: true }, + ).then(() => { + localStorage.removeItem('githubIntegrationSetupFromFlagsmith') + childWindow.close() + }) + } + }) + } + render() { return (
)} + {Utils.getFlagsmithHasFeature('github_integration') && + this.props.integration.isExternalInstallation && ( + <> +
+ + +
+ +
+ + { + closeModal() + }} + /> +
+ + )} {this.state.fields && this.state.fields.map((field) => ( <> @@ -256,27 +336,28 @@ const CreateEditIntegration = class extends Component { - {!this.props.readOnly && ( -
- {!!this.props.modal && ( - + )} + - )} - -
- )} + + )} ) } diff --git a/frontend/web/components/modals/CreateFlag.js b/frontend/web/components/modals/CreateFlag.js index 8aa5d1f7ee3a..75c10da3e794 100644 --- a/frontend/web/components/modals/CreateFlag.js +++ b/frontend/web/components/modals/CreateFlag.js @@ -27,14 +27,21 @@ import JSONReference from 'components/JSONReference' import ErrorMessage from 'components/ErrorMessage' import Permission from 'common/providers/Permission' import IdentitySelect from 'components/IdentitySelect' -import { setInterceptClose, setModalTitle } from './base/ModalDefault'; +import { setInterceptClose, setModalTitle } from './base/ModalDefault' import Icon from 'components/Icon' import ModalHR from './ModalHR' import FeatureValue from 'components/FeatureValue' import FlagOwnerGroups from 'components/FlagOwnerGroups' import ExistingChangeRequestAlert from 'components/ExistingChangeRequestAlert' import Button from 'components/base/forms/Button' +import { getGithubIntegration } from 'common/services/useGithubIntegration' +import { createExternalResource } from 'common/services/useExternalResource' +import { getStore } from 'common/store' import { removeUserOverride } from 'components/RemoveUserOverride' +import MyIssueSelect from 'components/MyIssuesSelect' +import MyPullRequestsSelect from 'components/MyPullRequestsSelect' +import MyRepositoriesSelect from 'components/MyRepositoriesSelect' +import ExternalResourcesTable from 'components/ExternalResourcesTable' const CreateFlag = class extends Component { static displayName = 'CreateFlag' @@ -71,6 +78,10 @@ const CreateFlag = class extends Component { enabledIndentity: false, enabledSegment: false, environmentFlag: this.props.environmentFlag, + externalResource: {}, + externalResources: [], + githubId: '', + hasIntegrationWithGithub: false, hide_from_client, identityVariations: this.props.identityFlag && @@ -89,6 +100,8 @@ const CreateFlag = class extends Component { multivariate_options: _.cloneDeep(multivariate_options), name, period: 30, + repoName: '', + repoOwner: '', selectedIdentity: null, tab: tab || 0, tags: tags || [], @@ -173,6 +186,17 @@ const CreateFlag = class extends Component { ) { this.getFeatureUsage() } + + if (Utils.getFlagsmithHasFeature('github_integration')) { + getGithubIntegration(getStore(), { + organisation_id: AccountStore.getOrganisation().id, + }).then((res) => { + this.setState({ + githubId: res?.data?.results[0]?.id, + hasIntegrationWithGithub: !!res?.data?.results?.length, + }) + }) + } } componentWillUnmount() { @@ -486,11 +510,18 @@ const CreateFlag = class extends Component { description, enabledIndentity, enabledSegment, + externalResourceType, + featureExternalResource, + githubId, + hasIntegrationWithGithub, hide_from_client, initial_value, isEdit, multivariate_options, name, + repoName, + repoOwner, + status, } = this.state const FEATURE_ID_MAXLENGTH = Constants.forms.maxLength.FEATURE_ID @@ -513,6 +544,21 @@ const CreateFlag = class extends Component { const existingChangeRequest = this.props.changeRequest const hideIdentityOverridesTab = Utils.getShouldHideIdentityOverridesTab() const noPermissions = this.props.noPermissions + const _createExternalResourse = () => { + createExternalResource(getStore(), { + body: { + feature: projectFlag.id, + metadata: { status: status }, + type: + externalResourceType === 'Github Issue' + ? 'GITHUB_ISSUE' + : 'GITHUB_PR', + url: featureExternalResource, + }, + feature_id: projectFlag.id, + project_id: `${this.props.projectId}`, + }) + } let regexValid = true try { if (!isEdit && name && regex) { @@ -587,9 +633,97 @@ const CreateFlag = class extends Component { placeholder="e.g. 'This determines what size the header is' " /> + {Utils.getFlagsmithHasFeature('github_integration') && + hasIntegrationWithGithub && + projectFlag?.id && ( + <> + + + { + const repoData = v.split('/') + this.setState({ + repoName: repoData[0], + repoOwner: repoData[1], + }) + }} + /> + {repoName && repoOwner && ( + <> + + +
+ + setRepositoryOwner(Utils.safeParseEventValue(e)) + } + disabled + type='text' + title={'Repository Owner'} + inputClassName='input--wide' + placeholder='repositoryOwner' + /> +
+