mirror of
https://github.com/zhigang1992/devhub.git
synced 2026-06-10 06:50:12 +08:00
New feature: Private repository support
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
import React from 'react'
|
||||
import { Image, Text, TextStyle, View, ViewStyle } from 'react-native'
|
||||
|
||||
import { LoadState } from '@devhub/core'
|
||||
import { EnhancedLoadState } from '@devhub/core'
|
||||
import { useCSSVariablesOrSpringAnimatedTheme } from '../../hooks/use-css-variables-or-spring--animated-theme'
|
||||
import { useReduxAction } from '../../hooks/use-redux-action'
|
||||
import * as actions from '../../redux/actions'
|
||||
@@ -48,7 +48,7 @@ export interface EmptyCardsProps {
|
||||
errorMessage?: string
|
||||
errorTitle?: string
|
||||
fetchNextPage: (() => void) | undefined
|
||||
loadState: LoadState
|
||||
loadState: EnhancedLoadState
|
||||
refresh: (() => void | Promise<void>) | undefined
|
||||
}
|
||||
|
||||
|
||||
@@ -5,16 +5,14 @@ import {
|
||||
Column,
|
||||
constants,
|
||||
EnhancedGitHubEvent,
|
||||
EnhancedLoadState,
|
||||
isItemRead,
|
||||
LoadState,
|
||||
} from '@devhub/core'
|
||||
import useKeyPressCallback from '../../hooks/use-key-press-callback'
|
||||
import { useKeyboardScrolling } from '../../hooks/use-keyboard-scrolling'
|
||||
import { useReduxAction } from '../../hooks/use-redux-action'
|
||||
import { useReduxState } from '../../hooks/use-redux-state'
|
||||
import { bugsnag, ErrorBoundary } from '../../libs/bugsnag'
|
||||
import * as actions from '../../redux/actions'
|
||||
import * as selectors from '../../redux/selectors'
|
||||
import { contentPadding } from '../../styles/variables'
|
||||
import { Button } from '../common/Button'
|
||||
import { FlatListWithOverlay } from '../common/FlatListWithOverlay'
|
||||
@@ -30,7 +28,7 @@ export interface EventCardsProps {
|
||||
errorMessage: EmptyCardsProps['errorMessage']
|
||||
events: EnhancedGitHubEvent[]
|
||||
fetchNextPage: (() => void) | undefined
|
||||
loadState: LoadState
|
||||
loadState: EnhancedLoadState
|
||||
refresh: EmptyCardsProps['refresh']
|
||||
repoIsKnown?: boolean
|
||||
swipeable?: boolean
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
} from '@devhub/core'
|
||||
import { useReduxState } from '../../hooks/use-redux-state'
|
||||
import * as selectors from '../../redux/selectors'
|
||||
import * as colors from '../../styles/colors'
|
||||
import { contentPadding } from '../../styles/variables'
|
||||
import {
|
||||
getIssueIconAndColor,
|
||||
@@ -29,6 +30,7 @@ import { NotificationCardHeader } from './partials/NotificationCardHeader'
|
||||
import { CommentRow } from './partials/rows/CommentRow'
|
||||
import { CommitRow } from './partials/rows/CommitRow'
|
||||
import { IssueOrPullRequestRow } from './partials/rows/IssueOrPullRequestRow'
|
||||
import { PrivateNotificationRow } from './partials/rows/PrivateNotificationRow'
|
||||
import { ReleaseRow } from './partials/rows/ReleaseRow'
|
||||
import { RepositoryRow } from './partials/rows/RepositoryRow'
|
||||
|
||||
@@ -48,10 +50,22 @@ const styles = StyleSheet.create({
|
||||
export const NotificationCard = React.memo((props: NotificationCardProps) => {
|
||||
const { notification, onlyOneRepository, isSelected } = props
|
||||
|
||||
const repoFullName =
|
||||
(notification &&
|
||||
(notification.repository.full_name || notification.repository.name)) ||
|
||||
''
|
||||
|
||||
const { owner: repoOwnerName, repo: repoName } = getOwnerAndRepo(repoFullName)
|
||||
|
||||
const itemRef = useRef<View>(null)
|
||||
const springAnimatedTheme = useSpringAnimatedTheme()
|
||||
const hasPrivateAccess = useReduxState(
|
||||
selectors.githubHasPrivateAccessSelector,
|
||||
|
||||
const hasPrivateAccess = useReduxState(state =>
|
||||
selectors.githubHasPrivateAccessToRepoSelector(
|
||||
state,
|
||||
repoOwnerName,
|
||||
repoName,
|
||||
),
|
||||
)
|
||||
|
||||
useEffect(
|
||||
@@ -78,11 +92,13 @@ export const NotificationCard = React.memo((props: NotificationCardProps) => {
|
||||
const isSaved = saved === true
|
||||
const isPrivate = isNotificationPrivate(notification)
|
||||
|
||||
const title = trimNewLinesAndSpaces(subject.title)
|
||||
const isPrivateAndCantSee = !!(
|
||||
isPrivate &&
|
||||
!hasPrivateAccess &&
|
||||
!notification.enhanced
|
||||
)
|
||||
|
||||
const repoFullName =
|
||||
notification.repository.full_name || notification.repository.name || ''
|
||||
const { owner: repoOwnerName, repo: repoName } = getOwnerAndRepo(repoFullName)
|
||||
const title = trimNewLinesAndSpaces(subject.title)
|
||||
|
||||
const subjectType = subject.type || ''
|
||||
|
||||
@@ -152,8 +168,10 @@ export const NotificationCard = React.memo((props: NotificationCardProps) => {
|
||||
const cardIconDetails = getNotificationIconAndColor(notification, (issue ||
|
||||
pullRequest ||
|
||||
undefined) as any)
|
||||
const cardIconName = cardIconDetails.icon
|
||||
const cardIconColor = cardIconDetails.color
|
||||
const cardIconName = isPrivateAndCantSee ? 'lock' : cardIconDetails.icon
|
||||
const cardIconColor = isPrivateAndCantSee
|
||||
? colors.yellow
|
||||
: cardIconDetails.color
|
||||
|
||||
const { icon: pullRequestIconName, color: pullRequestIconColor } = pullRequest
|
||||
? getPullRequestIconAndColor(pullRequest as any)
|
||||
@@ -396,18 +414,17 @@ export const NotificationCard = React.memo((props: NotificationCardProps) => {
|
||||
/>
|
||||
)}
|
||||
|
||||
{!!(isPrivate && !hasPrivateAccess && !notification.enhanced) && (
|
||||
<CommentRow
|
||||
key={`notification-privacy-support-row-${notification.id}`}
|
||||
analyticsLabel="about_private_access_from_notification"
|
||||
avatarURL={undefined}
|
||||
body="Coming soon: support for private notifications. Click here and subscribe to this issue if you want to be notified."
|
||||
isRead
|
||||
{!!isPrivateAndCantSee && (
|
||||
<PrivateNotificationRow
|
||||
key={`private-notification-row-${notification.id}`}
|
||||
isRead={isRead}
|
||||
smallLeftColumn={smallLeftColumn}
|
||||
url="https://github.com/devhubapp/devhub/issues/32"
|
||||
userLinkURL={undefined}
|
||||
username={undefined}
|
||||
textStyle={{ fontStyle: 'italic' }}
|
||||
ownerId={
|
||||
(notification.repository.owner &&
|
||||
notification.repository.owner.id) ||
|
||||
undefined
|
||||
}
|
||||
repoId={repo.id}
|
||||
/>
|
||||
)}
|
||||
</SpringAnimatedView>
|
||||
|
||||
@@ -5,8 +5,8 @@ import {
|
||||
Column,
|
||||
constants,
|
||||
EnhancedGitHubNotification,
|
||||
EnhancedLoadState,
|
||||
isItemRead,
|
||||
LoadState,
|
||||
} from '@devhub/core'
|
||||
import useKeyPressCallback from '../../hooks/use-key-press-callback'
|
||||
import { useKeyboardScrolling } from '../../hooks/use-keyboard-scrolling'
|
||||
@@ -27,7 +27,7 @@ export interface NotificationCardsProps {
|
||||
columnIndex: number
|
||||
errorMessage: EmptyCardsProps['errorMessage']
|
||||
fetchNextPage: (() => void) | undefined
|
||||
loadState: LoadState
|
||||
loadState: EnhancedLoadState
|
||||
notifications: EnhancedGitHubNotification[]
|
||||
refresh: EmptyCardsProps['refresh']
|
||||
repoIsKnown?: boolean
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import React from 'react'
|
||||
import { View } from 'react-native'
|
||||
|
||||
import { constants } from '@devhub/core'
|
||||
import { useCSSVariablesOrSpringAnimatedTheme } from '../../../../hooks/use-css-variables-or-spring--animated-theme'
|
||||
import { SpringAnimatedText } from '../../../animated/spring/SpringAnimatedText'
|
||||
import { Link } from '../../../common/Link'
|
||||
import { cardStyles, getCardStylesForTheme } from '../../styles'
|
||||
import { cardRowStyles } from './styles'
|
||||
|
||||
export interface PrivateNotificationRowProps {
|
||||
isRead?: boolean
|
||||
ownerId?: number | string | undefined
|
||||
repoId?: number | string | undefined
|
||||
smallLeftColumn?: boolean
|
||||
}
|
||||
|
||||
export const PrivateNotificationRow = React.memo(
|
||||
(props: PrivateNotificationRowProps) => {
|
||||
const springAnimatedTheme = useCSSVariablesOrSpringAnimatedTheme()
|
||||
|
||||
const { ownerId, repoId, smallLeftColumn } = props
|
||||
|
||||
return (
|
||||
<View style={cardRowStyles.container}>
|
||||
<View
|
||||
style={[
|
||||
cardStyles.leftColumn,
|
||||
smallLeftColumn
|
||||
? cardStyles.leftColumn__small
|
||||
: cardStyles.leftColumn__big,
|
||||
cardStyles.leftColumnAlignTop,
|
||||
]}
|
||||
/>
|
||||
|
||||
<View style={cardStyles.rightColumn}>
|
||||
<Link
|
||||
analyticsLabel="setup_github_app_from_private_notification"
|
||||
href={
|
||||
ownerId
|
||||
? `https://github.com/apps/${
|
||||
constants.GITHUB_APP_CANNONICAL_ID
|
||||
}/installations/new/permissions?suggested_target_id=${ownerId}&repository_ids[]=${repoId ||
|
||||
''}`
|
||||
: `https://github.com/apps/${
|
||||
constants.GITHUB_APP_CANNONICAL_ID
|
||||
}/installations/new`
|
||||
}
|
||||
openOnNewTab={false}
|
||||
style={cardRowStyles.mainContentContainer}
|
||||
>
|
||||
<SpringAnimatedText
|
||||
style={[
|
||||
getCardStylesForTheme(springAnimatedTheme).commentText,
|
||||
getCardStylesForTheme(springAnimatedTheme).mutedText,
|
||||
{ fontStyle: 'italic' },
|
||||
]}
|
||||
>
|
||||
Install the GitHub App to unlock details from private
|
||||
notifications.
|
||||
</SpringAnimatedText>
|
||||
</Link>
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
},
|
||||
)
|
||||
@@ -85,9 +85,6 @@ export const ColumnOptions = React.memo((props: ColumnOptionsProps) => {
|
||||
const springAnimatedTheme = useCSSVariablesOrSpringAnimatedTheme()
|
||||
|
||||
const columnIds = useReduxState(selectors.columnIdsSelector)
|
||||
const hasPrivateAccess = useReduxState(
|
||||
selectors.githubHasPrivateAccessSelector,
|
||||
)
|
||||
|
||||
const deleteColumn = useReduxAction(actions.deleteColumn)
|
||||
const moveColumn = useReduxAction(actions.moveColumn)
|
||||
@@ -429,20 +426,14 @@ export const ColumnOptions = React.memo((props: ColumnOptionsProps) => {
|
||||
})()}
|
||||
|
||||
{(() => {
|
||||
const isPrivateChecked =
|
||||
column.type === 'notifications'
|
||||
? column.filters && column.filters.private === true
|
||||
: hasPrivateAccess &&
|
||||
!(column.filters && column.filters.private === false)
|
||||
const isPrivateChecked = !(
|
||||
column.filters && column.filters.private === false
|
||||
)
|
||||
|
||||
const isPublicChecked = !(
|
||||
column.filters && column.filters.private === true
|
||||
)
|
||||
|
||||
const canShowPrivateContent = hasPrivateAccess // || column.type === 'notifications'
|
||||
|
||||
if (!canShowPrivateContent && !isPrivateChecked) return null
|
||||
|
||||
const getFilterValue = (
|
||||
showPublic?: boolean,
|
||||
showPrivate?: boolean,
|
||||
@@ -483,10 +474,7 @@ export const ColumnOptions = React.memo((props: ColumnOptionsProps) => {
|
||||
analyticsLabel="public"
|
||||
checked={isPublicChecked}
|
||||
containerStyle={{ flexGrow: 1 }}
|
||||
disabled={
|
||||
isPublicChecked &&
|
||||
(!isPrivateChecked || !canShowPrivateContent)
|
||||
}
|
||||
disabled={isPublicChecked && !isPrivateChecked}
|
||||
label="Public"
|
||||
// labelIcon="globe"
|
||||
onChange={checked => {
|
||||
@@ -503,10 +491,7 @@ export const ColumnOptions = React.memo((props: ColumnOptionsProps) => {
|
||||
analyticsLabel="private"
|
||||
checked={isPrivateChecked}
|
||||
containerStyle={{ flexGrow: 1 }}
|
||||
disabled={
|
||||
(isPrivateChecked && !isPublicChecked) ||
|
||||
(!isPrivateChecked && !canShowPrivateContent)
|
||||
}
|
||||
disabled={isPrivateChecked && !isPublicChecked}
|
||||
label="Private"
|
||||
// labelIcon="lock"
|
||||
onChange={checked => {
|
||||
|
||||
@@ -79,10 +79,6 @@ export const EventOrNotificationColumn = React.memo(
|
||||
),
|
||||
)
|
||||
|
||||
const hasPrivateAccess = useReduxState(
|
||||
selectors.githubHasPrivateAccessSelector,
|
||||
)
|
||||
|
||||
const clearableItems = (filteredItems as any[]).filter(
|
||||
(item: EnhancedGitHubEvent | EnhancedGitHubNotification) => {
|
||||
return !!(item && !item.saved) /* && isItemRead(item) */
|
||||
@@ -168,15 +164,15 @@ export const EventOrNotificationColumn = React.memo(
|
||||
|
||||
const hasAnyFilter =
|
||||
column.type === 'notifications'
|
||||
? notificationColumnHasAnyFilter(
|
||||
{ ...column.filters, clearedAt: undefined },
|
||||
hasPrivateAccess,
|
||||
)
|
||||
? notificationColumnHasAnyFilter({
|
||||
...column.filters,
|
||||
clearedAt: undefined,
|
||||
})
|
||||
: column.type === 'activity'
|
||||
? activityColumnHasAnyFilter(
|
||||
{ ...column.filters, clearedAt: undefined },
|
||||
hasPrivateAccess,
|
||||
)
|
||||
? activityColumnHasAnyFilter({
|
||||
...column.filters,
|
||||
clearedAt: undefined,
|
||||
})
|
||||
: false
|
||||
|
||||
// column doesnt have any filter,
|
||||
|
||||
@@ -377,42 +377,6 @@ export function AddColumnModal(props: AddColumnModalProps) {
|
||||
|
||||
<Spacer flex={1} minHeight={contentPadding} />
|
||||
|
||||
<Link
|
||||
analyticsLabel="about_private_access_from_addcolumn"
|
||||
href="https://github.com/devhubapp/devhub/issues/32"
|
||||
openOnNewTab
|
||||
>
|
||||
<SpringAnimatedText
|
||||
style={{
|
||||
paddingHorizontal: contentPadding,
|
||||
lineHeight: 20,
|
||||
fontSize: 12,
|
||||
color: springAnimatedTheme.foregroundColorMuted50,
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
Coming soon: support for private repositories. Click here and
|
||||
subscribe to this issue if you want to be notified.
|
||||
</SpringAnimatedText>
|
||||
</Link>
|
||||
<Link
|
||||
analyticsLabel="twitter_from_addcolumn"
|
||||
href="https://twitter.com/devhub_app"
|
||||
openOnNewTab
|
||||
>
|
||||
<SpringAnimatedText
|
||||
style={{
|
||||
paddingHorizontal: contentPadding,
|
||||
lineHeight: 20,
|
||||
fontSize: 12,
|
||||
color: springAnimatedTheme.foregroundColorMuted50,
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
Also, follow @devhub_app on Twitter!
|
||||
</SpringAnimatedText>
|
||||
</Link>
|
||||
|
||||
<Spacer
|
||||
height={isFabVisible ? fabSize + 2 * fabSpacing : contentPadding}
|
||||
/>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import React, { useCallback, useEffect, useRef } from 'react'
|
||||
|
||||
import {
|
||||
ActivityColumnSubscription,
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
constants,
|
||||
EnhancedGitHubEvent,
|
||||
getOlderEventDate,
|
||||
InstallationResponse,
|
||||
Omit,
|
||||
} from '@devhub/core'
|
||||
import { View } from 'react-native'
|
||||
@@ -17,6 +16,7 @@ import { GenericMessageWithButtonView } from '../components/cards/GenericMessage
|
||||
import { NoTokenView } from '../components/cards/NoTokenView'
|
||||
import { Button } from '../components/common/Button'
|
||||
import { Link } from '../components/common/Link'
|
||||
import { useGitHubAPI } from '../hooks/use-github-api'
|
||||
import { useReduxAction } from '../hooks/use-redux-action'
|
||||
import { useReduxState } from '../hooks/use-redux-state'
|
||||
import { octokit } from '../libs/github'
|
||||
@@ -50,6 +50,10 @@ export const EventCardsContainer = React.memo(
|
||||
|
||||
const data = (firstSubscription && firstSubscription.data) || {}
|
||||
|
||||
const isNotFound = (data.errorMessage || '')
|
||||
.toLowerCase()
|
||||
.includes('not found')
|
||||
|
||||
const owner =
|
||||
(firstSubscription &&
|
||||
(('owner' in firstSubscription.params &&
|
||||
@@ -57,14 +61,15 @@ export const EventCardsContainer = React.memo(
|
||||
('org' in firstSubscription.params &&
|
||||
firstSubscription.params.org))) ||
|
||||
undefined
|
||||
const repo =
|
||||
(firstSubscription &&
|
||||
('repo' in firstSubscription.params &&
|
||||
firstSubscription.params.repo)) ||
|
||||
undefined
|
||||
|
||||
// TODO: Get from redux state
|
||||
const installationResponse = { owner, repo } as any
|
||||
const ownerResponse = useGitHubAPI(
|
||||
octokit.users.getByUsername,
|
||||
isNotFound && owner ? { username: owner } : null,
|
||||
)
|
||||
|
||||
const installationsLoadState = useReduxState(
|
||||
selectors.installationsLoadStateSelector,
|
||||
)
|
||||
|
||||
const fetchColumnSubscriptionRequest = useReduxAction(
|
||||
actions.fetchColumnSubscriptionRequest,
|
||||
@@ -166,14 +171,25 @@ export const EventCardsContainer = React.memo(
|
||||
return <NoTokenView githubAppType={githubAppToken ? 'oauth' : 'both'} />
|
||||
}
|
||||
|
||||
if (
|
||||
(firstSubscription.data.errorMessage || '')
|
||||
.toLowerCase()
|
||||
.includes('not found')
|
||||
) {
|
||||
if (isNotFound) {
|
||||
if (!githubAppToken) return <NoTokenView githubAppType="app" />
|
||||
|
||||
if (installationResponse.ownerId) {
|
||||
if (
|
||||
ownerResponse.loadingState === 'loading' ||
|
||||
installationsLoadState === 'loading'
|
||||
) {
|
||||
return (
|
||||
<EmptyCards
|
||||
clearedAt={undefined}
|
||||
columnId={column.id}
|
||||
fetchNextPage={undefined}
|
||||
loadState="loading"
|
||||
refresh={undefined}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (ownerResponse.data && ownerResponse.data.id) {
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
@@ -188,26 +204,23 @@ export const EventCardsContainer = React.memo(
|
||||
buttonView={
|
||||
<Link
|
||||
analyticsLabel="setup_github_app"
|
||||
href={
|
||||
installationResponse.ownerId
|
||||
? `https://github.com/apps/${
|
||||
constants.GITHUB_APP_CANNONICAL_ID
|
||||
}/installations/new/permissions?suggested_target_id=${
|
||||
installationResponse.ownerId
|
||||
}${
|
||||
installationResponse.repoId
|
||||
? `&repository_ids[]=${installationResponse.repoId}`
|
||||
: ``
|
||||
}`
|
||||
: `https://github.com/apps/${
|
||||
constants.GITHUB_APP_CANNONICAL_ID
|
||||
}/installations/new`
|
||||
}
|
||||
href={`https://github.com/apps/${
|
||||
constants.GITHUB_APP_CANNONICAL_ID
|
||||
}/installations/new/permissions?suggested_target_id=${
|
||||
ownerResponse.data.id
|
||||
}`}
|
||||
openOnNewTab={false}
|
||||
>
|
||||
<Button
|
||||
children="Install GitHub App"
|
||||
disabled={installationResponse.isLoading}
|
||||
loading={installationResponse.isLoading}
|
||||
disabled={
|
||||
firstSubscription.data.loadState === 'loading' ||
|
||||
firstSubscription.data.loadState === 'loading_first'
|
||||
}
|
||||
loading={
|
||||
firstSubscription.data.loadState === 'loading' ||
|
||||
firstSubscription.data.loadState === 'loading_first'
|
||||
}
|
||||
onPress={undefined}
|
||||
/>
|
||||
</Link>
|
||||
@@ -219,18 +232,6 @@ export const EventCardsContainer = React.memo(
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
if (installationResponse.isLoading) {
|
||||
return (
|
||||
<EmptyCards
|
||||
clearedAt={undefined}
|
||||
columnId={column.id}
|
||||
fetchNextPage={undefined}
|
||||
loadState="loading"
|
||||
refresh={undefined}
|
||||
/>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -239,7 +240,11 @@ export const EventCardsContainer = React.memo(
|
||||
key={`event-cards-${column.id}`}
|
||||
errorMessage={firstSubscription.data.errorMessage || ''}
|
||||
fetchNextPage={canFetchMoreRef.current ? fetchNextPage : undefined}
|
||||
loadState={firstSubscription.data.loadState || 'not_loaded'}
|
||||
loadState={
|
||||
installationsLoadState === 'loading' && !filteredItems.length
|
||||
? 'loading_first'
|
||||
: firstSubscription.data.loadState || 'not_loaded'
|
||||
}
|
||||
events={filteredItems}
|
||||
refresh={refresh}
|
||||
/>
|
||||
|
||||
@@ -44,6 +44,10 @@ export const NotificationCardsContainer = React.memo(
|
||||
|
||||
const data = (firstSubscription && firstSubscription.data) || {}
|
||||
|
||||
const installationsLoadState = useReduxState(
|
||||
selectors.installationsLoadStateSelector,
|
||||
)
|
||||
|
||||
const fetchColumnSubscriptionRequest = useReduxAction(
|
||||
actions.fetchColumnSubscriptionRequest,
|
||||
)
|
||||
@@ -157,7 +161,11 @@ export const NotificationCardsContainer = React.memo(
|
||||
key={`notification-cards-${column.id}`}
|
||||
errorMessage={firstSubscription.data.errorMessage || ''}
|
||||
fetchNextPage={canFetchMoreRef.current ? fetchNextPage : undefined}
|
||||
loadState={firstSubscription.data.loadState || 'not_loaded'}
|
||||
loadState={
|
||||
installationsLoadState === 'loading' && !filteredItems.length
|
||||
? 'loading_first'
|
||||
: firstSubscription.data.loadState || 'not_loaded'
|
||||
}
|
||||
notifications={filteredItems}
|
||||
refresh={refresh}
|
||||
/>
|
||||
|
||||
51
packages/components/src/hooks/use-github-api.ts
Normal file
51
packages/components/src/hooks/use-github-api.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
import {
|
||||
GitHubExtractParamsFromMethod,
|
||||
GitHubExtractResponseFromMethod,
|
||||
LoadState,
|
||||
} from '@devhub/core'
|
||||
|
||||
export function useGitHubAPI<M extends (params?: any, callback?: any) => any>(
|
||||
method: M,
|
||||
params: GitHubExtractParamsFromMethod<M> | null,
|
||||
) {
|
||||
const [state, setState] = useState({
|
||||
data: null as GitHubExtractResponseFromMethod<M>['data'] | null,
|
||||
error: null as string | null,
|
||||
loadingState: 'not_loaded' as LoadState,
|
||||
})
|
||||
|
||||
useEffect(
|
||||
() => {
|
||||
if (params === null) {
|
||||
setState({ data: null, error: null, loadingState: 'not_loaded' })
|
||||
return
|
||||
}
|
||||
|
||||
;(async () => {
|
||||
setState(s => ({ ...s, loadingState: 'loading' }))
|
||||
|
||||
try {
|
||||
const response = (await method(
|
||||
params,
|
||||
)) as GitHubExtractResponseFromMethod<M>
|
||||
|
||||
const data = response && response.data
|
||||
|
||||
setState(s => ({ ...s, data, loadingState: 'loaded' }))
|
||||
} catch (error) {
|
||||
setState(s => ({
|
||||
...s,
|
||||
data: null,
|
||||
error: `${(error && error.message) || error || 'Error'}`,
|
||||
loadingState: 'error',
|
||||
}))
|
||||
}
|
||||
})()
|
||||
},
|
||||
[method, JSON.stringify(params)],
|
||||
)
|
||||
|
||||
return state
|
||||
}
|
||||
@@ -77,24 +77,28 @@ export async function getNotifications(
|
||||
|
||||
export async function getActivity<T extends GitHubActivityType>(
|
||||
type: T,
|
||||
_params: any = {},
|
||||
{ subscriptionId = '', useCache = true } = {},
|
||||
params: any = {},
|
||||
{ githubToken = '', subscriptionId = '', useCache = true } = {},
|
||||
) {
|
||||
const cacheKey = JSON.stringify([type, _params, subscriptionId])
|
||||
const cacheKey = JSON.stringify([type, params, subscriptionId])
|
||||
const cacheValue = cache[cacheKey]
|
||||
|
||||
const params = { ..._params }
|
||||
params.headers = params.headers || {}
|
||||
params.headers['If-None-Match'] = ''
|
||||
params.headers.Accept = 'application/vnd.github.shadow-cat-preview'
|
||||
const _params = { ...params }
|
||||
_params.headers = _params.headers || {}
|
||||
_params.headers['If-None-Match'] = ''
|
||||
_params.headers.Accept = 'application/vnd.github.shadow-cat-preview'
|
||||
|
||||
if (githubToken) {
|
||||
_params.headers.Authorization = `token ${githubToken}`
|
||||
}
|
||||
|
||||
if (cacheValue && useCache) {
|
||||
if (cacheValue.headers['last-modified']) {
|
||||
params.headers['If-Modified-Since'] = cacheValue.headers['last-modified']
|
||||
_params.headers['If-Modified-Since'] = cacheValue.headers['last-modified']
|
||||
}
|
||||
|
||||
if (cacheValue.headers.etag) {
|
||||
params.headers['If-None-Match'] = cacheValue.headers.etag
|
||||
_params.headers['If-None-Match'] = cacheValue.headers.etag
|
||||
}
|
||||
}
|
||||
|
||||
@@ -102,23 +106,23 @@ export async function getActivity<T extends GitHubActivityType>(
|
||||
const response = await (() => {
|
||||
switch (type) {
|
||||
case 'ORG_PUBLIC_EVENTS':
|
||||
return octokit.activity.listPublicEventsForOrg(params)
|
||||
return octokit.activity.listPublicEventsForOrg(_params)
|
||||
case 'PUBLIC_EVENTS':
|
||||
return octokit.activity.listPublicEvents(params)
|
||||
return octokit.activity.listPublicEvents(_params)
|
||||
case 'REPO_EVENTS':
|
||||
return octokit.activity.listRepoEvents(params)
|
||||
return octokit.activity.listRepoEvents(_params)
|
||||
case 'REPO_NETWORK_EVENTS':
|
||||
return octokit.activity.listPublicEventsForRepoNetwork(params)
|
||||
return octokit.activity.listPublicEventsForRepoNetwork(_params)
|
||||
case 'USER_EVENTS':
|
||||
return octokit.activity.listEventsForUser(params)
|
||||
return octokit.activity.listEventsForUser(_params)
|
||||
case 'USER_ORG_EVENTS':
|
||||
return octokit.activity.listEventsForOrg(params)
|
||||
return octokit.activity.listEventsForOrg(_params)
|
||||
case 'USER_PUBLIC_EVENTS':
|
||||
return octokit.activity.listPublicEventsForUser(params)
|
||||
return octokit.activity.listPublicEventsForUser(_params)
|
||||
case 'USER_RECEIVED_EVENTS':
|
||||
return octokit.activity.listReceivedEventsForUser(params)
|
||||
return octokit.activity.listReceivedEventsForUser(_params)
|
||||
case 'USER_RECEIVED_PUBLIC_EVENTS':
|
||||
return octokit.activity.listReceivedPublicEventsForUser(params)
|
||||
return octokit.activity.listReceivedPublicEventsForUser(_params)
|
||||
default:
|
||||
throw new Error(
|
||||
`No api method configured for activity type '${type}'.`,
|
||||
|
||||
@@ -2,5 +2,6 @@ export * from './api'
|
||||
export * from './auth'
|
||||
export * from './columns'
|
||||
export * from './config'
|
||||
export * from './installations'
|
||||
export * from './navigation'
|
||||
export * from './subscriptions'
|
||||
|
||||
19
packages/components/src/redux/actions/installations.ts
Normal file
19
packages/components/src/redux/actions/installations.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import {
|
||||
FetchInstallationsOptions,
|
||||
InstallationsConnection,
|
||||
} from '@devhub/core'
|
||||
import { createAction, createErrorAction } from '../helpers'
|
||||
|
||||
export function fetchInstallationsRequest(payload: FetchInstallationsOptions) {
|
||||
return createAction('FETCH_INSTALLATIONS_REQUEST', payload)
|
||||
}
|
||||
|
||||
export function fetchInstallationsSuccess(payload: InstallationsConnection) {
|
||||
return createAction('FETCH_INSTALLATIONS_SUCCESS', payload)
|
||||
}
|
||||
|
||||
export function fetchInstallationsFailure<E extends Error>(
|
||||
error: E & { status?: number },
|
||||
) {
|
||||
return createErrorAction('FETCH_INSTALLATIONS_FAILURE', error)
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ColumnSubscription, GitHubApiHeaders } from '@devhub/core'
|
||||
import { ColumnSubscription, GitHubAPIHeaders } from '@devhub/core'
|
||||
import { createAction, createErrorActionWithPayload } from '../helpers'
|
||||
|
||||
export function fetchColumnSubscriptionRequest(payload: {
|
||||
@@ -33,7 +33,7 @@ export function fetchSubscriptionSuccess(payload: {
|
||||
subscriptionId: string
|
||||
data: any
|
||||
canFetchMore: boolean
|
||||
github: GitHubApiHeaders
|
||||
github: GitHubAPIHeaders
|
||||
}) {
|
||||
return createAction('FETCH_SUBSCRIPTION_SUCCESS', payload)
|
||||
}
|
||||
@@ -42,7 +42,7 @@ export function fetchSubscriptionFailure<E extends Error>(
|
||||
payload: {
|
||||
subscriptionType: ColumnSubscription['type']
|
||||
subscriptionId: string
|
||||
github: GitHubApiHeaders | undefined
|
||||
github: GitHubAPIHeaders | undefined
|
||||
},
|
||||
error: E & { status?: number },
|
||||
) {
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import immer from 'immer'
|
||||
|
||||
import { GitHubApiHeaders } from '@devhub/core'
|
||||
import { GitHubAPIHeaders } from '@devhub/core'
|
||||
import { Reducer } from '../types'
|
||||
|
||||
export interface State {
|
||||
github?: GitHubApiHeaders
|
||||
github?: GitHubAPIHeaders
|
||||
}
|
||||
|
||||
const initialState: State = {
|
||||
|
||||
@@ -6,6 +6,7 @@ import { appReducer } from './app'
|
||||
import { authReducer } from './auth'
|
||||
import { columnsReducer } from './columns'
|
||||
import { configReducer } from './config'
|
||||
import { installationsReducer } from './installations'
|
||||
import { navigationReducer } from './navigation'
|
||||
import { subscriptionsReducer } from './subscriptions'
|
||||
|
||||
@@ -15,6 +16,7 @@ const _rootReducer = combineReducers({
|
||||
auth: authReducer,
|
||||
columns: columnsReducer,
|
||||
config: configReducer,
|
||||
installations: installationsReducer,
|
||||
navigation: navigationReducer,
|
||||
subscriptions: subscriptionsReducer,
|
||||
})
|
||||
|
||||
@@ -1,30 +1,40 @@
|
||||
import immer from 'immer'
|
||||
import _ from 'lodash'
|
||||
|
||||
import {
|
||||
Installation,
|
||||
InstallationAccount,
|
||||
InstallationRepository,
|
||||
InstallationResponse,
|
||||
} from '@devhub/core'
|
||||
import { Installation, LoadState } from '@devhub/core'
|
||||
import { normalizeUsername } from '../../utils/helpers/github/shared'
|
||||
import { Reducer } from '../types'
|
||||
|
||||
export interface State {
|
||||
allInstallationIds: number[]
|
||||
allOwnerIds: number[]
|
||||
allRepoIds: number[]
|
||||
byInstallationId: Record<number, InstallationResponse>
|
||||
byOwnerName: Record<number, number>
|
||||
byRepoName: Record<number, number>
|
||||
allIds: number[]
|
||||
byId: Record<number, Installation>
|
||||
|
||||
allOwnerNames: string[]
|
||||
byOwnerName: Record<string, number>
|
||||
|
||||
allRepoFulNames: string[]
|
||||
byRepoFullName: Record<string, number>
|
||||
|
||||
error?: string | null
|
||||
lastFetchedAt: string | null
|
||||
loadState: LoadState
|
||||
totalInstallationCount: number
|
||||
}
|
||||
|
||||
const initialState: State = {
|
||||
allInstallationIds: [],
|
||||
allOwnerIds: [],
|
||||
allRepoIds: [],
|
||||
byInstallationId: {},
|
||||
allIds: [],
|
||||
byId: {},
|
||||
|
||||
allOwnerNames: [],
|
||||
byOwnerName: {},
|
||||
byRepoName: {},
|
||||
|
||||
allRepoFulNames: [],
|
||||
byRepoFullName: {},
|
||||
|
||||
error: null,
|
||||
lastFetchedAt: null,
|
||||
loadState: 'not_loaded',
|
||||
totalInstallationCount: 0,
|
||||
}
|
||||
|
||||
export const installationsReducer: Reducer<State> = (
|
||||
@@ -32,6 +42,89 @@ export const installationsReducer: Reducer<State> = (
|
||||
action,
|
||||
) => {
|
||||
switch (action.type) {
|
||||
case 'FETCH_INSTALLATIONS_REQUEST':
|
||||
return immer(state, draft => {
|
||||
draft.lastFetchedAt = new Date().toISOString()
|
||||
draft.loadState = 'loading'
|
||||
})
|
||||
|
||||
case 'FETCH_INSTALLATIONS_SUCCESS':
|
||||
return immer(state, draft => {
|
||||
draft.error = null
|
||||
draft.loadState = 'loaded'
|
||||
|
||||
const { nodes, totalCount } = action.payload
|
||||
|
||||
if (nodes) {
|
||||
draft.allIds = []
|
||||
draft.byId = {}
|
||||
|
||||
draft.allOwnerNames = []
|
||||
draft.byOwnerName = {}
|
||||
|
||||
const allRepoFulNames: State['allRepoFulNames'] = []
|
||||
const byRepoFullName: State['byRepoFullName'] = {}
|
||||
|
||||
draft.totalInstallationCount = totalCount || 0
|
||||
|
||||
nodes.forEach(installation => {
|
||||
if (!(installation && installation.id)) return
|
||||
|
||||
draft.allIds.push(installation.id)
|
||||
draft.byId[installation.id] = installation
|
||||
|
||||
const ownerName = normalizeUsername(
|
||||
(installation.account && installation.account.login) || undefined,
|
||||
)
|
||||
if (ownerName) {
|
||||
draft.allOwnerNames.push(ownerName)
|
||||
draft.byOwnerName[ownerName] = installation.id
|
||||
}
|
||||
|
||||
const repos =
|
||||
installation.repositoriesConnection &&
|
||||
installation.repositoriesConnection.nodes
|
||||
|
||||
if (repos) {
|
||||
repos.forEach(repo => {
|
||||
if (!(repo && repo.repoName)) return
|
||||
|
||||
const _ownerName = normalizeUsername(
|
||||
repo.ownerName || undefined,
|
||||
)
|
||||
if (_ownerName) {
|
||||
draft.allOwnerNames.push(_ownerName)
|
||||
draft.byOwnerName[_ownerName] = installation.id!
|
||||
}
|
||||
|
||||
const repoName = `${repo.repoName}`.trim().toLowerCase()
|
||||
if (repoName) {
|
||||
const repoFullName = `${_ownerName}/${repoName}`
|
||||
allRepoFulNames.push(repoFullName)
|
||||
byRepoFullName[repoFullName] = installation.id!
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
if (allRepoFulNames.length) {
|
||||
draft.allRepoFulNames = allRepoFulNames
|
||||
draft.byRepoFullName = byRepoFullName
|
||||
}
|
||||
|
||||
draft.allIds = _.uniq(draft.allIds)
|
||||
draft.allOwnerNames = _.uniq(draft.allOwnerNames)
|
||||
draft.allRepoFulNames = _.uniq(draft.allRepoFulNames)
|
||||
}
|
||||
})
|
||||
|
||||
case 'FETCH_INSTALLATIONS_FAILURE':
|
||||
return immer(state, draft => {
|
||||
draft.error = `${(action.error && action.error.message) ||
|
||||
action.error}`
|
||||
draft.loadState = 'error'
|
||||
})
|
||||
|
||||
default:
|
||||
return state
|
||||
}
|
||||
|
||||
@@ -130,7 +130,9 @@ function* onLoginRequest(
|
||||
|
||||
yield put(
|
||||
actions.loginFailure(
|
||||
error.response.data &&
|
||||
error &&
|
||||
error.response &&
|
||||
error.response.data &&
|
||||
error.response.data.errors &&
|
||||
error.response.data.errors[0],
|
||||
),
|
||||
|
||||
@@ -4,6 +4,7 @@ import { apiSagas } from './api'
|
||||
import { authSagas } from './auth'
|
||||
import { columnsSagas } from './columns'
|
||||
import { configSagas } from './config'
|
||||
import { installationSagas } from './installations'
|
||||
import { subscriptionsSagas } from './subscriptions'
|
||||
|
||||
export function* rootSaga() {
|
||||
@@ -12,6 +13,7 @@ export function* rootSaga() {
|
||||
yield fork(authSagas),
|
||||
yield fork(columnsSagas),
|
||||
yield fork(configSagas),
|
||||
yield fork(installationSagas),
|
||||
yield fork(subscriptionsSagas),
|
||||
])
|
||||
}
|
||||
|
||||
83
packages/components/src/redux/sagas/installations.ts
Normal file
83
packages/components/src/redux/sagas/installations.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import _ from 'lodash'
|
||||
import { delay } from 'redux-saga'
|
||||
import {
|
||||
all,
|
||||
fork,
|
||||
put,
|
||||
race,
|
||||
select,
|
||||
take,
|
||||
takeEvery,
|
||||
} from 'redux-saga/effects'
|
||||
|
||||
import { fetchInstallations, InstallationsConnection } from '@devhub/core'
|
||||
import { bugsnag } from '../../libs/bugsnag'
|
||||
import * as actions from '../actions'
|
||||
import * as selectors from '../selectors'
|
||||
import { ExtractActionFromActionCreator } from '../types/base'
|
||||
|
||||
// Fetch new installation tokens every X minutes
|
||||
function* init() {
|
||||
let isFirstTime = true
|
||||
|
||||
while (true) {
|
||||
const { action } = yield race({
|
||||
delay: delay(1000 * 60 * 50),
|
||||
action: take(['LOGIN_SUCCESS', 'LOGIN_FAILURE', 'LOGOUT']),
|
||||
})
|
||||
|
||||
if (action && action.type === 'LOGIN_SUCCESS') isFirstTime = true
|
||||
|
||||
const state = yield select()
|
||||
|
||||
const isLogged = selectors.isLoggedSelector(state)
|
||||
if (!isLogged) continue
|
||||
|
||||
const appToken = selectors.appTokenSelector(state)
|
||||
if (!appToken) continue
|
||||
|
||||
yield put(
|
||||
actions.fetchInstallationsRequest({
|
||||
appToken,
|
||||
includeInstallationRepositories: isFirstTime,
|
||||
includeInstallationToken: true,
|
||||
}),
|
||||
)
|
||||
|
||||
isFirstTime = false
|
||||
}
|
||||
}
|
||||
|
||||
function* onFetchRequest(
|
||||
action: ExtractActionFromActionCreator<
|
||||
typeof actions.fetchInstallationsRequest
|
||||
>,
|
||||
) {
|
||||
const {
|
||||
appToken,
|
||||
includeInstallationRepositories,
|
||||
includeInstallationToken,
|
||||
} = action.payload
|
||||
|
||||
try {
|
||||
const response: InstallationsConnection = yield fetchInstallations({
|
||||
appToken,
|
||||
includeInstallationRepositories,
|
||||
includeInstallationToken,
|
||||
})
|
||||
|
||||
yield put(actions.fetchInstallationsSuccess(response))
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch installations', error)
|
||||
bugsnag.notify(error)
|
||||
|
||||
yield put(actions.fetchInstallationsFailure(error))
|
||||
}
|
||||
}
|
||||
|
||||
export function* installationSagas() {
|
||||
yield all([
|
||||
yield fork(init),
|
||||
yield takeEvery('FETCH_INSTALLATIONS_REQUEST', onFetchRequest),
|
||||
])
|
||||
}
|
||||
@@ -22,7 +22,7 @@ import {
|
||||
createNotificationsCache,
|
||||
EnhancementCache,
|
||||
enhanceNotifications,
|
||||
getGitHubApiHeadersFromHeader,
|
||||
getGitHubAPIHeadersFromHeader,
|
||||
getNotificationsEnhancementMap,
|
||||
getOlderEventDate,
|
||||
getOlderNotificationDate,
|
||||
@@ -52,10 +52,15 @@ function* init() {
|
||||
'LOGIN_FAILURE',
|
||||
'LOGOUT',
|
||||
'REPLACE_COLUMNS_AND_SUBSCRIPTIONS',
|
||||
'FETCH_INSTALLATIONS_SUCCESS',
|
||||
]),
|
||||
})
|
||||
|
||||
const forceFetchAll = !!(action && action.type === 'LOGIN_SUCCESS')
|
||||
const forceFetchAll = !!(
|
||||
action &&
|
||||
(action.type === 'LOGIN_SUCCESS' ||
|
||||
action.type === 'FETCH_INSTALLATIONS_SUCCESS')
|
||||
)
|
||||
|
||||
const _isFirstTime = isFirstTime
|
||||
isFirstTime = false
|
||||
@@ -77,7 +82,7 @@ function* init() {
|
||||
const subscriptions = selectors.subscriptionsArrSelector(state)
|
||||
if (!(subscriptions && subscriptions.length)) continue
|
||||
|
||||
const github = selectors.githubApiHeadersSelector(state)
|
||||
const github = selectors.githubAPIHeadersSelector(state)
|
||||
|
||||
// TODO: Eventually the number of subscriptions wont be 1x1 with the number of columns
|
||||
// Because columns will be able to have multiple subscriptions.
|
||||
@@ -242,11 +247,23 @@ function* onFetchRequest(
|
||||
|
||||
const subscription = selectors.subscriptionSelector(state, subscriptionId)
|
||||
|
||||
// TODO: Fix github app token handling
|
||||
const owner =
|
||||
(subscription &&
|
||||
(('owner' in subscription.params && subscription.params.owner) ||
|
||||
('org' in subscription.params && subscription.params.org))) ||
|
||||
undefined
|
||||
|
||||
const installationToken = selectors.installationTokenByOwnerSelector(
|
||||
state,
|
||||
owner,
|
||||
)
|
||||
|
||||
const githubOAuthToken = selectors.githubOAuthTokenSelector(state)!
|
||||
|
||||
const githubToken =
|
||||
selectors.githubOAuthTokenSelector(state) ||
|
||||
(subscription && subscription.type === 'activity' && installationToken) ||
|
||||
githubOAuthToken ||
|
||||
selectors.githubAppTokenSelector(state)
|
||||
const hasPrivateAccess = selectors.githubHasPrivateAccessSelector(state)
|
||||
|
||||
const page = Math.max(1, _params.page || 1)
|
||||
const perPage = Math.min(
|
||||
@@ -291,11 +308,19 @@ function* onFetchRequest(
|
||||
|
||||
const enhancementMap = yield call(
|
||||
getNotificationsEnhancementMap,
|
||||
newItems,
|
||||
mergedItems,
|
||||
{
|
||||
cache: notificationsCache,
|
||||
githubToken,
|
||||
hasPrivateAccess,
|
||||
getGitHubInstallationTokenForRepo: (
|
||||
ownerName: string | undefined,
|
||||
repoName: string | undefined,
|
||||
) =>
|
||||
selectors.installationTokenByRepoSelector(
|
||||
state,
|
||||
ownerName,
|
||||
repoName,
|
||||
),
|
||||
githubOAuthToken,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -316,6 +341,7 @@ function* onFetchRequest(
|
||||
} else if (subscription && subscription.type === 'activity') {
|
||||
const response = yield call(getActivity, subscription.subtype, params, {
|
||||
subscriptionId,
|
||||
githubToken,
|
||||
})
|
||||
headers = (response && response.headers) || {}
|
||||
|
||||
@@ -341,7 +367,7 @@ function* onFetchRequest(
|
||||
)
|
||||
}
|
||||
|
||||
const github = getGitHubApiHeadersFromHeader(headers)
|
||||
const github = getGitHubAPIHeadersFromHeader(headers)
|
||||
|
||||
yield put(
|
||||
actions.fetchSubscriptionSuccess({
|
||||
@@ -360,7 +386,7 @@ function* onFetchRequest(
|
||||
// bugsnag.notify(error)
|
||||
|
||||
const headers = error && error.response && error.response.headers
|
||||
const github = getGitHubApiHeadersFromHeader(headers)
|
||||
const github = getGitHubAPIHeadersFromHeader(headers)
|
||||
|
||||
yield put(
|
||||
actions.fetchSubscriptionFailure(
|
||||
|
||||
@@ -2,5 +2,5 @@ import { RootState } from '../types'
|
||||
|
||||
const s = (state: RootState) => state.api || {}
|
||||
|
||||
export const githubApiHeadersSelector = (state: RootState) =>
|
||||
export const githubAPIHeadersSelector = (state: RootState) =>
|
||||
s(state).github || {}
|
||||
|
||||
@@ -15,10 +15,6 @@ export const isLoggedSelector = (state: RootState) =>
|
||||
export const appTokenSelector = (state: RootState) =>
|
||||
s(state).appToken || undefined
|
||||
|
||||
// TODO: Support private repositories after migrating to GitHub App
|
||||
// @see https://github.com/devhubapp/devhub/issues/32
|
||||
export const githubHasPrivateAccessSelector = (_state: RootState) => false
|
||||
|
||||
export const githubAppTokenDetailsSelector = (state: RootState) => {
|
||||
const user = s(state).user
|
||||
return (user && user.github.app) || undefined
|
||||
|
||||
@@ -2,5 +2,6 @@ export * from './api'
|
||||
export * from './auth'
|
||||
export * from './columns'
|
||||
export * from './config'
|
||||
export * from './installations'
|
||||
export * from './navigation'
|
||||
export * from './subscriptions'
|
||||
|
||||
89
packages/components/src/redux/selectors/installations.ts
Normal file
89
packages/components/src/redux/selectors/installations.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import { createSelector } from 'reselect'
|
||||
|
||||
import { normalizeUsername } from '../../utils/helpers/github/shared'
|
||||
import { RootState } from '../types'
|
||||
|
||||
const s = (state: RootState) => state.installations || {}
|
||||
|
||||
export const installationIdsSelector = (state: RootState) =>
|
||||
s(state).allIds || []
|
||||
|
||||
export const installationsLoadStateSelector = (state: RootState) =>
|
||||
s(state).loadState
|
||||
|
||||
export const installationSelector = createSelector(
|
||||
(state: RootState) => s(state).byId,
|
||||
(_state: RootState, id: number) => id,
|
||||
(byId, id) => byId[id],
|
||||
)
|
||||
|
||||
export const installationByOwnerSelector = createSelector(
|
||||
(state: RootState) => s(state).byId,
|
||||
(state: RootState) => s(state).byOwnerName,
|
||||
(_state: RootState, ownerName: string | undefined) => ownerName,
|
||||
(byId, byOwnerName, _ownerName) => {
|
||||
const ownerName = `${_ownerName || ''}`.trim().toLowerCase()
|
||||
const installationId = ownerName && byOwnerName[ownerName]
|
||||
|
||||
return (installationId && byId[installationId]) || undefined
|
||||
},
|
||||
)
|
||||
|
||||
export const installationByRepoSelector = createSelector(
|
||||
(state: RootState) => s(state).byId,
|
||||
(state: RootState) => s(state).byRepoFullName,
|
||||
(
|
||||
_state: RootState,
|
||||
ownerName: string | undefined,
|
||||
_repoName: string | undefined,
|
||||
) => ownerName,
|
||||
(
|
||||
_state: RootState,
|
||||
_ownerName: string | undefined,
|
||||
repoName: string | undefined,
|
||||
) => repoName,
|
||||
(byId, byRepoFullName, _ownerName, _repoName) => {
|
||||
const ownerName = normalizeUsername(_ownerName)
|
||||
const repoName = normalizeUsername(_repoName)
|
||||
const fullName = ownerName && repoName && `${ownerName}/${repoName}`
|
||||
const installationId = fullName && byRepoFullName[fullName]
|
||||
|
||||
return (installationId && byId[installationId]) || undefined
|
||||
},
|
||||
)
|
||||
|
||||
export const installationTokenByOwnerSelector = (
|
||||
state: RootState,
|
||||
ownerName: string | undefined,
|
||||
) => {
|
||||
const installation = installationByOwnerSelector(state, ownerName)
|
||||
|
||||
const tokenDetails = installation && installation.tokenDetails
|
||||
return (tokenDetails && tokenDetails.token) || undefined
|
||||
}
|
||||
|
||||
export const installationTokenByRepoSelector = (
|
||||
state: RootState,
|
||||
ownerName: string | undefined,
|
||||
repoName: string | undefined,
|
||||
) => {
|
||||
const installation = installationByRepoSelector(state, ownerName, repoName)
|
||||
|
||||
const tokenDetails = installation && installation.tokenDetails
|
||||
return (tokenDetails && tokenDetails.token) || undefined
|
||||
}
|
||||
|
||||
export const githubHasPrivateAccessToOwnerSelector = (
|
||||
state: RootState,
|
||||
ownerName: string | undefined,
|
||||
) => {
|
||||
return !!installationTokenByOwnerSelector(state, ownerName)
|
||||
}
|
||||
|
||||
export const githubHasPrivateAccessToRepoSelector = (
|
||||
state: RootState,
|
||||
ownerName: string | undefined,
|
||||
repoName: string | undefined,
|
||||
) => {
|
||||
return !!installationTokenByRepoSelector(state, ownerName, repoName)
|
||||
}
|
||||
@@ -13,7 +13,6 @@ import {
|
||||
getFilteredNotifications,
|
||||
} from '../../utils/helpers/filters'
|
||||
import { RootState } from '../types'
|
||||
import { githubHasPrivateAccessSelector } from './auth'
|
||||
|
||||
const s = (state: RootState) => state.subscriptions || {}
|
||||
|
||||
@@ -85,23 +84,17 @@ export const createFilteredSubscriptionsDataSelector = () => {
|
||||
subscriptionsDataSelector(state, subscriptionIds),
|
||||
(state: RootState, _subscriptionIds: string[], filters: ColumnFilters) =>
|
||||
filters,
|
||||
(state: RootState) => githubHasPrivateAccessSelector(state),
|
||||
(type, items, filters, hasPrivateAccess) => {
|
||||
(type, items, filters) => {
|
||||
if (!(items && items.length)) return []
|
||||
|
||||
if (type === 'notifications') {
|
||||
return getFilteredNotifications(
|
||||
items as EnhancedGitHubNotification[],
|
||||
filters,
|
||||
hasPrivateAccess,
|
||||
)
|
||||
}
|
||||
|
||||
return getFilteredEvents(
|
||||
items as EnhancedGitHubEvent[],
|
||||
filters,
|
||||
hasPrivateAccess,
|
||||
)
|
||||
return getFilteredEvents(items as EnhancedGitHubEvent[], filters)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -49,14 +49,11 @@ export function itemPassesFilterRecord(
|
||||
|
||||
function baseColumnHasAnyFilter(
|
||||
filters: NotificationColumnFilters | undefined,
|
||||
hasPrivateAccess: boolean,
|
||||
) {
|
||||
if (!filters) return false
|
||||
|
||||
if (filters.clearedAt) return true
|
||||
if (hasPrivateAccess && typeof filters.private === 'boolean') return true
|
||||
if (!hasPrivateAccess && filters.private === true) return true
|
||||
|
||||
if (typeof filters.private === 'boolean') return true
|
||||
if (typeof filters.saved === 'boolean') return true
|
||||
if (typeof filters.unread === 'boolean') return true
|
||||
|
||||
@@ -65,11 +62,10 @@ function baseColumnHasAnyFilter(
|
||||
|
||||
export function activityColumnHasAnyFilter(
|
||||
filters: ActivityColumnFilters | undefined,
|
||||
hasPrivateAccess: boolean,
|
||||
) {
|
||||
if (!filters) return false
|
||||
|
||||
if (baseColumnHasAnyFilter(filters, hasPrivateAccess)) return true
|
||||
if (baseColumnHasAnyFilter(filters)) return true
|
||||
|
||||
if (
|
||||
filters.activity &&
|
||||
@@ -83,11 +79,10 @@ export function activityColumnHasAnyFilter(
|
||||
|
||||
export function notificationColumnHasAnyFilter(
|
||||
filters: NotificationColumnFilters | undefined,
|
||||
hasPrivateAccess: boolean,
|
||||
) {
|
||||
if (!filters) return false
|
||||
|
||||
if (baseColumnHasAnyFilter(filters, hasPrivateAccess)) return true
|
||||
if (baseColumnHasAnyFilter(filters)) return true
|
||||
|
||||
if (filters.notifications && filters.notifications.participating) {
|
||||
return true
|
||||
@@ -106,24 +101,13 @@ export function notificationColumnHasAnyFilter(
|
||||
export function getFilteredNotifications(
|
||||
notifications: EnhancedGitHubNotification[],
|
||||
filters: NotificationColumnFilters | undefined,
|
||||
hasPrivateAccess: boolean,
|
||||
) {
|
||||
let _notifications = sortNotifications(notifications)
|
||||
|
||||
const reasonsFilter =
|
||||
filters && filters.notifications && filters.notifications.reasons
|
||||
|
||||
// Note: GitHub always includes private notifications
|
||||
// even if our hasPrivateAccess (because this checks private repo access)
|
||||
// TL/DR, it will show private notifications, but without enhancement
|
||||
// (without issue details, comment content, etc)
|
||||
if (
|
||||
filters &&
|
||||
(notificationColumnHasAnyFilter(filters, hasPrivateAccess) ||
|
||||
(!hasPrivateAccess &&
|
||||
typeof filters.private === 'boolean' &&
|
||||
_notifications.find(n => isNotificationPrivate(n))))
|
||||
) {
|
||||
if (filters && notificationColumnHasAnyFilter(filters)) {
|
||||
_notifications = _notifications.filter(notification => {
|
||||
if (!itemPassesFilterRecord(reasonsFilter, notification.reason, true))
|
||||
return false
|
||||
@@ -136,7 +120,6 @@ export function getFilteredNotifications(
|
||||
}
|
||||
|
||||
if (
|
||||
// (!hasPrivateAccess && isNotificationPrivate(notification)) ||
|
||||
typeof filters.private === 'boolean' &&
|
||||
isNotificationPrivate(notification) !== filters.private
|
||||
) {
|
||||
@@ -168,17 +151,12 @@ export function getFilteredNotifications(
|
||||
export function getFilteredEvents(
|
||||
events: EnhancedGitHubEvent[],
|
||||
filters: ActivityColumnFilters | undefined,
|
||||
hasPrivateAccess: boolean,
|
||||
) {
|
||||
let _events = sortEvents(events)
|
||||
|
||||
const activityFilter = filters && filters.activity && filters.activity.types
|
||||
|
||||
if (
|
||||
filters &&
|
||||
(activityColumnHasAnyFilter(filters, hasPrivateAccess) ||
|
||||
(!hasPrivateAccess && _events.find(e => isEventPrivate(e))))
|
||||
) {
|
||||
if (filters && activityColumnHasAnyFilter(filters)) {
|
||||
_events = _events.filter(event => {
|
||||
if (!itemPassesFilterRecord(activityFilter, event.type, true))
|
||||
return false
|
||||
@@ -191,9 +169,8 @@ export function getFilteredEvents(
|
||||
}
|
||||
|
||||
if (
|
||||
(!hasPrivateAccess && isEventPrivate(event)) ||
|
||||
(typeof filters.private === 'boolean' &&
|
||||
isEventPrivate(event) !== filters.private)
|
||||
typeof filters.private === 'boolean' &&
|
||||
isEventPrivate(event) !== filters.private
|
||||
) {
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -86,3 +86,8 @@ export function getNotificationIconAndColor(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeUsername(username: string | undefined) {
|
||||
if (!username || typeof username !== 'string') return undefined
|
||||
return username.trim().toLowerCase()
|
||||
}
|
||||
|
||||
@@ -1,29 +1,33 @@
|
||||
import axios, { AxiosResponse } from 'axios'
|
||||
|
||||
import { constants, InstallationResponse } from '..'
|
||||
import { constants, InstallationsConnection } from '..'
|
||||
|
||||
export async function fetchInstallationToken(
|
||||
params: { owner: string; repo?: string },
|
||||
options: { appToken: string },
|
||||
) {
|
||||
const { owner, repo } = params
|
||||
const { appToken: token } = options
|
||||
export interface FetchInstallationsOptions {
|
||||
appToken: string
|
||||
includeInstallationRepositories: boolean
|
||||
includeInstallationToken: boolean
|
||||
}
|
||||
|
||||
export async function fetchInstallations(options: FetchInstallationsOptions) {
|
||||
const {
|
||||
appToken,
|
||||
includeInstallationRepositories,
|
||||
includeInstallationToken,
|
||||
} = options
|
||||
|
||||
const response: AxiosResponse<{
|
||||
data: {
|
||||
getInstallationToken: InstallationResponse | null
|
||||
getInstallationsConnection: InstallationsConnection | null
|
||||
}
|
||||
errors?: any[]
|
||||
}> = await axios.post(
|
||||
constants.GRAPHQL_ENDPOINT,
|
||||
{
|
||||
variables: { owner, repo },
|
||||
query: `
|
||||
query($owner: String!, $repo: String) {
|
||||
getInstallationToken(input: { owner: $owner, repo: $repo }) {
|
||||
ownerId
|
||||
repoId
|
||||
installation {
|
||||
query {
|
||||
getInstallationsConnection {
|
||||
totalCount
|
||||
nodes {
|
||||
id
|
||||
account {
|
||||
id
|
||||
@@ -32,17 +36,35 @@ export async function fetchInstallationToken(
|
||||
avatarURL
|
||||
htmlURL
|
||||
}
|
||||
}
|
||||
installationToken
|
||||
installationTokenExpiresAt
|
||||
installationRepositories {
|
||||
id
|
||||
nodeId
|
||||
ownerName
|
||||
repoName
|
||||
private
|
||||
permissions
|
||||
htmlURL
|
||||
${
|
||||
includeInstallationRepositories
|
||||
? `
|
||||
repositoriesConnection {
|
||||
totalCount
|
||||
nodes {
|
||||
id
|
||||
nodeId
|
||||
ownerName
|
||||
repoName
|
||||
private
|
||||
permissions
|
||||
htmlURL
|
||||
}
|
||||
}
|
||||
`
|
||||
: ''
|
||||
}
|
||||
${
|
||||
includeInstallationToken
|
||||
? `
|
||||
tokenDetails {
|
||||
token
|
||||
expiresAt
|
||||
}
|
||||
`
|
||||
: ''
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -50,16 +72,16 @@ export async function fetchInstallationToken(
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
Authorization: `bearer ${token}`,
|
||||
Authorization: `bearer ${appToken}`,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
const { data, errors } = response.data
|
||||
|
||||
if (errors && errors.length) {
|
||||
if ((errors && errors.length) || !(data && data.getInstallationsConnection)) {
|
||||
throw Object.assign(new Error('GraphQL Error'), { response })
|
||||
}
|
||||
|
||||
return data
|
||||
return data.getInstallationsConnection
|
||||
}
|
||||
|
||||
@@ -14,12 +14,15 @@ export async function getNotificationsEnhancementMap(
|
||||
notifications: EnhancedGitHubNotification[],
|
||||
{
|
||||
cache = new Map(),
|
||||
githubToken,
|
||||
hasPrivateAccess,
|
||||
getGitHubInstallationTokenForRepo,
|
||||
githubOAuthToken,
|
||||
}: {
|
||||
cache: EnhancementCache | undefined | undefined
|
||||
githubToken: string
|
||||
hasPrivateAccess: boolean
|
||||
getGitHubInstallationTokenForRepo: (
|
||||
owner: string | undefined,
|
||||
repo: string | undefined,
|
||||
) => string | undefined
|
||||
githubOAuthToken: string
|
||||
},
|
||||
): Promise<Record<string, NotificationPayloadEnhancement>> {
|
||||
const promises = notifications.map(async notification => {
|
||||
@@ -28,6 +31,9 @@ export async function getNotificationsEnhancementMap(
|
||||
const { owner, repo } = getOwnerAndRepo(notification.repository.full_name)
|
||||
if (!(owner && repo)) return
|
||||
|
||||
const installationToken = getGitHubInstallationTokenForRepo(owner, repo)
|
||||
const githubToken = installationToken || githubOAuthToken
|
||||
|
||||
const commentId = getCommentIdFromUrl(
|
||||
notification.subject.latest_comment_url,
|
||||
)
|
||||
@@ -35,7 +41,7 @@ export async function getNotificationsEnhancementMap(
|
||||
const enhance: NotificationPayloadEnhancement = {}
|
||||
|
||||
const isPrivate = notification.repository.private
|
||||
const hasAccess = !isPrivate || hasPrivateAccess
|
||||
const hasAccess = !isPrivate || !!installationToken
|
||||
if (!hasAccess) return
|
||||
|
||||
const hasSubjectCache = cache.has(notification.subject.url)
|
||||
@@ -76,12 +82,14 @@ export async function getNotificationsEnhancementMap(
|
||||
error,
|
||||
)
|
||||
cache.set(notification.subject.url, false)
|
||||
if (!enhance.enhanced) enhance.enhanced = false
|
||||
return
|
||||
}
|
||||
} else if (hasSubjectCache) {
|
||||
if (subjectCache && subjectCache.data)
|
||||
if (subjectCache && subjectCache.data) {
|
||||
enhance[subjectField] = subjectCache.data
|
||||
enhance.enhanced = true
|
||||
enhance.enhanced = true
|
||||
} else if (!enhance.enhanced) enhance.enhanced = false
|
||||
}
|
||||
|
||||
if (commentId && !hasCommentCache) {
|
||||
@@ -105,6 +113,7 @@ export async function getNotificationsEnhancementMap(
|
||||
error,
|
||||
)
|
||||
cache.set(notification.subject.latest_comment_url, false)
|
||||
if (!enhance.enhanced) enhance.enhanced = false
|
||||
}
|
||||
} else if (!commentId) {
|
||||
enhance.comment = undefined
|
||||
@@ -112,7 +121,7 @@ export async function getNotificationsEnhancementMap(
|
||||
if (commentCache && commentCache.data) {
|
||||
enhance.comment = commentCache.data
|
||||
enhance.enhanced = true
|
||||
}
|
||||
} else if (!enhance.enhanced) enhance.enhanced = false
|
||||
}
|
||||
|
||||
if (!Object.keys(enhance).length) return
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
ColumnSubscription,
|
||||
EnhancedGitHubEvent,
|
||||
EnhancedGitHubNotification,
|
||||
GitHubApiHeaders,
|
||||
GitHubAPIHeaders,
|
||||
GitHubIcon,
|
||||
GitHubPullRequest,
|
||||
} from '../../types'
|
||||
@@ -237,8 +237,8 @@ export function createSubscriptionObjectsWithId(
|
||||
}))
|
||||
}
|
||||
|
||||
export function getGitHubApiHeadersFromHeader(headers: Record<string, any>) {
|
||||
const github: GitHubApiHeaders = {}
|
||||
export function getGitHubAPIHeadersFromHeader(headers: Record<string, any>) {
|
||||
const github: GitHubAPIHeaders = {}
|
||||
|
||||
if (!headers) return github
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from './api'
|
||||
export * from './helpers'
|
||||
export * from './mappers'
|
||||
export * from './types'
|
||||
|
||||
@@ -63,7 +63,7 @@ export interface ColumnSubscriptionData<
|
||||
Item extends EnhancedGitHubNotification | EnhancedGitHubEvent
|
||||
> {
|
||||
items?: Item[]
|
||||
loadState?: LoadState
|
||||
loadState?: EnhancedLoadState
|
||||
errorMessage?: string
|
||||
canFetchMore?: boolean
|
||||
lastFetchedAt?: string
|
||||
@@ -307,13 +307,9 @@ export type ModalPayload =
|
||||
|
||||
export type ModalPayloadWithIndex = ModalPayload & { index: number }
|
||||
|
||||
export type LoadState =
|
||||
| 'error'
|
||||
| 'loaded'
|
||||
| 'loading'
|
||||
| 'loading_first'
|
||||
| 'loading_more'
|
||||
| 'not_loaded'
|
||||
export type LoadState = 'error' | 'loaded' | 'loading' | 'not_loaded'
|
||||
|
||||
export type EnhancedLoadState = LoadState | 'loading_first' | 'loading_more'
|
||||
|
||||
export type EnhancementCache = Map<
|
||||
string,
|
||||
|
||||
@@ -18,6 +18,15 @@ export type GitHubExtractParamsFromMethod<F> = F extends (
|
||||
? P
|
||||
: never
|
||||
|
||||
export type GitHubExtractResponseFromMethod<F> = F extends (
|
||||
params: any,
|
||||
callback: any,
|
||||
) => infer R
|
||||
? R extends Promise<infer RR>
|
||||
? RR
|
||||
: R
|
||||
: never
|
||||
|
||||
export interface GitHubUser {
|
||||
id: string | number
|
||||
node_id?: string
|
||||
@@ -238,6 +247,7 @@ export interface GitHubRepo {
|
||||
full_name?: string
|
||||
fork: boolean
|
||||
private: boolean
|
||||
owner?: GitHubOrg | GitHubUser | undefined
|
||||
url: string // https://api.github.com/repos/facebook/react
|
||||
html_url: string // https://github.com/facebook/react
|
||||
}
|
||||
@@ -829,7 +839,7 @@ export interface GitHubNotification {
|
||||
url: string
|
||||
}
|
||||
|
||||
export interface GitHubApiHeaders {
|
||||
export interface GitHubAPIHeaders {
|
||||
pollInterval?: number
|
||||
rateLimitLimit?: number
|
||||
rateLimitRemaining?: number
|
||||
|
||||
@@ -62,66 +62,74 @@ export interface User {
|
||||
}
|
||||
|
||||
export interface GitHubPlan {
|
||||
name: string
|
||||
space?: number
|
||||
collaboratorsCount?: number
|
||||
privateReposLimit?: number
|
||||
name?: string | null
|
||||
space?: number | null
|
||||
collaboratorsCount?: number | null
|
||||
privateReposLimit?: number | null
|
||||
}
|
||||
|
||||
export interface InstallationPermissions {
|
||||
metadata: string
|
||||
contents: string
|
||||
issues: string
|
||||
single_file: string
|
||||
metadata?: string | null
|
||||
contents?: string | null
|
||||
issues?: string | null
|
||||
single_file?: string | null
|
||||
}
|
||||
|
||||
export interface InstallationRepositoryPermissions {
|
||||
admin: boolean
|
||||
push: boolean
|
||||
pull: boolean
|
||||
admin?: boolean | null
|
||||
push?: boolean | null
|
||||
pull?: boolean | null
|
||||
}
|
||||
|
||||
export interface InstallationAccount {
|
||||
id: number
|
||||
nodeId: string
|
||||
gravatarId?: string
|
||||
login: string
|
||||
type?: string
|
||||
avatarURL: string
|
||||
siteAdmin?: boolean
|
||||
url: string
|
||||
htmlURL?: string
|
||||
}
|
||||
|
||||
export interface Installation {
|
||||
id: number
|
||||
account: InstallationAccount
|
||||
appId: number
|
||||
targetId: number
|
||||
targetType: string
|
||||
permissions: InstallationPermissions
|
||||
events: string[]
|
||||
singleFileName: string
|
||||
htmlURL: string
|
||||
id?: number | null
|
||||
nodeId?: string | null
|
||||
gravatarId?: string | null
|
||||
login?: string | null
|
||||
type?: string | null
|
||||
avatarURL?: string | null
|
||||
siteAdmin?: boolean | null
|
||||
url?: string | null
|
||||
htmlURL?: string | null
|
||||
}
|
||||
|
||||
export interface InstallationRepository {
|
||||
id: number
|
||||
nodeId: string
|
||||
ownerName: string
|
||||
repoName: string
|
||||
private: boolean
|
||||
permissions: InstallationRepositoryPermissions | null
|
||||
language: string
|
||||
description?: string
|
||||
htmlURL: string
|
||||
id?: number | null
|
||||
nodeId?: string | null
|
||||
ownerName?: string | null
|
||||
repoName?: string | null
|
||||
private?: boolean | null
|
||||
permissions?: InstallationRepositoryPermissions | null
|
||||
language?: string | null
|
||||
description?: string | null
|
||||
htmlURL?: string | null
|
||||
}
|
||||
|
||||
export interface InstallationResponse {
|
||||
ownerId?: number | null
|
||||
repoId?: number | null
|
||||
installation?: Installation | null
|
||||
installationRepositories?: InstallationRepository[] | null
|
||||
installationToken?: string | null
|
||||
installationTokenExpiresAt?: string | null
|
||||
export interface InstallationRepositoriesConnection {
|
||||
nodes?: InstallationRepository[] | null
|
||||
totalCount?: number | null
|
||||
}
|
||||
|
||||
export interface InstallationTokenDetails {
|
||||
token?: string | null
|
||||
expiresAt?: string | null
|
||||
}
|
||||
|
||||
export interface Installation {
|
||||
id?: number | null
|
||||
account?: InstallationAccount | null
|
||||
appId?: number | null
|
||||
targetId?: number | null
|
||||
targetType?: string | null
|
||||
permissions?: InstallationPermissions | null
|
||||
events?: string[] | null
|
||||
singleFileName?: string | null
|
||||
repositoriesConnection?: InstallationRepositoriesConnection | null
|
||||
tokenDetails?: InstallationTokenDetails | null
|
||||
htmlURL?: string | null
|
||||
}
|
||||
|
||||
export interface InstallationsConnection {
|
||||
nodes?: Installation[] | null
|
||||
totalCount?: number | null
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ export const DEFAULT_DARK_THEME = 'dark-purple'
|
||||
export const DEFAULT_LIGHT_THEME = 'light-purple'
|
||||
export const DEFAULT_THEME_PAIR: ThemePair = { id: 'auto', color: '' }
|
||||
|
||||
export const DEFAULT_GITHUB_OAUTH_SCOPES = ['notifications']
|
||||
export const DEFAULT_GITHUB_OAUTH_SCOPES = ['notifications', 'user:email']
|
||||
export const DEFAULT_PAGINATION_PER_PAGE = 10
|
||||
|
||||
export const API_BASE_URL = 'https://api.devhubapp.com'
|
||||
|
||||
Reference in New Issue
Block a user