New feature: Private repository support

This commit is contained in:
Bruno Lemos
2019-02-13 06:20:37 -02:00
parent 53fe5368fc
commit bdad2df7a7
37 changed files with 764 additions and 334 deletions

View File

@@ -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
}

View File

@@ -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

View File

@@ -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>

View File

@@ -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

View File

@@ -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>
)
},
)

View File

@@ -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 => {

View File

@@ -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,

View File

@@ -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}
/>

View File

@@ -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}
/>

View File

@@ -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}
/>

View 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
}

View File

@@ -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}'.`,

View File

@@ -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'

View 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)
}

View File

@@ -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 },
) {

View File

@@ -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 = {

View File

@@ -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,
})

View File

@@ -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
}

View File

@@ -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],
),

View File

@@ -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),
])
}

View 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),
])
}

View File

@@ -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(

View File

@@ -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 || {}

View File

@@ -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

View File

@@ -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'

View 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)
}

View File

@@ -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)
},
)
}

View File

@@ -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
}

View File

@@ -86,3 +86,8 @@ export function getNotificationIconAndColor(
}
}
}
export function normalizeUsername(username: string | undefined) {
if (!username || typeof username !== 'string') return undefined
return username.trim().toLowerCase()
}

View File

@@ -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
}

View File

@@ -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

View File

@@ -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

View File

@@ -1,3 +1,4 @@
export * from './api'
export * from './helpers'
export * from './mappers'
export * from './types'

View File

@@ -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,

View File

@@ -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

View File

@@ -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
}

View File

@@ -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'