Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,12 @@ import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { PopoverController } from '@ionic/angular';
import { ErrorLogger } from '@sneat/core';
import { Subject, of } from 'rxjs';
import { Subject, of, throwError } from 'rxjs';

import { NewProjectFormComponent } from './new-project-form.component';
import { DatatugNavService } from '../../services/nav/datatug-nav.service';
import { ProjectService } from '../../services/project/project.service';
import { GithubRepoError } from '../../services/repo/github/github-api';
import { GithubProjectCreateService } from '../../services/repo/github/github-project-create.service';
import { GithubOAuthService } from '../../services/repo/github/github-oauth.service';
import { GithubReposService } from '../../services/repo/github/github-repos.service';
Expand Down Expand Up @@ -337,6 +338,50 @@ describe('NewProjectFormComponent creating in a GitHub repo', () => {
);
});

it('shows what GitHub did not return when it made the repository but sent no default branch', () => {
oauth.isSignedIn = true;
oauth.accessToken = 'gho_token';
(component as unknown as { selectedRepo: { set: (v: string) => void } }).selectedRepo.set(
'__new__',
);
component.newRepoName = 'my-projects';
repos.createRepo = vi.fn(() =>
throwError(
() =>
new GithubRepoError(
'default-branch',
'GitHub created my-projects, but did not return the default branch of my-projects, so DataTug cannot tell which branch to commit to.',
),
),
);
component.store = 'github';

component.create();

expect(formErrorOf(component)).toContain('did not return the default branch');
expect(createProject).not.toHaveBeenCalled();
});

it('shows what GitHub did not return when the chosen repository has no default branch', () => {
oauth.isSignedIn = true;
oauth.accessToken = 'gho_token';
(component as unknown as { selectedRepo: { set: (v: string) => void } }).selectedRepo.set(
'datatug/demo-projects',
);
component.store = 'github';
component.title = 'My project';

component.create();
createProject$.error(
new GithubRepoError(
'default-branch',
'GitHub did not return the default branch of datatug/demo-projects, so DataTug cannot tell which branch to commit to.',
),
);

expect(formErrorOf(component)).toContain('did not return the default branch');
});

