Simplify GitHub service a little

- this branch will be force pushed again
This commit is contained in:
Mattias Granlund 2024-03-14 18:28:31 +01:00
parent 229b9b9bc9
commit 5d927abfcc
26 changed files with 448 additions and 318 deletions

View File

@ -1,7 +1,7 @@
import { capture } from '$lib/analytics/posthog';
import { CombinedBranch } from '$lib/branches/types';
import { Observable, combineLatest } from 'rxjs';
import { startWith, switchMap } from 'rxjs/operators';
import { shareReplay, startWith, switchMap } from 'rxjs/operators';
import type { GitHubService } from '$lib/github/service';
import type { PullRequest } from '$lib/github/types';
import type { RemoteBranchService } from '$lib/stores/remoteBranches';
@ -20,20 +20,17 @@ export class BranchService {
) {
const vbranchesWithEmpty$ = vbranchService.branches$.pipe(startWith([]));
const branchesWithEmpty$ = remoteBranchService.branches$.pipe(startWith([]));
const prWithEmpty$ = githubService.prs$.pipe(startWith([]));
const prWithEmpty$ = githubService.prs$;
this.branches$ = combineLatest([vbranchesWithEmpty$, branchesWithEmpty$, prWithEmpty$]).pipe(
switchMap(
([vbranches, remoteBranches, pullRequests]) =>
new Observable<CombinedBranch[]>((observer) => {
const contributions = mergeBranchesAndPrs(
vbranches,
pullRequests,
remoteBranches || []
);
observer.next(contributions);
})
)
switchMap(([vbranches, remoteBranches, pullRequests]) => {
return new Observable<CombinedBranch[]>((observer) => {
const contributions = mergeBranchesAndPrs(vbranches, pullRequests, remoteBranches || []);
observer.next(contributions);
observer.complete();
});
}),
shareReplay(1)
);
}

View File

@ -11,6 +11,7 @@
import type { BranchService } from '$lib/branches/service';
import type { GitHubService } from '$lib/github/service';
import type { BranchController } from '$lib/vbranches/branchController';
import type { BaseBranchService } from '$lib/vbranches/branchStoresCache';
import type { BaseBranch, Branch } from '$lib/vbranches/types';
export let project: Project;
@ -25,6 +26,7 @@
export let branchController: BranchController;
export let branchService: BranchService;
export let githubService: GitHubService;
export let baseBranchService: BaseBranchService;
export let user: User | undefined;
@ -117,6 +119,7 @@
{base}
{cloud}
{branchController}
{baseBranchService}
{branchService}
branchCount={branches.filter((c) => c.active).length}
{projectPath}

View File

@ -37,6 +37,7 @@
import type { GitHubService } from '$lib/github/service';
import type { Persisted } from '$lib/persisted/persisted';
import type { BranchController } from '$lib/vbranches/branchController';
import type { BaseBranchService } from '$lib/vbranches/branchStoresCache';
import type { BaseBranch, Branch, LocalFile, RemoteBranchData } from '$lib/vbranches/types';
export let branch: Branch;
@ -52,15 +53,12 @@
export let githubService: GitHubService;
export let selectedOwnership: Writable<Ownership>;
export let commitBoxOpen: Writable<boolean>;
export let isLaneCollapsed: Persisted<boolean>;
export let baseBranchService: BaseBranchService;
const aiGenEnabled = projectAiGenEnabled(project.id);
const aiGenAutoBranchNamingEnabled = projectAiGenAutoBranchNamingEnabled(project.id);
let scrollViewport: HTMLElement;
let rsViewport: HTMLElement;
const userSettings = getContext<SettingsStore>(SETTINGS_CONTEXT);
const defaultBranchWidthRem = persisted<number>(24, 'defaulBranchWidth' + project.id);
const laneWidthKey = 'laneWidth_';
@ -68,6 +66,8 @@
let laneWidth: number;
let remoteBranchData: RemoteBranchData | undefined;
let scrollViewport: HTMLElement;
let rsViewport: HTMLElement;
$: upstream = branch.upstream;
$: if (upstream) reloadRemoteBranch();
@ -215,6 +215,7 @@
{branch}
{branchService}
{githubService}
{baseBranchService}
{isUnapplied}
isLaneCollapsed={$isLaneCollapsed}
/>

View File

@ -23,6 +23,7 @@
import type { BranchService } from '$lib/branches/service';
import type { GitHubService } from '$lib/github/service';
import type { BranchController } from '$lib/vbranches/branchController';
import type { BaseBranchService } from '$lib/vbranches/branchStoresCache';
export let branch: Branch;
export let isUnapplied = false;
@ -35,6 +36,7 @@
export let user: User | undefined;
export let projectPath: string;
export let githubService: GitHubService;
export let baseBranchService: BaseBranchService;
$: selectedOwnership = writable(Ownership.fromBranch(branch));
$: selected = setSelected($selectedFiles, branch);
@ -82,6 +84,7 @@
{cloud}
{branchController}
{branchService}
{baseBranchService}
{selectedOwnership}
bind:commitBoxOpen
bind:isLaneCollapsed

View File

@ -19,8 +19,6 @@
export let projectId: string;
export let pr: PullRequest | undefined;
// $: prStatus$ = githubService.getStatus($pr$?.targetBranch);
let meatballButton: HTMLDivElement;
let container: HTMLDivElement;
let isApplying = false;

View File

@ -24,7 +24,6 @@
export const textFilter$ = new BehaviorSubject<string | undefined>(undefined);
const githubEnabled$ = githubService.isEnabled$;
const userSettings = getContext<SettingsStore>(SETTINGS_CONTEXT);
const height = persisted<number | undefined>(undefined, 'branchesHeight');
@ -149,7 +148,7 @@
{includeStashed}
{hideBots}
{hideInactive}
showPrCheckbox={$githubEnabled$}
showPrCheckbox={githubService.isEnabled}
on:action
/>
</BranchesHeader>

View File

@ -19,8 +19,7 @@
export let hasCommits: boolean;
$: githubServiceState$ = githubService.getState(branch.id);
$: githubEnabled$ = githubService.isEnabled$;
$: pr$ = githubService.get(branch.upstreamName);
$: pr = githubService.getPr(branch.upstreamName);
let isPushing: boolean;
let isMerging: boolean;
@ -41,7 +40,7 @@
async function createPr(createPrOpts: CreatePrOpts): Promise<PullRequest | undefined> {
const opts = { ...defaultPrOpts, ...createPrOpts };
if (!githubService.isEnabled()) {
if (!githubService.isEnabled) {
toast.error('Cannot create PR without GitHub credentials');
return;
}
@ -62,15 +61,15 @@
{#if !isUnapplied && type != 'integrated'}
<div class="actions" class:hasCommits>
{#if $githubEnabled$ && (type == 'local' || type == 'remote')}
{#if githubService.isEnabled && (type == 'local' || type == 'remote')}
<PushButton
wide
isLoading={isPushing || $githubServiceState$?.busy}
isPr={!!$pr$}
isPr={!!pr}
{type}
{branch}
{projectId}
githubEnabled={$githubEnabled$}
githubEnabled={true}
on:trigger={async (e) => {
try {
if (e.detail.action == BranchAction.Pr) {

View File

@ -5,21 +5,22 @@
import Button from '$lib/components/Button.svelte';
import Modal from '$lib/components/Modal.svelte';
import SectionCard from '$lib/components/SectionCard.svelte';
import { getAuthenticated } from '$lib/github/user';
import { copyToClipboard } from '$lib/utils/clipboard';
import * as toasts from '$lib/utils/toasts';
import type { GitHubService } from '$lib/github/service';
import type { UserService } from '$lib/stores/user';
export let userService: UserService;
export let githubService: GitHubService;
export let minimal = false;
export let disabled = false;
$: user$ = userService.user$;
let isCheckingStatus = false;
let loading = false;
let userCode = '';
let deviceCode = '';
let gitHubOauthModal: Modal;
function gitHubStartOauth() {
initDeviceOauth().then((verification) => {
@ -29,37 +30,33 @@
});
}
let gitHubOauthModal: Modal;
function gitHubOauthCheckStatus(deviceCode: string) {
isCheckingStatus = true;
let u = $user$;
checkAuthStatus({ deviceCode })
.then(async (access_token) => {
if (u) {
u.github_access_token = access_token;
u.github_username = await getAuthenticated(access_token);
userService.setUser(u);
toasts.success('GitHub authenticated');
isCheckingStatus = false;
gitHubOauthModal.close();
}
})
.catch((err) => {
console.log(err);
isCheckingStatus = false;
gitHubOauthModal.close();
});
async function gitHubOauthCheckStatus(deviceCode: string) {
loading = true;
let user = $user$;
if (!user) return;
try {
const accessToken = await checkAuthStatus({ deviceCode });
user.github_access_token = accessToken;
// TODO: Refactor so we don't have to call this twice
userService.setUser(user);
user.github_username = await githubService.fetchGitHubLogin();
userService.setUser(user);
toasts.success('GitHub authenticated');
} catch (err: any) {
console.error(err);
toasts.success('GitHub authentication failed');
} finally {
loading = false;
gitHubOauthModal.close();
}
}
function forgetGitHub(): void {
let u = $user$;
if (u) {
u.github_access_token = '';
u.github_username = '';
userService.setUser(u);
let user = $user$;
if (user) {
user.github_access_token = '';
user.github_username = '';
userService.setUser(user);
}
}
</script>
@ -149,9 +146,9 @@
<Button
kind="filled"
color="primary"
loading={isCheckingStatus}
{loading}
on:click={async () => {
gitHubOauthCheckStatus(deviceCode);
await gitHubOauthCheckStatus(deviceCode);
}}
>
Check the status and close

View File

@ -5,6 +5,7 @@
import DecorativeSplitView from '$lib/components/DecorativeSplitView.svelte';
import type { AuthService } from '$lib/backend/auth';
import type { Project, ProjectService } from '$lib/backend/projects';
import type { GitHubService } from '$lib/github/service';
import type { UserService } from '$lib/stores/user';
import type { BranchController } from '$lib/vbranches/branchController';
import type { BaseBranchService } from '$lib/vbranches/branchStoresCache';
@ -17,6 +18,7 @@
export let project: Project;
export let userService: UserService;
export let remoteBranches: { name: string }[];
export let githubService: GitHubService;
$: user$ = userService.user$;
@ -63,6 +65,7 @@
projectId={project.id}
{userService}
{remoteBranches}
{githubService}
on:branchSelected={(e) => {
selectedBranch = e.detail;
}}

View File

@ -10,11 +10,13 @@
import { projectAiGenEnabled } from '$lib/config/config';
import { projectAiGenAutoBranchNamingEnabled } from '$lib/config/config';
import { createEventDispatcher } from 'svelte';
import type { GitHubService } from '$lib/github/service';
import type { UserService } from '$lib/stores/user';
export let userService: UserService;
export let projectId: string;
export let remoteBranches: { name: string }[];
export let githubService: GitHubService;
$: user$ = userService.user$;
@ -165,7 +167,7 @@
</svelte:fragment>
<svelte:fragment slot="actions">
{#if !$user$?.github_access_token}
<GithubIntegration {userService} disabled={!$user$} />
<GithubIntegration {userService} {githubService} disabled={!$user$} />
{/if}
</svelte:fragment>
</SetupFeature>

View File

@ -1,13 +1,16 @@
<script lang="ts">
import IconButton from './IconButton.svelte';
import MergeButton from './MergeButton.svelte';
import Tag, { type TagColor } from './Tag.svelte';
import ViewPrContextMenu from '$lib/components/ViewPrContextMenu.svelte';
import { sleep } from '$lib/utils/sleep';
import * as toasts from '$lib/utils/toasts';
import { openExternalUrl } from '$lib/utils/url';
import { onDestroy } from 'svelte';
import type { BranchService } from '$lib/branches/service';
import type { GitHubService } from '$lib/github/service';
import type { ChecksStatus } from '$lib/github/types';
import type { ChecksStatus, DetailedPullRequest } from '$lib/github/types';
import type { BaseBranchService } from '$lib/vbranches/branchStoresCache';
import type { Branch } from '$lib/vbranches/types';
import type iconsJson from '../icons/icons.json';
@ -17,12 +20,33 @@
export let githubService: GitHubService;
export let projectId: string;
export let isUnapplied = false;
export let baseBranchService: BaseBranchService;
let isMerging = false;
let isFetching = false;
let checksStatus: ChecksStatus | null | undefined;
let isFetchingDetails = false;
let checksStatus: ChecksStatus | null | undefined = undefined;
let detailedPr: DetailedPullRequest | undefined;
$: pr$ = githubService.get(branch.upstreamName);
$: pr$ = githubService.getPr$(branch.upstreamName);
$: if (branch && pr$) {
isFetchingDetails = true;
sleep(1000).then(() => {
updateDetailedPullRequest($pr$?.targetBranch, false);
fetchChecks();
});
}
async function updateDetailedPullRequest(targetBranch: string | undefined, skipCache: boolean) {
isFetchingDetails = true;
try {
detailedPr = await githubService.getDetailedPullRequest(targetBranch, skipCache);
} catch {
toasts.error('Failed to fetch PR details');
} finally {
isFetchingDetails = false;
}
}
function updateContextMenu(copyablePrUrl: string) {
if (popupMenu) popupMenu.$destroy();
@ -32,7 +56,7 @@
});
}
async function fetchPrStatus() {
async function fetchChecks() {
isFetching = true;
try {
checksStatus = await githubService.checks($pr$?.targetBranch);
@ -69,17 +93,16 @@
// Stop polling for status.
return;
}
setTimeout(() => fetchPrStatus(), timeUntilUdate * 1000);
setTimeout(() => fetchChecks(), timeUntilUdate * 1000);
}
$: checksIcon = getChecksIcon(checksStatus);
$: checksIcon = getChecksIcon(checksStatus, isFetching);
$: checksColor = getChecksColor(checksStatus);
$: checksText = getChecksText(checksStatus);
$: statusIcon = getStatusIcon();
$: statusColor = getStatusColor();
$: statusLabel = getPrStatusLabel();
$: if ($pr$) fetchPrStatus();
// TODO: Refactor away the code duplication in the following functions
function getChecksColor(status: ChecksStatus): TagColor | undefined {
if (!status) return;
@ -91,9 +114,12 @@
return 'warning';
}
function getChecksIcon(status: ChecksStatus): keyof typeof iconsJson | undefined {
if (isFetching) return 'spinner';
if (!status) return;
function getChecksIcon(
status: ChecksStatus,
fetching: boolean
): keyof typeof iconsJson | undefined {
if (status === null) return;
if (fetching || !status) return 'spinner';
if (!status.hasChecks) return;
if (status.error) return 'error-small';
if (status.completed) {
@ -103,8 +129,8 @@
return 'spinner';
}
function statusToTagText(status: ChecksStatus | undefined): string | undefined {
if (!status) return;
function getChecksText(status: ChecksStatus | undefined | null): string | undefined {
if (!status) return 'Checks';
if (!status.hasChecks) return 'No checks';
if (status.error) return 'error';
if (status.completed) {
@ -144,10 +170,21 @@
</script>
{#if $pr$?.htmlUrl}
{@const pr = $pr$}
<div class="card pr-card">
<div class="floating-button">
<IconButton
icon="update-small"
loading={isFetchingDetails}
on:click={async () => {
updateDetailedPullRequest(pr?.targetBranch, true);
fetchChecks();
}}
/>
</div>
<div class="pr-title text-base-13 font-semibold">
<span style="color: var(--clr-theme-scale-ntrl-50)">PR #{$pr$.number}:</span>
{$pr$.title}
<span style="color: var(--clr-theme-scale-ntrl-50)">PR #{pr.number}:</span>
{pr.title}
</div>
<div class="pr-tags">
<Tag
@ -165,10 +202,10 @@
filled={checksIcon == 'success-small'}
clickable
verticalOrientation={isLaneCollapsed}
on:mousedown={fetchPrStatus}
on:mousedown={fetchChecks}
help="Checks status"
>
{statusToTagText(checksStatus)}
{checksText}
</Tag>
{:else}
<Tag
@ -187,7 +224,7 @@
shrinkable
verticalOrientation={isLaneCollapsed}
on:mousedown={(e) => {
const url = $pr$?.htmlUrl;
const url = pr?.htmlUrl;
if (url) openExternalUrl(url);
e.preventDefault();
e.stopPropagation();
@ -209,28 +246,28 @@
determining "no checks will run for this PR" such that we can show the merge button
immediately.
-->
{#if $pr$}
{#if pr}
<div class="pr-actions">
<MergeButton
{projectId}
wide
disabled={isFetching || isUnapplied || !$pr$ || (!!checksStatus && !checksStatus.success)}
disabled={isFetching || isUnapplied || !pr || detailedPr?.mergeableState == 'blocked'}
loading={isMerging}
help="Merge pull request and refresh"
on:click={async (e) => {
if (!pr) return;
isMerging = true;
const method = e.detail.method;
try {
if ($pr$) {
await githubService.merge($pr$.number, method);
}
await githubService.merge(pr.number, method);
} catch {
// TODO: Should we show the error from GitHub?
toasts.error('Failed to merge pull request');
} finally {
isMerging = false;
await fetchPrStatus();
await fetchChecks();
await branchService.reloadVirtualBranches();
baseBranchService.reload();
}
}}
/>
@ -241,6 +278,7 @@
<style lang="postcss">
.pr-card {
position: relative;
padding: var(--space-14);
}
@ -260,4 +298,10 @@
margin-top: var(--space-14);
display: flex;
}
.floating-button {
position: absolute;
right: var(--space-6);
top: var(--space-6);
}
</style>

View File

@ -23,7 +23,7 @@
e.stopPropagation();
if (cloudEnabled) syncToCloud(projectId); // don't wait for this
await baseBranchService.fetchFromTarget();
if (githubService.isEnabled()) {
if (githubService.isEnabled) {
await githubService.reload();
}
}}

View File

@ -1,9 +0,0 @@
import { Octokit } from '@octokit/rest';
export function newClient(authToken: string) {
return new Octokit({
auth: authToken,
userAgent: 'GitButler Client',
baseUrl: 'https://api.github.com'
});
}

View File

@ -1,14 +1,15 @@
import { newClient } from '$lib/github/client';
import {
type PullRequest,
type GitHubIntegrationContext,
ghResponseToInstance,
type ChecksStatus,
MergeMethod
MergeMethod,
type DetailedPullRequest,
parseGitHubDetailedPullRequest as parsePullRequestResponse
} from '$lib/github/types';
import { showToast, type Toast } from '$lib/notifications/toasts';
import { sleep } from '$lib/utils/sleep';
import * as toasts from '$lib/utils/toasts';
import { Octokit } from '@octokit/rest';
import lscache from 'lscache';
import posthog from 'posthog-js';
import {
@ -25,95 +26,100 @@ import {
} from 'rxjs';
import {
catchError,
distinct,
delay,
finalize,
map,
retry,
shareReplay,
switchMap,
take,
tap,
timeout
} from 'rxjs/operators';
import type { UserService } from '$lib/stores/user';
import type { BaseBranchService } from '$lib/vbranches/branchStoresCache';
import type { Octokit } from '@octokit/rest';
export type PrAction = 'creating_pr';
export type PrState = { busy: boolean; branchId: string; action?: PrAction };
export type PrCacheKey = { value: Promise<DetailedPullRequest | undefined>; fetchedAt: Date };
export class GitHubService {
prs$: Observable<PullRequest[]>;
error$ = new BehaviorSubject<string | undefined>(undefined);
readonly prs$ = new BehaviorSubject<PullRequest[]>([]);
private prCache = new Map<string, PrCacheKey>();
private error$ = new BehaviorSubject<string | undefined>(undefined);
private stateMap = new Map<string, BehaviorSubject<PrState>>();
private reload$ = new BehaviorSubject<{ skipCache: boolean } | undefined>(undefined);
private fresh$ = new Subject<void>();
private ctx$: Observable<GitHubIntegrationContext | undefined>;
private octokit$: Observable<Octokit | undefined>;
// For use with user initiated actions like merging
private ctx: GitHubIntegrationContext | undefined;
private octokit: Octokit | undefined;
private enabled = false;
private _octokit: Octokit | undefined;
private _repo: string | undefined;
private _owner: string | undefined;
constructor(
userService: UserService,
private baseBranchService: BaseBranchService
accessToken$: Observable<string | undefined>,
remoteUrl$: Observable<string | undefined>
) {
// A few things will cause the baseBranch to update, so we filter for distinct
// changes to the remoteUrl.
const distinctUrl$ = baseBranchService.base$.pipe(distinct((ctx) => ctx?.remoteUrl));
combineLatest([accessToken$, remoteUrl$])
.pipe(
tap(([accessToken, remoteUrl]) => {
if (!remoteUrl?.includes('github') || !accessToken) {
return of();
}
this._octokit = new Octokit({
auth: accessToken,
userAgent: 'GitButler Client',
baseUrl: 'https://api.github.com'
});
const [owner, repo] = remoteUrl.split('.git')[0].split(/\/|:/).slice(-2);
this._repo = repo;
this._owner = owner;
}),
shareReplay(1)
)
.subscribe();
this.ctx$ = combineLatest([userService.user$, distinctUrl$]).pipe(
switchMap(([user, baseBranch]) => {
const remoteUrl = baseBranch?.remoteUrl;
const authToken = user?.github_access_token;
const username = user?.github_username || '';
if (!remoteUrl || !remoteUrl.includes('github') || !authToken) {
this.enabled = false;
return of();
}
this.enabled = true;
const [owner, repo] = remoteUrl.split('.git')[0].split(/\/|:/).slice(-2);
return of({ authToken, owner, repo, username });
}),
distinct((val) => JSON.stringify(val)),
tap((ctx) => (this.ctx = ctx)),
shareReplay(1)
);
// Create a github client
this.octokit$ = this.ctx$.pipe(
map((ctx) => (ctx ? newClient(ctx.authToken) : undefined)),
tap((octokit) => (this.octokit = octokit)),
shareReplay(1)
);
// Load pull requests
this.prs$ = combineLatest([this.ctx$, this.octokit$, this.reload$]).pipe(
tap(() => this.error$.next(undefined)),
switchMap(([ctx, octokit, reload]) => {
if (!ctx || !octokit) return EMPTY;
const prs = loadPrs(ctx, octokit, !!reload?.skipCache);
this.fresh$.next();
return prs;
}),
shareReplay(1),
catchError((err) => {
console.log(err);
this.error$.next(err);
return of([]);
})
);
combineLatest([this.reload$, accessToken$, remoteUrl$])
.pipe(
tap(() => this.error$.next(undefined)),
switchMap(([reload]) => {
if (!this.isEnabled) return EMPTY;
const prs = this.fetchPrs(!!reload?.skipCache);
this.fresh$.next();
return prs;
}),
shareReplay(1),
catchError((err) => {
console.error(err);
toasts.error('Failed to load pull requests');
this.error$.next(err);
return of([]);
}),
tap((prs) => this.prs$.next(prs))
)
.subscribe();
}
isEnabled(): boolean {
return this.enabled;
get isEnabled(): boolean {
return !!this._octokit;
}
get isEnabled$(): Observable<boolean> {
return this.octokit$.pipe(map((octokit) => !!octokit));
get octokit(): Octokit {
if (!this._octokit) throw new Error('No GitHub client available');
return this._octokit;
}
get repo(): string {
if (!this._repo) throw new Error('No repo name specified');
return this._repo;
}
get owner(): string {
if (!this._owner) throw new Error('No owner name specified');
return this._owner;
}
get prs() {
return this.prs$.value;
}
async reload(): Promise<void> {
@ -132,8 +138,74 @@ export class GitHubService {
return await fresh;
}
get(branch: string | undefined): Observable<PullRequest | undefined> | undefined {
fetchPrs(skipCache: boolean): Observable<PullRequest[]> {
return new Observable<PullRequest[]>((subscriber) => {
const key = this.owner + '/' + this.repo;
if (!skipCache) {
const cachedRsp = lscache.get(key);
if (cachedRsp) subscriber.next(cachedRsp.data.map(ghResponseToInstance));
}
try {
this.octokit.rest.pulls
.list({
owner: this.owner,
repo: this.repo
})
.then((rsp) => {
lscache.set(key, rsp, 1440); // 1 day ttl
subscriber.next(rsp.data.map(ghResponseToInstance));
})
.catch((e) => subscriber.error(e));
} catch (e) {
console.error(e);
}
});
}
async getDetailedPullRequest(
branch: string | undefined,
skipCache: boolean
): Promise<DetailedPullRequest | undefined> {
if (!branch) return;
// We should remove this cache when `list_virtual_branches` no longer triggers
// immedate updates on the subscription.
const cacheHit = this.prCache.get(branch);
if (cacheHit && !skipCache) {
if (new Date().getTime() - cacheHit.fetchedAt.getTime() < 1000 * 5) {
return await cacheHit.value;
}
}
const pr = this.getPr(branch);
if (!pr) {
toasts.error('Failed to get pull request data'); // TODO: Notify user
return;
}
const resp = await this.octokit.pulls.get({
owner: this.owner,
repo: this.repo,
pull_number: pr.number,
headers: {
'X-GitHub-Api-Version': '2022-11-28'
}
});
const detailedPr = Promise.resolve(parsePullRequestResponse(resp.data));
if (detailedPr) this.prCache.set(branch, { value: detailedPr, fetchedAt: new Date() });
return await detailedPr;
}
getPr(branch: string | undefined): PullRequest | undefined {
if (!branch) return;
return this.prs?.find((pr) => pr.targetBranch == branch);
}
getPr$(branch: string | undefined): Observable<PullRequest | undefined> {
if (!branch) return of(undefined);
return this.prs$.pipe(map((prs) => prs.find((pr) => pr.targetBranch == branch)));
}
@ -165,48 +237,37 @@ export class GitHubService {
upstreamName: string,
draft: boolean
): Promise<{ pr: PullRequest } | { err: string | { message: string; help: string } }> {
if (!this.enabled) {
throw "Can't create PR when service not enabled";
}
this.setBusy('creating_pr', branchId);
return firstValueFrom(
// We have to wrap with defer becasue using `async` functions with operators
// create a promise that will stay rejected when rejected.
defer(() =>
combineLatest([this.octokit$, this.ctx$]).pipe(
switchMap(async ([octokit, ctx]) => {
if (!octokit || !ctx) {
throw "Can't create PR without credentials";
}
try {
const rsp = await octokit.rest.pulls.create({
owner: ctx.owner,
repo: ctx.repo,
head: upstreamName,
base,
title,
body,
draft
});
await this.reload();
posthog.capture('PR Successful');
return { pr: ghResponseToInstance(rsp.data) };
} catch (err: any) {
const toast = mapErrorToToast(err);
if (toast) {
// TODO: This needs disambiguation, not the same as `toasts.error`
// Show toast with rich content
showToast(toast);
// Handled errors should not be retried
return { err };
} else {
// Rethrow so that error is retried
throw err;
}
}
})
)
).pipe(
defer(async () => {
try {
const rsp = await this.octokit.rest.pulls.create({
owner: this.owner,
repo: this.repo,
head: upstreamName,
base,
title,
body,
draft
});
posthog.capture('PR Successful');
return { pr: ghResponseToInstance(rsp.data) };
} catch (err: any) {
const toast = mapErrorToToast(err);
if (toast) {
// TODO: This needs disambiguation, not the same as `toasts.error`
// Show toast with rich content
showToast(toast);
// Handled errors should not be retried
return { err };
} else {
// Rethrow so that error is retried
throw err;
}
}
}).pipe(
retry({
count: 2,
delay: 500
@ -248,7 +309,13 @@ export class GitHubService {
}
return throwError(() => err.message);
}),
tap(() => this.setIdle(branchId))
tap(() => this.setIdle(branchId)),
// Makes finalize happen after first and only result
take(1),
// Wait for GitHub to become eventually consistent. If we refresh too quickly then
// then it'll show as mergeable and no checks even if checks are present.
delay(1000),
finalize(async () => await this.reload())
)
);
}
@ -260,7 +327,8 @@ export class GitHubService {
// the pull request has been created.
let resp: Awaited<ReturnType<typeof this.fetchChecksWithRetries>>;
try {
resp = await this.fetchChecksWithRetries(ref, 5, 2000);
// resp = await this.fetchChecksWithRetries(ref, 5, 2000);
resp = await this.fetchChecks(ref);
} catch (err: any) {
return { error: err };
}
@ -291,6 +359,17 @@ export class GitHubService {
};
}
async fetchChecks(ref: string) {
return await this.octokit.checks.listForRef({
owner: this.owner,
repo: this.repo,
ref: ref,
headers: {
'X-GitHub-Api-Version': '2022-11-28'
}
});
}
async fetchChecksWithRetries(ref: string, retries: number, delayMs: number) {
let resp = await this.fetchChecks(ref);
let retried = 0;
@ -303,61 +382,28 @@ export class GitHubService {
return resp;
}
async fetchChecks(ref: string) {
if (!this.octokit || !this.ctx) throw 'GitHub client not available';
return await this.octokit.checks.listForRef({
owner: this.ctx.owner,
repo: this.ctx.repo,
ref: ref,
headers: {
'X-GitHub-Api-Version': '2022-11-28'
}
});
}
async merge(pullNumber: number, method: MergeMethod) {
if (!this.octokit || !this.ctx) return;
try {
await this.octokit.pulls.merge({
owner: this.ctx.owner,
repo: this.ctx.repo,
return await this.octokit.pulls.merge({
owner: this.owner,
repo: this.repo,
pull_number: pullNumber,
merge_method: method
});
await this.baseBranchService.fetchFromTarget();
} finally {
this.reload();
}
}
}
function loadPrs(
ctx: GitHubIntegrationContext,
octokit: Octokit,
skipCache: boolean
): Observable<PullRequest[]> {
return new Observable<PullRequest[]>((subscriber) => {
const key = ctx.owner + '/' + ctx.repo;
if (!skipCache) {
const cachedRsp = lscache.get(key);
if (cachedRsp) subscriber.next(cachedRsp.data.map(ghResponseToInstance));
}
async fetchGitHubLogin(): Promise<string> {
try {
octokit.rest.pulls
.list({
owner: ctx.owner,
repo: ctx.repo
})
.then((rsp) => {
lscache.set(key, rsp, 1440); // 1 day ttl
subscriber.next(rsp.data.map(ghResponseToInstance));
});
const rsp = await this.octokit.users.getAuthenticated();
return rsp.data.login;
} catch (e) {
console.error(e);
throw e;
}
});
}
}
/**

View File

@ -1,13 +1,6 @@
import type { Author } from '$lib/vbranches/types';
import type { RestEndpointMethodTypes } from '@octokit/rest';
export interface GitHubIntegrationContext {
authToken: string;
owner: string;
repo: string;
username: string;
}
export interface Label {
name: string;
description: string | undefined;
@ -30,6 +23,36 @@ export interface PullRequest {
closedAt?: Date;
}
export type DetailedGitHubPullRequest = RestEndpointMethodTypes['pulls']['get']['response']['data'];
export interface DetailedPullRequest {
targetBranch: string;
createdAt: Date;
mergedAt?: Date;
closedAt?: Date;
htmlUrl: string;
mergeable: boolean;
mergeableState: string;
rebaseable: boolean;
squashable: boolean;
}
export function parseGitHubDetailedPullRequest(
data: DetailedGitHubPullRequest
): DetailedPullRequest {
return {
targetBranch: data.base.ref,
htmlUrl: data.html_url,
createdAt: new Date(data.created_at),
mergedAt: data.merged_at ? new Date(data.merged_at) : undefined,
closedAt: data.closed_at ? new Date(data.closed_at) : undefined,
mergeable: !!data.mergeable,
mergeableState: data.mergeable_state,
rebaseable: !!data.rebaseable,
squashable: !!data.mergeable // Enabled whenever merge is enabled
};
}
export type ChecksStatus =
| {
startedAt?: Date;
@ -46,13 +69,11 @@ export function ghResponseToInstance(
| RestEndpointMethodTypes['pulls']['create']['response']['data']
| RestEndpointMethodTypes['pulls']['list']['response']['data'][number]
): PullRequest {
const labels: Label[] = pr.labels.map((label) => {
return {
name: label.name,
description: label.description || undefined,
color: label.color
};
});
const labels: Label[] = pr.labels.map((label) => ({
name: label.name,
description: label.description || undefined,
color: label.color
}));
return {
htmlUrl: pr.html_url,

View File

@ -1,12 +0,0 @@
import { newClient } from '$lib/github/client';
export async function getAuthenticated(authTokenstring: string): Promise<string> {
const octokit = newClient(authTokenstring);
try {
const rsp = await octokit.users.getAuthenticated();
return rsp.data.login;
} catch (e) {
console.log(e);
throw e;
}
}

View File

@ -4,15 +4,15 @@ import { getCloudApiClient, type User } from '$lib/backend/cloud';
import { invoke } from '$lib/backend/ipc';
import { sleep } from '$lib/utils/sleep';
import { openExternalUrl } from '$lib/utils/url';
import { BehaviorSubject, Observable, Subject, merge, shareReplay } from 'rxjs';
import { BehaviorSubject, Observable, Subject, distinct, map, merge, shareReplay } from 'rxjs';
export class UserService {
private cloud = getCloudApiClient();
private readonly cloud = getCloudApiClient();
reset$ = new Subject<User | undefined>();
loading$ = new BehaviorSubject(false);
readonly reset$ = new Subject<User | undefined>();
readonly loading$ = new BehaviorSubject(false);
user$ = merge(
readonly user$ = merge(
new Observable<User | undefined>((subscriber) => {
invoke<User | undefined>('get_user').then((user) => {
if (user) {
@ -25,6 +25,11 @@ export class UserService {
this.reset$
).pipe(shareReplay(1));
readonly accessToken$ = this.user$.pipe(
map((user) => user?.github_access_token),
distinct()
);
constructor() {}
async setUser(user: User | undefined) {

View File

@ -109,21 +109,24 @@ function subscribeToVirtualBranches(projectId: string, callback: (branches: Bran
}
export class BaseBranchService {
base$: Observable<BaseBranch | null | undefined>;
busy$ = new BehaviorSubject(false);
error$ = new BehaviorSubject<any>(undefined);
private reload$ = new BehaviorSubject<void>(undefined);
readonly base$: Observable<BaseBranch | null | undefined>;
readonly busy$ = new BehaviorSubject(false);
readonly error$ = new BehaviorSubject<any>(undefined);
private readonly reload$ = new BehaviorSubject<void>(undefined);
constructor(
private projectId: string,
private readonly projectId: string,
readonly remoteUrl$: BehaviorSubject<string | undefined>,
fetches$: Observable<unknown>,
head$: Observable<string>
readonly head$: Observable<string>
) {
this.base$ = combineLatest([fetches$, head$, this.reload$]).pipe(
debounceTime(100),
switchMap(async () => {
this.busy$.next(true);
return await getBaseBranch({ projectId });
const baseBranch = await getBaseBranch({ projectId });
if (baseBranch?.remoteUrl) this.remoteUrl$.next(baseBranch.remoteUrl);
return baseBranch;
}),
tap(() => this.busy$.next(false)),
// Start with undefined to prevent delay in updating $baseBranch$ value in

View File

@ -5,9 +5,10 @@ import { getCloudApiClient } from '$lib/backend/cloud';
import { ProjectService } from '$lib/backend/projects';
import { UpdaterService } from '$lib/backend/updater';
import { appMetricsEnabled, appErrorReportingEnabled } from '$lib/config/appSettings';
import { GitHubService } from '$lib/github/service';
import { UserService } from '$lib/stores/user';
import lscache from 'lscache';
import { config } from 'rxjs';
import { BehaviorSubject, config } from 'rxjs';
// call on startup so we don't accumulate old items
lscache.flushExpired();
@ -19,8 +20,6 @@ export const ssr = false;
export const prerender = false;
export const csr = true;
let homeDir: () => Promise<string>;
export async function load({ fetch: realFetch }: { fetch: typeof fetch }) {
appErrorReportingEnabled()
.onDisk()
@ -32,19 +31,37 @@ export async function load({ fetch: realFetch }: { fetch: typeof fetch }) {
.then((enabled) => {
if (enabled) initPostHog();
});
const userService = new UserService();
// TODO: Find a workaround to avoid this dynamic import
// https://github.com/sveltejs/kit/issues/905
homeDir = (await import('@tauri-apps/api/path')).homeDir;
const defaultPath = await homeDir();
const defaultPath = await (await import('@tauri-apps/api/path')).homeDir();
const authService = new AuthService();
const cloud = getCloudApiClient({ fetch: realFetch });
const projectService = new ProjectService(defaultPath);
const updaterService = new UpdaterService();
const userService = new UserService();
const user$ = userService.user$;
// We're declaring a remoteUrl$ observable here that is written to by `BaseBranchService`. This
// is a bit awkard, but `GitHubService` needs to be available at the root scoped layout.ts, such
// that we can perform actions related to GitHub that do not depend on repo information.
// We should evaluate whether or not to split this service into two separate services. That
// way we would not need `remoteUrl$` for the non-repo service, and therefore the other one
// could easily get an observable of the remote url from `BaseBranchService`.
const remoteUrl$ = new BehaviorSubject<string | undefined>(undefined);
const githubService = new GitHubService(userService.accessToken$, remoteUrl$);
return {
authService: new AuthService(),
projectService: new ProjectService(defaultPath),
cloud: getCloudApiClient({ fetch: realFetch }),
updaterService: new UpdaterService(),
authService,
cloud,
githubService,
projectService,
updaterService,
userService,
user$: userService.user$
// These observables are provided for convenience
remoteUrl$,
user$
};
}

View File

@ -1,6 +1,7 @@
<script lang="ts">
import { syncToCloud } from '$lib/backend/cloud';
import { handleMenuActions } from '$lib/backend/menuActions';
import { ProjectService } from '$lib/backend/projects';
import FullscreenLoading from '$lib/components/FullscreenLoading.svelte';
import Navigation from '$lib/components/Navigation.svelte';
import NotOnGitButlerBranch from '$lib/components/NotOnGitButlerBranch.svelte';
@ -8,12 +9,13 @@
import { subscribe as menuSubscribe } from '$lib/menu';
import * as hotkeys from '$lib/utils/hotkeys';
import { unsubscribe } from '$lib/utils/random';
import { onDestroy, onMount } from 'svelte';
import { onDestroy, onMount, setContext } from 'svelte';
import type { LayoutData } from './$types';
import { goto } from '$app/navigation';
export let data: LayoutData;
setContext(ProjectService, data.projectService);
$: projectService = data.projectService;
$: branchController = data.branchController;
$: githubService = data.githubService;
@ -51,16 +53,20 @@
goto(`/${projectId}/setup`, { replaceState: true });
}
onMount(() =>
unsubscribe(
onMount(() => {
return unsubscribe(
menuSubscribe(data.projectId),
hotkeys.on('Meta+Shift+S', () => syncToCloud(projectId))
)
);
);
});
onDestroy(() => {
clearFetchInterval();
});
$: if (data) {
setContext('hello', data.projectId);
}
</script>
{#if !$project$}

View File

@ -1,5 +1,4 @@
import { BranchService } from '$lib/branches/service';
import { GitHubService } from '$lib/github/service';
import { getFetchNotifications } from '$lib/stores/fetches';
import { getHeads } from '$lib/stores/head';
import { RemoteBranchService } from '$lib/stores/remoteBranches';
@ -10,13 +9,21 @@ import { map } from 'rxjs';
export const prerender = false;
export async function load({ params, parent }) {
const { authService, projectService, userService } = await parent();
// prettier-ignore
const {
authService,
githubService,
projectService,
remoteUrl$,
} = await parent();
const projectId = params.projectId;
const project$ = projectService.getProject(projectId);
const fetches$ = getFetchNotifications(projectId);
const heads$ = getHeads(projectId);
const gbBranchActive$ = heads$.pipe(map((head) => head == 'gitbutler/integration'));
const baseBranchService = new BaseBranchService(projectId, fetches$, heads$);
const baseBranchService = new BaseBranchService(projectId, remoteUrl$, fetches$, heads$);
const vbranchService = new VirtualBranchService(projectId, gbBranchActive$);
const remoteBranchService = new RemoteBranchService(
@ -31,8 +38,6 @@ export async function load({ params, parent }) {
remoteBranchService,
baseBranchService
);
const githubService = new GitHubService(userService, baseBranchService);
const branchService = new BranchService(
vbranchService,
remoteBranchService,
@ -40,19 +45,18 @@ export async function load({ params, parent }) {
branchController
);
const user$ = userService.user$;
return {
projectId,
authService,
branchController,
baseBranchService,
githubService,
vbranchService,
remoteBranchService,
user$,
project$,
branchController,
branchService,
githubService,
projectId,
remoteBranchService,
vbranchService,
// These observables are provided for convenience
project$,
gbBranchActive$
};
}

View File

@ -20,7 +20,6 @@
$: project$ = data.project$;
$: activeBranches$ = vbranchService.activeBranches$;
$: error$ = vbranchService.branchesError$;
$: githubEnabled$ = githubService.isEnabled$;
let viewport: HTMLDivElement;
let contents: HTMLDivElement;
@ -30,7 +29,7 @@
function shouldShowHttpsWarning() {
if (httpsWarningBannerDismissed) return false;
if (!$base$?.remoteUrl.startsWith('https')) return false;
if ($base$?.remoteUrl.includes('github.com') && $githubEnabled$) return false;
if ($base$?.remoteUrl.includes('github.com') && githubService.isEnabled) return false;
return true;
}
</script>
@ -54,6 +53,7 @@
<Board
{branchController}
{branchService}
{baseBranchService}
project={$project$}
{cloud}
base={$base$}

View File

@ -14,7 +14,7 @@
$: base$ = data.baseBranchService.base$;
$: branch = $branches$?.find((b) => b.sha == $page.params.sha);
$: pr$ = githubService.get(branch?.displayName);
$: pr = githubService.getPr(branch?.displayName);
</script>
{#if $error$}
@ -27,7 +27,7 @@
projectPath={$project$.path}
project={$project$}
base={$base$}
pr={$pr$}
{pr}
{branchController}
{branch}
/>

View File

@ -14,6 +14,7 @@
$: authService = data.authService;
$: projectId = data.projectId;
$: project$ = data.project$;
$: githubService = data.githubService;
</script>
{#await getRemoteBranches(projectId)}
@ -37,6 +38,7 @@
{branchController}
{userService}
{remoteBranches}
{githubService}
/>
{/if}
{:catch}

View File

@ -38,6 +38,7 @@
{branch}
{branchController}
{branchService}
{baseBranchService}
base={$baseBranch$}
{cloud}
project={$project$}

View File

@ -28,7 +28,7 @@
export let data: PageData;
const { cloud, user$, userService, authService } = data;
$: ({ cloud, user$, userService, authService, githubService } = data);
const fileTypes = ['image/jpeg', 'image/png'];
// TODO: Maybe break these into components?
@ -324,7 +324,7 @@
{:else if currentSection === 'integrations'}
<ContentWrapper title="Integrations">
{#if $user$}
<GithubIntegration {userService} />
<GithubIntegration {userService} {githubService} />
{/if}
</ContentWrapper>
{/if}