it('requires a name before creating a new repository', () => {
oauth.isSignedIn = true;
oauth.accessToken = 'gho_token';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,10 @@ import { IProjectContext, parseDatatugStoreRef } from '../../nav/nav-models';
import { DatatugNavService } from '../../services/nav/datatug-nav.service';
import { DatatugServicesProjectModule } from '../../services/project/datatug-services-project.module';
import { ProjectService } from '../../services/project/project.service';
import { IGithubRepo } from '../../services/repo/github/github-api';
import {
GithubRepoError,
IGithubRepo,
} from '../../services/repo/github/github-api';
import {
GithubOAuthService,
GithubSignInRedirecting,
Expand Down Expand Up @@ -249,7 +252,9 @@ export class NewProjectFormComponent implements ViewDidEnter {
error: (err) => {
this.isCreating.set(false);
this.formError.set(
`Failed to create the repository "${name}" on GitHub.`,
err instanceof GithubRepoError
? err.message
: `Failed to create the repository "${name}" on GitHub.`,
);
this.errorLogger.logError(err, 'Failed to create a GitHub repo');
},
Expand Down Expand Up @@ -283,7 +288,9 @@ export class NewProjectFormComponent implements ViewDidEnter {
error: (err) => {
this.isCreating.set(false);
this.formError.set(
`Failed to create the project in ${fullName}. Check that your GitHub access allows writing to it.`,
err instanceof GithubRepoError
? err.message
: `Failed to create the project in ${fullName}. Check that your GitHub access allows writing to it.`,
);
this.errorLogger.logError(
err,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ describe('DatatugStoreGithubService.watchProjectItem', () => {
function createService(getRawJson: ReturnType<typeof vi.fn>) {
TestBed.configureTestingModule({
providers: [
{ provide: GithubProjectReaderService, useValue: { getRawJson } },
{ provide: GithubProjectReaderService, useValue: { getRawJson, visitEpoch: () => '0.0' } },
],
});
return TestBed.inject(DatatugStoreGithubService);
Expand Down Expand Up @@ -113,7 +113,7 @@ describe('DatatugStoreGithubService.getProjectSummary: ids it cannot read', () =
function createService(getRawJson: ReturnType<typeof vi.fn>) {
TestBed.configureTestingModule({
providers: [
{ provide: GithubProjectReaderService, useValue: { getRawJson } },
{ provide: GithubProjectReaderService, useValue: { getRawJson, visitEpoch: () => '0.0' } },
],
});
return TestBed.inject(DatatugStoreGithubService);
Expand Down Expand Up @@ -143,7 +143,11 @@ describe('DatatugStoreGithubService.getProjectSummary: reads through the reader
providers: [
{
provide: GithubProjectReaderService,
useValue: { getRawJson, readInfo: () => of({ state }) },
useValue: {
getRawJson,
visitEpoch: () => '0.0',
readInfo: () => of({ state }),
},
},
],
});
Expand All @@ -170,3 +174,41 @@ describe('DatatugStoreGithubService.getProjectSummary: reads through the reader
expect((error as GithubProjectNotFoundError).reason).toBe(state);
});
});

describe('DatatugStoreGithubService.getProjectSummary: the summary follows the commit of the reader', () => {
it('a summary read before the reader moved to another commit is read again, once; until it moves again it is kept', () => {
let epoch = '0.0';
let title = 'at the old commit';
const getRawJson = vi.fn(() => of({ id: 'r@o@d', title }));
TestBed.configureTestingModule({
providers: [
{
provide: GithubProjectReaderService,
useValue: { getRawJson, visitEpoch: () => epoch },
},
],
});
const service = TestBed.inject(DatatugStoreGithubService);
const titles: string[] = [];
const read = () =>
service
.getProjectSummary('r@o@d')
.subscribe((p) => titles.push(p.title as string));

read();
read();
expect(getRawJson).toHaveBeenCalledTimes(1);

epoch = '0.1'; // the visit moved to another commit
title = 'at the new commit';
read();
read();
expect(getRawJson).toHaveBeenCalledTimes(2);
expect(titles).toEqual([
'at the old commit',
'at the old commit',
'at the new commit',
'at the new commit',
]);
});
});
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { IFolder, IFolderItem } from '../../models/definition/folder';
import { IProjectSummary } from '../../models/definition/project';
import { IDatatugStoreService } from './datatug-store.service.interface';
import { Observable, defer, of, throwError } from 'rxjs';
import { Observable, of, throwError } from 'rxjs';
import { map, shareReplay, switchMap } from 'rxjs/operators';
import { Injectable, inject } from '@angular/core';
import {
Expand Down Expand Up @@ -34,9 +34,11 @@ export class DatatugStoreGithubService implements IDatatugStoreService {
// reads the project summary (for its `boards` list), so without this a
// page that renders both the project summary AND the folder tabs (the
// project page itself) would fetch `datatug-project.json` twice.
// Kept with the reader's `visitEpoch` at the time: a summary read at a commit the visit has since moved away from
// (the reader asked GitHub again about a commit it only remembered, and was told another) is not served again.
private readonly summaryCache = new Map<
string,
Observable<IProjectSummary>
{ readonly epoch: string; readonly summary: Observable<IProjectSummary> }
>();

/**
Expand All @@ -55,58 +57,57 @@ export class DatatugStoreGithubService implements IDatatugStoreService {
}

getProjectSummary(projectId: string): Observable<IProjectSummary> {
let cached = this.summaryCache.get(projectId);
if (cached) {
return cached;
const cached = this.summaryCache.get(projectId);
if (cached && cached.epoch === this.githubReader.visitEpoch(projectId)) {
return cached.summary;
}
try {
assertReadableGithubProjectId(projectId);
} catch (err) {
return throwError(() => err);
}
const epoch = this.githubReader.visitEpoch(projectId);

// Through the reader: the same commit as the listing and every other file, one cache, one request (the old
// second, separate read of this file could come from a different commit than the rest of the page).
// `defer`: `shareReplay` drops a failed read and the next subscriber reads again, instead of getting the same
// The reader's observable is lazy (a read starts on subscription, a failed one is read again by the next
// subscriber), and `shareReplay` drops a failed read: the next subscriber reads again, instead of getting the same
// failure back until the page is reloaded.
cached = defer(() =>
this.githubReader.getRawJson<IProjectSummary>(
projectId,
'datatug-project.json',
),
).pipe(
switchMap((p) =>
p
? of(p)
: this.githubReader
.readInfo(projectId)
.pipe(
switchMap((info) =>
throwError(
() =>
new GithubProjectNotFoundError(
projectId,
info.state === 'moved' ? 'moved' : 'missing',
),
const summary = this.githubReader
.getRawJson<IProjectSummary>(projectId, 'datatug-project.json')
.pipe(
switchMap((p) =>
p
? of(p)
: this.githubReader
.readInfo(projectId)
.pipe(
switchMap((info) =>
throwError(
() =>
new GithubProjectNotFoundError(
projectId,
info.state === 'moved' ? 'moved' : 'missing',
),
),
),
),
),
),
map((p) => {
if (p.id === projectId) {
return p;
}
if (p.id) {
console.warn(
`Request project info with projectId=${projectId} but response JSON have id=${p.id}`,
);
}
return { ...p, id: projectId };
}),
shareReplay(1),
);
this.summaryCache.set(projectId, cached);
return cached;
),
map((p) => {
if (p.id === projectId) {
return p;
}
if (p.id) {
console.warn(
`Request project info with projectId=${projectId} but response JSON have id=${p.id}`,
);
}
return { ...p, id: projectId };
}),
shareReplay(1),
);
this.summaryCache.set(projectId, { epoch, summary });
return summary;
}

/**
Expand Down
Loading
Loading